Преглед изворни кода

掌握度计算和学情报告生成

yemeishu пре 16 часа
родитељ
комит
7e59dba366
3 измењених фајлова са 856 додато и 443 уклоњено
  1. 576 290
      app/Services/ExamAnswerAnalysisService.php
  2. 154 122
      app/Services/MasteryCalculator.php
  3. 126 31
      app/Services/MistakeBookService.php

Разлика између датотеке није приказан због своје велике величине
+ 576 - 290
app/Services/ExamAnswerAnalysisService.php


+ 154 - 122
app/Services/MasteryCalculator.php

@@ -52,10 +52,9 @@ class MasteryCalculator
                 'kp_code' => $kpCode,
                 'attempts_provided' => $attempts !== null,
             ]);
+            // 【公司要求】返回值:只返回核心掌握度数据
             return [
                 'mastery' => 0.0,
-                'confidence' => 0.0,
-                'trend' => 'insufficient',
                 'total_attempts' => 0,
                 'correct_attempts' => 0,
                 'accuracy_rate' => 0.0,
@@ -74,6 +73,7 @@ class MasteryCalculator
 
         $masteryData = $this->calculateMasteryWithExamDifficulty($studentId, $kpCode, $attempts, $examBaseDifficulty);
 
+        // 【公司要求】日志输出:只记录核心掌握度数据
         Log::info('掌握度计算完成', [
             'student_id' => $studentId,
             'kp_code' => $kpCode,
@@ -82,8 +82,6 @@ class MasteryCalculator
             'total_attempts' => count($attempts),
             'correct_attempts' => $masteryData['correct_attempts'],
             'final_mastery' => $masteryData['mastery'],
-            'confidence' => $masteryData['confidence'],
-            'trend' => $masteryData['trend'],
         ]);
 
         return $masteryData;
@@ -114,7 +112,7 @@ class MasteryCalculator
             ->where('kp_code', $kpCode)
             ->first();
 
-        $oldMastery = $historyMastery->mastery_level ?? 0.5; // 默认0.5
+        $oldMastery = $historyMastery->mastery_level ?? 0.0; // 默认 0.0
 
         // 统计正确和错误次数
         $totalAttempts = count($attempts);
@@ -128,7 +126,7 @@ class MasteryCalculator
             $isCorrect = boolval($attempt['is_correct'] ?? false);
             $questionDifficulty = floatval($attempt['question_difficulty'] ?? 0.6);
 
-            // 难度映射:将0.0-1.0的浮点数难度映射为1-4等级
+            // 难度映射:将0.0-1.0的浮点数难度映射为 1-4 等级
             $questionLevel = $this->mapDifficultyToLevel($questionDifficulty);
 
             // 根据难度关系计算权重变化
@@ -153,75 +151,83 @@ class MasteryCalculator
             ]);
         }
 
-        // 计算平均变化(避免单次考试影响过大)
-        $averageChange = $totalAttempts > 0 ? $totalChange / $totalAttempts : 0.0;
+        // 【公司要求】数值更新:newMastery = oldMastery + change
+        $newMastery = $oldMastery + $totalChange;
 
-        // 应用权重调整(避免单次考试变化过大)
-        $weightedChange = $averageChange * min($totalAttempts / 10.0, 1.0); // 最多10次考试达到满权重
-
-        // 新掌握度 = 旧掌握度 + 加权变化
-        $newMastery = $oldMastery + $weightedChange;
-
-        // 边界限制:0.0 ~ 1.0
+        // 【公司要求】边界限制:0.0 ~ 1.0
         $newMastery = max(0.0, min(1.0, $newMastery));
 
-        // 计算置信度(基于答题次数)
-        $confidence = $this->calculateConfidence($attempts);
-
-        // 判断趋势
-        $trend = $this->determineTrend($attempts);
-
-        // 保存到数据库
+        // 【公司要求】保存到数据库(只保存核心掌握度数据)
         DB::table('student_knowledge_mastery')
             ->updateOrInsert(
                 ['student_id' => $studentId, 'kp_code' => $kpCode],
                 [
                     'mastery_level' => $newMastery,
-                    'confidence_level' => $confidence,
+                    'confidence_level' => 0.0, // 不再计算置信度
                     'total_attempts' => ($historyMastery->total_attempts ?? 0) + $totalAttempts,
                     'correct_attempts' => ($historyMastery->correct_attempts ?? 0) + $correctAttempts,
-                    'mastery_trend' => $trend,
+                    'mastery_trend' => 'stable', // 不再判断趋势,统一设为stable
                     'last_mastery_update' => now(),
                     'updated_at' => now(),
                 ]
             );
 
+        // 【公司要求】返回值:只返回核心掌握度数据
         return [
             'mastery' => round($newMastery, 4),
-            'confidence' => round($confidence, 4),
-            'trend' => $trend,
             'total_attempts' => $totalAttempts,
             'correct_attempts' => $correctAttempts,
             'accuracy_rate' => round(($correctAttempts / $totalAttempts) * 100, 2),
             'old_mastery' => $oldMastery,
-            'change' => round($weightedChange, 4),
+            'change' => round($totalChange, 4),
             'details' => [
                 'exam_base_difficulty' => $examBaseDifficulty,
-                'total_change' => round($totalChange, 4),
-                'average_change' => round($averageChange, 4),
-                'weighted_change' => round($weightedChange, 4),
+                'total_change' => round($totalChange, 4)
             ],
         ];
     }
 
     /**
-     * 难度映射:将0.0-1.0的浮点数难度映射为1-4等级
+     * 【公司要求】难度映射:将题目中0.0-1.0的浮点数难度映射为1-4等级
+     *
+     * 公司要求:
+     * 0.0 ~ 0.25 -> 1级(筑基)
+     * 0.25 ~ 0.5 -> 2级(提分)
+     * 0.5 ~ 0.75 -> 3级(培优)
+     * 0.75 ~ 1.0 -> 4级(竞赛)
+     *
+     * @param float $difficulty 题目难度(0.0-1.0浮点数)
+     * @return int 难度等级(1-4级)
      */
     private function mapDifficultyToLevel(float $difficulty): int
     {
         if ($difficulty >= 0.0 && $difficulty < 0.25) {
-            return 1; // 1级
+            return 1; // 1级(筑基)
         } elseif ($difficulty >= 0.25 && $difficulty < 0.5) {
-            return 2; // 2级
+            return 2; // 2级(提分)
         } elseif ($difficulty >= 0.5 && $difficulty < 0.75) {
-            return 3; // 3级
+            return 3; // 3级(培优)
         } else {
-            return 4; // 4级
+            return 4; // 4级(竞赛)
         }
     }
 
     /**
-     * 根据难度关系计算权重变化
+     * 【公司要求】根据难度关系计算权重变化
+     *
+     * 公司要求的三种难度关系及权重:
+     * 1. 越级(Question > {学案基准难度}):对 +0.15 / 错 -0.05
+     * 2. 适应(Question = {学案基准难度}):对 +0.10 / 错 -0.10
+     * 3. 降级(Question < {学案基准难度}):对 +0.05 / 错 -0.15
+     *
+     * 学案基准难度获取:
+     * - 来源:试卷表(papers.difficulty_category)
+     * - 映射:筑基→1级,提分→2级,培优→3级,竞赛→4级
+     *
+     * @param int $questionLevel 题目难度等级(1-4)
+     * @param int $examBaseDifficulty 学案基准难度(1-4)
+     * @param bool $isCorrect 答题是否正确
+     * @return float 权重变化值
      */
     private function calculateWeightByDifficultyRelation(int $questionLevel, int $examBaseDifficulty, bool $isCorrect): float
     {
@@ -237,77 +243,6 @@ class MasteryCalculator
         }
     }
 
-    /**
-     * 计算置信度
-     */
-    private function calculateConfidence(array $attempts): float
-    {
-        if (empty($attempts)) {
-            return 0.0;
-        }
-
-        $totalAttempts = count($attempts);
-
-        // 基于答题次数的置信度:答题越多,置信度越高
-        // 5次以下线性增长,5次以上增长放缓
-        if ($totalAttempts < 5) {
-            $baseConfidence = $totalAttempts / 5.0;
-        } else {
-            $baseConfidence = 0.5 + (1.0 - 0.5) * (1 - exp(-($totalAttempts - 5) / 10));
-        }
-
-        // 计算正确率
-        $correctAttempts = count(array_filter($attempts, fn($a) => $a['is_correct']));
-        $accuracyRate = $correctAttempts / $totalAttempts;
-
-        // 正确率也影响置信度
-        $accuracyFactor = 0.5 + $accuracyRate * 0.5;
-
-        // 综合置信度
-        $confidence = $baseConfidence * $accuracyFactor;
-
-        return min($confidence, 1.0);
-    }
-
-    /**
-     * 判断学习趋势
-     */
-    private function determineTrend(array $attempts): string
-    {
-        if (count($attempts) < 3) {
-            return 'insufficient'; // 数据不足
-        }
-
-        // 按时间排序
-        usort($attempts, function($a, $b) {
-            $timeA = strtotime($a['completed_at'] ?? $a['created_at'] ?? 0);
-            $timeB = strtotime($b['completed_at'] ?? $b['created_at'] ?? 0);
-            return $timeA <=> $timeB;
-        });
-
-        // 分为前后两部分
-        $midPoint = intdiv(count($attempts), 2);
-        $firstHalf = array_slice($attempts, 0, $midPoint);
-        $secondHalf = array_slice($attempts, $midPoint);
-
-        // 计算前半部分和后半部分的正确率
-        $firstHalfCorrect = count(array_filter($firstHalf, fn($a) => $a['is_correct']));
-        $firstHalfAccuracy = $firstHalfCorrect / count($firstHalf);
-
-        $secondHalfCorrect = count(array_filter($secondHalf, fn($a) => $a['is_correct']));
-        $secondHalfAccuracy = $secondHalfCorrect / count($secondHalf);
-
-        $improvement = $secondHalfAccuracy - $firstHalfAccuracy;
-
-        if ($improvement > 0.1) {
-            return 'improving'; // 提升
-        } elseif ($improvement < -0.1) {
-            return 'declining'; // 下降
-        } else {
-            return 'stable'; // 稳定
-        }
-    }
-
     /**
      * 获取学生的答题记录
      */
@@ -323,6 +258,18 @@ class MasteryCalculator
             ->get();
 
         foreach ($stepAttempts as $step) {
+            // 【关键修复】从题目表查询题目难度
+            $questionDifficulty = 0.6; // 默认难度
+            if ($step->question_id) {
+                $question = DB::table('questions')
+                    ->where('id', $step->question_id)
+                    ->orWhere('question_code', $step->question_id)
+                    ->first();
+                if ($question && $question->difficulty !== null) {
+                    $questionDifficulty = floatval($question->difficulty);
+                }
+            }
+
             $attempts[] = [
                 'student_id' => $step->student_id,
                 'paper_id' => $step->exam_id,
@@ -331,6 +278,7 @@ class MasteryCalculator
                 'is_correct' => (bool) $step->is_correct,
                 'score_obtained' => $step->step_score,
                 'max_score' => $step->step_score, // 步骤分数本身就是满分
+                'question_difficulty' => $questionDifficulty, // 【新增】题目难度
                 'created_at' => $step->created_at,
             ];
         }
@@ -343,6 +291,18 @@ class MasteryCalculator
                 ->get();
 
             foreach ($questionAttempts as $question) {
+                // 【关键修复】从题目表查询题目难度
+                $questionDifficulty = 0.6; // 默认难度
+                if ($question->question_id) {
+                    $questionInfo = DB::table('questions')
+                        ->where('id', $question->question_id)
+                        ->orWhere('question_code', $question->question_id)
+                        ->first();
+                    if ($questionInfo && $questionInfo->difficulty !== null) {
+                        $questionDifficulty = floatval($questionInfo->difficulty);
+                    }
+                }
+
                 $attempts[] = [
                     'student_id' => $question->student_id,
                     'paper_id' => $question->exam_id,
@@ -351,6 +311,7 @@ class MasteryCalculator
                     'is_correct' => ($question->score_obtained ?? 0) > 0,
                     'score_obtained' => $question->score_obtained ?? 0,
                     'max_score' => $question->max_score ?? 0,
+                    'question_difficulty' => $questionDifficulty, // 【新增】题目难度
                     'created_at' => $question->created_at,
                 ];
             }
@@ -471,12 +432,61 @@ class MasteryCalculator
     }
 
     /**
-     * 【新功能】计算父节点掌握度(子节点平均值)
+     * 【公司要求】计算父节点掌握度(所有子节点算术平均值)
+     *
+     * 公司要求:
+     * 1. 查询结构:通过 knowledge_points 表获取知识点的层级关系(parent_kp_code)
+     * 2. 平均值计算:如果一个知识点是父节点,它的掌握度不再从数据库直接读,
+     *    而是实时计算其下所有子节点掌握度的算术平均数
+     * 3. 多级递归:支持多级结构,能够从最底层逐级向上求平均
+     *
+     * 递归计算流程:
+     * - 从最底层叶子节点开始,逐级向上计算每个父节点的掌握度
+     * - 父节点掌握度 = 所有直接子节点掌握度的算术平均数
+     *
+     * @param string $studentId 学生ID
+     * @param string $parentKpCode 父节点编码
+     * @param int $maxDepth 最大递归深度(默认10级)
+     * @return float 父节点掌握度(0.0-1.0)
+     */
+    public function calculateParentMastery(string $studentId, string $parentKpCode, int $maxDepth = 3): float
+    {
+        return $this->calculateParentMasteryRecursive($studentId, $parentKpCode, 1, $maxDepth);
+    }
+
+    /**
+     * 【公司要求】递归计算父节点掌握度(多级结构支持)
+     *
+     * 多级递归计算流程:
+     * 1. 获取父节点的所有直接子节点
+     * 2. 对每个子节点:
+     *    - 如果子节点也是父节点,递归计算其掌握度
+     *    - 如果子节点是叶子节点,从 student_knowledge_mastery 表读取掌握度
+     * 3. 计算所有子节点掌握度的算术平均数
+     * 4. 返回父节点掌握度
+     *
+     * @param string $studentId 学生ID
+     * @param string $parentKpCode 父节点编码
+     * @param int $currentDepth 当前递归深度
+     * @param int $maxDepth 最大递归深度(防止无限递归)
+     * @return float 父节点掌握度
      */
-    public function calculateParentMastery(string $studentId, string $parentKpCode): float
+    private function calculateParentMasteryRecursive(string $studentId, string $parentKpCode, int $currentDepth, int $maxDepth): float
     {
         try {
-            // 获取所有子节点
+            // 【公司要求】防止无限递归
+            if ($currentDepth > $maxDepth) {
+                Log::warning('父节点掌握度计算达到最大递归深度', [
+                    'student_id' => $studentId,
+                    'parent_kp_code' => $parentKpCode,
+                    'current_depth' => $currentDepth,
+                    'max_depth' => $maxDepth
+                ]);
+                return 0.0;
+            }
+
+            // 【公司要求】查询结构:通过 knowledge_points 表获取知识点的层级关系(parent_kp_code)
+            // 获取所有直接子节点
             $childKps = DB::table('knowledge_points')
                 ->where('parent_kp_code', $parentKpCode)
                 ->pluck('kp_code')
@@ -484,44 +494,66 @@ class MasteryCalculator
 
             if (empty($childKps)) {
                 // 如果没有子节点,返回0
+                Log::debug('父节点没有子节点,返回0', [
+                    'student_id' => $studentId,
+                    'parent_kp_code' => $parentKpCode
+                ]);
                 return 0.0;
             }
 
-            // 获取所有子节点的掌握度
+            // 【公司要求】多级递归:支持多级结构,能够从最底层逐级向上求平均
+            // 获取所有子节点的掌握度(递归计算)
             $masteryLevels = [];
             foreach ($childKps as $childKpCode) {
-                $mastery = DB::table('student_knowledge_mastery')
-                    ->where('student_id', $studentId)
-                    ->where('kp_code', $childKpCode)
-                    ->value('mastery_level');
-
-                if ($mastery !== null) {
-                    $masteryLevels[] = floatval($mastery);
+                // 【公司要求】多级递归:递归计算子节点掌握度
+                $childMastery = $this->calculateParentMasteryRecursive($studentId, $childKpCode, $currentDepth + 1, $maxDepth);
+
+                // 如果子节点没有子节点,则从student_knowledge_mastery表读取
+                if ($childMastery == 0.0) {
+                    $mastery = DB::table('student_knowledge_mastery')
+                        ->where('student_id', $studentId)
+                        ->where('kp_code', $childKpCode)
+                        ->value('mastery_level');
+
+                    if ($mastery !== null) {
+                        $masteryLevels[] = floatval($mastery);
+                    }
+                } else {
+                    // 子节点有子节点,使用递归计算的结果
+                    $masteryLevels[] = $childMastery;
                 }
             }
 
             if (empty($masteryLevels)) {
+                Log::warning('父节点所有子节点都没有掌握度数据', [
+                    'student_id' => $studentId,
+                    'parent_kp_code' => $parentKpCode,
+                    'child_count' => count($childKps)
+                ]);
                 return 0.0;
             }
 
-            // 计算算术平均数
+            // 【公司要求】平均值计算:父节点掌握度 = 所有子节点掌握度的算术平均数
             $averageMastery = array_sum($masteryLevels) / count($masteryLevels);
+            $result = round($averageMastery, 4);
 
-            Log::info('父节点掌握度计算', [
+            Log::info('父节点掌握度计算完成', [
                 'student_id' => $studentId,
                 'parent_kp_code' => $parentKpCode,
                 'child_count' => count($childKps),
                 'mastery_count' => count($masteryLevels),
-                'average_mastery' => round($averageMastery, 4),
+                'average_mastery' => $result,
                 'child_masteries' => $masteryLevels,
+                'current_depth' => $currentDepth
             ]);
 
-            return round($averageMastery, 4);
+            return $result;
 
         } catch (\Exception $e) {
             Log::error('计算父节点掌握度失败', [
                 'student_id' => $studentId,
                 'parent_kp_code' => $parentKpCode,
+                'current_depth' => $currentDepth,
                 'error' => $e->getMessage(),
             ]);
             return 0.0;

+ 126 - 31
app/Services/MistakeBookService.php

@@ -70,14 +70,28 @@ class MistakeBookService
             $existingMistake = $query->first();
 
             if ($existingMistake) {
+                // 合并知识点到已有记录中
+                if ($kpIds) {
+                    $existingKpIds = $existingMistake->kp_ids ?? [];
+                    $newKpIds = is_array($kpIds) ? $kpIds : [$kpIds];
+
+                    // 合并并去重
+                    $mergedKpIds = array_values(array_unique(array_merge($existingKpIds, $newKpIds)));
+
+                    // 更新记录(仅更新 kp_ids)
+                    $existingMistake->update([
+                        'kp_ids' => $mergedKpIds,
+                    ]);
+                }
+
                 return [
                     'duplicate' => true,
                     'mistake_id' => $existingMistake->id,
-                    'message' => '错题已存在',
+                    'message' => '错题已存在,已合并知识点',
                 ];
             }
 
-            // 创建错题记录
+            // 创建错题记录(合并知识点到数组)
             $mistake = MistakeRecord::create([
                 'student_id' => $studentId,
                 'question_id' => $questionId,
@@ -87,7 +101,7 @@ class MistakeBookService
                 'question_text' => $questionText,
                 'knowledge_point' => $knowledgePoint,
                 'explanation' => $explanation,
-                'kp_ids' => $kpIds,
+                'kp_ids' => is_array($kpIds) ? $kpIds : ($kpIds ? [$kpIds] : null), // 确保是数组
                 'source' => $source,
                 'created_at' => $happenedAt,
                 'review_status' => MistakeRecord::REVIEW_STATUS_PENDING,
@@ -683,18 +697,19 @@ class MistakeBookService
         // 【新增】通过 question_id 关联获取题目详情
         $questionDetails = $this->getQuestionDetails($mistake->question_id);
 
-        // 【新增】通过 kp_ids 获取知识点信息
-        $knowledgePoints = $this->getKnowledgePoints($mistake->kp_ids);
+        // 【新增】通过 kp_ids 或 textbook_catalog_nodes_id 获取知识点信息
+        $knowledgePoints = $this->getKnowledgePoints(
+            $mistake->kp_ids,
+            $mistake->question_id,
+            $questionDetails['textbook_catalog_nodes_id'] ?? null
+        );
 
         $data = [
+            // ========== 错题记录基本信息 ==========
             'id' => $mistake->id,
             'student_id' => $mistake->student_id,
             'question_id' => $mistake->question_id,
             'paper_id' => $mistake->paper_id,
-            'question_text' => $mistake->question_text,
-            'student_answer' => $mistake->student_answer,
-            // 【优先】questions 表的答案,其次才是错题本自带的
-            'correct_answer' => $questionDetails['answer'] ?? $mistake->correct_answer,
             'created_at' => $mistake->created_at?->toISOString(),
             'review_status' => $mistake->review_status,
             'review_status_label' => $mistake->review_status_label,
@@ -705,25 +720,36 @@ class MistakeBookService
             'next_review_at' => $mistake->next_review_at?->toISOString(),
             'error_type' => $mistake->error_type,
             'error_type_label' => $mistake->error_type_label,
-            // 【移除】kp_ids、source、source_label、knowledge_point 字段
-            // 'kp_ids' => $mistake->kp_ids,
-            // 'source' => $mistake->source,
-            // 'source_label' => $mistake->source_label,
-            // 'knowledge_point' => $mistake->knowledge_point,
             'explanation' => $mistake->explanation,
             'skill_ids' => $mistake->skill_ids,
             'difficulty' => $mistake->difficulty,
             'difficulty_level' => $mistake->difficulty_level,
             'importance' => $mistake->importance,
             'mastery_level' => $mistake->mastery_level,
-            // 【新增】知识点信息
-            'knowledge_points' => $knowledgePoints,
-            // 【保留】options、question_type 字段(从 questions 表查询的)
+
+            // ========== 题目信息(优先呈现)==========
+            // 题目内容:优先使用 questions.stem,其次使用错题本自带
+            'question_text' => $questionDetails['stem'] ?? $mistake->question_text,
+            // 答案:优先使用 questions.answer,其次使用错题本自带
+            'correct_answer' => $questionDetails['answer'] ?? $mistake->correct_answer,
+            // 解题过程/解析:优先使用 questions.solution
+            'solution' => $questionDetails['solution'] ?? $mistake->explanation,
+            // 选项
             'options' => $questionDetails['options'] ?? null,
+            // 题目类型
             'question_type' => $questionDetails['question_type'] ?? null,
-            // 【移除】answer、solution 字段(从 questions 表查询的)
-            // 'answer' => $questionDetails['answer'] ?? null,
-            // 'solution' => $questionDetails['solution'] ?? null,
+            // 题目难度(从题目表获取)
+            'question_difficulty' => $questionDetails['difficulty'] ?? null,
+            // 题目标签
+            'question_tags' => $questionDetails['tags'] ?? null,
+            // 题目来源
+            'question_source' => $questionDetails['source'] ?? null,
+
+            // 学生答案(始终使用错题本中的记录)
+            'student_answer' => $mistake->student_answer,
+
+            // ========== 知识点信息 ==========
+            'knowledge_points' => $knowledgePoints,
         ];
 
         if ($detailed) {
@@ -788,12 +814,15 @@ class MistakeBookService
             }
 
             return [
-                'stem' => $question->stem ?? null,
-                'options' => $question->options ?? null,
-                'answer' => $question->answer ?? null,
-                'solution' => $question->solution ?? null,
+                'stem' => $question->stem ?? null,  // 题目内容(题干)
+                'options' => $question->options ?? null,  // 选项
+                'answer' => $question->answer ?? null,  // 答案
+                'solution' => $question->solution ?? null,  // 解题过程/解析
                 'difficulty' => $question->difficulty ?? null,
                 'question_type' => $question->question_type ?? null,
+                'textbook_catalog_nodes_id' => $question->textbook_catalog_nodes_id ?? null,
+                'tags' => $question->tags ?? null,  // 标签
+                'source' => $question->source ?? null,  // 来源
             ];
         } catch (\Exception $e) {
             Log::warning('获取题目详情失败', [
@@ -805,18 +834,39 @@ class MistakeBookService
     }
 
     /**
-     * 【新增】通过 kp_ids 获取知识点信息
+     * 【新增】通过 kp_ids 或 textbook_catalog_nodes_id 获取知识点信息
+     *
+     * 逻辑:
+     * 1. 如果有 kp_ids,直接从 knowledge_points 表查询
+     * 2. 如果没有 kp_ids,通过 textbook_catalog_nodes_id 关联 textbook_chapter_knowledge_relation 表获取 kp_id
      */
-    private function getKnowledgePoints(?array $kpIds): array
+    private function getKnowledgePoints(?array $kpIds, ?string $questionId = null, ?int $catalogNodesId = null): array
     {
-        if (empty($kpIds)) {
-            return [];
+        // 如果有 kp_ids,直接查询
+        if (!empty($kpIds)) {
+            return $this->queryKnowledgePointsByCodes($kpIds);
         }
 
+        // 如果没有 kp_ids,通过题目ID和目录节点ID关联查询
+        if ($questionId || $catalogNodesId) {
+            $kpCodesFromRelation = $this->getKpCodesFromRelation($questionId, $catalogNodesId);
+            if (!empty($kpCodesFromRelation)) {
+                return $this->queryKnowledgePointsByCodes($kpCodesFromRelation);
+            }
+        }
+
+        return [];
+    }
+
+    /**
+     * 通过 kp_codes 查询知识点信息
+     */
+    private function queryKnowledgePointsByCodes(array $kpCodes): array
+    {
         try {
             $knowledgePoints = \DB::connection('mysql')
                 ->table('knowledge_points')
-                ->whereIn('kp_code', $kpIds)
+                ->whereIn('kp_code', $kpCodes)
                 ->select(['kp_code', 'name', 'parent_kp_code'])
                 ->get()
                 ->toArray();
@@ -829,8 +879,53 @@ class MistakeBookService
                 ];
             }, $knowledgePoints);
         } catch (\Exception $e) {
-            Log::warning('获取知识点信息失败', [
-                'kp_ids' => $kpIds,
+            Log::warning('通过kp_codes获取知识点信息失败', [
+                'kp_codes' => $kpCodes,
+                'error' => $e->getMessage()
+            ]);
+            return [];
+        }
+    }
+
+    /**
+     * 通过 textbook_catalog_nodes_id 关联 textbook_chapter_knowledge_relation 表获取 kp_codes
+     */
+    private function getKpCodesFromRelation(?string $questionId, ?int $catalogNodesId): array
+    {
+        try {
+            $query = \DB::connection('mysql')
+                ->table('textbook_chapter_knowledge_relation')
+                ->where('is_deleted', 0);
+
+            if ($catalogNodesId) {
+                $query->where('catalog_chapter_id', $catalogNodesId);
+            } elseif ($questionId) {
+                // 如果没有catalog_nodes_id,尝试从questions表获取
+                $question = \DB::connection('mysql')
+                    ->table('questions')
+                    ->where('id', $questionId)
+                    ->first();
+
+                if ($question && $question->textbook_catalog_nodes_id) {
+                    $query->where('catalog_chapter_id', $question->textbook_catalog_nodes_id);
+                }
+            }
+
+            $relations = $query->select('kp_code')->get();
+
+            if ($relations->isNotEmpty()) {
+                Log::debug('通过目录节点关联获取到知识点', [
+                    'question_id' => $questionId,
+                    'catalog_nodes_id' => $catalogNodesId,
+                    'kp_codes' => $relations->pluck('kp_code')->toArray()
+                ]);
+            }
+
+            return $relations->pluck('kp_code')->toArray();
+        } catch (\Exception $e) {
+            Log::warning('通过目录节点关联获取kp_codes失败', [
+                'question_id' => $questionId,
+                'catalog_nodes_id' => $catalogNodesId,
                 'error' => $e->getMessage()
             ]);
             return [];

Неке датотеке нису приказане због велике количине промена