Jelajahi Sumber

feat: 调整章节摸底和按章节知识点顺序组卷

gwd 1 Minggu lalu
induk
melakukan
bc175d794d

+ 2 - 0
app/Models/Paper.php

@@ -21,6 +21,7 @@ class Paper extends Model
         'teacher_id',
         'paper_name',
         'paper_type',
+        'diagnostic_chapter_id', // 摸底的章节ID(章节摸底时记录)
         'total_questions',
         'total_score',
         'status',
@@ -41,6 +42,7 @@ class Paper extends Model
         'status' => 'string',
         'difficulty_category' => 'string',
         'paper_type' => 'integer',
+        'diagnostic_chapter_id' => 'integer',
         'created_at' => 'datetime',
         'updated_at' => 'datetime',
         'completed_at' => 'datetime',

+ 92 - 0
app/Models/StudentKnowledgeMastery.php

@@ -134,6 +134,98 @@ 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; // 没有知识点,视为达标
+        }
+
+        // 获取掌握度
+        $levels = self::query()
+            ->where('student_id', $studentId)
+            ->whereIn('kp_code', $kpCodes)
+            ->pluck('mastery_level', 'kp_code')
+            ->toArray();
+
+        // 获取有题目的知识点
+        $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;
+
+            $level = isset($levels[$kpCode]) ? (float) $levels[$kpCode] : 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;
+        }
+
+        // 获取掌握度
+        $levels = self::query()
+            ->where('student_id', $studentId)
+            ->whereIn('kp_code', $kpCodes)
+            ->pluck('mastery_level', 'kp_code')
+            ->toArray();
+
+        // 获取有题目的知识点
+        $kpCodesWithQuestions = \App\Models\Question::query()
+            ->whereIn('kp_code', $kpCodes)
+            ->distinct()
+            ->pluck('kp_code')
+            ->toArray();
+
+        foreach ($kpCodes as $kpCode) {
+            // 跳过没有题目的知识点
+            if (!in_array($kpCode, $kpCodesWithQuestions)) {
+                continue;
+            }
+
+            $level = isset($levels[$kpCode]) ? (float) $levels[$kpCode] : 0.0;
+            if ($level < $threshold) {
+                return $kpCode;
+            }
+        }
+
+        return null; // 全部达标
+    }
+
     /**
      * 计算掌握度等级
      */

+ 313 - 0
app/Services/DiagnosticChapterService.php

@@ -242,4 +242,317 @@ 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)
+            ->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,
+                    'chapter_id' => $chapter->id,
+                    'has_diagnostic' => $hasDiagnostic,
+                    '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()
+            ->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 [];
+        }
+
+        // 获取掌握度
+        $levels = StudentKnowledgeMastery::query()
+            ->where('student_id', $studentId)
+            ->whereIn('kp_code', $kpCodes)
+            ->pluck('mastery_level', 'kp_code')
+            ->toArray();
+
+        // 获取每个知识点的题目数量
+        $questionCounts = \App\Models\Question::query()
+            ->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;
+            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_count' => count($kpCodes),
+            'result_kp_count' => count($result),
+            'total_questions' => $totalQuestions,
+            'threshold' => $threshold,
+        ]);
+
+        return $result;
+    }
 }

+ 208 - 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))
         };
     }
@@ -1677,4 +1692,187 @@ 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' => $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' => $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,
+            'textbook_id' => $textbookId,
+            'grade' => $grade,
+            'paper_name' => $params['paper_name'] ?? ('智能学习_' . now()->format('Ymd_His')),
+            'assembleType' => 1, // paper_type 记录为智能组卷
+        ]);
+
+        return $this->buildKnowledgePointAssembleParams($enhanced);
+    }
 }

+ 1 - 0
app/Services/QuestionBankService.php

@@ -566,6 +566,7 @@ 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(章节摸底时记录)
                     'total_questions' => 0, // 临时设为0,处理完题目后再更新
                     'total_score' => $examData['total_score'] ?? 0,
                     'status' => 'draft',

+ 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 的原有功能