Files
ny-exam-platform/bootstrap.php
T
nxxy335 0fd880624f feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
2026-09-10 17:59:45 +08:00

430 lines
13 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__));
define('NY_DATA', NY_ROOT . '/data');
// ---------------- 环境预检 ----------------
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'];
}
/** 校验 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'] as $sub) {
$dir = NY_DATA . $sub;
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
}
// 保护数据目录(Apache)
if (!file_exists(NY_DATA . '/.htaccess')) {
@file_put_contents(NY_DATA . '/.htaccess', "Deny from all\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,
'full_scores' => [],
'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';
// 默认配置 + 文件覆盖(DB 后端本批次已下线,统一走文件存储)
$cfg = ny_default_config();
$saved = ny_read_json(ny_config_file(), null);
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];
}
}
$GLOBALS['__ny_cfg_source'] = 'file';
}
$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
{
$GLOBALS['__ny_cfg_source'] = 'file';
return ny_write_json(ny_config_file(), $cfg);
}
/** 安全的字符串化(避免数组/对象转字符串报错) */
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];
}
});