Files
ny-exam-platform/lib/DataCache.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

118 lines
3.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
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
/**
* 数据结果缓存(短期 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;
}
}