Pārlūkot izejas kodu

optimize mistake-book timeline loading and priority sorting

Batch-load attempt timelines from step records to remove per-item query overhead, and switch default list sorting to priority_default with deterministic in-page priority ordering.

Made-with: Cursor
yemeishu 3 nedēļas atpakaļ
vecāks
revīzija
a06f7f43f0

+ 1 - 1
app/Http/Controllers/Api/MistakeBookController.php

@@ -42,7 +42,7 @@ class MistakeBookController extends Controller
             // 设置默认值
             $params['page'] = (int) ($params['page'] ?? 1);
             $params['per_page'] = (int) ($params['per_page'] ?? 20);
-            $params['sort_by'] = $params['sort_by'] ?? 'created_at_desc';
+            $params['sort_by'] = $params['sort_by'] ?? 'priority_default';
 
             // 调用服务层获取错题列表
             $result = $this->mistakeBookService->listMistakes($params);

+ 246 - 1
app/Services/MistakeBookService.php

@@ -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,
+        ];
+    }
+
     /**
      * 清除缓存
      */