旧代码误读 surveySetting.whiteLists(实为题目选项白名单),导致线上从未触发。现改为读取 /api/project/partner/list 的 RESPONDENT_SYS_USER/IMP_USER 参与者作为应考名单,并保留 whiteLists 作为极老版本兜底。 dashboard.php 移除未实现的提交率卡片;全_score 默认 100 已在 data 层修正。e2e 测试 mock 与断言同步改为真实模型,全量 132 测试通过。
649 lines
26 KiB
PHP
649 lines
26 KiB
PHP
<?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 / questions / list 下,统一兼容
|
||
$self = $node;
|
||
$children = null;
|
||
foreach (['children', 'questions', 'list', 'items'] as $ck) {
|
||
if (isset($node[$ck]) && is_array($node[$ck])) {
|
||
$children = $node[$ck];
|
||
break;
|
||
}
|
||
}
|
||
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* 组装结果
|
||
*/
|
||
/**
|
||
* 读取某份试卷的独立配置(满分 / 合格率 / 免考名单)。
|
||
* 优先 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;
|
||
}
|
||
}
|
||
|
||
// 基础名单(全名单):用于答卷匹配,不受白名单影响
|
||
$rosterAll = Roster::all();
|
||
if ($exempt !== []) {
|
||
$rosterAll = array_values(array_filter(
|
||
$rosterAll,
|
||
static fn(array $s): bool => !isset($exempt[trim((string)($s['sid'] ?? ''))])
|
||
));
|
||
}
|
||
// 注:$students(应考名单)与 $studentInfo 在解析出 $survey(含白名单)后统一构建,见下方
|
||
|
||
$project = $client->getProject($projectId);
|
||
$survey = $project['survey'] ?? null;
|
||
if (is_string($survey) && $survey !== '') {
|
||
$decoded = json_decode($survey, true);
|
||
if (is_array($decoded)) {
|
||
$survey = $decoded;
|
||
}
|
||
}
|
||
|
||
// 卷内「考试白名单 / 通讯录名单」:决定「应考名单」(缺/待考名单来源)。
|
||
// 真实来源 = 卷王项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||
// 由 SurveyKingClient::getWhitelist -> getRespondentPartners(/api/project/partner/list) 读取;
|
||
// 未配置受访者名单时「按现在的方案显示」(使用全名单)。
|
||
$paperWhitelist = [];
|
||
try {
|
||
$paperWhitelist = $client->getWhitelist($projectId, $survey);
|
||
} catch (Throwable $e) {
|
||
$paperWhitelist = [];
|
||
}
|
||
$useWhitelist = ($paperWhitelist !== []);
|
||
$whitelistTotal = 0;
|
||
|
||
if ($useWhitelist) {
|
||
// 以学号 / 登陆账号 / 姓名(大小写不敏感)把白名单成员匹配回名单,
|
||
// 命中的取名单里的姓名/班级/组别;未命中的以白名单值作占位,避免漏统计。
|
||
$rosterMap = [];
|
||
foreach ($rosterAll as $s) {
|
||
$sid = strtolower(trim((string)($s['sid'] ?? '')));
|
||
$login = strtolower(trim((string)($s['login'] ?? '')));
|
||
$name = strtolower(trim((string)($s['name'] ?? '')));
|
||
if ($sid) { $rosterMap['sid:' . $sid] = $s; }
|
||
if ($login) { $rosterMap['login:' . $login] = $s; }
|
||
if ($name) { $rosterMap['name:' . $name] = $s; }
|
||
}
|
||
$students = [];
|
||
$seen = [];
|
||
foreach ($paperWhitelist as $v) {
|
||
$key = strtolower(trim((string)$v));
|
||
$m = $rosterMap['sid:' . $key] ?? $rosterMap['login:' . $key] ?? $rosterMap['name:' . $key] ?? null;
|
||
if ($m !== null) {
|
||
$sid = trim((string)($m['sid'] ?? ''));
|
||
if ($sid !== '' && isset($seen[$sid])) {
|
||
continue;
|
||
}
|
||
if ($sid !== '') { $seen[$sid] = true; }
|
||
$students[] = $m;
|
||
} else {
|
||
if (isset($seen['wk:' . $key])) {
|
||
continue;
|
||
}
|
||
$seen['wk:' . $key] = true;
|
||
$students[] = ['sid' => $v, 'name' => $v, 'cls' => '', 'grp' => '', 'login' => ''];
|
||
}
|
||
}
|
||
$whitelistTotal = count($students);
|
||
} else {
|
||
$students = $rosterAll;
|
||
}
|
||
|
||
// 姓名 -> 学号 / 班级(规范化):全名单 + 白名单独有成员都纳入,
|
||
// 保证所有提交(含白名单外提交)都能匹配到姓名。
|
||
$studentInfo = [];
|
||
$addInfo = static function (array $s) use (&$studentInfo): void {
|
||
$k = ny_norm_name((string)($s['name'] ?? ''));
|
||
if ($k !== '') {
|
||
$studentInfo[$k] = [
|
||
'sid' => (string)$s['sid'],
|
||
'cls' => (string)($s['cls'] ?? ''),
|
||
'grp' => (string)($s['grp'] ?? ''),
|
||
];
|
||
}
|
||
};
|
||
foreach ($rosterAll as $s) { $addInfo($s); }
|
||
if ($useWhitelist) {
|
||
foreach ($students as $s) { $addInfo($s); }
|
||
}
|
||
|
||
$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($studentInfo[ny_norm_name($v)])) {
|
||
$name = $v;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if ($name === '' && $questions === []) {
|
||
foreach ($answerMap as $v) {
|
||
$s = ny_flatten_value($v);
|
||
if ($s !== '' && isset($studentInfo[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);
|
||
}
|
||
|
||
// 考试时长(metaInfo 可能缺失,先整体判空避免每答案两条 Undefined key 警告)
|
||
$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,
|
||
];
|
||
|
||
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),
|
||
'whitelist_driven' => $useWhitelist,
|
||
'whitelist_total' => $whitelistTotal,
|
||
// 本卷独立配置(前端据此回显满分/合格率/免考);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'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 把聚合后的学生行展开为"每次考试一行",用于导出全部考试记录 / 明细内全部展示。
|
||
* 每行携带该次考试的成绩、提交时间、时长,以及该学生的总考试次数 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];
|
||
}
|
||
}
|