学号 / 姓名 / 成绩 / 提交时间 / 考试时长 */ 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 */ 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]; } }