Переглянути джерело

Merge branch 'hotfix/fix_learning_progress' into dev

yemeishu 1 тиждень тому
батько
коміт
4922f321f0

+ 13 - 1
app/Http/Controllers/Api/IntelligentExamController.php

@@ -152,6 +152,9 @@ class IntelligentExamController extends Controller
         try {
             $questions = [];
             $result = null;
+            // 【新增】初始化章节摸底和智能组卷的关键字段
+            $diagnosticChapterId = null;
+            $explanationKpCodes = null;
 
             if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
                 $questionIds = $this->resolveMistakeQuestionIds(
@@ -227,6 +230,10 @@ class IntelligentExamController extends Controller
                     $difficultyCategory = $result['stats']['difficulty_category'];
                 }
 
+                // 【新增】提取章节摸底和智能组卷的关键字段
+                $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
+                $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
+
                 $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']);
             }
 
@@ -258,14 +265,19 @@ class IntelligentExamController extends Controller
             $totalScore = array_sum(array_column($questions, 'score'));
 
             // 第二步:保存试卷到数据库(同步)
+            // 【修复】策略可能修改assembleType(如章节智能组卷检测到全部掌握后转为下一章节摸底)
+            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
             $paperId = $this->questionBankService->saveExamToDatabase([
                 'paper_name' => $paperName,
                 'student_id' => $data['student_id'],
                 'teacher_id' => $data['teacher_id'] ?? null,
-                'assembleType' => $assembleType,
+                'assembleType' => $finalAssembleType,
                 'difficulty_category' => $difficultyCategory,
                 'total_score' => $totalScore, // 使用计算后的实际总分
                 'questions' => $questions,
+                // 【新增】章节摸底和智能组卷的关键字段
+                'diagnostic_chapter_id' => $diagnosticChapterId ?? null,
+                'explanation_kp_codes' => $explanationKpCodes ?? null,
             ]);
 
             if (! $paperId) {

+ 7 - 3
app/Http/Controllers/Api/QuestionPdfController.php

@@ -45,7 +45,7 @@ class QuestionPdfController extends Controller
             'teacher_name' => 'nullable|string|max:50',
             'paper_name' => 'nullable|string|max:100',
             'include_grading' => 'nullable|boolean',
-            'source' => 'nullable|string|in:default,ai', // 题库来源:default=默认表(questions_tem),ai=ai表(questions_ai)
+            'source' => 'nullable|string|in:default,ai,main', // 题库来源:default=questions_tem,ai=questions_ai,main=questions
         ]);
 
         if ($validator->fails()) {
@@ -63,7 +63,7 @@ class QuestionPdfController extends Controller
         $teacherName = $request->input('teacher_name', '');
         $paperName = $request->input('paper_name', '专项练习');
         $includeGrading = $request->input('include_grading', false);
-        $source = $request->input('source', 'default'); // 题库来源:default=questions_tem, ai=questions_ai
+        $source = $request->input('source', 'default'); // 题库来源:default=questions_tem, ai=questions_ai, main=questions
 
         Log::info('生成指定题目PDF', [
             'question_ids' => $questionIds,
@@ -74,7 +74,11 @@ class QuestionPdfController extends Controller
 
         try {
             // 1. Fetch questions from database
-            $tableName = $source === 'ai' ? 'questions_ai' : 'questions_tem';
+            $tableName = match ($source) {
+                'ai' => 'questions_ai',
+                'main' => 'questions',
+                default => 'questions_tem',
+            };
 
             $questions = DB::connection('remote_mysql')
                 ->table($tableName)

+ 32 - 28
app/Http/Controllers/ExamPdfController.php

@@ -1007,36 +1007,40 @@ class ExamPdfController extends Controller
         // 生成时间(格式:2026年01月30日 15:04:05)
         $generateDateTime = now()->format('Y年m月d日 H:i:s');
 
-        // 提取并去重知识点代码(优先 paper_questions.knowledge_point,缺失时回退到题库 kp_code)
-        $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
-        $kpCodes = [];
-        $seen = [];
-
-        $questionBankIds = $paperQuestions
-            ->pluck('question_bank_id')
-            ->filter()
-            ->unique()
-            ->values();
-        $questionKpMap = [];
-        if ($questionBankIds->isNotEmpty()) {
-            $questionKpMap = \App\Models\Question::whereIn('id', $questionBankIds)
-                ->pluck('kp_code', 'id')
-                ->toArray();
-        }
-
-        foreach ($paperQuestions as $pq) {
-            $kpCode = trim((string) ($pq->knowledge_point ?? ''));
-            if ($kpCode === '' && ! empty($pq->question_bank_id)) {
-                $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
+        // 优先使用 paper 中保存的 explanation_kp_codes(组卷时指定的知识点,最多2个)
+        $kpCodes = $paper->explanation_kp_codes ?? [];
+
+        // 如果没有保存 explanation_kp_codes,回退到从题目中提取(兼容旧数据)
+        if (empty($kpCodes)) {
+            $paperQuestions = \App\Models\PaperQuestion::where('paper_id', $paper_id)->get();
+            $seen = [];
+
+            $questionBankIds = $paperQuestions
+                ->pluck('question_bank_id')
+                ->filter()
+                ->unique()
+                ->values();
+            $questionKpMap = [];
+            if ($questionBankIds->isNotEmpty()) {
+                $questionKpMap = \App\Models\Question::whereIn('id', $questionBankIds)
+                    ->pluck('kp_code', 'id')
+                    ->toArray();
             }
-            if ($kpCode === '') {
-                continue;
-            }
-            if (isset($seen[$kpCode])) {
-                continue;
+
+            foreach ($paperQuestions as $pq) {
+                $kpCode = trim((string) ($pq->knowledge_point ?? ''));
+                if ($kpCode === '' && ! empty($pq->question_bank_id)) {
+                    $kpCode = trim((string) ($questionKpMap[$pq->question_bank_id] ?? ''));
+                }
+                if ($kpCode === '') {
+                    continue;
+                }
+                if (isset($seen[$kpCode])) {
+                    continue;
+                }
+                $seen[$kpCode] = true;
+                $kpCodes[] = $kpCode;
             }
-            $seen[$kpCode] = true;
-            $kpCodes[] = $kpCode;
         }
 
         // 使用 ExamPdfExportService 构建知识点数据

+ 1 - 1
app/Models/KnowledgePoint.php

@@ -9,7 +9,7 @@ class KnowledgePoint extends Model
 {
     use HasFactory;
 
-    protected $table = 'knowledge_points_copy1'; // 正式环境回归:knowledge_points
+    protected $table = 'knowledge_points'; // 测试服务器 knowledge_points_copy1
 
     protected $fillable = [
         'kp_code',

+ 4 - 0
app/Models/Paper.php

@@ -21,6 +21,8 @@ class Paper extends Model
         'teacher_id',
         'paper_name',
         'paper_type',
+        'diagnostic_chapter_id', // 摸底的章节ID(章节摸底时记录)
+        'explanation_kp_codes', // 学案讲解的知识点列表(最多2个)
         'total_questions',
         'total_score',
         'status',
@@ -41,6 +43,8 @@ class Paper extends Model
         'status' => 'string',
         'difficulty_category' => 'string',
         'paper_type' => 'integer',
+        'diagnostic_chapter_id' => 'integer',
+        'explanation_kp_codes' => 'array', // JSON 自动转数组
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
         'completed_at' => 'datetime',

+ 129 - 4
app/Models/StudentKnowledgeMastery.php

@@ -16,6 +16,7 @@ class StudentKnowledgeMastery extends Model
         'student_id',
         'kp_code',
         'mastery_level',
+        'direct_mastery_level', // 直接学习掌握度(答题计算),判断达标时优先使用
         'confidence_level',
         'total_attempts',
         'correct_attempts',
@@ -41,6 +42,7 @@ class StudentKnowledgeMastery extends Model
 
     protected $casts = [
         'mastery_level' => 'decimal:4',
+        'direct_mastery_level' => 'decimal:4',
         'confidence_level' => 'decimal:4',
         'mastery_change' => 'decimal:4',
         'avg_time_seconds' => 'decimal:2',
@@ -118,14 +120,25 @@ class StudentKnowledgeMastery extends Model
             return false;
         }
 
-        $levels = self::query()
+        // 使用 DB::table 避免 Eloquent accessor 把数字转成文字标签
+        // 【新增】同时获取 direct_mastery_level,判断时优先使用
+        $records = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
             ->where('student_id', $studentId)
             ->whereIn('kp_code', $kpCodes)
-            ->pluck('mastery_level', 'kp_code')
-            ->toArray();
+            ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
+            ->keyBy('kp_code');
 
         foreach ($kpCodes as $kpCode) {
-            $level = isset($levels[$kpCode]) ? (float) $levels[$kpCode] : 0.0;
+            $record = $records->get($kpCode);
+            // 取 direct_mastery_level 和 mastery_level 的最大值
+            // 避免"学了之后反而从达标变成未达标"的问题
+            if ($record) {
+                $direct = $record->direct_mastery_level;
+                $mastery = (float) $record->mastery_level;
+                $level = $direct !== null ? max((float) $direct, $mastery) : $mastery;
+            } else {
+                $level = 0.0;
+            }
             if ($level < $threshold) {
                 return false;
             }
@@ -134,6 +147,118 @@ class StudentKnowledgeMastery extends Model
         return true;
     }
 
+    /**
+     * 判断所有知识点是否达标(跳过没有题目的知识点)
+     * 用于章节摸底后的知识点学习流程
+     *
+     * @param int $studentId 学生ID
+     * @param array $kpCodes 知识点编码列表
+     * @param float $threshold 达标阈值(默认0.9)
+     * @return bool 是否全部达标
+     */
+    public static function allAtLeastSkipNoQuestions(int $studentId, array $kpCodes, float $threshold = 0.9): bool
+    {
+        if (empty($kpCodes)) {
+            return true; // 没有知识点,视为达标
+        }
+
+        // 获取掌握度(使用 DB::table 避免 Eloquent accessor 把数字转成文字标签)
+        // 【新增】同时获取 direct_mastery_level,判断时优先使用
+        $records = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
+            ->where('student_id', $studentId)
+            ->whereIn('kp_code', $kpCodes)
+            ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
+            ->keyBy('kp_code');
+
+        // 获取有题目的知识点
+        $kpCodesWithQuestions = \App\Models\Question::query()
+            ->whereIn('kp_code', $kpCodes)
+            ->distinct()
+            ->pluck('kp_code')
+            ->toArray();
+
+        $hasAnyKpWithQuestions = false;
+
+        foreach ($kpCodes as $kpCode) {
+            // 跳过没有题目的知识点
+            if (!in_array($kpCode, $kpCodesWithQuestions)) {
+                continue;
+            }
+
+            $hasAnyKpWithQuestions = true;
+
+            // 取 direct_mastery_level 和 mastery_level 的最大值
+            // 避免"学了之后反而从达标变成未达标"的问题
+            $record = $records->get($kpCode);
+            if ($record) {
+                $direct = $record->direct_mastery_level;
+                $mastery = (float) $record->mastery_level;
+                $level = $direct !== null ? max((float) $direct, $mastery) : $mastery;
+            } else {
+                $level = 0.0;
+            }
+            if ($level < $threshold) {
+                return false;
+            }
+        }
+
+        // 如果没有任何有题的知识点,视为达标
+        return $hasAnyKpWithQuestions ? true : true;
+    }
+
+    /**
+     * 获取第一个未达标的知识点(跳过没有题目的知识点)
+     *
+     * @param int $studentId 学生ID
+     * @param array $kpCodes 知识点编码列表(按顺序)
+     * @param float $threshold 达标阈值(默认0.9)
+     * @return string|null 第一个未达标的知识点编码,如果全部达标返回null
+     */
+    public static function getFirstUnmasteredKpCode(int $studentId, array $kpCodes, float $threshold = 0.9): ?string
+    {
+        if (empty($kpCodes)) {
+            return null;
+        }
+
+        // 获取掌握度(使用 DB::table 避免 Eloquent accessor 把数字转成文字标签)
+        // 【新增】同时获取 direct_mastery_level,判断时优先使用
+        $records = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
+            ->where('student_id', $studentId)
+            ->whereIn('kp_code', $kpCodes)
+            ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
+            ->keyBy('kp_code');
+
+        // 获取有题目的知识点
+        $kpCodesWithQuestions = \App\Models\Question::query()
+            ->whereIn('kp_code', $kpCodes)
+            ->distinct()
+            ->pluck('kp_code')
+            ->toArray();
+
+        foreach ($kpCodes as $kpCode) {
+            // 跳过没有题目的知识点
+            if (!in_array($kpCode, $kpCodesWithQuestions)) {
+                continue;
+            }
+
+            // 取 direct_mastery_level 和 mastery_level 的最大值
+            // 避免"学了之后反而从达标变成未达标"的问题
+            $record = $records->get($kpCode);
+            if ($record) {
+                $direct = $record->direct_mastery_level;
+                $mastery = (float) $record->mastery_level;
+                $level = $direct !== null ? max((float) $direct, $mastery) : $mastery;
+            } else {
+                $level = 0.0;
+            }
+            if ($level < $threshold) {
+                return $kpCode;
+            }
+        }
+
+        return null; // 全部达标
+    }
+
     /**
      * 计算掌握度等级
      */

+ 364 - 0
app/Services/DiagnosticChapterService.php

@@ -46,6 +46,9 @@ class DiagnosticChapterService
 
             $kpCodes = TextbookChapterKnowledgeRelation::query()
                 ->whereIn('catalog_chapter_id', $sectionIds)
+                ->where(function ($q) {
+                    $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+                })
                 ->pluck('kp_code')
                 ->filter()
                 ->unique()
@@ -107,6 +110,9 @@ class DiagnosticChapterService
 
         $kpCodes = TextbookChapterKnowledgeRelation::query()
             ->whereIn('catalog_chapter_id', $sectionIds)
+            ->where(function ($q) {
+                $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+            })
             ->pluck('kp_code')
             ->filter()
             ->unique()
@@ -162,6 +168,9 @@ class DiagnosticChapterService
 
             $kpCodes = TextbookChapterKnowledgeRelation::query()
                 ->whereIn('catalog_chapter_id', $sectionIds)
+                ->where(function ($q) {
+                    $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+                })
                 ->pluck('kp_code')
                 ->filter()
                 ->unique()
@@ -242,4 +251,359 @@ class DiagnosticChapterService
 
         return $ordered;
     }
+
+    // ========== 以下是新增的方法(不扩展子知识点)==========
+
+    /**
+     * 获取章节的知识点(不扩展子知识点)
+     * 直接返回 section 绑定的知识点
+     */
+    public function getChapterKnowledgePointsSimple(int $chapterId): array
+    {
+        $sectionIds = TextbookCatalog::query()
+            ->where('parent_id', $chapterId)
+            ->where('node_type', 'section')
+            ->orderBy('display_no')
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->pluck('id')
+            ->toArray();
+
+        if (empty($sectionIds)) {
+            return [
+                'section_ids' => [],
+                'kp_codes' => [],
+            ];
+        }
+
+        $kpCodes = TextbookChapterKnowledgeRelation::query()
+            ->whereIn('catalog_chapter_id', $sectionIds)
+            ->where(function ($q) {
+                $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+            })
+            ->pluck('kp_code')
+            ->filter()
+            ->unique()
+            ->values()
+            ->toArray();
+
+        // 不扩展子知识点,直接返回
+        return [
+            'section_ids' => $sectionIds,
+            'kp_codes' => $kpCodes,
+        ];
+    }
+
+    /**
+     * 判断章节是否已经摸底过
+     */
+    public function hasChapterDiagnostic(int $studentId, int $chapterId): bool
+    {
+        return \App\Models\Paper::query()
+            ->where('student_id', $studentId)
+            ->where('paper_type', 0) // 摸底类型
+            ->where('diagnostic_chapter_id', $chapterId)
+            ->exists();
+    }
+
+    /**
+     * 获取第一个未摸底的章节
+     * 用于章节摸底流程
+     */
+    public function getFirstUndiagnosedChapter(int $textbookId, int $studentId): ?array
+    {
+        $chapters = TextbookCatalog::query()
+            ->where('textbook_id', $textbookId)
+            ->where('node_type', 'chapter')
+            ->orderBy('display_no')
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->get();
+
+        if ($chapters->isEmpty()) {
+            return null;
+        }
+
+        foreach ($chapters as $chapter) {
+            // 检查是否已摸底
+            if (!$this->hasChapterDiagnostic($studentId, $chapter->id)) {
+                $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
+
+                // 过滤掉没有题目的知识点
+                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+
+                if (empty($kpCodesWithQuestions)) {
+                    // 这个章节没有题目,跳过
+                    continue;
+                }
+
+                Log::info('DiagnosticChapterService: 找到第一个未摸底的章节', [
+                    'textbook_id' => $textbookId,
+                    'student_id' => $studentId,
+                    'chapter_id' => $chapter->id,
+                    'chapter_name' => $chapter->name ?? '',
+                    'kp_count' => count($kpCodesWithQuestions),
+                ]);
+
+                return [
+                    'chapter_id' => $chapter->id,
+                    'chapter_name' => $chapter->name ?? '',
+                    'section_ids' => $chapterData['section_ids'],
+                    'kp_codes' => $kpCodesWithQuestions,
+                ];
+            }
+        }
+
+        // 所有章节都已摸底,返回第一章(重新开始)
+        $firstChapter = $chapters->first();
+        $chapterData = $this->getChapterKnowledgePointsSimple($firstChapter->id);
+        $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+
+        Log::info('DiagnosticChapterService: 所有章节都已摸底,返回第一章', [
+            'textbook_id' => $textbookId,
+            'student_id' => $studentId,
+            'chapter_id' => $firstChapter->id,
+        ]);
+
+        return [
+            'chapter_id' => $firstChapter->id,
+            'chapter_name' => $firstChapter->name ?? '',
+            'section_ids' => $chapterData['section_ids'],
+            'kp_codes' => $kpCodesWithQuestions,
+            'is_restart' => true, // 标记是重新开始
+        ];
+    }
+
+    /**
+     * 获取当前应该学习的章节(第一个有未达标知识点的章节)
+     * 用于智能组卷流程
+     */
+    public function getCurrentLearningChapter(int $textbookId, int $studentId, float $threshold = 0.9): ?array
+    {
+        $chapters = TextbookCatalog::query()
+            ->where('textbook_id', $textbookId)
+            ->where('node_type', 'chapter')
+            ->orderBy('display_no')
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->get();
+
+        if ($chapters->isEmpty()) {
+            return null;
+        }
+
+        foreach ($chapters as $chapter) {
+            $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
+            $kpCodes = $chapterData['kp_codes'];
+
+            if (empty($kpCodes)) {
+                continue;
+            }
+
+            // 使用新方法判断是否达标(跳过无题知识点)
+            $allMastered = StudentKnowledgeMastery::allAtLeastSkipNoQuestions($studentId, $kpCodes, $threshold);
+
+            if (!$allMastered) {
+                // 检查是否已摸底
+                $hasDiagnostic = $this->hasChapterDiagnostic($studentId, $chapter->id);
+
+                Log::info('DiagnosticChapterService: 找到当前学习章节', [
+                    'textbook_id' => $textbookId,
+                    'student_id' => $studentId,
+                    'student_id_type' => gettype($studentId),
+                    'chapter_id' => $chapter->id,
+                    'chapter_name' => $chapter->name ?? '',
+                    'has_diagnostic' => $hasDiagnostic,
+                    'kp_codes' => $kpCodes, // 显示实际的知识点列表
+                    'kp_count' => count($kpCodes),
+                ]);
+
+                return [
+                    'chapter_id' => $chapter->id,
+                    'chapter_name' => $chapter->name ?? '',
+                    'section_ids' => $chapterData['section_ids'],
+                    'kp_codes' => $kpCodes,
+                    'has_diagnostic' => $hasDiagnostic,
+                ];
+            }
+        }
+
+        // 所有章节都达标
+        Log::info('DiagnosticChapterService: 所有章节都达标', [
+            'textbook_id' => $textbookId,
+            'student_id' => $studentId,
+        ]);
+
+        return null;
+    }
+
+    /**
+     * 获取下一个章节
+     */
+    public function getNextChapter(int $textbookId, int $currentChapterId): ?array
+    {
+        $chapters = TextbookCatalog::query()
+            ->where('textbook_id', $textbookId)
+            ->where('node_type', 'chapter')
+            ->orderBy('display_no')
+            ->orderBy('sort_order')
+            ->orderBy('id')
+            ->get();
+
+        $foundCurrent = false;
+        foreach ($chapters as $chapter) {
+            if ($foundCurrent) {
+                $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
+                $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
+
+                if (!empty($kpCodesWithQuestions)) {
+                    return [
+                        'chapter_id' => $chapter->id,
+                        'chapter_name' => $chapter->name ?? '',
+                        'section_ids' => $chapterData['section_ids'],
+                        'kp_codes' => $kpCodesWithQuestions,
+                    ];
+                }
+            }
+
+            if ($chapter->id === $currentChapterId) {
+                $foundCurrent = true;
+            }
+        }
+
+        return null; // 没有下一章
+    }
+
+    /**
+     * 过滤出有题目的知识点
+     */
+    public function filterKpCodesWithQuestions(array $kpCodes): array
+    {
+        if (empty($kpCodes)) {
+            return [];
+        }
+
+        $kpCodesWithQuestions = \App\Models\Question::query()
+            ->where('audit_status', 0) // 只获取审核通过的题目
+            ->whereIn('kp_code', $kpCodes)
+            ->distinct()
+            ->pluck('kp_code')
+            ->toArray();
+
+        // 保持原有顺序
+        return array_values(array_intersect($kpCodes, $kpCodesWithQuestions));
+    }
+
+    /**
+     * 按顺序获取未达标的知识点(用于智能组卷)
+     *
+     * @param int $studentId 学生ID
+     * @param array $kpCodes 知识点列表(按顺序)
+     * @param float $threshold 达标阈值
+     * @param int $maxCount 最多返回几个知识点
+     * @param int $minQuestions 每个知识点最少需要的题目数
+     * @return array 未达标的知识点列表
+     */
+    public function getUnmasteredKpCodesInOrder(
+        int $studentId,
+        array $kpCodes,
+        float $threshold = 0.9,
+        int $maxCount = 2,
+        int $minQuestions = 20
+    ): array {
+        if (empty($kpCodes)) {
+            return [];
+        }
+
+        // 获取掌握度(使用 DB::table 避免 Eloquent accessor 把数字转成文字标签)
+        // 【新增】同时获取 direct_mastery_level 和 mastery_level,判断时优先使用 direct_mastery_level
+        $masteryRecords = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
+            ->where('student_id', $studentId)
+            ->whereIn('kp_code', $kpCodes)
+            ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
+            ->keyBy('kp_code');
+
+        // 构建有效掌握度映射:取 direct_mastery_level 和 mastery_level 的最大值
+        // 避免"学了之后反而从达标变成未达标"的问题
+        $levels = [];
+        foreach ($masteryRecords as $kpCode => $record) {
+            $direct = $record->direct_mastery_level;
+            $mastery = (float) $record->mastery_level;
+
+            if ($direct !== null) {
+                // 取两者最大值:直接学习达标或聚合值达标,都算达标
+                $levels[$kpCode] = max((float) $direct, $mastery);
+            } else {
+                $levels[$kpCode] = $mastery;
+            }
+        }
+
+        // 【调试】记录查询到的掌握度
+        Log::info('DiagnosticChapterService: 查询掌握度结果', [
+            'student_id' => $studentId,
+            'student_id_type' => gettype($studentId),
+            'input_kp_codes' => $kpCodes,
+            'found_levels' => $levels,
+            'raw_records' => $masteryRecords->toArray(),
+        ]);
+
+        // 获取每个知识点的题目数量(只统计审核通过的题目)
+        $questionCounts = \App\Models\Question::query()
+            ->where('audit_status', 0) // 只获取审核通过的题目
+            ->whereIn('kp_code', $kpCodes)
+            ->selectRaw('kp_code, COUNT(*) as count')
+            ->groupBy('kp_code')
+            ->pluck('count', 'kp_code')
+            ->toArray();
+
+        $result = [];
+        $totalQuestions = 0;
+
+        foreach ($kpCodes as $kpCode) {
+            // 跳过没有题目的知识点
+            $count = $questionCounts[$kpCode] ?? 0;
+            if ($count === 0) {
+                continue;
+            }
+
+            // 检查是否达标
+            $level = isset($levels[$kpCode]) ? (float) $levels[$kpCode] : 0.0;
+            $isMastered = $level >= $threshold;
+            Log::info("DiagnosticChapterService: 检查知识点", [
+                'kp_code' => $kpCode,
+                'level' => $level,
+                'threshold' => $threshold,
+                'is_mastered' => $isMastered,
+                'level_found_in_db' => isset($levels[$kpCode]),
+            ]);
+            if ($level >= $threshold) {
+                continue;
+            }
+
+            // 添加到结果
+            $result[] = $kpCode;
+            $totalQuestions += $count;
+
+            // 检查是否达到最大数量
+            if (count($result) >= $maxCount) {
+                break;
+            }
+
+            // 检查题目数量是否足够
+            if ($totalQuestions >= $minQuestions) {
+                break;
+            }
+        }
+
+        Log::info('DiagnosticChapterService: 获取未达标知识点', [
+            'student_id' => $studentId,
+            'input_kp_codes' => $kpCodes,
+            'result_kp_codes' => $result,
+            'levels_found' => $levels,
+            'total_questions' => $totalQuestions,
+            'threshold' => $threshold,
+        ]);
+
+        return $result;
+    }
 }

+ 8 - 0
app/Services/ExamPdfExportService.php

@@ -2371,6 +2371,10 @@ class ExamPdfExportService
             // 1. 生成试卷PDF(不含答案)
             $examHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, false);
             if ($examHtml) {
+                // 【修复】使用服务端 KaTeX 预渲染 LaTeX 公式
+                if ($this->katexRenderer) {
+                    $examHtml = $this->katexRenderer->renderHtml($examHtml);
+                }
                 $examPdf = $this->buildPdf($examHtml);
                 if ($examPdf) {
                     $examPath = "custom_exams/{$paper->paper_id}_exam.pdf";
@@ -2384,6 +2388,10 @@ class ExamPdfExportService
             if ($includeGrading) {
                 $gradingHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, true);
                 if ($gradingHtml) {
+                    // 【修复】使用服务端 KaTeX 预渲染 LaTeX 公式
+                    if ($this->katexRenderer) {
+                        $gradingHtml = $this->katexRenderer->renderHtml($gradingHtml);
+                    }
                     $gradingPdf = $this->buildPdf($gradingHtml);
                     if ($gradingPdf) {
                         $gradingPath = "custom_exams/{$paper->paper_id}_grading.pdf";

+ 220 - 10
app/Services/ExamTypeStrategy.php

@@ -30,7 +30,12 @@ class ExamTypeStrategy
 
     /**
      * 根据组卷类型构建参数
-     * assembleType: 0-新摸底, 8-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-错题本, 9-原摸底
+     * assembleType: 0-章节摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-错题本, 8-智能组卷(新), 9-原摸底
+     *
+     * 映射规则(前端不改,后端动态处理):
+     * - 0, 9(摸底)→ 章节摸底(新逻辑)
+     * - 1, 8(智能组卷)→ 按知识点顺序学习(新逻辑)
+     * - 2, 3, 4, 5 → 保持原有逻辑不变
      */
     public function buildParams(array $baseParams, int $assembleType): array
     {
@@ -39,15 +44,25 @@ class ExamTypeStrategy
             'base_params_keys' => array_keys($baseParams)
         ]);
 
-        return match($assembleType) {
-            0 => $this->applyDifficultyDistribution($this->buildInitialDiagnosticParams($baseParams)), // 新摸底
-            1 => $this->applyDifficultyDistribution($this->buildIntelligentAssembleParams($baseParams)), // 智能组卷(旧参数兼容)
-            2 => $this->applyDifficultyDistribution($this->buildKnowledgePointAssembleParams($baseParams)), // 知识点组卷
-            3 => $this->applyDifficultyDistribution($this->buildTextbookAssembleParams($baseParams)), // 教材组卷
-            4 => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams)), // 通用
-            5 => $this->applyDifficultyDistribution($this->buildMistakeParams($baseParams)), // 追练
-            8 => $this->applyDifficultyDistribution($this->buildIntelligentAssembleParams($baseParams)), // 智能组卷
-            9 => $this->applyDifficultyDistribution($this->buildDiagnosticParams($baseParams)), // 原摸底
+        // 映射 assembleType 到实际处理逻辑
+        $actualType = match($assembleType) {
+            0, 9 => 'chapter_diagnostic',   // 摸底 → 章节摸底(新逻辑)
+            1, 8 => 'chapter_intelligent',  // 智能组卷 → 按知识点顺序学习(新逻辑)
+            default => $assembleType        // 其他保持不变
+        };
+
+        Log::info('ExamTypeStrategy: assembleType 映射', [
+            'original' => $assembleType,
+            'actual' => $actualType,
+        ]);
+
+        return match($actualType) {
+            'chapter_diagnostic' => $this->applyDifficultyDistribution($this->buildChapterDiagnosticParams($baseParams)), // 章节摸底(新)
+            'chapter_intelligent' => $this->applyDifficultyDistribution($this->buildChapterIntelligentParams($baseParams)), // 按知识点顺序学习(新)
+            2 => $this->applyDifficultyDistribution($this->buildKnowledgePointAssembleParams($baseParams)), // 知识点组卷(不动)
+            3 => $this->applyDifficultyDistribution($this->buildTextbookAssembleParams($baseParams)), // 教材组卷(不动)
+            4 => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams)), // 通用(不动)
+            5 => $this->applyDifficultyDistribution($this->buildMistakeParams($baseParams)), // 追练(不动)
             default => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams))
         };
     }
@@ -1128,6 +1143,9 @@ class ExamTypeStrategy
             $allKpData = DB::table('textbook_chapter_knowledge_relation as tckr')
                 ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
                 ->whereIn('tckr.catalog_chapter_id', $chapterIds)
+                ->where(function ($q) {
+                    $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
+                })
                 ->select('tckr.kp_code', 'tcn.sort_order', 'tcn.id as chapter_id', 'tcn.title as chapter_title')
                 ->orderBy('tcn.sort_order', 'asc')
                 ->orderBy('tckr.kp_code', 'asc')
@@ -1243,6 +1261,9 @@ class ExamTypeStrategy
             // 修复方案1:使用GROUP BY替代DISTINCT,并选择需要的字段
             $kpCodes = DB::table('textbook_chapter_knowledge_relation')
                 ->whereIn('catalog_chapter_id', $chapterIds)
+                ->where(function ($q) {
+                    $q->where('is_deleted', 0)->orWhereNull('is_deleted');
+                })
                 ->select('kp_code')
                 ->groupBy('kp_code')
                 ->pluck('kp_code')
@@ -1514,6 +1535,9 @@ class ExamTypeStrategy
             $stats = DB::table('textbook_chapter_knowledge_relation as tckr')
                 ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
                 ->whereIn('tckr.catalog_chapter_id', $chapterIdList)
+                ->where(function ($q) {
+                    $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
+                })
                 ->select(
                     'tckr.catalog_chapter_id as chapter_id',
                     'tcn.title as chapter_title',
@@ -1677,4 +1701,190 @@ class ExamTypeStrategy
             return [];
         }
     }
+
+    // ========== 以下是新增的章节摸底和智能组卷方法 ==========
+
+    /**
+     * 章节摸底(新逻辑)
+     * - 找第一个未摸底的章节
+     * - 用该章节的知识点出题(不扩展子知识点)
+     * - 过滤掉没有题目的知识点
+     * - 保存时记录 diagnostic_chapter_id
+     */
+    private function buildChapterDiagnosticParams(array $params): array
+    {
+        $diagnosticService = app(DiagnosticChapterService::class);
+        $textbookId = $params['textbook_id'] ?? null;
+        $studentId = (int) ($params['student_id'] ?? 0);
+        $grade = $params['grade'] ?? null;
+        $totalQuestions = $params['total_questions'] ?? 20;
+
+        Log::info('ExamTypeStrategy: 构建章节摸底参数', [
+            'textbook_id' => $textbookId,
+            'student_id' => $studentId,
+        ]);
+
+        if (!$textbookId) {
+            Log::warning('ExamTypeStrategy: 章节摸底需要 textbook_id 参数');
+            return $this->buildGeneralParams($params);
+        }
+
+        // 找第一个未摸底的章节
+        $chapterInfo = $diagnosticService->getFirstUndiagnosedChapter((int) $textbookId, $studentId);
+
+        if (empty($chapterInfo) || empty($chapterInfo['kp_codes'])) {
+            Log::warning('ExamTypeStrategy: 章节摸底未找到有效章节', [
+                'textbook_id' => $textbookId,
+                'student_id' => $studentId,
+            ]);
+            return $this->buildGeneralParams($params);
+        }
+
+        Log::info('ExamTypeStrategy: 章节摸底参数构建完成', [
+            'textbook_id' => $textbookId,
+            'chapter_id' => $chapterInfo['chapter_id'],
+            'chapter_name' => $chapterInfo['chapter_name'] ?? '',
+            'section_count' => count($chapterInfo['section_ids'] ?? []),
+            'kp_count' => count($chapterInfo['kp_codes']),
+            'total_questions' => $totalQuestions,
+            'is_restart' => $chapterInfo['is_restart'] ?? false,
+        ]);
+
+        $enhanced = array_merge($params, [
+            'kp_code_list' => $chapterInfo['kp_codes'],
+            'chapter_id_list' => $chapterInfo['section_ids'],
+            'diagnostic_chapter_id' => $chapterInfo['chapter_id'], // 记录摸底的章节ID
+            'textbook_id' => $textbookId,
+            // 注意:不传 grade 参数,禁用"从其他知识点补充"的逻辑
+            // 用户需求:题目不够就不够,不从其他知识点补充
+            'paper_name' => $params['paper_name'] ?? ('章节摸底_' . ($chapterInfo['chapter_name'] ?? '') . '_' . now()->format('Ymd_His')),
+            'assembleType' => 0, // 确保 paper_type 记录为摸底
+        ]);
+
+        return $this->buildKnowledgePointAssembleParams($enhanced);
+    }
+
+    /**
+     * 按知识点顺序学习(新逻辑)
+     * - 找当前应该学习的章节(有未达标知识点的章节)
+     * - 如果该章节未摸底,返回摸底参数
+     * - 按顺序找未达标的知识点,最多2个
+     * - 如果都达标,进入下一章摸底
+     */
+    private function buildChapterIntelligentParams(array $params): array
+    {
+        $diagnosticService = app(DiagnosticChapterService::class);
+        $textbookId = $params['textbook_id'] ?? null;
+        $studentId = (int) ($params['student_id'] ?? 0);
+        $grade = $params['grade'] ?? null;
+        $totalQuestions = $params['total_questions'] ?? 20;
+
+        Log::info('ExamTypeStrategy: 构建按知识点顺序学习参数', [
+            'textbook_id' => $textbookId,
+            'student_id' => $studentId,
+        ]);
+
+        if (!$textbookId) {
+            Log::warning('ExamTypeStrategy: 智能组卷需要 textbook_id 参数');
+            return $this->buildGeneralParams($params);
+        }
+
+        // 获取当前应该学习的章节
+        $chapterInfo = $diagnosticService->getCurrentLearningChapter((int) $textbookId, $studentId, 0.9);
+
+        // 情况1: 所有章节都达标,从第一章重新开始摸底
+        if (empty($chapterInfo)) {
+            Log::info('ExamTypeStrategy: 所有章节都达标,从第一章重新开始摸底');
+            return $this->buildChapterDiagnosticParams($params);
+        }
+
+        // 情况2: 当前章节未摸底,需要先摸底
+        if (!($chapterInfo['has_diagnostic'] ?? false)) {
+            Log::info('ExamTypeStrategy: 当前章节未摸底,先进行摸底', [
+                'chapter_id' => $chapterInfo['chapter_id'],
+            ]);
+            return $this->buildChapterDiagnosticParams($params);
+        }
+
+        // 情况3: 已摸底,按顺序找未达标的知识点
+        $unmasteredKpCodes = $diagnosticService->getUnmasteredKpCodesInOrder(
+            $studentId,
+            $chapterInfo['kp_codes'],
+            0.9,  // 阈值
+            2,    // 最多2个知识点
+            $totalQuestions
+        );
+
+        // 情况4: 当前章节所有知识点都达标,进入下一章摸底
+        if (empty($unmasteredKpCodes)) {
+            Log::info('ExamTypeStrategy: 当前章节所有知识点都达标,进入下一章', [
+                'current_chapter_id' => $chapterInfo['chapter_id'],
+            ]);
+
+            // 获取下一章
+            $nextChapter = $diagnosticService->getNextChapter((int) $textbookId, $chapterInfo['chapter_id']);
+
+            if ($nextChapter) {
+                // 检查下一章是否已摸底
+                $hasNextDiagnostic = $diagnosticService->hasChapterDiagnostic($studentId, $nextChapter['chapter_id']);
+
+                if (!$hasNextDiagnostic) {
+                    Log::info('ExamTypeStrategy: 下一章未摸底,进行摸底', [
+                        'next_chapter_id' => $nextChapter['chapter_id'],
+                    ]);
+                    // 直接设置下一章的参数,不递归调用
+                    $enhanced = array_merge($params, [
+                        'kp_code_list' => $nextChapter['kp_codes'],
+                        'chapter_id_list' => $nextChapter['section_ids'],
+                        'diagnostic_chapter_id' => $nextChapter['chapter_id'],
+                        'textbook_id' => $textbookId,
+                        // 注意:不传 grade 参数,禁用"从其他知识点补充"的逻辑
+                        'paper_name' => $params['paper_name'] ?? ('章节摸底_' . ($nextChapter['chapter_name'] ?? '') . '_' . now()->format('Ymd_His')),
+                        'assembleType' => 0,
+                    ]);
+                    return $this->buildKnowledgePointAssembleParams($enhanced);
+                } else {
+                    // 下一章已摸底,找下一章的未达标知识点
+                    $nextUnmasteredKpCodes = $diagnosticService->getUnmasteredKpCodesInOrder(
+                        $studentId,
+                        $nextChapter['kp_codes'],
+                        0.9,
+                        2,
+                        $totalQuestions
+                    );
+
+                    if (!empty($nextUnmasteredKpCodes)) {
+                        $unmasteredKpCodes = $nextUnmasteredKpCodes;
+                        $chapterInfo = $nextChapter;
+                    }
+                }
+            }
+
+            // 如果没有下一章或下一章也都达标,从头开始
+            if (empty($unmasteredKpCodes)) {
+                Log::info('ExamTypeStrategy: 所有章节都学完,从第一章重新开始');
+                return $this->buildChapterDiagnosticParams($params);
+            }
+        }
+
+        Log::info('ExamTypeStrategy: 按知识点顺序学习参数构建完成', [
+            'textbook_id' => $textbookId,
+            'chapter_id' => $chapterInfo['chapter_id'],
+            'kp_codes' => $unmasteredKpCodes,
+            'kp_count' => count($unmasteredKpCodes),
+            'total_questions' => $totalQuestions,
+        ]);
+
+        $enhanced = array_merge($params, [
+            'kp_code_list' => $unmasteredKpCodes,
+            'explanation_kp_codes' => $unmasteredKpCodes, // 学案讲解只显示这些知识点(最多2个)
+            'textbook_id' => $textbookId,
+            // 注意:不传 grade 参数,禁用"从其他知识点补充"的逻辑
+            // 用户需求:题目不够就不够,不从其他知识点补充
+            'paper_name' => $params['paper_name'] ?? ('智能学习_' . now()->format('Ymd_His')),
+            'assembleType' => 1, // paper_type 记录为智能组卷
+        ]);
+
+        return $this->buildKnowledgePointAssembleParams($enhanced);
+    }
 }

+ 11 - 1
app/Services/LearningAnalyticsService.php

@@ -1605,6 +1605,10 @@ class LearningAnalyticsService
                 'success' => true,
                 'message' => '智能出卷成功',
                 'questions' => $selectedQuestions,
+                // 【新增】传递章节摸底和智能组卷的关键字段
+                'diagnostic_chapter_id' => $params['diagnostic_chapter_id'] ?? null,
+                'explanation_kp_codes' => $params['explanation_kp_codes'] ?? null,
+                'assemble_type' => $params['assembleType'] ?? null, // 策略可能修改assembleType(如章节智能→章节摸底)
                 'stats' => [
                     'total_selected' => count($selectedQuestions),
                     'source_questions' => count($allQuestions),
@@ -2978,6 +2982,9 @@ class LearningAnalyticsService
             // 查询同年级其他知识点的题目
             $query = \App\Models\Question::query();
 
+            // 只获取审核通过的题目
+            $query->where('audit_status', 0);
+
             $stageGrade = $this->normalizeQuestionStageGrade($grade);
             if ($stageGrade !== null) {
                 $query->where('grade', $stageGrade);
@@ -3100,7 +3107,10 @@ class LearningAnalyticsService
             $query = DB::table('textbook_chapter_knowledge_relation as tckr')
                 ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
                 ->join('textbooks as t', 'tcn.textbook_id', '=', 't.id')
-                ->where('t.grade', $grade);
+                ->where('t.grade', $grade)
+                ->where(function ($q) {
+                    $q->where('tckr.is_deleted', 0)->orWhereNull('tckr.is_deleted');
+                });
 
             // 【修复超纲问题】如果有 textbook_id,严格限制在该教材范围内
             // 避免七年级上册学生拿到七年级下册的知识点

+ 4 - 16
app/Services/MasteryCalculator.php

@@ -158,11 +158,13 @@ class MasteryCalculator
         $newMastery = max(0.0, min(1.0, $newMastery));
 
         // 【公司要求】保存到数据库(只保存核心掌握度数据)
+        // 【新增】同时更新 direct_mastery_level(直接学习掌握度),用于判断达标时优先使用
         DB::table('student_knowledge_mastery')
             ->updateOrInsert(
                 ['student_id' => $studentId, 'kp_code' => $kpCode],
                 [
                     'mastery_level' => $newMastery,
+                    'direct_mastery_level' => $newMastery, // 直接学习掌握度,不会被子节点聚合覆盖
                     'confidence_level' => 0.0, // 不再计算置信度
                     'total_attempts' => ($historyMastery->total_attempts ?? 0) + $totalAttempts,
                     'correct_attempts' => ($historyMastery->correct_attempts ?? 0) + $correctAttempts,
@@ -328,23 +330,9 @@ class MasteryCalculator
         $results = [];
 
         foreach ($kpCodes as $kpCode) {
+            // calculateMasteryLevel 内部的 calculateMasteryWithExamDifficulty 已经会保存到数据库
+            // 包括 direct_mastery_level,所以这里不需要再次保存
             $masteryData = $this->calculateMasteryLevel($studentId, $kpCode);
-
-            // 保存到数据库
-            DB::table('student_knowledge_mastery')
-                ->updateOrInsert(
-                    ['student_id' => $studentId, 'kp_code' => $kpCode],
-                    [
-                        'mastery_level' => $masteryData['mastery'],
-                        'confidence_level' => $masteryData['confidence'],
-                        'total_attempts' => $masteryData['total_attempts'],
-                        'correct_attempts' => $masteryData['correct_attempts'],
-                        'mastery_trend' => $masteryData['trend'],
-                        'last_mastery_update' => now(),
-                        'updated_at' => now(),
-                    ]
-                );
-
             $results[$kpCode] = $masteryData;
         }
 

+ 65 - 29
app/Services/MathFormulaProcessor.php

@@ -55,9 +55,10 @@ class MathFormulaProcessor
 
             case 'delimited':
                 // 已包含定界符的内容($...$, $$...$$, \(...\), \[...\])
-                // 直接返回,cleanInsideDelimiters() 已经清理了内部内容
+                // cleanInsideDelimiters() 已经清理了内部内容
                 // 渲染工作由客户端 KaTeX 或服务端 KatexRenderer 完成
-                return $content;
+                // 【关键修复】将定界符内的 < > 编码为 HTML 实体,避免被浏览器当作 HTML 标签处理
+                return self::encodeAngleBracketsInDelimiters($content);
 
             case 'plain_text':
             default:
@@ -158,6 +159,41 @@ class MathFormulaProcessor
         return $content;
     }
 
+    /**
+     * 【新增】将定界符内的 < > 编码为 HTML 实体
+     * 避免 LaTeX 公式中的 < > 被浏览器当作 HTML 标签处理
+     * 例如:$x<4$ 中的 <4 会被浏览器当作无效标签移除
+     */
+    private static function encodeAngleBracketsInDelimiters(string $content): string
+    {
+        $encodeInner = function (string $tex): string {
+            // 将 < 和 > 编码为 HTML 实体,KaTeX 会正确处理这些实体
+            return str_replace(['<', '>'], ['&lt;', '&gt;'], $tex);
+        };
+
+        // 1. 处理 $$...$$ 块级公式
+        $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) use ($encodeInner) {
+            return '$$' . $encodeInner($matches[1]) . '$$';
+        }, $content);
+
+        // 2. 处理 $...$ 行内公式(避免与$$冲突)
+        $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) use ($encodeInner) {
+            return '$' . $encodeInner($matches[1]) . '$';
+        }, $content);
+
+        // 3. 处理 \(...\) 行内公式
+        $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) use ($encodeInner) {
+            return '\\(' . $encodeInner($matches[1]) . '\\)';
+        }, $content);
+
+        // 4. 处理 \[...\] 块级公式
+        $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) use ($encodeInner) {
+            return '\\[' . $encodeInner($matches[1]) . '\\]';
+        }, $content);
+
+        return $content;
+    }
+
     /**
      * 【新增】检查内容中是否包含任意定界符(不要求整个字符串被包裹)
      */
@@ -219,51 +255,49 @@ class MathFormulaProcessor
     private static function wrapPureMath(string $content): string
     {
         // 已经是纯数学格式,直接用 $ 包裹
-        return '$'.$content.'$';
+        // 【修复】编码 < > 避免被浏览器当作 HTML 标签
+        $encoded = str_replace(['<', '>'], ['&lt;', '&gt;'], $content);
+        return '$' . $encoded . '$';
     }
 
     /**
      * 清理定界符内部的 HTML 标签
+     * 【修复】不再使用 strip_tags(),因为它会把 LaTeX 中的 < > 当作标签删除
+     * 例如:$x<4$ 中的 <4 会被 strip_tags 误删
      */
     private static function cleanInsideDelimiters(string $content): string
     {
         // 修复:使用更精确的正则表达式,避免模式冲突
-        // 先处理 $$...$$ 模式,然后处理单个 $...$ 模式(但确保不被$$包含)
+        // 只移除真正的 HTML 标签(如 <span>, <br>, </div> 等),保留数学符号 < >
+
+        // 定义安全的 HTML 标签清理函数(只移除真正的 HTML 标签)
+        $cleanHtmlTags = function (string $tex): string {
+            // 只移除看起来像 HTML 标签的内容(以字母开头的标签)
+            // 例如:<span>, </div>, <br/>, 但保留 <4, >0, x<y 等数学表达式
+            $tex = preg_replace('/<\/?[a-zA-Z][a-zA-Z0-9]*[^>]*>/', '', $tex);
+            // 解码 HTML 实体
+            $tex = html_entity_decode($tex, ENT_QUOTES, 'UTF-8');
+            return trim($tex);
+        };
 
         // 1. 处理 $$...$$ 显示公式
-        $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) {
-            $cleanContent = strip_tags($matches[1]);
-            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
-            $cleanContent = trim($cleanContent);
-
-            return '$$'.$cleanContent.'$$';
+        $content = preg_replace_callback('/\$\$([\s\S]*?)\$\$/', function ($matches) use ($cleanHtmlTags) {
+            return '$$' . $cleanHtmlTags($matches[1]) . '$$';
         }, $content);
 
         // 2. 处理 \(...\) 行内公式
-        $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) {
-            $cleanContent = strip_tags($matches[1]);
-            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
-            $cleanContent = trim($cleanContent);
-
-            return '\\('.$cleanContent.'\\)';
+        $content = preg_replace_callback('/\\\\\(([\s\S]*?)\\\\\)/', function ($matches) use ($cleanHtmlTags) {
+            return '\\(' . $cleanHtmlTags($matches[1]) . '\\)';
         }, $content);
 
         // 3. 处理 \[...\] 显示公式
-        $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) {
-            $cleanContent = strip_tags($matches[1]);
-            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
-            $cleanContent = trim($cleanContent);
-
-            return '\\['.$cleanContent.'\\]';
+        $content = preg_replace_callback('/\\\\\[([\s\S]*?)\\\\\]/', function ($matches) use ($cleanHtmlTags) {
+            return '\\[' . $cleanHtmlTags($matches[1]) . '\\]';
         }, $content);
 
         // 4. 最后处理 $...$ 行内公式(避免与$$冲突)
-        $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) {
-            $cleanContent = strip_tags($matches[1]);
-            $cleanContent = html_entity_decode($cleanContent, ENT_QUOTES, 'UTF-8');
-            $cleanContent = trim($cleanContent);
-
-            return '$'.$cleanContent.'$';
+        $content = preg_replace_callback('/(?<!\$)\$([^$\n]+?)\$(?!\$)/', function ($matches) use ($cleanHtmlTags) {
+            return '$' . $cleanHtmlTags($matches[1]) . '$';
         }, $content);
 
         return $content;
@@ -313,7 +347,9 @@ class MathFormulaProcessor
                     return $math;
                 }
 
-                return '$'.$math.'$';
+                // 【修复】编码 < > 避免被浏览器当作 HTML 标签
+                $encoded = str_replace(['<', '>'], ['&lt;', '&gt;'], $math);
+                return '$' . $encoded . '$';
             }
 
             return $matches[0];

+ 2 - 0
app/Services/QuestionBankService.php

@@ -566,6 +566,8 @@ class QuestionBankService
                     'teacher_id' => $examData['teacher_id'] ?? '',
                     'paper_name' => $examData['paper_name'] ?? '未命名试卷',
                     'paper_type' => $examData['assembleType'] ?? 'auto_generated', // 组卷类型: 0 摸底; 1 智能组卷,2. 知识点组卷,3 教材组卷
+                    'diagnostic_chapter_id' => $examData['diagnostic_chapter_id'] ?? null, // 摸底的章节ID(章节摸底时记录)
+                    'explanation_kp_codes' => $examData['explanation_kp_codes'] ?? null, // 学案讲解的知识点列表(最多2个)
                     'total_questions' => 0, // 临时设为0,处理完题目后再更新
                     'total_score' => $examData['total_score'] ?? 0,
                     'status' => 'draft',

+ 2 - 1
app/Services/QuestionExpansionService.php

@@ -234,7 +234,8 @@ class QuestionExpansionService
         }
 
         try {
-            $questions = Question::whereIn('kp_code', $kpCodes)
+            $questions = Question::where('audit_status', 0) // 只获取审核通过的题目
+                ->whereIn('kp_code', $kpCodes)
                 ->whereNotIn('id', $excludeQuestionIds)
                 ->select(['id'])
                 ->limit($limit)

+ 128 - 57
app/Services/StudentProgressService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Cache;
+use App\Models\Student;
 
 class StudentProgressService
 {
@@ -15,8 +16,12 @@ class StudentProgressService
     {
         Log::info('获取学生学习进度', ['student_id' => $studentId]);
 
+        $studentGrade = Student::query()
+            ->where('student_id', $studentId)
+            ->value('grade');
+
         // 获取知识图谱结构
-        $graphData = $this->getKnowledgeGraphStructure();
+        $graphData = $this->getKnowledgeGraphStructure($studentGrade ? (int) $studentGrade : null);
         $leafKpCodes = $graphData['leafKpCodes'];
         $leafKpCodesSet = $graphData['leafKpCodesSet'];
         $kpCodes = $graphData['kpCodes'];
@@ -24,6 +29,18 @@ class StudentProgressService
 
         // 获取学生掌握度数据
         $mergedData = $this->getStudentMasteryData($studentId);
+        $mergedCountBefore = count($mergedData);
+        // 仅保留叶子节点数据,避免父节点干扰进度
+        $mergedData = array_filter($mergedData, function ($item) use ($leafKpCodesSet) {
+            return isset($leafKpCodesSet[$item['kp_code']]);
+        });
+        if ($mergedCountBefore > 0 && count($mergedData) !== $mergedCountBefore) {
+            Log::info('学习进度过滤父节点', [
+                'student_id' => $studentId,
+                'before' => $mergedCountBefore,
+                'after' => count($mergedData),
+            ]);
+        }
 
         // 如果没有数据,返回空结构(和批量接口行为一致)
         if (empty($mergedData)) {
@@ -44,9 +61,7 @@ class StudentProgressService
         }
 
         // 筛选叶子节点
-        $childMasteryData = collect($mergedData)->filter(function ($item) use ($leafKpCodesSet) {
-            return isset($leafKpCodesSet[$item['kp_code']]);
-        });
+        $childMasteryData = collect($mergedData);
 
         // 如果没有子知识点数据,也返回空结构
         if ($childMasteryData->isEmpty()) {
@@ -79,7 +94,7 @@ class StudentProgressService
             'child_mastery_sum' => round($totalChildMasterySum, 4),
             'max_child_score' => round($maxChildScore, 4),
             'learning_progress_percentage' => round($learningProgress * 100, 2),
-            'data_source' => implode(', ', collect($mergedData)->pluck('source_table')->unique()->toArray()),
+            'data_source' => 'student_knowledge_mastery',
             'child_mastery_max' => round($childMasteryData->max('mastery_level'), 4),
             'child_mastery_min' => round($childMasteryData->min('mastery_level'), 4),
             'child_mastery_avg' => round($childMasteryData->avg('mastery_level'), 4),
@@ -129,11 +144,13 @@ class StudentProgressService
 
         Log::info('批量获取学生学习进度', ['count' => count($studentIds)]);
 
-        // 获取知识图谱结构(所有学生共用)
-        $graphData = $this->getKnowledgeGraphStructure();
-        $leafKpCodes = $graphData['leafKpCodes'];
-        $leafKpCodesSet = $graphData['leafKpCodesSet'];
-        $maxChildScore = count($leafKpCodes) * 1.0;
+        $gradeMap = Student::query()
+            ->whereIn('student_id', $studentIds)
+            ->pluck('grade', 'student_id')
+            ->toArray();
+
+        $stageGraph = [];
+        $stageStats = [];
 
         // 批量获取掌握度数据
         $batchData = $this->getBatchStudentMasteryData($studentIds);
@@ -145,6 +162,19 @@ class StudentProgressService
 
         foreach ($studentIds as $studentId) {
             $studentId = (string) $studentId;
+            $grade = $gradeMap[$studentId] ?? null;
+            $stageKey = $this->getStageKey($grade ? (int) $grade : null);
+
+            if (!isset($stageGraph[$stageKey])) {
+                $stageGraph[$stageKey] = $this->getKnowledgeGraphStructure($grade ? (int) $grade : null);
+                $stageStats[$stageKey] = [
+                    'total_child_knowledge_points' => count($stageGraph[$stageKey]['leafKpCodes']),
+                    'student_count' => 0,
+                ];
+            }
+            $stageStats[$stageKey]['student_count']++;
+            $leafKpCodesSet = $stageGraph[$stageKey]['leafKpCodesSet'];
+            $maxChildScore = count($stageGraph[$stageKey]['leafKpCodes']) * 1.0;
             $mergedData = [];
 
             // 从 student_knowledge_mastery 获取
@@ -183,12 +213,25 @@ class StudentProgressService
                     'learning_progress' => 0,
                     'learning_progress_percentage' => 0,
                     'mastered_child_count' => 0,
-                    'total_child_count' => count($leafKpCodes),
+                    'total_child_count' => count($stageGraph[$stageKey]['leafKpCodes']),
                     'has_data' => false,
                 ];
                 continue;
             }
 
+            $mergedCountBefore = count($mergedData);
+            // 仅保留叶子节点数据,避免父节点干扰进度
+            $mergedData = array_filter($mergedData, function ($item) use ($leafKpCodesSet) {
+                return isset($leafKpCodesSet[$item['kp_code']]);
+            });
+            if ($mergedCountBefore > 0 && count($mergedData) !== $mergedCountBefore) {
+                Log::info('批量学习进度过滤父节点', [
+                    'student_id' => $studentId,
+                    'before' => $mergedCountBefore,
+                    'after' => count($mergedData),
+                ]);
+            }
+
             // 筛选叶子节点并计算
             $childMasterySum = 0;
             $masteredChildCount = 0;
@@ -207,7 +250,7 @@ class StudentProgressService
                 'learning_progress' => round($learningProgress, 6),
                 'learning_progress_percentage' => round($learningProgress * 100, 2),
                 'mastered_child_count' => $masteredChildCount,
-                'total_child_count' => count($leafKpCodes),
+                'total_child_count' => count($stageGraph[$stageKey]['leafKpCodes']),
                 'child_mastery_sum' => round($childMasterySum, 4),
                 'has_data' => true,
             ];
@@ -220,7 +263,7 @@ class StudentProgressService
             'data' => $results,
             'meta' => [
                 'total_students' => count($studentIds),
-                'total_child_knowledge_points' => count($leafKpCodes),
+                'stage_summary' => $stageStats,
                 'calculated_at' => now()->toISOString(),
             ]
         ];
@@ -229,26 +272,92 @@ class StudentProgressService
     /**
      * 获取知识图谱结构(缓存 5 分钟)
      */
-    private function getKnowledgeGraphStructure(): array
+    private function getKnowledgeGraphStructure(?int $grade = null): array
     {
-        return Cache::remember('knowledge_graph_structure', 300, function () {
-            $allKps = DB::connection('remote_mysql')
+        $stageKey = $this->getStageKey($grade);
+        $cacheKey = 'knowledge_graph_structure_' . $stageKey;
+
+        return Cache::remember($cacheKey, 300, function () use ($grade) {
+            $query = DB::connection('remote_mysql')
                 ->table('knowledge_points')
-                ->select(['kp_code', 'parent_kp_code'])
-                ->get();
+                ->select(['kp_code', 'parent_kp_code', 'grade']);
+
+            $stageLabel = $this->getStageLabel($grade);
+            if ($stageLabel) {
+                $query->where('grade', $stageLabel);
+            }
+
+            $allKps = $query->get();
 
             $kpCodes = $allKps->pluck('kp_code')->toArray();
             $parentCodes = $allKps->whereNotNull('parent_kp_code')->pluck('parent_kp_code')->unique()->toArray();
             $leafKpCodes = array_values(array_diff($kpCodes, $parentCodes));
+            $maxDepth = $this->calculateMaxDepth($allKps);
 
             return [
                 'kpCodes' => $kpCodes,
                 'leafKpCodes' => $leafKpCodes,
                 'leafKpCodesSet' => array_flip($leafKpCodes),
+                'maxDepth' => $maxDepth,
             ];
         });
     }
 
+    private function getStageLabel(?int $grade): ?string
+    {
+        if ($grade === null || $grade <= 0) {
+            return null;
+        }
+
+        if ($grade <= 6) {
+            return '小学';
+        }
+
+        if ($grade <= 9) {
+            return '初中';
+        }
+
+        return '高中';
+    }
+
+    private function getStageKey(?int $grade): string
+    {
+        $label = $this->getStageLabel($grade);
+        return $label ?: 'all';
+    }
+
+    private function calculateMaxDepth($knowledgePoints): int
+    {
+        $children = [];
+        foreach ($knowledgePoints as $kp) {
+            if (!empty($kp->parent_kp_code)) {
+                $children[$kp->parent_kp_code][] = $kp->kp_code;
+            }
+        }
+
+        $depthCache = [];
+        $maxDepth = 1;
+        $visit = function ($kpCode) use (&$visit, &$children, &$depthCache, &$maxDepth): int {
+            if (isset($depthCache[$kpCode])) {
+                return $depthCache[$kpCode];
+            }
+            if (empty($children[$kpCode])) {
+                $depthCache[$kpCode] = 1;
+                return 1;
+            }
+            $childDepths = array_map(fn ($child) => $visit($child), $children[$kpCode]);
+            $depthCache[$kpCode] = 1 + max($childDepths);
+            $maxDepth = max($maxDepth, $depthCache[$kpCode]);
+            return $depthCache[$kpCode];
+        };
+
+        foreach ($knowledgePoints as $kp) {
+            $visit($kp->kp_code);
+        }
+
+        return $maxDepth;
+    }
+
     /**
      * 获取单个学生的掌握度数据
      */
@@ -277,37 +386,6 @@ class StudentProgressService
             Log::warning('从 student_knowledge_mastery 获取数据失败', ['error' => $e->getMessage()]);
         }
 
-        try {
-            $simpleData = DB::connection('remote_mysql')
-                ->table('student_mastery')
-                ->where('student_id', $studentId)
-                ->select(['kp as kp_code', 'mastery', 'attempts as total_attempts', 'correct as correct_attempts', 'updated_at'])
-                ->get();
-
-            foreach ($simpleData as $item) {
-                $kpCode = $item->kp_code;
-                $masteryLevel = (float) $item->mastery;
-
-                if (isset($mergedData[$kpCode])) {
-                    if ($masteryLevel > $mergedData[$kpCode]['mastery_level']) {
-                        $mergedData[$kpCode]['mastery_level'] = $masteryLevel;
-                        $mergedData[$kpCode]['source_table'] = 'student_mastery (updated)';
-                    }
-                } else {
-                    $mergedData[$kpCode] = [
-                        'kp_code' => $kpCode,
-                        'mastery_level' => $masteryLevel,
-                        'total_attempts' => $item->total_attempts ?? 0,
-                        'correct_attempts' => $item->correct_attempts ?? 0,
-                        'source_table' => 'student_mastery',
-                        'updated_at' => $item->updated_at ?? null
-                    ];
-                }
-            }
-        } catch (\Exception $e) {
-            Log::warning('从 student_mastery 获取数据失败', ['error' => $e->getMessage()]);
-        }
-
         return $mergedData;
     }
 
@@ -323,16 +401,9 @@ class StudentProgressService
             ->get()
             ->groupBy('student_id');
 
-        $simpleData = DB::connection('remote_mysql')
-            ->table('student_mastery')
-            ->whereIn('student_id', $studentIds)
-            ->select(['student_id', 'kp as kp_code', 'mastery'])
-            ->get()
-            ->groupBy('student_id');
-
         return [
             'detailed' => $detailedData->toArray(),
-            'simple' => $simpleData->toArray(),
+            'simple' => [],
         ];
     }
 }

+ 30 - 0
database/migrations/2026_02_01_000001_add_diagnostic_chapter_id_to_papers_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('papers', function (Blueprint $table) {
+            $table->integer('diagnostic_chapter_id')->nullable()->after('paper_type')->comment('摸底的章节ID');
+            $table->index('diagnostic_chapter_id', 'idx_papers_diagnostic_chapter_id');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::table('papers', function (Blueprint $table) {
+            $table->dropIndex('idx_papers_diagnostic_chapter_id');
+            $table->dropColumn('diagnostic_chapter_id');
+        });
+    }
+};

+ 158 - 0
docs/chapter-diagnostic-plan.md

@@ -0,0 +1,158 @@
+# 章节摸底 + 按知识点顺序学习 方案文档
+
+## 1. 需求概述
+
+### 1.1 核心流程
+
+```
+章节摸底(20题) → 按知识点顺序学习 → 知识点达标(90%) → 下一个知识点 → 章节全部达标 → 下一章摸底
+```
+
+### 1.2 关键规则
+
+1. **知识点来源**:section 直接绑定的知识点(不扩展子知识点)
+2. **90%判断**:跳过没有题目的知识点
+3. **智能组卷**:按顺序找第一个未达标知识点,最多2个知识点
+4. **题目不够**:不从其他知识点补充,不够就不够
+5. **章节流转**:所有知识点达标 → 下一章摸底
+
+---
+
+## 2. assembleType 映射关系
+
+### 2.1 前端传入 → 后端实际处理
+
+| 前端传入 | 后端实际处理 | 说明 |
+|---------|-------------|------|
+| 0 (旧摸底) | 0 → buildChapterDiagnosticParams | 章节摸底(新逻辑) |
+| 9 (原摸底) | 0 → buildChapterDiagnosticParams | 章节摸底(新逻辑) |
+| 1 (智能组卷) | 8 → buildChapterIntelligentParams | 按知识点顺序学习(新逻辑) |
+| 2/3/4/5 | 保持不变 | 原有逻辑不动 |
+
+### 2.2 paper_type 值
+
+| paper_type | 含义 |
+|------------|------|
+| 0 | 摸底(包括章节摸底) |
+| 1 | 智能组卷 |
+| 2 | 知识点组卷 |
+| 3 | 教材组卷 |
+| 5 | 追练 |
+
+---
+
+## 3. 数据库修改
+
+### 3.1 papers 表增加字段
+
+```sql
+ALTER TABLE papers ADD COLUMN diagnostic_chapter_id INT NULL COMMENT '摸底的章节ID';
+```
+
+---
+
+## 4. 代码修改清单
+
+### 4.1 新增方法(不影响原有逻辑)
+
+| 文件 | 方法 | 用途 |
+|------|------|------|
+| ExamTypeStrategy.php | `buildChapterDiagnosticParams` | 章节摸底(新逻辑) |
+| ExamTypeStrategy.php | `buildChapterIntelligentParams` | 按知识点顺序学习(新逻辑) |
+| DiagnosticChapterService.php | `getChapterKnowledgePointsWithoutExpand` | 获取知识点(不扩展子知识点) |
+| DiagnosticChapterService.php | `getFirstUnmasteredKnowledgePointInOrder` | 按顺序找第一个未达标知识点 |
+| DiagnosticChapterService.php | `hasChapterDiagnostic` | 判断章节是否已摸底 |
+| StudentKnowledgeMastery.php | `allAtLeastSkipNoQuestions` | 判断达标(跳过无题知识点) |
+
+### 4.2 修改方法
+
+| 文件 | 方法 | 修改内容 |
+|------|------|---------|
+| ExamTypeStrategy.php | `buildParams` | 增加 assembleType 映射逻辑 |
+
+### 4.3 保持不变的方法
+
+- `buildDiagnosticParams`(原摸底)
+- `buildIntelligentAssembleParams`(原智能组卷)
+- `buildKnowledgePointAssembleParams`(知识点组卷)
+- `buildTextbookAssembleParams`(教材组卷)
+- `buildMistakeParams`(追练)
+- `buildGeneralParams`(通用)
+
+---
+
+## 5. 详细逻辑
+
+### 5.1 章节摸底 (buildChapterDiagnosticParams)
+
+```
+1. 获取学生当前教材
+2. 找第一个未完成摸底的章节:
+   - 遍历章节,检查 papers 表是否有 paper_type=0 且 diagnostic_chapter_id=章节ID 的记录
+   - 如果没有,说明这个章节需要摸底
+3. 获取该章节所有 section 绑定的知识点(不扩展子知识点)
+4. 过滤掉没有题目的知识点
+5. 用这些知识点出题(20题)
+6. 保存时记录 diagnostic_chapter_id
+```
+
+### 5.2 按知识点顺序学习 (buildChapterIntelligentParams)
+
+```
+1. 获取当前应该学习的章节(第一个有未达标知识点的章节)
+2. 检查该章节是否已摸底
+   - 如果没有摸底 → 返回章节摸底参数
+3. 获取该章节所有知识点(按 section 顺序)
+4. 按顺序遍历,找第一个未达标的知识点(跳过无题知识点)
+5. 检查题目数量:
+   - 如果不够20题,补充下一个未达标知识点
+   - 最多2个知识点
+6. 如果所有知识点都达标 → 返回下一章的摸底参数
+```
+
+### 5.3 90%达标判断 (allAtLeastSkipNoQuestions)
+
+```
+1. 遍历章节的所有知识点
+2. 检查每个知识点是否有题目
+   - 没有题目 → 跳过
+3. 检查掌握度是否 >= 90%
+   - 有题但没有掌握度记录 → 视为 0%,未达标
+4. 所有有题的知识点都达标 → 返回 true
+```
+
+---
+
+## 6. 执行步骤
+
+1. [x] 创建方案文档
+2. [x] 创建 migration 增加 diagnostic_chapter_id 字段
+   - 文件: `database/migrations/2026_02_01_000001_add_diagnostic_chapter_id_to_papers_table.php`
+3. [x] 修改 StudentKnowledgeMastery 增加新方法
+   - `allAtLeastSkipNoQuestions`: 判断达标(跳过无题知识点)
+   - `getFirstUnmasteredKpCode`: 获取第一个未达标的知识点
+4. [x] 修改 DiagnosticChapterService 增加新方法
+   - `getChapterKnowledgePointsSimple`: 获取知识点(不扩展子知识点)
+   - `hasChapterDiagnostic`: 判断章节是否已摸底
+   - `getFirstUndiagnosedChapter`: 获取第一个未摸底的章节
+   - `getCurrentLearningChapter`: 获取当前学习章节
+   - `getNextChapter`: 获取下一个章节
+   - `filterKpCodesWithQuestions`: 过滤有题的知识点
+   - `getUnmasteredKpCodesInOrder`: 按顺序获取未达标知识点
+5. [x] 修改 ExamTypeStrategy 增加新方法和映射逻辑
+   - 修改 `buildParams`: 增加 assembleType 映射
+   - 新增 `buildChapterDiagnosticParams`: 章节摸底
+   - 新增 `buildChapterIntelligentParams`: 按知识点顺序学习
+6. [x] 修改 QuestionBankService 保存 diagnostic_chapter_id
+7. [x] 修改 Paper 模型增加 diagnostic_chapter_id 字段
+8. [ ] 执行 migration
+9. [ ] 测试验证
+
+---
+
+## 7. 风险控制
+
+- 所有原有方法保持不变
+- 新增独立方法处理新逻辑
+- 通过 assembleType 映射控制流程
+- 即使新逻辑有问题,也不影响 assembleType=2/3/4/5 的原有功能