Files
ny-exam-platform/lib/DataService.php
T
nxxy335 0174b2abc2 fix: 考试次数与历史始终展示全部提交(不再被 dedupe 吞成 1 条)
- aggregateByStudent 修复:attempts(真实次数) 与 attempts_list(每次提交明细)
  无论 dedupe 取值都使用全部提交;dedupe 仅决定主明细表代表成绩取最新
  还是最高分。此前 dedupe=true(默认)把 attempts 写成 1、attempts_list 只留
  最新一份,导致考试次数列和点击历史都只显示 1 条,多次刷题数据丢失。
- best_score 改为跨全部尝试的真实最高分,修复历史弹窗标题最高分错误。
- 新增 tests/test-aggregate.php 覆盖:11次/2次/1次提交、dedupe 两种取值、
  重考低分、导出展开全部记录,共 17 项断言全部通过。

数据印证:第二周测试(1).xlsx 共 146 份提交 / 35 人,31 人多次提交
(最多 11 次),修复后考试次数与历史均完整呈现。
2026-09-10 10:41:42 +08:00

511 lines
20 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'];
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
usort($rows, static function (array $x, array $y): int {
$d = ($y['submit_ts'] ?? 0) <=> ($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);
});
// 缺考名单
$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,
];
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,
'roster' => $students,
'roster_count' => count($students),
'updated_at' => date('Y-m-d H:i:s'),
];
}
/**
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 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];
}
}