Files
ny-exam-platform/lib/DataService.php
T

316 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
/**
* 数据组装:卷王答卷 -> 学号 / 姓名 / 成绩 / 提交时间 / 考试时长
*/
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']);
$rows = [];
$unmatched = [];
$seenSid = [];
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;
}
if ($dedupe && isset($seenSid[$sid])) {
$idx = $seenSid[$sid];
// 保留提交时间最新的一份
if ($ts >= ($rows[$idx]['submit_ts'] ?? 0)) {
$rows[$idx] = $row;
$seenSid[$sid] = $idx;
}
continue;
}
$seenSid[$sid] = count($rows);
$rows[] = $row;
}
// 成绩明细默认按提交时间降序(最新在前),学号作为次级排序
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),
];
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'),
];
}
}