= 8.0 */ declare(strict_types=1); const NY_APP_NAME = 'NY问卷考试系统数据平台'; const NY_VERSION = '1.0.0'; define('NY_ROOT', str_replace('\\', '/', __DIR__)); define('NY_DATA', NY_ROOT . '/data'); // ---------------- 环境预检 ---------------- if (PHP_VERSION_ID < 80000) { http_response_code(500); header('Content-Type: text/html; charset=utf-8'); echo '

环境不满足

本平台需要 PHP 8.0 及以上,当前版本:' . PHP_VERSION . '

'; 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 '

缺少 PHP 扩展

请在 php.ini 中启用:' . implode('、', $missing) . '

' . '

Windows:去掉 extension=' . $missing[0] . ' 前面的分号并重启服务。' . '(zip 扩展可选,未启用时会用内置解析器读取 xlsx)

'; 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']; } /** 校验 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 = (string)file_get_contents('php://input'); 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); } // ---------------- 登录失败限流 ---------------- /** * 登录失败限流:按 IP 记录连续失败次数与时间戳(写入 data/login_fail.json)。 * 5 次失败后锁 10 分钟。仅防御暴力破解,不做超强语义(单机小工具足够)。 */ function ny_login_throttle_check(): array { $path = NY_DATA . '/login_fail.json'; $ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $rec = ['count' => 0, 'until' => 0]; if (is_file($path)) { $j = json_decode((string)file_get_contents($path), true); if (is_array($j) && isset($j[$ip])) { $rec = array_merge($rec, $j[$ip]); } } return $rec; } function ny_login_throttle_hit(): void { $path = NY_DATA . '/login_fail.json'; $ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $all = is_file($path) ? (json_decode((string)file_get_contents($path), true) ?: []) : []; $rec = isset($all[$ip]) ? $all[$ip] : ['count' => 0, 'until' => 0]; $rec['count'] = ((int)($rec['count'] ?? 0)) + 1; if ($rec['count'] >= 5) { $rec['count'] = 0; // 重新计数,进入冷却 $rec['until'] = time() + 600; // 锁 10 分钟 } $all[$ip] = $rec; @file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX); } function ny_login_throttle_reset(): void { $path = NY_DATA . '/login_fail.json'; $ip = (string)($_SERVER['REMOTE_ADDR'] ?? 'unknown'); if (!is_file($path)) { return; } $all = json_decode((string)file_get_contents($path), true) ?: []; if (isset($all[$ip])) { unset($all[$ip]); @file_put_contents($path, json_encode($all, JSON_UNESCAPED_UNICODE), LOCK_EX); } } /** 判断是否已安装(只看文件 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', '/snapshots'] 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', "403 Forbidden

403 Forbidden

\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); 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', 'Snapshot' => NY_ROOT . '/lib/Snapshot.php', 'Db' => NY_ROOT . '/lib/Db.php', ]; if (isset($map[$class]) && is_file($map[$class])) { require $map[$class]; } });