|
|
@@ -14,6 +14,7 @@ class MistakeBookService
|
|
|
{
|
|
|
protected string $learningAnalyticsBase;
|
|
|
protected int $timeout;
|
|
|
+ private array $attemptTimelineCache = [];
|
|
|
|
|
|
// 缓存时间(秒)
|
|
|
const CACHE_TTL_SUMMARY = 600; // 10分钟
|
|
|
@@ -322,11 +323,34 @@ class MistakeBookService
|
|
|
->take($perPage)
|
|
|
->get();
|
|
|
|
|
|
+ // 批量预加载当前页作答轨迹,避免逐条查询
|
|
|
+ $this->preloadAttemptTimelines($mistakes);
|
|
|
+
|
|
|
// 转换数据格式
|
|
|
$data = $mistakes->map(function ($mistake) {
|
|
|
return $this->transformMistakeRecord($mistake, false);
|
|
|
})->toArray();
|
|
|
|
|
|
+ if (($params['sort_by'] ?? 'priority_default') === 'priority_default') {
|
|
|
+ usort($data, function (array $a, array $b): int {
|
|
|
+ $createdCmp = strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
|
|
|
+ if ($createdCmp !== 0) {
|
|
|
+ return $createdCmp;
|
|
|
+ }
|
|
|
+
|
|
|
+ $aPending = (($a['review_status'] ?? '') === MistakeRecord::REVIEW_STATUS_PENDING) ? 0 : 1;
|
|
|
+ $bPending = (($b['review_status'] ?? '') === MistakeRecord::REVIEW_STATUS_PENDING) ? 0 : 1;
|
|
|
+ if ($aPending !== $bPending) {
|
|
|
+ return $aPending <=> $bPending;
|
|
|
+ }
|
|
|
+
|
|
|
+ $aLastCorrect = (($a['last_attempt_result'] ?? null) === '对') ? 1 : 0;
|
|
|
+ $bLastCorrect = (($b['last_attempt_result'] ?? null) === '对') ? 1 : 0;
|
|
|
+
|
|
|
+ return $aLastCorrect <=> $bLastCorrect;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
// 获取统计信息
|
|
|
$summary = $this->summarize($studentId);
|
|
|
|
|
|
@@ -827,8 +851,11 @@ class MistakeBookService
|
|
|
}
|
|
|
|
|
|
// 排序
|
|
|
- $sortBy = $params['sort_by'] ?? 'created_at_desc';
|
|
|
+ $sortBy = $params['sort_by'] ?? 'priority_default';
|
|
|
match ($sortBy) {
|
|
|
+ 'priority_default' => $query
|
|
|
+ // 数据库只做主排序,次排序在当前页内完成,避免相关子查询拖慢列表
|
|
|
+ ->orderByDesc('created_at'),
|
|
|
'created_at_asc' => $query->orderBy('created_at'),
|
|
|
'created_at_desc' => $query->orderByDesc('created_at'),
|
|
|
'review_status_asc' => $query->orderBy('review_status'),
|
|
|
@@ -851,6 +878,7 @@ class MistakeBookService
|
|
|
$mistake->question_id,
|
|
|
$questionDetails['textbook_catalog_nodes_id'] ?? null
|
|
|
);
|
|
|
+ $attemptTimeline = $this->buildAttemptTimeline($mistake);
|
|
|
|
|
|
$data = [
|
|
|
// ========== 错题记录基本信息 ==========
|
|
|
@@ -898,6 +926,13 @@ class MistakeBookService
|
|
|
|
|
|
// ========== 知识点信息 ==========
|
|
|
'knowledge_points' => $knowledgePoints,
|
|
|
+ // ========== 作答轨迹(错题创建后)==========
|
|
|
+ 'attempt_timeline' => $attemptTimeline['timeline'],
|
|
|
+ 'attempt_timeline_text' => $attemptTimeline['timeline_text'],
|
|
|
+ 'attempt_count' => $attemptTimeline['attempt_count'],
|
|
|
+ 'correct_count_after_mistake' => $attemptTimeline['correct_count'],
|
|
|
+ 'last_attempt_result' => $attemptTimeline['last_result'],
|
|
|
+ 'last_attempt_at' => $attemptTimeline['last_attempt_at'],
|
|
|
];
|
|
|
|
|
|
if ($detailed) {
|
|
|
@@ -912,6 +947,216 @@ class MistakeBookService
|
|
|
return $data;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 构建错题创建后的作答轨迹(权威口径:student_answer_steps)
|
|
|
+ *
|
|
|
+ * 规则:
|
|
|
+ * 1. 仅统计当前错题创建时间之后的作答
|
|
|
+ * 2. 同一 exam_id + question_id 视为一次尝试
|
|
|
+ * 3. 一次尝试中所有步骤都正确(min_correct=1)记为「对」,否则记为「错」
|
|
|
+ */
|
|
|
+ private function buildAttemptTimeline(MistakeRecord $mistake): array
|
|
|
+ {
|
|
|
+ $empty = [
|
|
|
+ 'timeline' => [],
|
|
|
+ 'timeline_text' => '',
|
|
|
+ 'attempt_count' => 0,
|
|
|
+ 'correct_count' => 0,
|
|
|
+ 'last_result' => null,
|
|
|
+ 'last_attempt_at' => null,
|
|
|
+ ];
|
|
|
+
|
|
|
+ if (empty($mistake->student_id) || empty($mistake->question_id) || empty($mistake->created_at)) {
|
|
|
+ return $empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isset($this->attemptTimelineCache[$mistake->id])) {
|
|
|
+ return $this->attemptTimelineCache[$mistake->id];
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $attemptRows = \DB::connection('mysql')
|
|
|
+ ->table('student_answer_steps')
|
|
|
+ ->selectRaw('exam_id, question_id, MIN(is_correct) AS min_correct, MAX(created_at) AS created_at')
|
|
|
+ ->where('student_id', $mistake->student_id)
|
|
|
+ ->where('question_id', $mistake->question_id)
|
|
|
+ ->where('created_at', '>', $mistake->created_at)
|
|
|
+ ->groupBy('exam_id', 'question_id')
|
|
|
+ ->orderBy('created_at', 'asc')
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ if ($attemptRows->isEmpty()) {
|
|
|
+ return $empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ $timeline = [];
|
|
|
+ $correctCount = 0;
|
|
|
+ $lastAttemptAt = null;
|
|
|
+ foreach ($attemptRows as $row) {
|
|
|
+ $isCorrect = (int) ($row->min_correct ?? 0) === 1;
|
|
|
+
|
|
|
+ $result = $isCorrect ? '对' : '错';
|
|
|
+ $timeline[] = $result;
|
|
|
+ if ($isCorrect) {
|
|
|
+ $correctCount++;
|
|
|
+ }
|
|
|
+ if (!empty($row->created_at)) {
|
|
|
+ $lastAttemptAt = $row->created_at;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'timeline' => $timeline,
|
|
|
+ 'timeline_text' => implode(',', $timeline),
|
|
|
+ 'attempt_count' => count($timeline),
|
|
|
+ 'correct_count' => $correctCount,
|
|
|
+ 'last_result' => end($timeline) ?: null,
|
|
|
+ 'last_attempt_at' => $lastAttemptAt ? \Carbon\Carbon::parse($lastAttemptAt)->toISOString() : null,
|
|
|
+ ];
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ Log::warning('构建错题作答轨迹失败', [
|
|
|
+ 'mistake_id' => $mistake->id,
|
|
|
+ 'student_id' => $mistake->student_id,
|
|
|
+ 'question_id' => $mistake->question_id,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $empty;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 批量预加载当前页错题的作答轨迹
|
|
|
+ */
|
|
|
+ private function preloadAttemptTimelines($mistakes): void
|
|
|
+ {
|
|
|
+ $this->attemptTimelineCache = [];
|
|
|
+ if ($mistakes->isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $studentIds = [];
|
|
|
+ $questionIds = [];
|
|
|
+ $pairKeys = [];
|
|
|
+ $minCreatedByPair = [];
|
|
|
+ foreach ($mistakes as $mistake) {
|
|
|
+ $studentId = (string) ($mistake->student_id ?? '');
|
|
|
+ $questionId = (string) ($mistake->question_id ?? '');
|
|
|
+ $createdAt = (string) ($mistake->created_at ?? '');
|
|
|
+ if ($studentId === '' || $questionId === '' || $createdAt === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $pairKey = $studentId.'|'.$questionId;
|
|
|
+ $pairKeys[$pairKey] = true;
|
|
|
+ $studentIds[$studentId] = true;
|
|
|
+ $questionIds[$questionId] = true;
|
|
|
+ if (!isset($minCreatedByPair[$pairKey]) || strcmp($createdAt, $minCreatedByPair[$pairKey]) < 0) {
|
|
|
+ $minCreatedByPair[$pairKey] = $createdAt;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (empty($pairKeys)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $rows = \DB::connection('mysql')
|
|
|
+ ->table('student_answer_steps')
|
|
|
+ ->whereIn('student_id', array_keys($studentIds))
|
|
|
+ ->whereIn('question_id', array_keys($questionIds))
|
|
|
+ ->orderBy('created_at', 'asc')
|
|
|
+ ->get(['student_id', 'question_id', 'exam_id', 'is_correct', 'created_at']);
|
|
|
+
|
|
|
+ $rowsByPair = [];
|
|
|
+ foreach ($rows as $row) {
|
|
|
+ $pairKey = (string) $row->student_id.'|'.(string) $row->question_id;
|
|
|
+ if (!isset($pairKeys[$pairKey])) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $minCreatedAt = $minCreatedByPair[$pairKey] ?? null;
|
|
|
+ if ($minCreatedAt !== null && strcmp((string) $row->created_at, (string) $minCreatedAt) <= 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $rowsByPair[$pairKey][] = $row;
|
|
|
+ }
|
|
|
+
|
|
|
+ foreach ($mistakes as $mistake) {
|
|
|
+ $pairKey = (string) ($mistake->student_id ?? '').'|'.(string) ($mistake->question_id ?? '');
|
|
|
+ $this->attemptTimelineCache[$mistake->id] = $this->buildAttemptTimelineFromRows(
|
|
|
+ $rowsByPair[$pairKey] ?? [],
|
|
|
+ (string) ($mistake->created_at ?? '')
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 基于步骤记录构建错题轨迹
|
|
|
+ *
|
|
|
+ * @param array<int, object> $rows
|
|
|
+ */
|
|
|
+ private function buildAttemptTimelineFromRows(array $rows, string $thresholdCreatedAt): array
|
|
|
+ {
|
|
|
+ $empty = [
|
|
|
+ 'timeline' => [],
|
|
|
+ 'timeline_text' => '',
|
|
|
+ 'attempt_count' => 0,
|
|
|
+ 'correct_count' => 0,
|
|
|
+ 'last_result' => null,
|
|
|
+ 'last_attempt_at' => null,
|
|
|
+ ];
|
|
|
+ if (empty($rows) || $thresholdCreatedAt === '') {
|
|
|
+ return $empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ $examAgg = [];
|
|
|
+ foreach ($rows as $row) {
|
|
|
+ $createdAt = (string) ($row->created_at ?? '');
|
|
|
+ if ($createdAt === '' || strcmp($createdAt, $thresholdCreatedAt) <= 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $examId = (string) ($row->exam_id ?? '');
|
|
|
+ if ($examId === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (!isset($examAgg[$examId])) {
|
|
|
+ $examAgg[$examId] = [
|
|
|
+ 'min_correct' => 1,
|
|
|
+ 'created_at' => $createdAt,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ $examAgg[$examId]['min_correct'] = min($examAgg[$examId]['min_correct'], (int) ($row->is_correct ?? 0));
|
|
|
+ if (strcmp($createdAt, (string) $examAgg[$examId]['created_at']) > 0) {
|
|
|
+ $examAgg[$examId]['created_at'] = $createdAt;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (empty($examAgg)) {
|
|
|
+ return $empty;
|
|
|
+ }
|
|
|
+
|
|
|
+ uasort($examAgg, fn ($a, $b) => strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? '')));
|
|
|
+
|
|
|
+ $timeline = [];
|
|
|
+ $correctCount = 0;
|
|
|
+ $lastAttemptAt = null;
|
|
|
+ foreach ($examAgg as $agg) {
|
|
|
+ $isCorrect = (int) ($agg['min_correct'] ?? 0) === 1;
|
|
|
+ $timeline[] = $isCorrect ? '对' : '错';
|
|
|
+ if ($isCorrect) {
|
|
|
+ $correctCount++;
|
|
|
+ }
|
|
|
+ $lastAttemptAt = (string) ($agg['created_at'] ?? $lastAttemptAt);
|
|
|
+ }
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'timeline' => $timeline,
|
|
|
+ 'timeline_text' => implode(',', $timeline),
|
|
|
+ 'attempt_count' => count($timeline),
|
|
|
+ 'correct_count' => $correctCount,
|
|
|
+ 'last_result' => end($timeline) ?: null,
|
|
|
+ 'last_attempt_at' => $lastAttemptAt ? \Carbon\Carbon::parse($lastAttemptAt)->toISOString() : null,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 清除缓存
|
|
|
*/
|