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

602 lines
24 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
/**
* 数据组装:卷王答卷 -> 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
*/
declare(strict_types=1);
class DataService
{
/** 容器型题目(分页、题组、说明)不参与取值 */
private const CONTAINER_TYPES = [
'pagination', 'page', 'group', 'survey', 'divider', 'tree',
];
/** 题目标题可能是富文本,去掉标签便于展示与匹配 */
private static function cleanTitle(string $t): string
{
$t = strip_tags($t);
$t = html_entity_decode($t, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('/\s+/u', ' ', $t) ?? '');
}
/**
* 展平题目树
* @return array<int,array{id:string,title:string,type:string,path:string}>
*/
public static function flattenQuestions(mixed $node, string $path = ''): array
{
$out = [];
if (!is_array($node)) {
return $out;
}
// children 在根或子节点
$self = $node;
$children = (isset($node['children']) && is_array($node['children'])) ? $node['children'] : null;
$id = isset($self['id']) ? (string)$self['id'] : '';
$title = isset($self['title']) ? self::cleanTitle((string)$self['title']) : '';
$type = isset($self['type']) ? (string)$self['type'] : '';
$isRoot = ($path === '');
if (!$isRoot && $id !== '' && !in_array(strtolower($type), self::CONTAINER_TYPES, true)) {
$out[] = [
'id' => $id,
'title' => $title !== '' ? $title : $id,
'type' => $type,
'path' => $path,
];
}
if ($children !== null) {
foreach ($children as $idx => $child) {
$subPath = $path === '' ? (string)($idx + 1) : $path . '-' . ($idx + 1);
foreach (self::flattenQuestions($child, $subPath) as $q) {
$out[] = $q;
}
}
}
return $out;
}
/** 根据关键词猜测"姓名"题 */
public static function guessNameQuestion(array $questions, string $keywords): array
{
$kws = array_values(array_filter(array_map('trim', preg_split('/[,,]/u', $keywords) ?: [])));
$candidates = [];
foreach ($questions as $q) {
$t = (string)$q['title'];
foreach ($kws as $kw) {
if ($kw !== '' && mb_stripos($t, $kw) !== false) {
$candidates[] = $q;
break;
}
}
}
return $candidates;
}
/**
* 组装结果
*/
public static function build(array $cfg, string $projectId, string $nameQid = ''): array
{
$client = new SurveyKingClient($cfg);
$students = Roster::all();
// 姓名 -> 学号 / 班级(规范化)
$studentInfo = [];
foreach ($students as $s) {
$k = ny_norm_name((string)$s['name']);
if ($k !== '') {
$studentInfo[$k] = [
'sid' => (string)$s['sid'],
'cls' => (string)($s['cls'] ?? ''),
'grp' => (string)($s['grp'] ?? ''),
];
}
}
$project = $client->getProject($projectId);
$survey = $project['survey'] ?? null;
if (is_string($survey) && $survey !== '') {
$decoded = json_decode($survey, true);
if (is_array($decoded)) {
$survey = $decoded;
}
}
$questions = self::flattenQuestions($survey);
$keywords = (string)($cfg['name_keywords'] ?? '姓名');
$nameCandidates = self::guessNameQuestion($questions, $keywords);
$autoQid = $nameCandidates[0]['id'] ?? '';
// 指定优先
$pickedQid = $nameQid !== '' ? $nameQid : $autoQid;
$candidateIds = [];
if ($pickedQid !== '') {
$candidateIds[] = $pickedQid;
}
foreach ($nameCandidates as $q) {
if (!in_array($q['id'], $candidateIds, true)) {
$candidateIds[] = $q['id'];
}
}
$answers = $client->listAnswers($projectId);
$includeTemp = !empty($cfg['include_temp_save']);
$dedupe = !empty($cfg['dedupe']);
$rawRows = []; // 已匹配学号的原始 row(每条 = 一次提交)
$unmatched = [];
foreach ($answers as $a) {
$tempSave = isset($a['tempSave']) ? (int)$a['tempSave'] : 1;
if (!$includeTemp && $tempSave === 0) {
continue; // 跳过暂存
}
$answerMap = isset($a['answer']) && is_array($a['answer']) ? $a['answer'] : [];
// 姓名:优先关键词命中的题目 -> 全量扫描能匹配名单的答案 -> 登录用户名
$name = '';
foreach ($candidateIds as $qid) {
if (isset($answerMap[$qid])) {
$v = ny_flatten_value($answerMap[$qid]);
if ($v !== '') {
$name = $v;
break;
}
}
}
if ($name === '') {
foreach ($questions as $q) {
$qid = (string)$q['id'];
if (!array_key_exists($qid, $answerMap)) {
continue;
}
$v = ny_flatten_value($answerMap[$qid]);
if ($v !== '' && isset($nameMap[ny_norm_name($v)])) {
$name = $v;
break;
}
}
}
if ($name === '' && $questions === []) {
foreach ($answerMap as $v) {
$s = ny_flatten_value($v);
if ($s !== '' && isset($nameMap[ny_norm_name($s)])) {
$name = $s;
break;
}
}
}
if ($name === '') {
$name = ny_str($a['createByName'] ?? '');
}
if ($name === '') {
$name = '(未填写姓名)';
}
// 成绩
$score = null;
if (isset($a['examScore']) && is_numeric($a['examScore'])) {
$score = (float)$a['examScore'];
}
// 提交时间
$ts = ny_to_timestamp($a['createAt'] ?? null);
if ($ts === 0) {
$ts = ny_to_timestamp($a['updateAt'] ?? null);
}
// 考试时长
$ai = $a['metaInfo']['answerInfo'] ?? [];
$start = is_array($ai) ? (int)($ai['startTime'] ?? 0) : 0;
$end = is_array($ai) ? (int)($ai['endTime'] ?? 0) : 0;
$dur = null;
if ($start > 0 && $end > $start) {
$dur = (int)round(($end - $start) / 1000);
}
$key = ny_norm_name($name);
$info = $studentInfo[$key] ?? null;
$sid = $info['sid'] ?? '';
$cls = $info['cls'] ?? '';
$grp = $info['grp'] ?? '';
$row = [
'sid' => $sid,
'name' => $name,
'cls' => $cls,
'grp' => $grp,
'score' => $score,
'submit_ts' => $ts,
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
'duration' => $dur,
'duration_txt'=> ny_format_duration($dur),
'answer_id' => ny_str($a['id'] ?? ''),
'matched' => $sid !== '',
'temp_save' => $tempSave === 0,
];
if ($sid === '') {
$unmatched[] = $row;
continue;
}
$rawRows[] = $row;
}
// 按学号聚合:dedupe=true 仅保留最新一份;false 则每名一行(代表=最高分)
// 并附带 attempts(次数) / attempts_list(每次提交明细),供看板展示"多次刷题"
$agg = self::aggregateByStudent($rawRows, $dedupe);
$rows = $agg['rows'];
$attemptsTotal = $agg['attempts_total'];
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
// 重要:排序键必须用「最近一次提交时间 last_submit_ts」,而不是代表行的 submit_ts。
// 否则当 dedupe=false(代表成绩=最高分)时,学生重考且分数更低,代表行仍是最高分那次,
// submit_ts 不变 → 该行会停在原来的位置只高亮,不会置顶,教师登记时看不到新提交。
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($rows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
if ($d !== 0) {
return $d;
}
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
// 缺考名单
$submittedSids = [];
foreach ($rows as $r) {
$submittedSids[(string)$r['sid']] = true;
}
$absent = [];
foreach ($students as $s) {
if (!isset($submittedSids[(string)$s['sid']])) {
$absent[] = [
'sid' => (string)$s['sid'],
'name' => (string)$s['name'],
'cls' => (string)($s['cls'] ?? ''),
'grp' => (string)($s['grp'] ?? ''),
];
}
}
usort($absent, static function (array $x, array $y): int {
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
// 统计
$scores = array_values(array_filter(array_map(static fn($r) => $r['score'], $rows), static fn($v) => $v !== null));
$durs = array_values(array_filter(array_map(static fn($r) => $r['duration'], $rows), static fn($v) => $v !== null));
$stats = [
'roster_total' => count($students),
'submitted' => count($rows),
'absent' => count($absent),
'unmatched' => count($unmatched),
'submit_rate' => count($students) > 0 ? round(count($rows) / count($students) * 100, 1) : 0,
'avg_score' => $scores !== [] ? round(array_sum($scores) / count($scores), 2) : null,
'max_score' => $scores !== [] ? max($scores) : null,
'min_score' => $scores !== [] ? min($scores) : null,
'avg_duration' => $durs !== [] ? (int)round(array_sum($durs) / count($durs)) : null,
'answer_total' => count($answers),
'attempts_total'=> $attemptsTotal,
];
// 异常检测(防作弊线索,仅供教师人工判断,不做自动判定)
$anomalies = self::detectAnomalies($rows, $stats);
return [
'project' => [
'id' => ny_str($project['id'] ?? $projectId),
'name' => ny_str($project['name'] ?? ''),
'mode' => ny_str($project['mode'] ?? ''),
],
'questions' => $questions,
'name_qid' => $pickedQid,
'name_candidates' => array_map(static fn($q) => ['id' => $q['id'], 'title' => $q['title']], $nameCandidates),
'rows' => $rows,
'absent' => $absent,
'unmatched' => $unmatched,
'stats' => $stats,
'anomalies' => $anomalies,
'roster' => $students,
'roster_count' => count($students),
'updated_at' => date('Y-m-d H:i:s'),
];
}
/**
* 异常检测(防作弊线索):基于聚合后的学生行,输出三类线索供教师人工复核。
* 1) 短时长高得分:用时远低于平均(<平均 40% 且 <180s)却接近满分;
* 2) 高频重考:考试次数明显多于多数人(>1 且 >= 平均次数+3);
* 3) 雷同成绩:多人分数完全相同(疑似互抄,需满足该分数下人数 >=3)。
*
* 返回值:['fast_high'=>[], 'frequent'=>[], 'identical'=>[['score'=>x,'count'=>n,'names'=>[...]]]]
*/
public static function detectAnomalies(array $rows, array $stats): array
{
$avgDur = (int)($stats['avg_duration'] ?? 0);
$fullHint = max(1, (float)($stats['max_score'] ?? 100));
$fastHigh = [];
$frequent = [];
$attemptsAll = [];
foreach ($rows as $r) {
$dur = $r['duration'] ?? null;
$score = $r['score'] ?? null;
$attempts = (int)($r['attempts'] ?? 1);
$attemptsAll[] = $attempts;
// 短时长高得分:有时长记录、明显快于平均、分数 >= 满分 85%
if ($dur !== null && $avgDur > 0 && $dur < max(60, (int)($avgDur * 0.4))
&& $score !== null && $score >= $fullHint * 0.85) {
$fastHigh[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'score' => $score,
'duration' => $dur,
'duration_txt' => (string)($r['duration_txt'] ?? ''),
];
}
// 高频重考:考试次数明显偏多
if ($attempts > 1) {
$frequent[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'attempts' => $attempts,
'best_score' => $r['best_score'] ?? null,
];
}
}
// 高频重考阈值:平均次数 + 3(且至少 4 次才够"高频")
$avgAttempts = $attemptsAll !== [] ? array_sum($attemptsAll) / count($attemptsAll) : 0;
$freqThreshold = max(4, (int)ceil($avgAttempts + 3));
$frequent = array_values(array_filter($frequent, static fn($f) => $f['attempts'] >= $freqThreshold));
usort($frequent, static fn($a, $b) => $b['attempts'] <=> $a['attempts']);
// 雷同成绩:按分数分组,出现 >=3 人同分(且分数不为 null)视为疑似
$scoreGroups = [];
foreach ($rows as $r) {
$score = $r['score'] ?? null;
if ($score === null) {
continue;
}
$key = (string)$score;
$scoreGroups[$key][] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
];
}
$identical = [];
foreach ($scoreGroups as $score => $members) {
if (count($members) >= 3) {
$identical[] = ['score' => (float)$score, 'count' => count($members), 'members' => $members];
}
}
usort($identical, static fn($a, $b) => $b['count'] <=> $a['count']);
return [
'fast_high' => $fastHigh,
'frequent' => $frequent,
'identical' => $identical,
];
}
/**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 attempts 与第几次 idx。
*
* @param array $rows 聚合后的行(每行含 attempts_list)
* @param bool $desc 是否按提交时间降序(新 -> 旧)
* @return array
*/
public static function expandAttempts(array $rows, bool $desc = true): array
{
$out = [];
foreach ($rows as $r) {
$list = (array)($r['attempts_list'] ?? []);
if ($list === []) {
// 没有明细时退回代表行本身
$one = $r;
$one['idx'] = 1;
$one['is_best'] = true;
$out[] = $one;
continue;
}
foreach ($list as $a) {
$out[] = [
'sid' => (string)($r['sid'] ?? ''),
'name' => (string)($r['name'] ?? ''),
'cls' => (string)($r['cls'] ?? ''),
'grp' => (string)($r['grp'] ?? ''),
'score' => $a['score'] ?? null,
'submit_ts' => (int)($a['submit_ts'] ?? 0),
'submit_at' => (string)($a['submit_at'] ?? '-'),
'duration' => $a['duration'] ?? null,
'duration_txt' => (string)($a['duration_txt'] ?? '-'),
'answer_id' => (string)($a['answer_id'] ?? ''),
'temp_save' => !empty($a['temp_save']),
'attempts' => (int)($r['attempts'] ?? 1),
'best_score' => $r['best_score'] ?? null,
'idx' => (int)($a['idx'] ?? 1),
'is_best' => !empty($a['is_best']),
'last_submit_ts' => (int)($r['last_submit_ts'] ?? 0),
'last_submit_at' => (string)($r['last_submit_at'] ?? '-'),
];
}
}
usort($out, static function (array $x, array $y) use ($desc): int {
// 注意:x<=>y 是升序,降序要用 y<=>x
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if (!$desc) {
$d = -$d;
}
if ($d !== 0) {
return $d;
}
// 同时间:学号升序
$a = (string)$x['sid'];
$b = (string)$y['sid'];
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/**
* 导出/展示用的排序:
* - time(默认):按提交时间降序,同时间按学号升序
* - sid:按学号升序,同一学号内多次考试再按提交时间降序
*
* @param array $rows expandAttempts 展开后的行
* @param string $mode 'time' | 'sid'
*/
public static function sortForExport(array $rows, string $mode = 'time'): array
{
$out = $rows;
if ($mode === 'sid') {
usort($out, static function ($x, $y): int {
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
$d = $a <=> $b;
} else {
$d = strnatcmp($a, $b);
}
if ($d !== 0) {
return $d;
}
return ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
});
return $out;
}
usort($out, static function ($x, $y): int {
$d = ((int)($y['submit_ts'] ?? 0)) <=> ((int)($x['submit_ts'] ?? 0));
if ($d !== 0) {
return $d;
}
$a = (string)($x['sid'] ?? '');
$b = (string)($y['sid'] ?? '');
if (is_numeric($a) && is_numeric($b)) {
return $a <=> $b;
}
return strnatcmp($a, $b);
});
return $out;
}
/**
* 按学号聚合一次提交的答卷记录,解决"同人多次刷卷"在看板中被当成多个人的问题。
*
* 关键语义(修复"考试次数=1 / 历史只有一条"的 bug):
* - attempts(真实考试次数) 与 attempts_list(每次提交明细) **始终等于全部提交**,与 dedupe 无关;
* 这样"考试次数列"与"点击查看历史"永远展示完整数据。
* - dedupe 仅决定主明细表"代表成绩"那一行取哪次考试的成绩:
* dedupe=true → 取提交时间最新的一份(默认,看板更干净)
* dedupe=false → 取最高分(同分时取最早达到该分数的那次)
*
* @param array $rows 已匹配学号的原始 row(每条 = 一次提交)
* @return array{rows:array, attempts_total:int}
*/
public static function aggregateByStudent(array $rows, bool $dedupe): array
{
$bySid = [];
foreach ($rows as $r) {
$bySid[(string)($r['sid'] ?? '')][] = $r;
}
$agg = [];
$attemptsTotal = 0;
foreach ($bySid as $sid => $list) {
$attemptsTotal += count($list);
// 升序:旧 -> 新
$sorted = $list;
usort($sorted, static function (array $x, array $y): int {
return ($x['submit_ts'] ?? 0) <=> ($y['submit_ts'] ?? 0);
});
// 最佳成绩(用于标记每次考试是否为最高分)
$maxScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($maxScore === null || $a['score'] > $maxScore)) {
$maxScore = $a['score'];
}
}
// 最近一次提交(升序排序后的最后一份)
$lastSubmitTs = (int)($sorted[count($sorted) - 1]['submit_ts'] ?? 0);
// 标记是否已给"最佳"打过标(同分时只标最早达到该分数的那次)
$bestMarked = false;
$idx = 0;
$attemptsList = array_map(static function (array $a) use (&$idx, $maxScore, &$bestMarked): array {
$idx++;
$isBest = (!$bestMarked && $a['score'] !== null && $maxScore !== null && (float)$a['score'] === (float)$maxScore);
if ($isBest) {
$bestMarked = true;
}
return [
'idx' => $idx,
'is_best' => $isBest,
'submit_at' => $a['submit_at'],
'submit_ts' => $a['submit_ts'],
'score' => $a['score'],
'duration' => $a['duration'],
'duration_txt' => $a['duration_txt'],
'answer_id' => $a['answer_id'],
'temp_save' => $a['temp_save'],
];
}, $sorted);
// 代表成绩选择(仅影响主明细表那一行的成绩,不影响考试次数 / 历史明细)
if ($dedupe) {
// 代表成绩 = 提交时间最新的一份(默认,看板更干净)
$row = $sorted[count($sorted) - 1];
} else {
// 代表成绩 = 最高分(null 成绩不参与比较,全部为 null 时回退为最近一份)
$best = null;
$bestScore = null;
foreach ($sorted as $a) {
if ($a['score'] !== null && ($bestScore === null || $a['score'] > $bestScore)) {
$bestScore = $a['score'];
$best = $a;
}
}
if ($best === null) {
$best = $sorted[count($sorted) - 1];
}
$row = $best;
}
// 关键修复:考试次数与历史明细始终使用真实全部提交,与去重无关
// (之前 dedupe=true 时把 attempts 写成 1、attempts_list 只留最新一份,导致"考试次数"和
// 历史弹窗都只显示 1 条,多次刷题的数据被吞掉)
$row['attempts'] = count($list);
$row['best_score'] = $maxScore; // 真实最高分(跨全部尝试),用于历史弹窗标题展示
$row['attempts_list'] = $attemptsList;
// 最近一次提交时间(与"代表成绩"可能是不同的一次考试):
// 用于"按提交时间排序"和"新提交高亮",保证重考(哪怕分数更低)也能被发现
$row['last_submit_ts'] = $lastSubmitTs;
$row['last_submit_at'] = $lastSubmitTs > 0 ? date('Y-m-d H:i:s', $lastSubmitTs) : '-';
$agg[] = $row;
}
return ['rows' => $agg, 'attempts_total' => $attemptsTotal];
}
}