feat: 成绩明细打印 + SQLite + 每卷独立配置(满分/合格率/免考) + 导航精简
- 成绩明细打印:默认成绩降序,可选按班级/组别分组对比(段首小计) - 修复看板退出全屏无反应(exitFullscreen 挂在 document 而非 documentElement) - 导航栏精简:三页统一为单一主标题,移除 Logo 与副标题 - 优化三端布局:重排断点、竖排隐藏 spacer、触摸设备热区 - 全面启用 SQLite(data/ny.db):配置/名单/每卷配置入库,无 pdo_sqlite 自动降级文件 - 新增独立合格率配置(全局 + 每卷),合格率不再写死 60% - 新增免考配置,仅对当前试卷生效,试卷间隔离 - 试卷独立满分从设置移出,改到主页面「试卷设置」 - 测试:新增 4 条每卷配置隔离断言,全套 113 项通过
This commit is contained in:
@@ -79,11 +79,73 @@ class DataService
|
||||
/**
|
||||
* 组装结果
|
||||
*/
|
||||
/**
|
||||
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
|
||||
* 优先 SQLite(ny_project_config 表);不可用时退回 config.json 里的 project_config 字段,
|
||||
* 保证没有 pdo_sqlite 的环境也能用(只是隔离粒度仍在,只是落到文件)。
|
||||
*
|
||||
* @return array{full_score:?int,pass_rate:?float,exempt:array<int,string>}
|
||||
*/
|
||||
public static function projectConfig(string $projectId): array
|
||||
{
|
||||
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||
if ($projectId === '') {
|
||||
return $def;
|
||||
}
|
||||
if (class_exists('Db') && Db::pdo() !== null) {
|
||||
return Db::projectConfigGet($projectId);
|
||||
}
|
||||
// 文件回退
|
||||
$all = [];
|
||||
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||
if (is_array($saved)) {
|
||||
$all = $saved;
|
||||
}
|
||||
$one = $all[$projectId] ?? null;
|
||||
return is_array($one) ? array_merge($def, $one) : $def;
|
||||
}
|
||||
|
||||
/** 写入某份试卷的独立配置 */
|
||||
public static function projectConfigSave(string $projectId, array $cfg): void
|
||||
{
|
||||
if ($projectId === '') {
|
||||
return;
|
||||
}
|
||||
if (class_exists('Db') && Db::pdo() !== null) {
|
||||
Db::projectConfigSet($projectId, $cfg);
|
||||
return;
|
||||
}
|
||||
$all = [];
|
||||
$saved = ny_read_json(NY_DATA . '/project_config.json', null);
|
||||
if (is_array($saved)) {
|
||||
$all = $saved;
|
||||
}
|
||||
$all[$projectId] = $cfg;
|
||||
ny_write_json(NY_DATA . '/project_config.json', $all);
|
||||
}
|
||||
|
||||
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
|
||||
{
|
||||
$client = new SurveyKingClient($cfg);
|
||||
|
||||
// 每卷独立配置(满分 / 合格率 / 免考名单):试卷之间互相隔离
|
||||
$pCfg = self::projectConfig($projectId);
|
||||
$exempt = [];
|
||||
foreach ((array)($pCfg['exempt'] ?? []) as $sid) {
|
||||
$sid = trim((string)$sid);
|
||||
if ($sid !== '') {
|
||||
$exempt[$sid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$students = Roster::all();
|
||||
// 免考:从名单中剔除,使其既不计入「总人数」也不计入「缺考」
|
||||
if ($exempt !== []) {
|
||||
$students = array_values(array_filter(
|
||||
$students,
|
||||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||||
));
|
||||
}
|
||||
// 姓名 -> 学号 / 班级(规范化)
|
||||
$studentInfo = [];
|
||||
foreach ($students as $s) {
|
||||
@@ -313,6 +375,12 @@ class DataService
|
||||
'anomalies' => $anomalies,
|
||||
'roster' => $students,
|
||||
'roster_count' => count($students),
|
||||
// 本卷独立配置(前端据此回显满分/合格率/免考);full_score 已按本卷解析
|
||||
'project_cfg' => [
|
||||
'full_score' => $pCfg['full_score'] ?? null,
|
||||
'pass_rate' => $pCfg['pass_rate'] ?? null,
|
||||
'exempt' => array_values(array_map('strval', array_keys($exempt))),
|
||||
],
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
+379
-51
@@ -1,83 +1,411 @@
|
||||
<?php
|
||||
/**
|
||||
* 数据库适配层(旧:pgsql/mysql/sqlite 三方言支持)
|
||||
* 本批次已下线,仅保留文件存储。如未来需要接入 DB,请在此文件实现 PDO 单例。
|
||||
* 数据库适配层 —— SQLite 单文件后端(零配置、无需额外服务)
|
||||
*
|
||||
* 设计要点:
|
||||
* 1. 数据文件固定为 data/ny.db(SQLite),不需要用户配置任何连接信息。
|
||||
* 2. 若环境没有 pdo_sqlite 扩展,整个层自动降级为「不可用」,
|
||||
* 上层(Roster / 配置)会回退到 JSON 文件存储 —— 保证老环境不会挂。
|
||||
* 3. 所有建表/加列都是幂等的(IF NOT EXISTS / 探测列后 ALTER),可重复执行。
|
||||
* 4. 首次启用时自动把已有的 data/roster.json、data/config.json 迁移进来,
|
||||
* 老数据不丢(迁移只发生在目标表为空时)。
|
||||
*
|
||||
* 表结构:
|
||||
* ny_settings (k TEXT PK, v TEXT) 全局配置(键 'config' 存整份配置 JSON)
|
||||
* ny_roster (sid TEXT PK, name, cls, grp, login)
|
||||
* ny_project_config (project_id TEXT PK, config_json TEXT) 每份试卷独立配置(满分/合格率/免考,试卷间隔离)
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
class Db
|
||||
{
|
||||
/** @var PDO|null 当前不可用,始终返回 null */
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isConnected(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function driver(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function lastError(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function params(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function connect(array $p): PDO
|
||||
{
|
||||
throw new RuntimeException('数据库后端已下线,请使用文件存储(data/config.json / data/roster.json)');
|
||||
}
|
||||
|
||||
public static function pdo(): ?PDO
|
||||
{
|
||||
return null;
|
||||
}
|
||||
private static string $err = '';
|
||||
private static bool $initTried = false;
|
||||
|
||||
public static function prefix(): string
|
||||
{
|
||||
return 'ny_';
|
||||
}
|
||||
|
||||
public static function migrate(): int
|
||||
/** 数据文件路径 */
|
||||
public static function dbFile(): string
|
||||
{
|
||||
return 0;
|
||||
return NY_DATA . '/ny.db';
|
||||
}
|
||||
|
||||
public static function ensureRosterLogin(): void {}
|
||||
/** 是否具备启用条件(有 pdo_sqlite 扩展) */
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return extension_loaded('pdo_sqlite');
|
||||
}
|
||||
|
||||
/** 是否已真正连上 */
|
||||
public static function isConnected(): bool
|
||||
{
|
||||
return self::pdo() !== null;
|
||||
}
|
||||
|
||||
public static function driver(): string
|
||||
{
|
||||
return self::pdo() !== null ? 'sqlite' : '';
|
||||
}
|
||||
|
||||
public static function lastError(): string
|
||||
{
|
||||
return self::$err;
|
||||
}
|
||||
|
||||
/** 连接参数(SQLite 固定,保留接口以兼容既有调用) */
|
||||
public static function params(): array
|
||||
{
|
||||
return ['driver' => 'sqlite', 'file' => self::dbFile()];
|
||||
}
|
||||
|
||||
public static function saveParams(array $p): bool
|
||||
{
|
||||
return true; // SQLite 无需外部配置
|
||||
}
|
||||
|
||||
/** 建立/复用连接;失败返回 null 而不抛异常(上层要能优雅降级) */
|
||||
public static function connect(array $p = []): PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
if (!self::isConfigured()) {
|
||||
self::$err = 'PHP 未启用 pdo_sqlite 扩展,已回退到文件存储';
|
||||
throw new RuntimeException(self::$err);
|
||||
}
|
||||
$file = self::dbFile();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $file, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
// WAL + 合理超时,避免多人同时刷新时 "database is locked"
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA synchronous=NORMAL');
|
||||
$pdo->exec('PRAGMA busy_timeout=5000');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
self::$pdo = $pdo;
|
||||
self::$err = '';
|
||||
self::migrate();
|
||||
return $pdo;
|
||||
} catch (Throwable $e) {
|
||||
self::$pdo = null;
|
||||
self::$err = $e->getMessage();
|
||||
throw new RuntimeException('SQLite 连接失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取连接(不可用返回 null) */
|
||||
public static function pdo(): ?PDO
|
||||
{
|
||||
if (self::$pdo instanceof PDO) {
|
||||
return self::$pdo;
|
||||
}
|
||||
if (self::$initTried) {
|
||||
return null; // 已经试过且失败,不再反复重试(每次请求最多试一次)
|
||||
}
|
||||
self::$initTried = true;
|
||||
try {
|
||||
return self::connect();
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 建表 + 索引(幂等,可重复调用) */
|
||||
public static function migrate(): int
|
||||
{
|
||||
$pdo = self::$pdo;
|
||||
if (!$pdo) {
|
||||
return 0;
|
||||
}
|
||||
$p = self::prefix();
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}settings (
|
||||
k TEXT PRIMARY KEY,
|
||||
v TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}roster (
|
||||
sid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
cls TEXT NOT NULL DEFAULT '',
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
login TEXT NOT NULL DEFAULT '',
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$p}project_config (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
)");
|
||||
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_{$p}roster_cls ON {$p}roster(cls)");
|
||||
self::ensureRosterLogin();
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** 旧库缺少 login 列时补上(幂等) */
|
||||
public static function ensureRosterLogin(): void
|
||||
{
|
||||
$pdo = self::$pdo;
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
$p = self::prefix();
|
||||
try {
|
||||
$cols = $pdo->query("PRAGMA table_info({$p}roster)")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$has = false;
|
||||
foreach ($cols as $c) {
|
||||
if (($c['name'] ?? '') === 'login') {
|
||||
$has = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$has) {
|
||||
$pdo->exec("ALTER TABLE {$p}roster ADD COLUMN login TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 设置 ----------------
|
||||
|
||||
public static function settingsAll(): array
|
||||
{
|
||||
return [];
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT k, v FROM {$p}settings");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[(string)$r['k']] = (string)$r['v'];
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function settingsSet(string $k, string $v): void {}
|
||||
/** SQLite 用 ON CONFLICT 做 upsert(其它方言见历史版本) */
|
||||
public static function settingsSet(string $k, string $v): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare(
|
||||
"INSERT INTO {$p}settings (k, v, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(k) DO UPDATE SET v=excluded.v, updated_at=excluded.updated_at"
|
||||
);
|
||||
$st->execute([$k, $v, time()]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function settingsGet(string $k, string $def = ''): string
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return $def;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("SELECT v FROM {$p}settings WHERE k = ?");
|
||||
$st->execute([$k]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false ? $def : (string)$v;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return $def;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 名单 ----------------
|
||||
|
||||
public static function rosterAll(): array
|
||||
{
|
||||
return [];
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT sid, name, cls, grp, login FROM {$p}roster ORDER BY sort ASC, sid ASC");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$out[] = [
|
||||
'sid' => (string)($r['sid'] ?? ''),
|
||||
'name' => (string)($r['name'] ?? ''),
|
||||
'cls' => (string)($r['cls'] ?? ''),
|
||||
'grp' => (string)($r['grp'] ?? ''),
|
||||
'login' => (string)($r['login'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function rosterReplace(array $students): void {}
|
||||
/** 全量替换名单(roster_save 语义) */
|
||||
public static function rosterReplace(array $students): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec("DELETE FROM {$p}roster");
|
||||
$st = $pdo->prepare(
|
||||
"INSERT INTO {$p}roster (sid, name, cls, grp, login, sort, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
$i = 0;
|
||||
foreach ($students as $s) {
|
||||
if (!is_array($s)) {
|
||||
continue;
|
||||
}
|
||||
$st->execute([
|
||||
(string)($s['sid'] ?? ''),
|
||||
(string)($s['name'] ?? ''),
|
||||
(string)($s['cls'] ?? ''),
|
||||
(string)($s['grp'] ?? ''),
|
||||
(string)($s['login'] ?? ''),
|
||||
$i++,
|
||||
time(),
|
||||
]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function rosterCount(): int
|
||||
{
|
||||
return 0;
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
return (int)$pdo->query("SELECT COUNT(*) FROM {$p}roster")->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 每卷独立配置(满分 / 合格率 / 免考名单) ----------------
|
||||
|
||||
/** @return array<string,array> 全部试卷配置 [project_id => cfg] */
|
||||
public static function projectConfigAll(): array
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->query("SELECT project_id, config_json FROM {$p}project_config");
|
||||
$out = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$j = json_decode((string)($r['config_json'] ?? '{}'), true);
|
||||
if (is_array($j)) {
|
||||
$out[(string)$r['project_id']] = $j;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array 单卷配置(缺省返回空结构) */
|
||||
public static function projectConfigGet(string $projectId): array
|
||||
{
|
||||
$def = ['full_score' => null, 'pass_rate' => null, 'exempt' => []];
|
||||
if ($projectId === '') {
|
||||
return $def;
|
||||
}
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return $def;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("SELECT config_json FROM {$p}project_config WHERE project_id = ?");
|
||||
$st->execute([$projectId]);
|
||||
$v = $st->fetchColumn();
|
||||
if ($v === false) {
|
||||
return $def;
|
||||
}
|
||||
$j = json_decode((string)$v, true);
|
||||
return is_array($j) ? array_merge($def, $j) : $def;
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
return $def;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入单卷配置(整份覆盖;null 表示"用全局默认") */
|
||||
public static function projectConfigSet(string $projectId, array $cfg): void
|
||||
{
|
||||
if ($projectId === '') {
|
||||
return;
|
||||
}
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare(
|
||||
"INSERT INTO {$p}project_config (project_id, config_json, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(project_id) DO UPDATE SET config_json=excluded.config_json, updated_at=excluded.updated_at"
|
||||
);
|
||||
$st->execute([$projectId, json_encode($cfg, JSON_UNESCAPED_UNICODE), time()]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public static function projectConfigDelete(string $projectId): void
|
||||
{
|
||||
$pdo = self::pdo();
|
||||
if (!$pdo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$p = self::prefix();
|
||||
$st = $pdo->prepare("DELETE FROM {$p}project_config WHERE project_id = ?");
|
||||
$st->execute([$projectId]);
|
||||
} catch (Throwable $e) {
|
||||
self::$err = $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-1
@@ -19,10 +19,49 @@ class Roster
|
||||
return class_exists('Db') && Db::pdo() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次启用 SQLite 时,把已有的 data/roster.json 迁进数据库(老数据不丢)。
|
||||
* 只在「DB 名单为空 且 文件有数据」时执行一次,之后由 DB 全权负责。
|
||||
*/
|
||||
private static function migrateFileToDbOnce(): void
|
||||
{
|
||||
if (!self::useDb()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (Db::rosterCount() > 0) {
|
||||
return; // DB 已有数据,不覆盖
|
||||
}
|
||||
$d = ny_read_json(self::file(), null);
|
||||
if (!is_array($d) || empty($d['students']) || !is_array($d['students'])) {
|
||||
return;
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($d['students'] as $s) {
|
||||
if (!is_array($s)) {
|
||||
continue;
|
||||
}
|
||||
$clean[] = [
|
||||
'sid' => trim((string)($s['sid'] ?? '')),
|
||||
'name' => trim((string)($s['name'] ?? '')),
|
||||
'cls' => trim((string)($s['cls'] ?? '')),
|
||||
'grp' => trim((string)($s['grp'] ?? '')),
|
||||
'login' => trim((string)($s['login'] ?? '')),
|
||||
];
|
||||
}
|
||||
if ($clean !== []) {
|
||||
Db::rosterReplace($clean);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// 迁移失败不影响主流程:仍可退回文件读取
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<int,array{sid:string,name:string,cls:string}> */
|
||||
public static function all(): array
|
||||
{
|
||||
if (self::useDb()) {
|
||||
self::migrateFileToDbOnce();
|
||||
return Db::rosterAll();
|
||||
}
|
||||
$d = ny_read_json(self::file(), null);
|
||||
@@ -105,7 +144,10 @@ class Roster
|
||||
public static function fingerprint(): string
|
||||
{
|
||||
if (self::useDb()) {
|
||||
return 'db:' . self::count();
|
||||
// 人数 + 内容哈希:名单内容变了指纹才变(只靠人数会在"换人"时误命中缓存)
|
||||
$n = self::count();
|
||||
$hash = md5(json_encode(self::all(), JSON_UNESCAPED_UNICODE));
|
||||
return 'db:' . $n . ':' . $hash;
|
||||
}
|
||||
$f = self::file();
|
||||
$mt = is_file($f) ? (string)filemtime($f) : '0';
|
||||
|
||||
Reference in New Issue
Block a user