feat: 完善/优化/新增大批次 + 安全加固 + 性能优化

- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
This commit is contained in:
2026-09-10 17:59:45 +08:00
parent 87750b20c4
commit 0fd880624f
14 changed files with 1784 additions and 196 deletions
+91
View File
@@ -104,6 +104,91 @@ function ny_logout(): void
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
{
@@ -148,6 +233,10 @@ function ny_default_config(): array
'history_display' => 'click',
// 成绩导出排序:time=按提交时间降序(默认);sid=按学号(同学号内按提交时间降序)
'export_sort' => 'time',
// 数据结果缓存秒数:0=每次实时拉取;建议 10~30。
// 拉取卷王全量答卷是重操作(多次 HTTP + 大量解码),缓存可避免多人同时刷新时重复打卷王。
// 名单一变指纹就变会自动失效;点"立即刷新"也会 force=1 跳过缓存。
'cache_ttl' => 15,
];
}
@@ -330,6 +419,8 @@ spl_autoload_register(function (string $class): void {
'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])) {