Files
ny-exam-platform/bootstrap.php
T
nxxy335 94ac381579 完成批次 L-R:限流移除/白名单驱动/打印增强/全屏/表格对齐/主题变量/滚动闪烁修复
- 移除登录失败限流;fix-config.php 修正 e2e 串味的 127.0.0.1 连接

- 白名单驱动缺/待考名单(DataService + SurveyKingClient::getWhitelist,复用已取 survey)

- 打印成绩明细增强(范围/合格列默认关/去页脚);看板全屏占满;表格 vertical-align:middle;隐藏登陆账号;导入默认追加

- 主题变量收口(:root 新增 brand/primary-2/ok-2 等,替换硬编码渐变);删死规则

- 表头/数据列对齐修复(index 未匹配表、dashboard 成员榜、draw 结果/历史/排除表);看板占位卡片配色一致

- 修复滚动闪烁:row-drop-in 动画经 _dropAnim 门控,仅强制重绘时播放

- 测试:新增 test-whitelist.js;run-all.sh 全绿 132
2026-09-13 10:25:48 +08:00

490 lines
16 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* NY 问卷考试系统数据平台 - 引导 / 公共函数
* PHP >= 8.0
*/
declare(strict_types=1);
const NY_APP_NAME = 'NY问卷考试系统数据平台';
const NY_VERSION = '1.0.0';
define('NY_ROOT', str_replace('\\', '/', __DIR__));
// 数据目录默认指向项目下的 data/;测试可通过 NY_DATA 环境变量整体重定向到临时目录,
// 避免 e2e 把 mock 服务的连接配置写进生产库(曾因此把生产 base_url 改成 127.0.0.1 导致登录失败)。
$nyData = NY_ROOT . '/data';
$nyDataEnv = getenv('NY_DATA');
if ($nyDataEnv !== false && $nyDataEnv !== '') {
$nyData = rtrim(str_replace('\\', '/', $nyDataEnv), '/');
}
define('NY_DATA', $nyData);
// ---------------- 环境预检 ----------------
if (PHP_VERSION_ID < 80000) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<h2>环境不满足</h2><p>本平台需要 <b>PHP 8.0 及以上</b>,当前版本:' . PHP_VERSION . '</p>';
exit;
}
$missing = [];
foreach (['curl' => 'curl', 'json' => 'json', 'mbstring' => 'mbstring'] as $fn => $ext) {
if (!extension_loaded($ext)) {
$missing[] = $ext;
}
}
if ($missing !== []) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<h2>缺少 PHP 扩展</h2><p>请在 php.ini 中启用:<b>' . implode('、', $missing) . '</b></p>'
. '<p>Windows:去掉 <code>extension=' . $missing[0] . '</code> 前面的分号并重启服务。'
. '(<code>zip</code> 扩展可选,未启用时会用内置解析器读取 xlsx)</p>';
exit;
}
date_default_timezone_set('Asia/Shanghai');
mb_internal_encoding('UTF-8');
// ---------------- 内存上限(数据接口可能处理大量答卷)----------------
// 拉取卷王全量答卷做 json_decode 时极易触顶 128M;在未被禁用 / 未被 php_admin_value 硬锁的前提下
// 临时放宽到 512M。若主机用 php_admin_value 锁定了 memory_limit,下面的 ini_set 会静默失效,
// 需要改主机 php.ini(或 fpm 池配置)才能生效。
(function (): void {
$cur = (string) ini_get('memory_limit');
if ($cur === '' || $cur === '-1') {
return; // 未设置 / 已无限制,无需处理
}
$curBytes = (int) $cur;
$unit = strtoupper(substr($cur, -1));
if ($unit === 'G') {
$curBytes *= 1024 * 1024 * 1024;
} elseif ($unit === 'M') {
$curBytes *= 1024 * 1024;
} elseif ($unit === 'K') {
$curBytes *= 1024;
}
$want = 512 * 1024 * 1024;
if ($curBytes > 0 && $curBytes < $want) {
@ini_set('memory_limit', '512M');
}
})();
// ---------------- 会话 ----------------
function ny_session_start(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name('NYSESS');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => $secure,
]);
@session_start();
}
/** 当前是否已登录(本系统会话) */
function ny_is_auth(): bool
{
ny_session_start();
return !empty($_SESSION['ny_auth']);
}
/** 标记登录 */
function ny_login(): void
{
ny_session_start();
$_SESSION['ny_auth'] = true;
}
/** 登出 */
function ny_logout(): void
{
ny_session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
// ---------------- CSRF 防护 ----------------
/**
* 会话绑定的 CSRF token:写操作(POST)必须携带,防止跨站请求伪造。
* 前端从 config_get / auth_check 响应里拿到 csrf_token,后续写请求附在
* X-CSRF-Token 请求头或 body 的 _csrf 字段。
*/
function ny_csrf_token(): string
{
ny_session_start();
if (empty($_SESSION['ny_csrf']) || !is_string($_SESSION['ny_csrf'])) {
$_SESSION['ny_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['ny_csrf'];
}
/**
* 取请求原始 body(仅读一次并缓存)。
* 写接口既要校验 CSRF(从 body 取 _csrf)又要解析业务 JSON,二者都需读 php://input;
* 大多数 SAPI 下 php://input 可重复读,但个别环境只能读一次,因此这里统一缓存避免第二次读为空。
*/
function ny_request_body(): string
{
if (!isset($GLOBALS['__ny_body'])) {
$GLOBALS['__ny_body'] = (string)@file_get_contents('php://input');
}
return $GLOBALS['__ny_body'];
}
/** 校验 CSRF token(写操作调用)。失败返回 false 并应拒绝请求。 */
function ny_csrf_ok(): bool
{
$given = '';
if (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$given = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
} elseif (!empty($_POST['_csrf'])) {
$given = (string)$_POST['_csrf'];
} else {
// JSON body 里的 _csrf(api.php 写接口多走 JSON)
$raw = ny_request_body();
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j) && isset($j['_csrf'])) {
$given = (string)$j['_csrf'];
}
}
}
$token = ny_csrf_token();
return $given !== '' && hash_equals($token, $given);
}
// ---------------- 登录失败限流(已移除) ----------------
// 注:单机内部工具场景下,5 次失败锁 10 分钟的限制常因配置写错/测环境串味而把正常用户挡在门外,
// 且无实际爆破价值(仅内网使用)。应需求已整体移除:登录不再限流,失败只即时提示原因。
/** 判断是否已安装(只看文件 config:base_url 非空即视为已具备连接配置) */
function ny_is_installed(): bool
{
$cfg = ny_load_config();
if (trim((string)($cfg['base_url'] ?? '')) === '') {
return false;
}
return is_file(ny_config_file());
}
// ---------------- 目录初始化 ----------------
foreach (['', '/cache', '/uploads'] as $sub) {
$dir = NY_DATA . $sub;
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
// Apache:禁止直接访问(nginx 不读 .htaccess,需在 server 配置中额外 deny,见 docs/部署与安全)
if (!file_exists($dir . '/.htaccess')) {
@file_put_contents($dir . '/.htaccess', "Deny from all\n");
}
// 兜底:即使目录被索引也不暴露文件清单
if (!file_exists($dir . '/index.html')) {
@file_put_contents($dir . '/index.html', "<!doctype html><meta charset=\"utf-8\"><title>403 Forbidden</title><h1>403 Forbidden</h1>\n");
}
}
// ---------------- 默认配置 ----------------
function ny_default_config(): array
{
return [
'base_url' => 'https://exam.nxxy335.top',
'api_prefix' => '/api',
'username' => 'admin',
'password' => '123456',
'verify_ssl' => true,
'timeout' => 20,
'refresh_interval' => 15,
'page_size' => 200,
'include_temp_save' => false,
'dedupe' => true,
'name_keywords' => '姓名,名字,学生姓名,考生姓名,您的姓名,姓名:,姓名:',
'full_score' => 100,
// 全局默认合格率(百分比,如 60 表示"≥满分的 60%"算合格)。
// 每份试卷可在主页面单独覆盖(project_config),试卷之间互相隔离。
'pass_rate' => 60,
'layout' => 'default',
// 历史成绩展示方式:click=点击成员弹窗查看(默认);inline=成绩明细内直接展开每次考试
'history_display' => 'click',
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
'export_sort' => 'time',
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
'cache_ttl' => 15,
];
}
function ny_config_file(): string
{
return NY_DATA . '/config.json';
}
/**
* 配置来源:db | file | default
* (default 表示既没读到 DB 也没读到文件——此时账号密码是出厂默认值,几乎必然连不上卷王)
*/
function ny_config_source(): string
{
return (string)($GLOBALS['__ny_cfg_source'] ?? 'default');
}
function ny_load_config(): array
{
$GLOBALS['__ny_cfg_source'] = 'default';
$cfg = ny_default_config();
$file = ny_config_file();
$saved = null;
// 1) 优先读 SQLite(data/ny.db 的 ny_settings.config)
if (class_exists('Db') && Db::pdo() !== null) {
$raw = Db::settingsGet('config', '');
if ($raw !== '') {
$j = json_decode($raw, true);
if (is_array($j)) {
$saved = $j;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
// DB 可用但尚无记录 → 把已有 config.json 迁进来(老数据不丢,只迁一次)
if ($saved === null) {
$fromFile = ny_read_json($file, null);
if (is_array($fromFile)) {
Db::settingsSet('config', json_encode($fromFile, JSON_UNESCAPED_UNICODE));
$saved = $fromFile;
$GLOBALS['__ny_cfg_source'] = 'db';
}
}
}
// 2) DB 不可用时退回文件
if ($saved === null) {
$saved = ny_read_json($file, null);
if (is_array($saved)) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
}
if (is_array($saved)) {
foreach ($cfg as $k => $v) {
if (array_key_exists($k, $saved)) {
$cfg[$k] = is_bool($v) ? (bool)$saved[$k] : $saved[$k];
}
}
}
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
return $cfg;
}
function ny_save_config(array $cfg): bool
{
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
$ok = false;
if (class_exists('Db') && Db::pdo() !== null) {
try {
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
$GLOBALS['__ny_cfg_source'] = 'db';
$ok = true;
} catch (Throwable $e) {
$ok = false;
}
}
$fileOk = ny_write_json(ny_config_file(), $cfg);
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
if ($fileOk && is_file(ny_config_file())) {
@chmod(ny_config_file(), 0600);
}
if (!$ok) {
$GLOBALS['__ny_cfg_source'] = 'file';
}
return $ok || $fileOk;
}
/**
* 解析「本卷满分 + 合格率」:
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
*
* @return array{0:int,1:float} [满分, 合格率百分比(如 60 表示 60%)]
*/
function ny_resolve_score_cfg(string $projectId, array $c): array
{
$pCfg = class_exists('DataService') ? DataService::projectConfig($projectId) : [];
$fs = (int)($pCfg['full_score'] ?? 0);
if ($fs < 1) {
$fs = (int)($c['full_score'] ?? 100);
}
if ($fs < 1) {
$fs = 100;
}
$pr = $pCfg['pass_rate'] ?? null;
if ($pr === null || $pr === '') {
$pr = $c['pass_rate'] ?? 60;
}
$pr = (float)$pr;
if ($pr <= 0 || $pr > 100) {
$pr = 60;
}
return [$fs, $pr];
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
function ny_str(mixed $v): string
{
if ($v === null || is_array($v) || is_object($v)) {
return '';
}
return trim((string)$v);
}
// ---------------- JSON 文件读写 ----------------
function ny_read_json(string $file, mixed $default = null): mixed
{
if (!is_file($file)) {
return $default;
}
$raw = @file_get_contents($file);
if ($raw === false || trim($raw) === '') {
return $default;
}
$data = json_decode($raw, true);
return $data === null ? $default : $data;
}
function ny_write_json(string $file, mixed $data): bool
{
$tmp = $file . '.tmp';
$ok = @file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
if ($ok === false) {
return false;
}
return @rename($tmp, $file) || @copy($tmp, $file);
}
// ---------------- 输出 ----------------
function ny_json(mixed $data, int $http = 200): void
{
http_response_code($http);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
}
function ny_ok(mixed $data = null): void
{
ny_json(['ok' => true, 'data' => $data]);
exit;
}
function ny_fail(string $msg, mixed $extra = null): void
{
ny_json(['ok' => false, 'msg' => $msg, 'extra' => $extra]);
exit;
}
// ---------------- 工具函数 ----------------
/** 规范化姓名:去空白、去全角空格、统一大小写 */
function ny_norm_name(string $s): string
{
$s = str_replace(["\xC2\xA0", ' '], '', $s);
$s = preg_replace('/\s+/u', '', $s) ?? '';
return mb_strtolower(trim($s));
}
/** 把任意结构的答卷值压平成字符串 */
function ny_flatten_value(mixed $v): string
{
if ($v === null) {
return '';
}
if (is_bool($v)) {
return $v ? '1' : '';
}
if (is_int($v) || is_float($v)) {
return trim((string)$v);
}
if (is_string($v)) {
return trim($v);
}
if (is_array($v)) {
foreach (['value', 'text', 'label', 'name', 'content', 'title'] as $k) {
if (isset($v[$k]) && (is_string($v[$k]) || is_numeric($v[$k]))) {
return trim((string)$v[$k]);
}
}
$parts = [];
foreach ($v as $item) {
$s = ny_flatten_value($item);
if ($s !== '') {
$parts[] = $s;
}
}
return implode(',', $parts);
}
return trim((string)$v);
}
/** 时间字段 -> 时间戳(秒) */
function ny_to_timestamp(mixed $v): int
{
if ($v === null || $v === '') {
return 0;
}
if (is_numeric($v)) {
$n = (int)$v;
if ($n > 1000000000000) { // 毫秒
return intdiv($n, 1000);
}
return $n;
}
$t = strtotime((string)$v);
return $t === false ? 0 : $t;
}
/** 秒 -> 12分34秒 */
function ny_format_duration(?int $sec): string
{
if ($sec === null || $sec < 0) {
return '-';
}
if ($sec < 60) {
return $sec . '秒';
}
$m = intdiv($sec, 60);
$s = $sec % 60;
if ($m < 60) {
return $m . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
}
$h = intdiv($m, 60);
return $h . '小时' . str_pad((string)($m % 60), 2, '0', STR_PAD_LEFT) . '分' . str_pad((string)$s, 2, '0', STR_PAD_LEFT) . '秒';
}
// ---------------- 类自动加载 ----------------
spl_autoload_register(function (string $class): void {
$map = [
'XlsxReader' => NY_ROOT . '/lib/XlsxReader.php',
'XlsxWriter' => NY_ROOT . '/lib/XlsxWriter.php',
'SurveyKingClient' => NY_ROOT . '/lib/SurveyKingClient.php',
'Roster' => NY_ROOT . '/lib/Roster.php',
'DataService' => NY_ROOT . '/lib/DataService.php',
'DataCache' => NY_ROOT . '/lib/DataCache.php',
'Db' => NY_ROOT . '/lib/Db.php',
];
if (isset($map[$class]) && is_file($map[$class])) {
require $map[$class];
}
});