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

309 lines
11 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
/**
* 学号名单管理
* 存储:data/roster.json -> {"students":[{"sid":"1","name":"张三","cls":"计算机2401"}], "updated_at":"..."}
* cls 为班级(可空),用于一份考试涉及多个班级时区分。
*/
declare(strict_types=1);
class Roster
{
private static function file(): string
{
return NY_DATA . '/roster.json';
}
/** 数据库是否可作为名单后端 */
private static function useDb(): bool
{
return class_exists('Db') && Db::pdo() !== null;
}
/** @return array<int,array{sid:string,name:string,cls:string}> */
public static function all(): array
{
if (self::useDb()) {
return Db::rosterAll();
}
$d = ny_read_json(self::file(), null);
if (!is_array($d) || !isset($d['students']) || !is_array($d['students'])) {
return [];
}
$out = [];
foreach ($d['students'] as $s) {
if (!is_array($s)) {
continue;
}
$out[] = [
'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'] ?? '')),
];
}
return $out;
}
public static function meta(): array
{
$d = ny_read_json(self::file(), null);
return [
'count' => self::count(),
'updated_at' => self::useDb() ? date('Y-m-d H:i:s') : (is_array($d) ? (string)($d['updated_at'] ?? '') : ''),
];
}
public static function save(array $students): bool
{
$clean = [];
foreach ($students as $s) {
$sid = trim((string)($s['sid'] ?? ''));
$name = trim((string)($s['name'] ?? ''));
if ($sid === '' && $name === '') {
continue;
}
$clean[] = [
'sid' => $sid,
'name' => $name,
'cls' => trim((string)($s['cls'] ?? '')),
'grp' => trim((string)($s['grp'] ?? '')),
'login' => trim((string)($s['login'] ?? '')),
];
}
if (self::useDb()) {
Db::rosterReplace($clean);
return true;
}
return ny_write_json(self::file(), [
'students' => $clean,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
public static function clear(): bool
{
if (self::useDb()) {
Db::rosterReplace([]);
return true;
}
return ny_write_json(self::file(), ['students' => [], 'updated_at' => date('Y-m-d H:i:s')]);
}
public static function count(): int
{
if (self::useDb()) {
return Db::rosterCount();
}
return count(self::all());
}
/**
* 名单指纹:名单内容一变就变,用于数据缓存 key。
* 只取「人数 + 文件修改时间 + 内容的轻量哈希」,避免每次全量序列化。
*/
public static function fingerprint(): string
{
if (self::useDb()) {
return 'db:' . self::count();
}
$f = self::file();
$mt = is_file($f) ? (string)filemtime($f) : '0';
$sz = is_file($f) ? (string)filesize($f) : '0';
$d = ny_read_json($f, null);
$hash = is_array($d) ? md5(json_encode($d['students'] ?? [], JSON_UNESCAPED_UNICODE)) : '0';
return $mt . ':' . $sz . ':' . $hash;
}
/**
* 首次使用时,若名单为空且存在 导入模板.xlsx,自动导入
*/
public static function ensureSeeded(): bool
{
if (self::count() > 0) {
return false;
}
$candidates = [
dirname(NY_ROOT) . '/导入模板.xlsx',
NY_ROOT . '/导入模板.xlsx',
NY_DATA . '/导入模板.xlsx',
];
foreach ($candidates as $f) {
if (is_file($f)) {
try {
$res = self::importFile($f, 'xlsx');
return $res['count'] > 0;
} catch (Throwable $e) {
// 忽略
}
}
}
return false;
}
/**
* 解析上传文件 -> 学生数组
* @return array{count:int,students:array}
*/
/**
* 解析上传文件 -> 学生数组
* @return array{count:int,skipped:int,duplicates:int,headers:array,students:array}
*/
public static function importFile(string $file, string $ext = ''): array
{
if ($ext === '') {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
}
$rows = [];
if ($ext === 'xlsx' || $ext === 'xlsm') {
$rows = XlsxReader::read($file);
} elseif ($ext === 'csv' || $ext === 'txt') {
$rows = self::parseCsv($file);
} else {
// 尝试按 xlsx 解析,失败再按 csv
try {
$rows = XlsxReader::read($file);
} catch (Throwable $e) {
$rows = self::parseCsv($file);
}
}
if ($rows === []) {
throw new RuntimeException('文件为空或无法解析(请确认是 xlsx/csv 且包含数据行)');
}
$meta = [];
$students = self::rowsToStudents($rows, $meta);
return [
'count' => count($students),
'skipped' => $meta['skipped'],
'duplicates' => $meta['duplicates'],
'headers' => $meta['headers'],
'students' => $students,
];
}
public static function parseCsv(string $file): array
{
$raw = (string)@file_get_contents($file);
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
$rows = [];
if (($fh = fopen('php://memory', 'r+')) === false) {
return [];
}
fwrite($fh, $raw);
rewind($fh);
while (($line = fgetcsv($fh, 0, ',')) !== false) {
if ($line === [null] || (count($line) === 1 && trim((string)$line[0]) === '')) {
continue;
}
$rows[] = array_map(static fn($v) => self::conv((string)$v), $line);
}
fclose($fh);
if ($rows === []) {
// 可能是 tab 分隔
foreach (preg_split('/\r?\n/', $raw) ?: [] as $line) {
if (trim($line) === '') {
continue;
}
$rows[] = array_map([self::class, 'conv'], explode("\t", $line));
}
}
return $rows;
}
private static function conv(string $v): string
{
$v = trim($v);
$enc = mb_detect_encoding($v, ['UTF-8', 'GB18030', 'GBK', 'BIG5'], true);
if ($enc !== false && strtoupper($enc) !== 'UTF-8') {
$v = mb_convert_encoding($v, 'UTF-8', $enc);
}
return $v;
}
/**
* 识别 学号/姓名/班级/组别/登陆账号 列
* 登陆账号仅用于随机抽取页导出白名单,界面任何位置都不显示
* @param array $rows
* @param array $meta 输出:['skipped'=>int,'duplicates'=>int,'headers'=>array]
* @return array<int,array{sid:string,name:string,cls:string,grp:string,login:string}>
*/
public static function rowsToStudents(array $rows, array &$meta = []): array
{
$meta = ['skipped' => 0, 'duplicates' => 0, 'headers' => []];
$sidCol = -1;
$nameCol = -1;
$clsCol = -1;
$grpCol = -1;
$loginCol = -1;
$headerIdx = -1;
// 找表头:同一行需同时命中「学号类」与「姓名类」关键词(容错全角空格/间隔)
foreach ($rows as $i => $row) {
$joined = implode('|', array_map('strval', $row));
if (preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|no\.?|id/i', $joined)
&& preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏|成\s*员/i', $joined)) {
$headerIdx = $i;
break;
}
}
$start = 0;
if ($headerIdx >= 0) {
$start = $headerIdx + 1;
foreach ($rows[$headerIdx] as $j => $cell) {
$c = (string)$cell;
if ($sidCol < 0 && preg_match('/学\s*号|编\s*号|序\s*号|学\s*员\s*号|号\s*码|^\s*no\.?\s*$|^\s*id\s*$/i', $c)) {
$sidCol = $j;
}
if ($nameCol < 0 && preg_match('/姓\s*名|名\s*字|学\s*生\s*姓\s*名|姓\s*氏/i', $c)) {
$nameCol = $j;
}
if ($clsCol < 0 && preg_match('/班\s*级|班级|class/i', $c)) {
$clsCol = $j;
}
if ($grpCol < 0 && preg_match('/组\s*别|分\s*组|组\s*次|^\s*组\s*$|group/i', $c)) {
$grpCol = $j;
}
if ($loginCol < 0 && preg_match('/登\s*[陆录]\s*账\s*号|登\s*[陆录]\s*账号|账\s*号|手\s*机\s*号|手\s*机/i', $c)) {
$loginCol = $j;
}
}
$meta['headers'] = [
'sid' => $sidCol, 'name' => $nameCol, 'cls' => $clsCol, 'grp' => $grpCol, 'login' => $loginCol,
];
}
if ($sidCol < 0) {
$sidCol = 0;
}
if ($nameCol < 0) {
$nameCol = $sidCol === 0 ? 1 : 0;
}
$out = [];
$seen = [];
for ($i = $start; $i < count($rows); $i++) {
$row = $rows[$i];
$sid = trim((string)($row[$sidCol] ?? ''));
$name = trim((string)($row[$nameCol] ?? ''));
if ($sid === '' && $name === '') {
$meta['skipped']++;
continue;
}
// 学号/登陆账号去掉 Excel 数字尾巴(如 2024012001.0 / 13912687810.0)
$sid = preg_replace('/\.0+$/', '', $sid) ?? $sid;
$cls = $clsCol >= 0 ? trim((string)($row[$clsCol] ?? '')) : '';
$grp = $grpCol >= 0 ? trim((string)($row[$grpCol] ?? '')) : '';
$login = $loginCol >= 0 ? trim((string)($row[$loginCol] ?? '')) : '';
$login = preg_replace('/\.0+$/', '', $login) ?? $login;
// 同一条学号多次出现:保留首次,其余记重复跳过(避免看板/统计被重复计入)
if ($sid !== '' && isset($seen[$sid])) {
$meta['duplicates']++;
continue;
}
$seen[$sid] = true;
$out[] = ['sid' => $sid, 'name' => $name, 'cls' => $cls, 'grp' => $grp, 'login' => $login];
}
return $out;
}
}