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:
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* 数据结果缓存(短期 TTL)
|
||||
*
|
||||
* 目的:拉取卷王全量答卷是重操作(多次 HTTP + 大量 json_decode),
|
||||
* 多个老师同时查看同一套卷、或页面按 5~15 秒自动刷新时,会重复打卷王。
|
||||
* 这里把 DataService::build() 的结果按「项目 + 配置 + 名单指纹」缓存一小段时间。
|
||||
*
|
||||
* 失效保证:名单变化(Roster::fingerprint)、连接/去重等配置变化都会改变 key。
|
||||
* 另外可显式 force=1 跳过缓存(前端"立即刷新"按钮使用)。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DataCache
|
||||
{
|
||||
private static function dir(): string
|
||||
{
|
||||
return NY_DATA . '/cache';
|
||||
}
|
||||
|
||||
/** 由若干要素拼成缓存 key */
|
||||
public static function key(array $parts): string
|
||||
{
|
||||
return md5(implode("\x1f", array_map(static fn($v) => (string)$v, $parts)));
|
||||
}
|
||||
|
||||
private static function path(string $key): string
|
||||
{
|
||||
return self::dir() . '/data_' . $key . '.json';
|
||||
}
|
||||
|
||||
private static function lockPath(string $key): string
|
||||
{
|
||||
return self::dir() . '/data_' . $key . '.lock';
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取缓存;不存在 / 过期 / 损坏都返回 null
|
||||
* @return array{at:int,result:array}|null
|
||||
*/
|
||||
public static function read(string $key, int $ttl): ?array
|
||||
{
|
||||
$f = self::path($key);
|
||||
if (!is_file($f)) {
|
||||
return null;
|
||||
}
|
||||
$raw = @file_get_contents($f);
|
||||
if ($raw === false || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
$d = json_decode($raw, true);
|
||||
if (!is_array($d) || !isset($d['at']) || !is_array($d['result'])) {
|
||||
return null;
|
||||
}
|
||||
if ($ttl > 0 && (time() - (int)$d['at']) > $ttl) {
|
||||
return null;
|
||||
}
|
||||
return ['at' => (int)$d['at'], 'result' => $d['result']];
|
||||
}
|
||||
|
||||
public static function write(string $key, array $result): bool
|
||||
{
|
||||
if (!is_dir(self::dir())) {
|
||||
@mkdir(self::dir(), 0775, true);
|
||||
}
|
||||
$tmp = self::path($key) . '.tmp';
|
||||
$ok = @file_put_contents($tmp, json_encode(
|
||||
['at' => time(), 'result' => $result],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
), LOCK_EX);
|
||||
if ($ok === false) {
|
||||
return false;
|
||||
}
|
||||
return @rename($tmp, self::path($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取构建锁(防止并发惊群:同一 key 只有一个请求真正去拉卷王)。
|
||||
*
|
||||
* @return resource|null 拿到锁返回句柄(需配合 releaseLock 释放);拿不到返回 null
|
||||
*/
|
||||
public static function acquireLock(string $key)
|
||||
{
|
||||
if (!is_dir(self::dir())) {
|
||||
@mkdir(self::dir(), 0775, true);
|
||||
}
|
||||
$fh = @fopen(self::lockPath($key), 'c');
|
||||
if ($fh === false) {
|
||||
return null;
|
||||
}
|
||||
if (!flock($fh, LOCK_EX | LOCK_NB)) {
|
||||
fclose($fh);
|
||||
return null; // 已有其它请求在构建
|
||||
}
|
||||
return $fh;
|
||||
}
|
||||
|
||||
public static function releaseLock($fh): void
|
||||
{
|
||||
if (is_resource($fh)) {
|
||||
@flock($fh, LOCK_UN);
|
||||
@fclose($fh);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空所有数据缓存(名单变更 / 手动清缓存时使用) */
|
||||
public static function clear(): int
|
||||
{
|
||||
$n = 0;
|
||||
foreach (glob(self::dir() . '/data_*.json') ?: [] as $f) {
|
||||
if (@unlink($f)) {
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user