Просмотр исходного кода

perf(exam): 优化章节摸底查询并减少重复IO

批量加载已摸底章节并引入章节载荷缓存,避免章节遍历中的 exists N+1 查询与重复章节数据读取。

Made-with: Cursor
yemeishu 1 месяц назад
Родитель
Сommit
5ece282a10
1 измененных файлов с 208 добавлено и 103 удалено
  1. 208 103
      app/Services/DiagnosticChapterService.php

+ 208 - 103
app/Services/DiagnosticChapterService.php

@@ -317,8 +317,9 @@ class DiagnosticChapterService
         $targetChapterIds = is_array($targetChapterIds)
             ? array_values(array_unique(array_filter(array_map('intval', $targetChapterIds), fn ($id) => $id > 0)))
             : [];
+        $payloadCache = [];
 
-        // 指定章节摸底流程:先在传入列表找未摸底章节,若都摸底则扩展到同教材继续找
+        // 指定章节摸底流程:以传入章节为锚点
         if (!empty($targetChapterIds)) {
             // 兜底:允许传入 section/subsection,自动向上映射到 chapter
             $targetChapterIds = $this->normalizeToChapterIds($targetChapterIds);
@@ -331,51 +332,23 @@ class DiagnosticChapterService
                 return null;
             }
 
-            // 保持传入顺序遍历,不使用教材排序字段重排
+            // 保持传入顺序,首个有效章作为锚点
             $chapterMap = TextbookCatalog::query()
                 ->whereIn('id', $targetChapterIds)
                 ->where('node_type', 'chapter')
                 ->get(['id', 'textbook_id', 'title', 'parent_id', 'node_type', 'sort_order', 'display_no'])
                 ->keyBy('id');
 
-            $orderedChapters = [];
+            $anchorChapter = null;
             foreach ($targetChapterIds as $chapterId) {
-                $chapter = $chapterMap->get($chapterId);
-                if (!$chapter) {
-                    continue;
-                }
-                $orderedChapters[] = $chapter;
-
-                // 指定列表优先找“未摸底”章节
-                if ($this->hasChapterDiagnostic($studentId, (int) $chapter->id)) {
-                    continue;
-                }
-
-                $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
-                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
-
-                if (empty($kpCodesWithQuestions)) {
-                    continue;
+                $candidate = $chapterMap->get($chapterId);
+                if ($candidate) {
+                    $anchorChapter = $candidate;
+                    break;
                 }
-
-                Log::info('DiagnosticChapterService: 指定章节摸底命中未摸底章节', [
-                    'textbook_id' => $chapter->textbook_id,
-                    'student_id' => $studentId,
-                    'target_chapter_ids' => $targetChapterIds,
-                    'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? $chapter->title ?? '',
-                    'kp_count' => count($kpCodesWithQuestions),
-                ]);
-
-                return [
-                    'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? $chapter->title ?? '',
-                    'section_ids' => $chapterData['section_ids'],
-                    'kp_codes' => $kpCodesWithQuestions,
-                ];
             }
 
-            if (empty($orderedChapters)) {
+            if (!$anchorChapter) {
                 Log::warning('DiagnosticChapterService: 指定章节未找到有效chapter节点', [
                     'student_id' => $studentId,
                     'target_chapter_ids' => $targetChapterIds,
@@ -383,8 +356,7 @@ class DiagnosticChapterService
                 return null;
             }
 
-            // 指定章节都已摸底:在同教材内继续按顺序找未摸底章节
-            $anchorTextbookId = (int) ($orderedChapters[0]->textbook_id ?? $textbookId);
+            $anchorTextbookId = (int) ($anchorChapter->textbook_id ?? $textbookId);
             $chaptersInTextbook = TextbookCatalog::query()
                 ->where('textbook_id', $anchorTextbookId)
                 ->where('node_type', 'chapter')
@@ -393,61 +365,127 @@ class DiagnosticChapterService
                 ->orderBy('id')
                 ->get();
 
-            foreach ($chaptersInTextbook as $chapter) {
-                if ($this->hasChapterDiagnostic($studentId, (int) $chapter->id)) {
-                    continue;
+            if ($chaptersInTextbook->isEmpty()) {
+                Log::warning('DiagnosticChapterService: 指定章节所属教材无章节', [
+                    'textbook_id' => $anchorTextbookId,
+                    'student_id' => $studentId,
+                    'target_chapter_ids' => $targetChapterIds,
+                ]);
+                return null;
+            }
+
+            $chaptersInTextbookIds = $chaptersInTextbook->pluck('id')->map(fn ($id) => (int) $id)->all();
+            $diagnosedSet = $this->getDiagnosedChapterIdSet($studentId, $chaptersInTextbookIds);
+
+            // 全教材都摸底:输入什么章就输出什么章(is_restart=true)
+            $allDiagnosed = true;
+            foreach ($chaptersInTextbookIds as $chapterId) {
+                if (!isset($diagnosedSet[$chapterId])) {
+                    $allDiagnosed = false;
+                    break;
                 }
+            }
 
-                $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
-                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
-                if (empty($kpCodesWithQuestions)) {
-                    continue;
+            $anchorPayload = $this->buildChapterPayload((int) $anchorChapter->id, $anchorChapter, $payloadCache);
+            if ($allDiagnosed) {
+                if ($anchorPayload !== null) {
+                    Log::info('DiagnosticChapterService: 全教材已摸底,返回输入锚点章节并重启', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'anchor_chapter_id' => $anchorChapter->id,
+                    ]);
+                    $anchorPayload['is_restart'] = true;
+                    return $anchorPayload;
                 }
 
-                Log::info('DiagnosticChapterService: 指定章节已摸底完,切换同教材后续未摸底章节', [
-                    'anchor_textbook_id' => $anchorTextbookId,
-                    'student_id' => $studentId,
-                    'target_chapter_ids' => $targetChapterIds,
-                    'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? $chapter->title ?? '',
-                    'kp_count' => count($kpCodesWithQuestions),
-                ]);
+                // 兜底返回教材第一章(is_restart=true)
+                $firstChapter = $chaptersInTextbook->first();
+                if ($firstChapter) {
+                    $firstPayload = $this->buildChapterPayload((int) $firstChapter->id, $firstChapter, $payloadCache);
+                    if ($firstPayload !== null) {
+                        $firstPayload['is_restart'] = true;
+                        Log::info('DiagnosticChapterService: 全教材已摸底且输入章无有效题,兜底第一章重启', [
+                            'textbook_id' => $anchorTextbookId,
+                            'student_id' => $studentId,
+                            'chapter_id' => $firstChapter->id,
+                        ]);
+                        return $firstPayload;
+                    }
+                }
 
-                return [
-                    'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? $chapter->title ?? '',
-                    'section_ids' => $chapterData['section_ids'],
-                    'kp_codes' => $kpCodesWithQuestions,
-                ];
+                return null;
             }
 
-            // 与普通摸底逻辑保持一致:全教材都摸底后重启,返回第一章
-            $firstChapter = $chaptersInTextbook->first();
-            if ($firstChapter) {
-                $chapterData = $this->getChapterKnowledgePointsSimple((int) $firstChapter->id);
-                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+            // 未全教材摸底:优先检查锚点章,未达标就仍以该章摸底
+            if ($anchorPayload !== null) {
+                $allMastered = StudentKnowledgeMastery::allAtLeastSkipNoQuestions(
+                    $studentId,
+                    $anchorPayload['kp_codes'],
+                    0.9
+                );
+
+                if (!$allMastered) {
+                    Log::info('DiagnosticChapterService: 锚点章节未达标,继续以该章摸底', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'anchor_chapter_id' => $anchorChapter->id,
+                        'kp_count' => count($anchorPayload['kp_codes']),
+                    ]);
+                    return $anchorPayload;
+                }
+            }
+
+            // 锚点达标后,按教材顺序向后找第一个未摸底章节
+            $anchorIndex = $chaptersInTextbook->search(fn ($c) => (int) $c->id === (int) $anchorChapter->id);
+            $anchorIndex = $anchorIndex === false ? 0 : (int) $anchorIndex;
+
+            for ($i = $anchorIndex + 1; $i < $chaptersInTextbook->count(); $i++) {
+                $chapter = $chaptersInTextbook[$i];
+                if (isset($diagnosedSet[(int) $chapter->id])) {
+                    continue;
+                }
+                $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
+                if ($payload === null) {
+                    continue;
+                }
 
-                Log::info('DiagnosticChapterService: 指定章节及同教材已摸底完成,重启到第一章', [
+                Log::info('DiagnosticChapterService: 锚点章节已达标,向后推进到未摸底章节', [
                     'textbook_id' => $anchorTextbookId,
                     'student_id' => $studentId,
-                    'target_chapter_ids' => $targetChapterIds,
-                    'chapter_id' => $firstChapter->id,
+                    'anchor_chapter_id' => $anchorChapter->id,
+                    'next_chapter_id' => $chapter->id,
                 ]);
+                return $payload;
+            }
 
-                return [
-                    'chapter_id' => $firstChapter->id,
-                    'chapter_name' => $firstChapter->name ?? $firstChapter->title ?? '',
-                    'section_ids' => $chapterData['section_ids'],
-                    'kp_codes' => $kpCodesWithQuestions,
-                    'is_restart' => true,
-                ];
+            // 若后续没有可用未摸底章,再从教材起点补找未摸底章
+            for ($i = 0; $i <= $anchorIndex; $i++) {
+                $chapter = $chaptersInTextbook[$i];
+                if (isset($diagnosedSet[(int) $chapter->id])) {
+                    continue;
+                }
+                $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
+                if ($payload !== null) {
+                    Log::info('DiagnosticChapterService: 锚点后无未摸底章,回到教材前序未摸底章节', [
+                        'textbook_id' => $anchorTextbookId,
+                        'student_id' => $studentId,
+                        'anchor_chapter_id' => $anchorChapter->id,
+                        'fallback_chapter_id' => $chapter->id,
+                    ]);
+                    return $payload;
+                }
             }
 
-            Log::warning('DiagnosticChapterService: 指定章节所属教材无章节可用于重启', [
-                'textbook_id' => $anchorTextbookId,
-                'student_id' => $studentId,
-                'target_chapter_ids' => $targetChapterIds,
-            ]);
+            // 理论兜底:返回输入章并重启
+            if ($anchorPayload !== null) {
+                $anchorPayload['is_restart'] = true;
+                Log::info('DiagnosticChapterService: 指定章节流程兜底返回输入章节重启', [
+                    'textbook_id' => $anchorTextbookId,
+                    'student_id' => $studentId,
+                    'anchor_chapter_id' => $anchorChapter->id,
+                ]);
+                return $anchorPayload;
+            }
 
             return null;
         }
@@ -464,15 +502,14 @@ class DiagnosticChapterService
             return null;
         }
 
+        $chapterIds = $chapters->pluck('id')->map(fn ($id) => (int) $id)->all();
+        $diagnosedSet = $this->getDiagnosedChapterIdSet($studentId, $chapterIds);
+
         foreach ($chapters as $chapter) {
             // 检查是否已摸底
-            if (!$this->hasChapterDiagnostic($studentId, $chapter->id)) {
-                $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
-
-                // 过滤掉没有题目的知识点
-                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
-
-                if (empty($kpCodesWithQuestions)) {
+            if (!isset($diagnosedSet[(int) $chapter->id])) {
+                $payload = $this->buildChapterPayload((int) $chapter->id, $chapter, $payloadCache);
+                if ($payload === null) {
                     // 这个章节没有题目,跳过
                     continue;
                 }
@@ -481,23 +518,20 @@ class DiagnosticChapterService
                     'textbook_id' => $textbookId,
                     'student_id' => $studentId,
                     'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? '',
-                    'kp_count' => count($kpCodesWithQuestions),
+                    'chapter_name' => $chapter->name ?? $chapter->title ?? '',
+                    'kp_count' => count($payload['kp_codes']),
                 ]);
 
-                return [
-                    'chapter_id' => $chapter->id,
-                    'chapter_name' => $chapter->name ?? '',
-                    'section_ids' => $chapterData['section_ids'],
-                    'kp_codes' => $kpCodesWithQuestions,
-                ];
+                return $payload;
             }
         }
 
         // 所有章节都已摸底,返回第一章(重新开始)
         $firstChapter = $chapters->first();
-        $chapterData = $this->getChapterKnowledgePointsSimple($firstChapter->id);
-        $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+        $firstPayload = $firstChapter ? $this->buildChapterPayload((int) $firstChapter->id, $firstChapter, $payloadCache) : null;
+        if ($firstPayload === null) {
+            return null;
+        }
 
         Log::info('DiagnosticChapterService: 所有章节都已摸底,返回第一章', [
             'textbook_id' => $textbookId,
@@ -505,13 +539,8 @@ class DiagnosticChapterService
             'chapter_id' => $firstChapter->id,
         ]);
 
-        return [
-            'chapter_id' => $firstChapter->id,
-            'chapter_name' => $firstChapter->name ?? '',
-            'section_ids' => $chapterData['section_ids'],
-            'kp_codes' => $kpCodesWithQuestions,
-            'is_restart' => true, // 标记是重新开始
-        ];
+        $firstPayload['is_restart'] = true;
+        return $firstPayload; // 标记是重新开始
     }
 
     /**
@@ -568,6 +597,82 @@ class DiagnosticChapterService
         return $chapterIds;
     }
 
+    /**
+     * 根据 chapter_id 生成摸底章节载荷,若章节无可用题目知识点则返回 null。
+     */
+    private function buildChapterPayload(int $chapterId, $chapter = null, ?array &$payloadCache = null): ?array
+    {
+        if (is_array($payloadCache) && array_key_exists($chapterId, $payloadCache)) {
+            return $payloadCache[$chapterId];
+        }
+
+        if ($chapter === null) {
+            $chapter = TextbookCatalog::query()
+                ->where('id', $chapterId)
+                ->where('node_type', 'chapter')
+                ->first(['id', 'title']);
+        }
+
+        if (!$chapter) {
+            if (is_array($payloadCache)) {
+                $payloadCache[$chapterId] = null;
+            }
+            return null;
+        }
+
+        $chapterData = $this->getChapterKnowledgePointsSimple($chapterId);
+        $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+        if (empty($kpCodesWithQuestions)) {
+            if (is_array($payloadCache)) {
+                $payloadCache[$chapterId] = null;
+            }
+            return null;
+        }
+
+        $payload = [
+            'chapter_id' => $chapterId,
+            'chapter_name' => $chapter->name ?? $chapter->title ?? '',
+            'section_ids' => $chapterData['section_ids'],
+            'kp_codes' => $kpCodesWithQuestions,
+        ];
+        if (is_array($payloadCache)) {
+            $payloadCache[$chapterId] = $payload;
+        }
+
+        return $payload;
+    }
+
+    /**
+     * 批量获取学生在指定章节中的已摸底集合,避免循环 exists N+1 查询。
+     *
+     * @param array<int> $chapterIds
+     * @return array<int, bool>
+     */
+    private function getDiagnosedChapterIdSet(int $studentId, array $chapterIds): array
+    {
+        $chapterIds = array_values(array_unique(array_filter(array_map('intval', $chapterIds), fn ($id) => $id > 0)));
+        if (empty($chapterIds)) {
+            return [];
+        }
+
+        $ids = \App\Models\Paper::query()
+            ->where('student_id', $studentId)
+            ->where('paper_type', 0)
+            ->whereIn('diagnostic_chapter_id', $chapterIds)
+            ->pluck('diagnostic_chapter_id')
+            ->map(fn ($id) => (int) $id)
+            ->unique()
+            ->values()
+            ->all();
+
+        $set = [];
+        foreach ($ids as $id) {
+            $set[$id] = true;
+        }
+
+        return $set;
+    }
+
     /**
      * 获取当前应该学习的章节(第一个有未达标知识点的章节)
      * 用于智能组卷流程