Parcourir la source

学情分析修复

yemeishu il y a 10 heures
Parent
commit
468fde4df8

+ 3 - 0
app/DTO/ExamAnalysisDataDto.php

@@ -13,6 +13,7 @@ class ExamAnalysisDataDto
         public readonly array $student,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
         public readonly array $insights,
         public readonly array $recommendations,
         public readonly ?string $analysisId = null
@@ -28,6 +29,7 @@ class ExamAnalysisDataDto
             student: $data['student'] ?? [],
             questions: $data['questions'] ?? [],
             mastery: $data['mastery'] ?? [],
+            parentMasteryLevels: $data['parent_mastery_levels'] ?? [], // 新增:父节点掌握度数据
             insights: $data['insights'] ?? [],
             recommendations: $data['recommendations'] ?? [],
             analysisId: $data['analysis_id'] ?? $data['analysisId'] ?? null,
@@ -44,6 +46,7 @@ class ExamAnalysisDataDto
             'student' => $this->student,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
             'insights' => $this->insights,
             'recommendations' => $this->recommendations,
             'analysis_id' => $this->analysisId,

+ 3 - 0
app/DTO/ReportPayloadDto.php

@@ -13,6 +13,7 @@ class ReportPayloadDto
         public readonly array $student,
         public readonly array $questions,
         public readonly array $mastery,
+        public readonly array $parentMasteryLevels, // 新增:父节点掌握度数据
         public readonly array $questionInsights,
         public readonly array $recommendations,
         public readonly array $analysisData = []
@@ -28,6 +29,7 @@ class ReportPayloadDto
             student: $dto->student,
             questions: $dto->questions,
             mastery: $dto->mastery,
+            parentMasteryLevels: $dto->parentMasteryLevels, // 新增:父节点掌握度数据
             questionInsights: $dto->insights,
             recommendations: $dto->recommendations,
             analysisData: $dto->toArray()
@@ -44,6 +46,7 @@ class ReportPayloadDto
             'student' => $this->student,
             'questions' => $this->questions,
             'mastery' => $this->mastery,
+            'parent_mastery_levels' => $this->parentMasteryLevels, // 新增:父节点掌握度数据
             'question_insights' => $this->questionInsights,
             'recommendations' => $this->recommendations,
             'analysis_data' => $this->analysisData,

+ 72 - 0
app/Filament/Pages/StudentAnalysis.php

@@ -34,6 +34,7 @@ class StudentAnalysis extends Page
     public array $weaknesses = [];
     public array $skills = [];
     public array $learningPath = [];
+    public array $knowledgePointHierarchy = []; // 【新增】父子知识点层级关系
 
     /**
      * 获取所有学生列表
@@ -82,6 +83,9 @@ class StudentAnalysis extends Page
 
         // 6. 获取知识点掌握度详情
         $this->masteryData = $overview['details'] ?? [];
+
+        // 7. 【新增】获取父子知识点层级关系
+        $this->knowledgePointHierarchy = $this->getKnowledgePointHierarchy($this->selectedStudentId);
     }
 
     /**
@@ -117,6 +121,74 @@ class StudentAnalysis extends Page
         return $path;
     }
 
+    /**
+     * 【新增】获取父子知识点层级关系
+     */
+    private function getKnowledgePointHierarchy(string $studentId): array
+    {
+        try {
+            // 使用MasteryCalculator的getStudentMasteryOverviewWithHierarchy方法
+            $masteryCalculator = app(MasteryCalculator::class);
+            $hierarchyData = $masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
+
+            // 构建父子关系数据
+            $hierarchy = [
+                'parent_mastery_levels' => $hierarchyData['parent_mastery_levels'] ?? [],
+                'child_knowledge_points' => [], // 每个父节点下的子知识点
+            ];
+
+            // 获取所有知识点及其父节点信息
+            $allKnowledgePoints = \DB::connection('mysql')
+                ->table('knowledge_points as kp')
+                ->leftJoin('student_knowledge_mastery as skm', function($join) use ($studentId) {
+                    $join->on('kp.kp_code', '=', 'skm.kp_code')
+                         ->where('skm.student_id', '=', $studentId);
+                })
+                ->select([
+                    'kp.kp_code',
+                    'kp.cn_name as kp_name',
+                    'kp.parent_kp_code',
+                    'kp.level',
+                    'skm.mastery_level',
+                    'skm.confidence_level',
+                    'skm.total_attempts',
+                    'skm.correct_attempts',
+                ])
+                ->get();
+
+            // 按父节点分组
+            foreach ($allKnowledgePoints as $kp) {
+                $parentCode = $kp->parent_kp_code ?: 'ROOT'; // 根节点标记
+
+                if (!isset($hierarchy['child_knowledge_points'][$parentCode])) {
+                    $hierarchy['child_knowledge_points'][$parentCode] = [];
+                }
+
+                $hierarchy['child_knowledge_points'][$parentCode][] = [
+                    'kp_code' => $kp->kp_code,
+                    'kp_name' => $kp->kp_name ?? $kp->kp_code,
+                    'level' => $kp->level ?? 1,
+                    'mastery_level' => floatval($kp->mastery_level ?? 0),
+                    'confidence_level' => floatval($kp->confidence_level ?? 0),
+                    'total_attempts' => intval($kp->total_attempts ?? 0),
+                    'correct_attempts' => intval($kp->correct_attempts ?? 0),
+                ];
+            }
+
+            return $hierarchy;
+
+        } catch (\Exception $e) {
+            \Illuminate\Support\Facades\Log::error('获取知识点层级关系失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage()
+            ]);
+            return [
+                'parent_mastery_levels' => [],
+                'child_knowledge_points' => []
+            ];
+        }
+    }
+
     private function getMasteryDetails(string $studentId): array
     {
         try {

+ 10 - 72
app/Http/Controllers/Api/ExamAnalysisApiController.php

@@ -7,6 +7,7 @@ use App\Services\ExamAnalysisService;
 use App\Services\TaskManager;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 
 class ExamAnalysisApiController extends Controller
@@ -126,15 +127,15 @@ class ExamAnalysisApiController extends Controller
     public function getPdfUrl(string $paperId): JsonResponse
     {
         try {
-            // 首先尝试从 student_reports 表直接查询(最快速的方式)
-            $report = \App\Models\StudentReport::where('paper_id', $paperId)
-                ->where('report_type', 'exam_analysis')
+            // 从 exam_analysis_results 表查询PDF URL
+            $report = DB::table('exam_analysis_results')
+                ->where('paper_id', $paperId)
                 ->first();
 
-            if ($report && $report->pdf_url && $report->generation_status === 'completed') {
+            if ($report && !empty($report->analysis_pdf_url)) {
                 Log::info('学情报告PDF URL查询成功(从数据库)', [
                     'paper_id' => $paperId,
-                    'pdf_url' => $report->pdf_url,
+                    'pdf_url' => $report->analysis_pdf_url,
                 ]);
 
                 return response()->json([
@@ -142,78 +143,15 @@ class ExamAnalysisApiController extends Controller
                     'data' => [
                         'paper_id' => $paperId,
                         'status' => 'completed',
-                        'pdf_url' => $report->pdf_url,
+                        'pdf_url' => $report->analysis_pdf_url,
                         'message' => '报告已生成',
-                        'generated_at' => $report->generated_at?->toISOString(),
+                        'generated_at' => $report->created_at ?? null,
                     ]
                 ], 200, [], JSON_UNESCAPED_SLASHES);
             }
 
-            // 如果数据库中没有,尝试从任务系统查找
-            $task = $this->taskManager->findAnalysisTaskByPaperId($paperId);
-
-            if ($task) {
-                $status = $task['status'];
-                $pdfUrl = $task['pdf_url'] ?? null;
-
-                if ($status === \App\Services\TaskManager::STATUS_COMPLETED && $pdfUrl) {
-                    // 任务已完成且有PDF URL
-                    Log::info('学情报告PDF URL查询成功(从任务系统)', [
-                        'paper_id' => $paperId,
-                        'task_id' => $task['task_id'],
-                        'pdf_url' => $pdfUrl,
-                    ]);
-
-                    return response()->json([
-                        'success' => true,
-                        'data' => [
-                            'paper_id' => $paperId,
-                            'status' => 'completed',
-                            'pdf_url' => $pdfUrl,
-                            'message' => '报告已生成',
-                            'generated_at' => $task['completed_at'] ?? null,
-                        ]
-                    ], 200, [], JSON_UNESCAPED_SLASHES);
-                } elseif ($status === \App\Services\TaskManager::STATUS_PROCESSING) {
-                    // 任务正在处理中
-                    Log::info('学情报告正在生成中', [
-                        'paper_id' => $paperId,
-                        'task_id' => $task['task_id'],
-                        'progress' => $task['progress'] ?? 0,
-                    ]);
-
-                    return response()->json([
-                        'success' => true,
-                        'data' => [
-                            'paper_id' => $paperId,
-                            'status' => 'processing',
-                            'pdf_url' => null,
-                            'message' => '报告正在生成中,请稍后刷新页面查看',
-                            'progress' => $task['progress'] ?? 0,
-                        ]
-                    ], 200, [], JSON_UNESCAPED_SLASHES);
-                } elseif ($status === \App\Services\TaskManager::STATUS_FAILED) {
-                    // 任务失败
-                    Log::warning('学情报告生成失败', [
-                        'paper_id' => $paperId,
-                        'task_id' => $task['task_id'],
-                        'error' => $task['error'] ?? '未知错误',
-                    ]);
-
-                    return response()->json([
-                        'success' => false,
-                        'data' => [
-                            'paper_id' => $paperId,
-                            'status' => 'failed',
-                            'pdf_url' => null,
-                            'message' => '报告生成失败:' . ($task['error'] ?? '未知错误'),
-                        ]
-                    ], 200, [], JSON_UNESCAPED_SLASHES);
-                }
-            }
-
-            // 既没有完成的任务,也没有正在进行的任务
-            Log::info('未找到学情报告任务', ['paper_id' => $paperId]);
+            // 如果数据库中没有找到报告
+            Log::info('未找到学情报告', ['paper_id' => $paperId]);
 
             return response()->json([
                 'success' => true,

+ 242 - 79
app/Http/Controllers/Api/PaperSubmitAnalysisController.php

@@ -34,10 +34,10 @@ class PaperSubmitAnalysisController extends Controller
      *
      * 请求格式:
      * {
-     *     "paperId": "paper_661325736792",
+     *     "paper_id": "paper_661325736792",
      *     "questions": [
      *         {
-     *             "question_bank_id": 876,
+     *             "question_id": 876,          // 前端使用 question_id
      *             "student_answer": "C",
      *             "is_correct": [0],           // 0=错, 1=对;简答题分步骤 [1,0,1]
      *             "teacher_comment": null
@@ -48,11 +48,11 @@ class PaperSubmitAnalysisController extends Controller
     public function analyze(Request $request): JsonResponse
     {
         try {
-            // 1. 验证请求数据
+            // 1. 验证请求数据(前端使用 question_id)
             $validator = Validator::make($request->all(), [
-                'paperId' => 'required|string|max:255',
+                'paper_id' => 'required|string|max:255',
                 'questions' => 'required|array|min:1',
-                'questions.*.question_bank_id' => 'required|integer',
+                'questions.*.question_id' => 'required|integer',  // 前端使用 question_id
                 'questions.*.student_answer' => 'nullable|string',
                 'questions.*.is_correct' => 'required|array|min:1',
                 'questions.*.is_correct.*' => 'integer|in:0,1',
@@ -67,7 +67,7 @@ class PaperSubmitAnalysisController extends Controller
                 ], 422);
             }
 
-            $paperId = $request->input('paperId');
+            $paperId = $request->input('paper_id');
             $questionsData = $request->input('questions');
 
             Log::info('开始处理试卷提交分析', [
@@ -94,7 +94,10 @@ class PaperSubmitAnalysisController extends Controller
                 ], 400);
             }
 
-            // 3. 获取题目详情并转换数据格式
+            // 3. 转换前端数据:将 question_id 转换为 question_bank_id
+            $questionsData = $this->convertQuestionIds($questionsData);
+
+            // 4. 获取题目详情并转换数据格式
             $transformedQuestions = $this->transformQuestionsData($questionsData, $paperId);
 
             // 4. 调用学情分析服务(可能失败,不影响错题本写入)
@@ -102,7 +105,7 @@ class PaperSubmitAnalysisController extends Controller
             $analysisError = null;
             try {
                 $examData = [
-                    'exam_id' => $paperId,
+                    'paper_id' => $paperId,
                     'student_id' => $studentId,
                     'questions' => $transformedQuestions['questions'],
                 ];
@@ -168,22 +171,51 @@ class PaperSubmitAnalysisController extends Controller
 
     /**
      * 转换前端数据格式为分析服务所需格式
+     * 【增强】处理未提交的题目,标记为"回答正确"
      */
     private function transformQuestionsData(array $questionsData, string $paperId): array
     {
         $transformedQuestions = [];
         $questionDetails = [];
-        $questionBankIds = array_column($questionsData, 'question_bank_id');
 
-        // 批量获取题目详情
-        $questionBankData = $this->fetchQuestionBankData($questionBankIds);
+        // 获取试卷中的所有题目
+        $allPaperQuestions = PaperQuestion::where('paper_id', $paperId)->get();
+        $allQuestionBankIds = $allPaperQuestions->pluck('question_bank_id')->toArray();
+
+        // 区分已提交和未提交的题目
+        $submittedIds = array_column($questionsData, 'question_bank_id');  // 转换后使用 question_bank_id
+        $notSubmittedIds = array_diff($allQuestionBankIds, $submittedIds);
+
+        Log::info('题目提交情况分析', [
+            'paper_id' => $paperId,
+            'total_questions' => count($allQuestionBankIds),
+            'submitted_count' => count($submittedIds),
+            'not_submitted_count' => count($notSubmittedIds),
+            'submitted_ids' => $submittedIds,
+            'not_submitted_ids' => $notSubmittedIds,
+        ]);
+
+        // 批量获取所有题目的详情(包括未提交的)
+        // 【优化】使用新的通用方法直接从题库关联知识点
+        $allQuestionBankData = [];
+        foreach ($allQuestionBankIds as $questionBankId) {
+            $questionData = $this->questionBankService->getQuestionKnowledgePoint($questionBankId);
+            $allQuestionBankData[$questionBankId] = $questionData;
+
+            // 调试日志
+            Log::info('获取题目知识点', [
+                'question_bank_id' => $questionBankId,
+                'kp_code' => $questionData['kp_code'] ?? null,
+                'kp_name' => $questionData['kp_name'] ?? null,
+            ]);
+        }
 
-        // 获取试卷中题目的分数信息
-        $paperQuestions = PaperQuestion::where('paper_id', $paperId)
-            ->whereIn('question_bank_id', $questionBankIds)
+        // 获取所有题目的分数信息
+        $allPaperQuestionsCollection = PaperQuestion::where('paper_id', $paperId)
             ->get()
             ->keyBy('question_bank_id');
 
+        // 处理已提交的题目
         foreach ($questionsData as $index => $questionData) {
             $questionBankId = $questionData['question_bank_id'];
             $isCorrectArray = $questionData['is_correct'];
@@ -191,26 +223,30 @@ class PaperSubmitAnalysisController extends Controller
             $teacherComment = $questionData['teacher_comment'] ?? null;
 
             // 获取题目详情
-            $qbData = $questionBankData[$questionBankId] ?? null;
-            $paperQuestion = $paperQuestions->get($questionBankId);
+            $qbData = $allQuestionBankData[$questionBankId] ?? null;
+            $paperQuestion = $allPaperQuestionsCollection->get($questionBankId);
 
             // 确定题目分数
-            $maxScore = $paperQuestion->score ?? ($qbData['score'] ?? 5.0);
-            $kpCode = $qbData['kp_code'] ?? ($paperQuestion->knowledge_point ?? 'K-GENERAL');
+            $maxScore = $paperQuestion->score ?? 5.0;
+            $kpCode = $qbData['kp_code'] ?? null;
+            if (!$kpCode) {
+                throw new \Exception("题目 {$questionBankId} 缺少知识点代码");
+            }
             $kpName = $qbData['kp_name'] ?? $kpCode;
-            $questionType = $qbData['question_type'] ?? ($paperQuestion->question_type ?? 'unknown');
+            $questionType = $qbData['question_type'] ?? 'unknown';
 
             // 保存详情供后续使用
             $questionDetails[$questionBankId] = [
                 'question_bank_id' => $questionBankId,
-                'stem' => $qbData['stem'] ?? ($paperQuestion->question_text ?? ''),
-                'answer' => $qbData['answer'] ?? ($paperQuestion->correct_answer ?? ''),
-                'solution' => $qbData['solution'] ?? ($paperQuestion->solution ?? ''),
+                'stem' => $qbData['question_content'] ?? ($paperQuestion->question_text ?? ''),
+                'answer' => $qbData['question_answer'] ?? ($paperQuestion->correct_answer ?? ''),
+                'solution' => $paperQuestion->solution ?? '',
                 'kp_code' => $kpCode,
                 'kp_name' => $kpName,
                 'question_type' => $questionType,
                 'max_score' => $maxScore,
-                'skills' => $qbData['skills'] ?? [],
+                'skills' => [],
+                'is_submitted' => true,
             ];
 
             // 转换 is_correct 数组为 steps 格式
@@ -239,9 +275,67 @@ class PaperSubmitAnalysisController extends Controller
                 'score' => $maxScore,
                 'score_obtained' => round($scoreObtained, 2),
                 'steps' => $totalSteps > 1 ? $steps : [], // 单步骤不传steps
+                'kp_code' => $kpCode, // 【修复】添加知识点代码,供ExamAnswerAnalysisService使用
+                'kp_name' => $kpName, // 【修复】添加知识点名称
+            ];
+        }
+
+        // 【新增】处理未提交的题目:标记为"回答正确"
+        foreach ($notSubmittedIds as $questionBankId) {
+            // 获取题目详情
+            $qbData = $allQuestionBankData[$questionBankId] ?? null;
+            $paperQuestion = $allPaperQuestionsCollection->get($questionBankId);
+
+            // 确定题目分数
+            $maxScore = $paperQuestion->score ?? 5.0;
+            $kpCode = $qbData['kp_code'] ?? null;
+            if (!$kpCode) {
+                throw new \Exception("题目 {$questionBankId} 缺少知识点代码");
+            }
+            $kpName = $qbData['kp_name'] ?? $kpCode;
+            $questionType = $qbData['question_type'] ?? 'unknown';
+
+            // 保存详情供后续使用
+            $questionDetails[$questionBankId] = [
+                'question_bank_id' => $questionBankId,
+                'stem' => $qbData['question_content'] ?? ($paperQuestion->question_text ?? ''),
+                'answer' => $qbData['question_answer'] ?? ($paperQuestion->correct_answer ?? ''),
+                'solution' => $paperQuestion->solution ?? '',
+                'kp_code' => $kpCode,
+                'kp_name' => $kpName,
+                'question_type' => $questionType,
+                'max_score' => $maxScore,
+                'skills' => [],
+                'is_submitted' => false, // 未提交标记
+            ];
+
+            // 未提交的题目:标记为完全正确(说明学生已经掌握,不需要作答)
+            $transformedQuestions[] = [
+                'question_id' => (string) $questionBankId,
+                'score' => $maxScore,
+                'score_obtained' => $maxScore, // 获得满分
+                'steps' => [], // 不需要步骤分析
+                'is_correct' => true, // 标记为正确
+                'is_submitted' => false, // 标记为未提交
+                'kp_code' => $kpCode, // 【修复】添加知识点代码,供ExamAnswerAnalysisService使用
+                'kp_name' => $kpName, // 【修复】添加知识点名称
             ];
+
+            Log::info('未提交题目处理', [
+                'question_bank_id' => $questionBankId,
+                'kp_code' => $kpCode,
+                'max_score' => $maxScore,
+                'reason' => '未作答视为已掌握',
+            ]);
         }
 
+        Log::info('题目数据转换完成', [
+            'paper_id' => $paperId,
+            'total_transformed' => count($transformedQuestions),
+            'submitted_count' => count($submittedIds),
+            'not_submitted_count' => count($notSubmittedIds),
+        ]);
+
         return [
             'questions' => $transformedQuestions,
             'question_details' => $questionDetails,
@@ -251,32 +345,9 @@ class PaperSubmitAnalysisController extends Controller
     /**
      * 批量获取题库题目详情
      */
-    private function fetchQuestionBankData(array $questionBankIds): array
-    {
-        try {
-            $response = $this->questionBankService->getQuestionsByIds($questionBankIds);
-            $questions = $response['data'] ?? $response;
-
-            // 转换为以 id 为 key 的数组
-            $result = [];
-            foreach ($questions as $question) {
-                $id = $question['id'] ?? $question['question_id'] ?? null;
-                if ($id) {
-                    $result[$id] = $question;
-                }
-            }
-            return $result;
-        } catch (\Exception $e) {
-            Log::warning('获取题库详情失败,使用空数据', [
-                'error' => $e->getMessage(),
-                'question_bank_ids' => $questionBankIds
-            ]);
-            return [];
-        }
-    }
-
     /**
      * 将错题写入错题本
+     * 【正确逻辑】只处理已提交的题目,未提交的题目默认正确,不写入错题本
      */
     private function addMistakesToBook(
         string $studentId,
@@ -286,60 +357,85 @@ class PaperSubmitAnalysisController extends Controller
     ): int {
         $mistakesAdded = 0;
 
+        // 只有已提交的题目才处理错题本
         foreach ($questionsData as $questionData) {
             $isCorrectArray = $questionData['is_correct'];
 
             // 判断是否有错误(数组中存在0)
             $hasError = in_array(0, $isCorrectArray, true);
             if (!$hasError) {
-                continue;
+                continue; // 全对,跳过
             }
 
             $questionBankId = $questionData['question_bank_id'];
             $detail = $questionDetails[$questionBankId] ?? [];
 
-            try {
-                $payload = [
-                    'student_id' => $studentId,
-                    'question_id' => $questionBankId,
-                    'paper_id' => $paperId,
-                    'my_answer' => $questionData['student_answer'] ?? '',
-                    'correct_answer' => $detail['answer'] ?? '',
-                    'question_text' => $detail['stem'] ?? '',
-                    'knowledge_point' => $detail['kp_name'] ?? '',
-                    'explanation' => $detail['solution'] ?? '',
-                    'kp_ids' => [$detail['kp_code'] ?? 'K-GENERAL'],
-                    'source' => "paper:{$paperId}",
-                    'happened_at' => now()->toISOString(),
-                ];
+            $mistakesAdded += $this->createMistakeRecord($studentId, $paperId, $questionData, $detail);
+        }
 
-                $result = $this->mistakeBookService->createMistake($payload);
+        // 【说明】未提交的题目不写入错题本,因为默认正确(已掌握)
 
-                // 如果不是重复记录,则计数
-                if (!($result['duplicate'] ?? false)) {
-                    $mistakesAdded++;
-                }
+        Log::info('错题本写入完成', [
+            'paper_id' => $paperId,
+            'submitted_questions' => count($questionsData),
+            'mistakes_added' => $mistakesAdded,
+            'note' => '未提交题目默认正确,未写入错题本',
+        ]);
+
+        return $mistakesAdded;
+    }
 
+    /**
+     * 创建单个错题记录
+     */
+    private function createMistakeRecord(
+        string $studentId,
+        string $paperId,
+        array $questionData,
+        array $detail
+    ): int {
+        try {
+            $payload = [
+                'student_id' => $studentId,
+                'question_id' => $questionData['question_bank_id'],
+                'paper_id' => $paperId,
+                'my_answer' => $questionData['student_answer'] ?? '',
+                'correct_answer' => $detail['answer'] ?? '',
+                'question_text' => $detail['stem'] ?? '',
+                'knowledge_point' => $detail['kp_name'] ?? '',
+                'explanation' => $detail['solution'] ?? '',
+                'kp_ids' => [$detail['kp_code']],
+                'source' => "paper:{$paperId}",
+                'happened_at' => now()->toISOString(),
+            ];
+
+            $result = $this->mistakeBookService->createMistake($payload);
+
+            // 如果不是重复记录,则计数
+            if (!($result['duplicate'] ?? false)) {
                 Log::debug('错题写入成功', [
                     'student_id' => $studentId,
-                    'question_bank_id' => $questionBankId,
+                    'question_bank_id' => $questionData['question_bank_id'],
                     'duplicate' => $result['duplicate'] ?? false
                 ]);
-
-            } catch (\Exception $e) {
-                Log::error('写入错题本失败', [
-                    'student_id' => $studentId,
-                    'question_bank_id' => $questionBankId,
-                    'error' => $e->getMessage()
-                ]);
+                return 1;
             }
-        }
 
-        return $mistakesAdded;
+            return 0;
+
+        } catch (\Exception $e) {
+            Log::error('写入错题本失败', [
+                'student_id' => $studentId,
+                'question_bank_id' => $questionData['question_bank_id'],
+                'error' => $e->getMessage()
+            ]);
+            return 0;
+        }
     }
 
     /**
      * 更新试卷状态和题目作答信息
+     * 【正确逻辑】未提交题目视为正确(已掌握),已提交错误题目标记为错误
      */
     private function updatePaperStatus(string $paperId, array $questionsData): void
     {
@@ -350,7 +446,15 @@ class PaperSubmitAnalysisController extends Controller
                 'completed_at' => now(),
             ]);
 
-            // 更新每道题目的作答信息
+            // 获取试卷中的所有题目(包括未提交的)
+            $allPaperQuestions = PaperQuestion::where('paper_id', $paperId)->get();
+            $allQuestionIds = $allPaperQuestions->pluck('question_bank_id')->toArray();
+
+            // 区分已提交和未提交的题目
+            $submittedIds = array_column($questionsData, 'question_bank_id');
+            $notSubmittedIds = array_diff($allQuestionIds, $submittedIds);
+
+            // 更新已提交题目的作答信息
             foreach ($questionsData as $questionData) {
                 $questionBankId = $questionData['question_bank_id'];
                 $isCorrectArray = $questionData['is_correct'];
@@ -375,7 +479,36 @@ class PaperSubmitAnalysisController extends Controller
                     ]);
             }
 
-            Log::info('试卷状态更新完成', ['paper_id' => $paperId]);
+            // 【修正】更新未提交题目的状态:视为正确(已掌握)
+            foreach ($notSubmittedIds as $questionBankId) {
+                // 未提交题目:视为正确(已掌握),获得满分
+                PaperQuestion::where('paper_id', $paperId)
+                    ->where('question_bank_id', $questionBankId)
+                    ->update([
+                        'student_answer' => null,
+                        'is_correct' => true, // 视为正确
+                        'score_ratio' => 1.0, // 获得满分
+                        'score_obtained' => DB::raw('score'), // 获得满分
+                        'teacher_comment' => '未作答(已掌握)',
+                        'graded_at' => now(),
+                    ]);
+
+                Log::debug('未提交题目状态更新', [
+                    'paper_id' => $paperId,
+                    'question_bank_id' => $questionBankId,
+                    'is_correct' => true,
+                    'score_ratio' => 1.0,
+                    'note' => '未提交题目视为正确(已掌握)',
+                ]);
+            }
+
+            Log::info('试卷状态更新完成', [
+                'paper_id' => $paperId,
+                'total_questions' => count($allQuestionIds),
+                'submitted_count' => count($submittedIds),
+                'not_submitted_count' => count($notSubmittedIds),
+                'note' => '未提交题目视为正确(已掌握)',
+            ]);
 
         } catch (\Exception $e) {
             Log::error('更新试卷状态失败', [
@@ -449,4 +582,34 @@ class PaperSubmitAnalysisController extends Controller
             ], 500);
         }
     }
+
+    /**
+     * 转换前端 question_id 为后端 question_bank_id
+     * 前端使用 question_id,后端转换为 question_bank_id 进行处理
+     */
+    private function convertQuestionIds(array $questionsData): array
+    {
+        $converted = [];
+
+        foreach ($questionsData as $question) {
+            // 将 question_id 转换为 question_bank_id
+            if (isset($question['question_id'])) {
+                $question['question_bank_id'] = $question['question_id'];
+                unset($question['question_id']);
+            }
+
+            $converted[] = $question;
+        }
+
+        Log::info('转换 question_id 为 question_bank_id', [
+            'original_count' => count($questionsData),
+            'converted_count' => count($converted),
+            'sample_conversion' => !empty($converted) ? [
+                'from' => $questionsData[0]['question_id'] ?? null,
+                'to' => $converted[0]['question_bank_id'] ?? null
+            ] : null
+        ]);
+
+        return $converted;
+    }
 }

+ 251 - 35
app/Http/Controllers/Api/StudentAnswerAnalysisController.php

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
 use App\Services\TaskManager;
 use App\Services\LocalAIAnalysisService;
 use App\Services\StudentAnswerAnalysisService;
+use App\Services\MasteryCalculator;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
@@ -16,9 +17,126 @@ class StudentAnswerAnalysisController extends Controller
     public function __construct(
         private readonly TaskManager $taskManager,
         private readonly LocalAIAnalysisService $aiAnalysisService,
-        private readonly StudentAnswerAnalysisService $answerAnalysisService
+        private readonly StudentAnswerAnalysisService $answerAnalysisService,
+        private readonly MasteryCalculator $masteryCalculator
     ) {}
 
+    /**
+     * 将学案难度分类转换为1-4等级
+     */
+    private function mapDifficultyCategoryToLevel(string $difficultyCategory): int
+    {
+        // 移除空格并转为小写比较
+        $category = trim($difficultyCategory);
+
+        // 数字直接返回
+        if (is_numeric($category)) {
+            return max(1, min(4, intval($category)));
+        }
+
+        // 中文映射
+        return match ($category) {
+            '基础', '1' => 1,  // 筑基
+            '进阶', '中等', '2' => 2,  // 提分
+            '培优', '3' => 3,  // 培优
+            '竞赛', '4' => 4,  // 竞赛
+            default => 2,  // 默认中级(提分)
+        };
+    }
+
+    /**
+     * 获取试卷的基准难度
+     */
+    private function getExamBaseDifficulty(string $paperId): int
+    {
+        try {
+            // 从papers表获取difficulty_category
+            $paper = DB::table('papers')
+                ->where('paper_id', $paperId)
+                ->first();
+
+            if (!$paper || empty($paper->difficulty_category)) {
+                Log::warning('未找到试卷或难度分类,使用默认难度', [
+                    'paper_id' => $paperId,
+                    'difficulty_category' => $paper->difficulty_category ?? null,
+                ]);
+                return 2; // 默认中级(提分)
+            }
+
+            $difficultyLevel = $this->mapDifficultyCategoryToLevel($paper->difficulty_category);
+
+            Log::info('获取试卷基准难度', [
+                'paper_id' => $paperId,
+                'difficulty_category' => $paper->difficulty_category,
+                'difficulty_level' => $difficultyLevel,
+            ]);
+
+            return $difficultyLevel;
+
+        } catch (\Exception $e) {
+            Log::error('获取试卷基准难度失败,使用默认难度', [
+                'paper_id' => $paperId,
+                'error' => $e->getMessage(),
+            ]);
+            return 2; // 默认中级(提分)
+        }
+    }
+
+    /**
+     * 更新父节点掌握度(基于子节点平均值)
+     */
+    private function updateParentMastery(string $studentId, string $kpCode): void
+    {
+        try {
+            // 获取知识点的父节点
+            $knowledgePoint = DB::table('knowledge_points')
+                ->where('kp_code', $kpCode)
+                ->first();
+
+            if (!$knowledgePoint || empty($knowledgePoint->parent_kp_code)) {
+                return; // 没有父节点,无需更新
+            }
+
+            $parentKpCode = $knowledgePoint->parent_kp_code;
+
+            // 使用MasteryCalculator计算父节点掌握度
+            $parentMastery = $this->masteryCalculator->calculateParentMastery($studentId, $parentKpCode);
+
+            // 保存到数据库(作为独立记录)
+            DB::table('student_knowledge_mastery')
+                ->updateOrInsert(
+                    ['student_id' => $studentId, 'kp_code' => $parentKpCode],
+                    [
+                        'mastery_level' => $parentMastery,
+                        'confidence_level' => 0.8, // 父节点置信度默认较高
+                        'total_attempts' => DB::raw('COALESCE(total_attempts, 0)'),
+                        'correct_attempts' => DB::raw('COALESCE(correct_attempts, 0)'),
+                        'mastery_trend' => 'calculated', // 标记为计算得出,非直接测量
+                        'last_mastery_update' => now(),
+                        'updated_at' => now(),
+                        'notes' => '父节点掌握度(基于子节点平均值计算)',
+                    ]
+                );
+
+            Log::info('父节点掌握度已更新', [
+                'student_id' => $studentId,
+                'child_kp_code' => $kpCode,
+                'parent_kp_code' => $parentKpCode,
+                'parent_mastery' => $parentMastery,
+            ]);
+
+            // 递归更新更上层的父节点
+            $this->updateParentMastery($studentId, $parentKpCode);
+
+        } catch (\Exception $e) {
+            Log::error('更新父节点掌握度失败', [
+                'student_id' => $studentId,
+                'kp_code' => $kpCode,
+                'error' => $e->getMessage(),
+            ]);
+        }
+    }
+
     /**
      * 接收学生作答结果并进行分析
      *
@@ -38,26 +156,15 @@ class StudentAnswerAnalysisController extends Controller
             $payload['student_id'] = (string) $payload['student_id'];
         }
 
-        // 验证参数
+        // 【修复】验证参数 - 支持questions数组和is_correct数组格式
         $validator = validator($payload, [
             'paper_id' => 'required|string',
             'student_id' => 'required|string|min:1',
-            'answers' => 'required|array',
-            'answers.*.question_id' => 'required|string',
-            'answers.*.question_number' => 'nullable|string',
-            'answers.*.is_correct' => 'required|boolean',
-            'answers.*.student_answer' => 'nullable|string',
-            'answers.*.correct_answer' => 'nullable|string',
-            'answers.*.score' => 'nullable|numeric',
-            'answers.*.max_score' => 'nullable|numeric',
-            'answers.*.step_scores' => 'nullable|array', // 简答题步骤得分
-            'answers.*.knowledge_point' => 'nullable|string',
-            'answers.*.question_type' => 'nullable|string',
-            'answer_time' => 'nullable|timestamp',
-            'submit_time' => 'nullable|timestamp',
-            'source_system' => 'nullable|string',
-            'callback_url' => 'nullable|url',
-            'missing_questions' => 'nullable|array', // 缺题列表(可选)
+            'questions' => 'required|array',
+            'questions.*.question_bank_id' => 'required|integer',
+            'questions.*.student_answer' => 'nullable|string',
+            'questions.*.is_correct' => 'required|array', // 支持数组格式(多小题/步骤)
+            'questions.*.teacher_comment' => 'nullable|string',
         ]);
 
         if ($validator->fails()) {
@@ -81,7 +188,7 @@ class StudentAnswerAnalysisController extends Controller
                 'task_id' => $taskId,
                 'paper_id' => $data['paper_id'],
                 'student_id' => $data['student_id'],
-                'answer_count' => count($data['answers']),
+                'question_count' => count($data['questions']),
             ]);
 
             // 触发后台分析处理
@@ -185,15 +292,53 @@ class StudentAnswerAnalysisController extends Controller
                 ];
             }
 
-            $this->taskManager->updateTaskProgress($taskId, 60, '正在保存分析结果...');
+            $this->taskManager->updateTaskProgress($taskId, 60, '正在计算掌握度(包括子知识点动态加减和父节点平均)...');
+
+            // 【新算法】使用MasteryCalculator计算掌握度
+            // 1. 获取学案基准难度
+            $examBaseDifficulty = $this->getExamBaseDifficulty($data['paper_id']);
+
+            // 2. 为每个知识点计算掌握度
+            $masteryResults = [];
+            foreach ($questionAnalyses as $analysis) {
+                if (empty($analysis['kp_code']) || $analysis['is_missing']) {
+                    continue; // 跳过没有知识点或缺题的题目
+                }
+
+                $kpCode = $analysis['kp_code'];
+                if (!isset($masteryResults[$kpCode])) {
+                    $masteryResults[$kpCode] = [];
+                }
+                $masteryResults[$kpCode][] = [
+                    'question_id' => $analysis['question_id'],
+                    'is_correct' => $analysis['is_correct'],
+                    'question_difficulty' => $analysis['difficulty'] ?? 0.5,
+                ];
+            }
+
+            // 3. 调用MasteryCalculator计算每个知识点的掌握度
+            foreach ($masteryResults as $kpCode => $attempts) {
+                $this->masteryCalculator->calculateMasteryLevel(
+                    $data['student_id'],
+                    $kpCode,
+                    $attempts,
+                    $examBaseDifficulty
+                );
+
+                // 4. 计算父节点掌握度(子节点平均值)
+                $this->updateParentMastery($data['student_id'], $kpCode);
+            }
+
+            $this->taskManager->updateTaskProgress($taskId, 65, '正在保存分析结果...');
 
             // 准备分析结果数据
             $analysisData = [
                 'question_results' => $questionAnalyses,
                 'total_questions' => count($questionAnalyses),
-                'correct_count' => count(array_filter($questionAnalyses, function($q) { return $q['correct'] ?? false; })),
-                'wrong_count' => count(array_filter($questionAnalyses, function($q) { return !($q['correct'] ?? true); })),
+                'correct_count' => count(array_filter($questionAnalyses, function($q) { return $q['is_correct'] ?? false; })),
+                'wrong_count' => count(array_filter($questionAnalyses, function($q) { return !($q['is_correct'] ?? true); })),
                 'model_used' => $questionAnalyses[0]['model_used'] ?? 'unknown',
+                'exam_base_difficulty' => $examBaseDifficulty,
             ];
 
             // 保存分析结果
@@ -210,7 +355,7 @@ class StudentAnswerAnalysisController extends Controller
 
             $this->taskManager->updateTaskProgress($taskId, 90, '正在生成学情分析报告...');
 
-            // 生成学情分析报告
+            // 生成学情分析报告PDF
             $reportUrl = $this->generateLearningReport($taskId, $data, $answerRecord, $questionAnalyses, $masterySnapshot);
 
             // 标记任务完成
@@ -221,7 +366,7 @@ class StudentAnswerAnalysisController extends Controller
                 'correct_count' => $answerRecord['correct_count'],
                 'wrong_count' => $answerRecord['wrong_count'],
                 'overall_mastery' => $masterySnapshot['overall_mastery'] ?? null,
-                'report_url' => $reportUrl, // 学情分析报告URL
+                // 'report_url' => $reportUrl, // 临时禁用PDF报告
             ]);
 
             Log::info('作答分析完成', [
@@ -270,8 +415,34 @@ class StudentAnswerAnalysisController extends Controller
      */
     private function processMissingQuestions(array $data): array
     {
-        $answers = $data['answers'] ?? [];
-        $submittedQuestionIds = array_column($answers, 'question_id');
+        // 【修复】处理questions数组格式
+        $questions = $data['questions'] ?? [];
+        $submittedQuestionIds = array_column($questions, 'question_bank_id');
+
+        // 将questions转换为answers格式以便后续处理
+        $answers = [];
+        foreach ($questions as $q) {
+            // 计算is_correct数组的平均值(判断整体是否正确)
+            $isCorrectArray = $q['is_correct'] ?? [];
+            $correctSteps = array_sum($isCorrectArray);
+            $totalSteps = count($isCorrectArray);
+            $isOverallCorrect = $totalSteps > 0 && ($correctSteps / $totalSteps) >= 0.6; // 60%以上步骤正确视为正确
+
+            $answers[] = [
+                'question_id' => $q['question_bank_id'],
+                'question_number' => null,
+                'is_correct' => $isOverallCorrect,
+                'student_answer' => $q['student_answer'] ?? '',
+                'correct_answer' => '',
+                'score' => $correctSteps * 2, // 假设每个步骤2分
+                'max_score' => $totalSteps * 2,
+                'knowledge_point' => null,
+                'question_type' => 'mixed',
+                'is_missing' => false,
+                'is_correct_array' => $isCorrectArray, // 保留原始数组
+                'answer_time' => $data['answer_time'] ?? now(),
+            ];
+        }
 
         // 获取缺题列表
         $missingQuestions = $data['missing_questions'] ?? [];
@@ -310,26 +481,71 @@ class StudentAnswerAnalysisController extends Controller
             }
         }
 
-        // 为每个缺题创建默认正确的记录
+        // 【修复】处理缺题和学生未作答的题目
         foreach ($missingQuestions as $missingQuestionId) {
+            // 使用 Model 获取缺题的详细信息
+            $questionDetails = \App\Models\PaperQuestion::where('paper_id', $data['paper_id'])
+                ->where('question_id', $missingQuestionId)
+                ->first();
+
+            // 获取知识点和分数信息
+            $kpCode = $questionDetails?->knowledge_point;
+            if (empty($kpCode)) {
+                // 【修复】不允许使用默认知识点,必须明确指定
+                Log::warning('StudentAnswerAnalysisController: 缺题缺少知识点信息,跳过掌握度计算', [
+                    'question_id' => $missingQuestionId,
+                    'paper_id' => $data['paper_id'],
+                ]);
+                continue; // 跳过该缺题,不参与掌握度计算
+            }
+            $maxScore = floatval($questionDetails?->score ?? 10);
+            $questionType = $questionDetails?->question_type ?? 'missing';
+
+            // 【关键】区分缺题和学生未作答:
+            // 1. 缺题(API请求中标记的):按正确计算,100%得分
+            // 2. 学生没作答的题目:按30%得分率计算
+            $isTrulyMissing = in_array($missingQuestionId, $data['missing_questions'] ?? []);
+            $scoreRate = $isTrulyMissing ? 1.0 : 0.3; // 缺题100%,未作答30%
+            $score = $maxScore * $scoreRate;
+            $isCorrect = ($scoreRate >= 0.6); // 按60%及格线判断
+
             $answers[] = [
                 'question_id' => $missingQuestionId,
-                'question_number' => $missingQuestionId,
-                'is_correct' => true, // 缺题默认正确
+                'question_number' => $questionDetails?->question_number ?? $missingQuestionId,
+                'is_correct' => $isCorrect,
                 'student_answer' => '[缺题]',
                 'correct_answer' => '[未作答]',
-                'score' => 0, // 缺题不计分
-                'max_score' => 0,
-                'knowledge_point' => null,
-                'question_type' => 'missing',
-                'is_missing' => true, // 标记为缺题
+                'score' => $score,
+                'max_score' => $maxScore,
+                'knowledge_point' => $kpCode, // 保留知识点信息,参与掌握度计算
+                'question_type' => $questionType,
+                'is_missing' => $isTrulyMissing, // 真正缺题标记
+                'missing_note' => $isTrulyMissing ? '缺题,按100%得分率计算' : '学生未作答,按30%得分率计算掌握度',
                 'answer_time' => $data['answer_time'] ?? now(),
             ];
         }
 
+        // 【新增】处理已提交但学生未作答的题目(student_answer为空或null)
+        foreach ($answers as &$answer) {
+            if (!empty($answer['student_answer']) && $answer['student_answer'] !== '[缺题]') {
+                continue; // 已作答的题目跳过
+            }
+
+            // 学生未作答的题目,按30%得分率计算
+            if (empty($answer['student_answer']) || $answer['student_answer'] === '') {
+                $maxScore = floatval($answer['max_score'] ?? 10);
+                $scoreRate = 0.3;
+                $score = $maxScore * $scoreRate;
+
+                $answer['score'] = $score;
+                $answer['is_correct'] = false; // 未作答视为错误
+                $answer['missing_note'] = '学生未作答,按30%得分率计算掌握度';
+            }
+        }
+
         Log::info('缺题处理完成', [
             'paper_id' => $data['paper_id'],
-            'submitted_count' => count($data['answers'] ?? []),
+            'submitted_count' => count($data['questions'] ?? []),
             'missing_count' => count($missingQuestions),
             'total_count' => count($answers),
         ]);

+ 365 - 0
app/Http/Controllers/Api/StudentKnowledgeController.php

@@ -0,0 +1,365 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Http\Controllers\Controller;
+use App\Models\StudentKnowledgeMastery;
+use App\Models\KnowledgePoint;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+class StudentKnowledgeController extends Controller
+{
+    /**
+     * 获取学生知识点掌握详情
+     *
+     * @param string $studentId
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getKnowledgePointsDetail(string $studentId, Request $request): JsonResponse
+    {
+        try {
+            // 验证学生是否存在
+            $student = \App\Models\Student::where('student_id', $studentId)->first();
+            if (!$student) {
+                return response()->json([
+                    'success' => false,
+                    'message' => '学生不存在',
+                ], 404);
+            }
+
+            // 获取查询参数
+            $level = $request->query('level'); // 筛选特定层级的知识点
+            $sort = $request->query('sort', 'mastery_asc'); // 排序方式
+
+            // 构建查询(解决字符集不匹配问题)
+            // 【修复】显式选择mastery_level和mastery_change字段,避免访问器转换
+            $query = StudentKnowledgeMastery::forStudent($studentId)
+                ->with('knowledgePoint') // 预加载知识点信息
+                ->select([
+                    'student_knowledge_mastery.*',
+                    'knowledge_points.name as kp_name',
+                    'knowledge_points.parent_kp_code',
+                    // 显式选择原始字段,避免访问器
+                    DB::raw('CAST(student_knowledge_mastery.mastery_level AS DECIMAL(10,4)) as mastery_level_raw'),
+                    DB::raw('CAST(student_knowledge_mastery.mastery_change AS DECIMAL(10,4)) as mastery_change_raw'),
+                ])
+                ->join('knowledge_points', 'student_knowledge_mastery.kp_code', '=', DB::raw('CAST(knowledge_points.kp_code AS CHAR)'));
+
+            // 按层级筛选
+            if ($level) {
+                if ($level === 'top') {
+                    // 只查询顶级知识点(没有父级)
+                    $query->whereNull('knowledge_points.parent_kp_code');
+                } elseif ($level === 'leaf') {
+                    // 只查询叶子知识点(没有子级)
+                    $query->whereNotIn('knowledge_points.kp_code', function ($q) {
+                        $q->select('parent_kp_code')
+                          ->from('knowledge_points')
+                          ->whereNotNull('parent_kp_code');
+                    });
+                }
+            }
+
+            // 排序
+            switch ($sort) {
+                case 'mastery_desc':
+                    $query->orderBy('mastery_level', 'desc');
+                    break;
+                case 'name_asc':
+                    $query->orderBy('kp_name', 'asc');
+                    break;
+                case 'name_desc':
+                    $query->orderBy('kp_name', 'desc');
+                    break;
+                case 'attempts_desc':
+                    $query->orderBy('total_attempts', 'desc');
+                    break;
+                case 'mastery_asc':
+                default:
+                    $query->orderBy('mastery_level', 'asc');
+                    break;
+            }
+
+            $masteryRecords = $query->get();
+
+            // 构建返回数据
+            $knowledgePoints = [];
+            foreach ($masteryRecords as $record) {
+                // 计算正确率
+                $correctRate = $record->total_attempts > 0
+                    ? round(($record->correct_attempts / $record->total_attempts) * 100, 2) / 100
+                    : 0.0;
+
+                // 计算稳定度(基于近期的变化趋势)
+                $stability = $this->calculateStability($record);
+
+                // 获取层级信息
+                $level = $record->knowledgePoint->parent_kp_code ? '子级' : '顶级';
+
+                // 获取父子关系
+                $parentName = null;
+                if ($record->knowledgePoint->parent_kp_code) {
+                    $parentKp = KnowledgePoint::where('kp_code', $record->knowledgePoint->parent_kp_code)->first();
+                    $parentName = $parentKp?->name ?? $record->knowledgePoint->parent_kp_code;
+                }
+
+                // 【修复】使用CAST后的原始字段,避免访问器转换
+                $rawMasteryLevel = $record->mastery_level_raw;
+                $rawMasteryChange = $record->mastery_change_raw;
+
+                $masteryLevel = is_numeric($rawMasteryLevel) ? (float)$rawMasteryLevel : 0.0;
+                $masteryChange = is_numeric($rawMasteryChange) ? (float)$rawMasteryChange : 0.0;
+
+                $knowledgePoints[] = [
+                    'kp_code' => $record->kp_code,
+                    'knowledge_point' => $record->kp_name ?: $record->kp_code, // 使用名称,如果没有名称则使用代码
+                    'mastery' => round($masteryLevel, 4),
+                    'stability' => $stability,
+                    'level' => $level,
+                    'parent_kp_code' => $record->knowledgePoint->parent_kp_code,
+                    'parent_kp_name' => $parentName,
+                    'last_updated' => $record->last_mastery_update?->toISOString(),
+                    'practice_count' => (int)$record->total_attempts,
+                    'correct_rate' => $correctRate,
+                    'mastery_change' => $masteryChange,
+                    'mastery_trend' => $record->mastery_trend,
+                    'trend_label' => $record->trend_label ?? '稳定',
+                ];
+            }
+
+            // 【新功能】获取父节点掌握度
+            $parentMasteryLevels = $this->getParentMasteryLevels($studentId, $knowledgePoints);
+
+            Log::info('获取学生知识点掌握详情', [
+                'student_id' => $studentId,
+                'count' => count($knowledgePoints),
+                'parent_count' => count($parentMasteryLevels),
+                'level' => $level,
+                'sort' => $sort,
+            ]);
+
+            return response()->json([
+                'success' => true,
+                'data' => [
+                    'student_id' => $studentId,
+                    'student_name' => $student->name,
+                    'knowledge_points' => array_values($knowledgePoints), // 重新索引
+                    'parent_mastery_levels' => $parentMasteryLevels, // 新增:父节点掌握度
+                    'total_count' => count($knowledgePoints),
+                ],
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取学生知识点掌握详情失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取失败:' . $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 计算稳定度
+     */
+    private function calculateStability(StudentKnowledgeMastery $record): float
+    {
+        // 如果数据不足,返回默认值
+        if ($record->total_attempts < 3) {
+            return 0.5;
+        }
+
+        // 基于掌握度变化计算稳定度
+        // 变化越小,稳定度越高
+        $changeAbs = abs($record->mastery_change ?? 0);
+
+        // 稳定度 = 1 - (变化幅度 * 10),范围0-1
+        $stability = max(0, min(1, 1 - ($changeAbs * 10)));
+
+        return round($stability, 4);
+    }
+
+    /**
+     * 获取学生知识点层级关系
+     *
+     * @param string $studentId
+     * @return JsonResponse
+     */
+    public function getKnowledgeHierarchy(string $studentId): JsonResponse
+    {
+        try {
+            // 获取学生的所有知识点
+            $masteryRecords = StudentKnowledgeMastery::forStudent($studentId)
+                ->with('knowledgePoint')
+                ->get();
+
+            // 构建父子关系
+            $hierarchy = [];
+            $processed = [];
+
+            foreach ($masteryRecords as $record) {
+                $kpCode = $record->kp_code;
+
+                // 避免重复处理
+                if (isset($processed[$kpCode])) {
+                    continue;
+                }
+
+                $kp = $record->knowledgePoint;
+                if (!$kp) {
+                    continue;
+                }
+
+                $processed[$kpCode] = true;
+
+                // 如果是顶级知识点
+                if (!$kp->parent_kp_code) {
+                    $hierarchy[] = [
+                        'kp_code' => $kpCode,
+                        'kp_name' => $kp->name ?: $kpCode,
+                        'mastery' => round($record->mastery_level, 4),
+                        'level' => 'top',
+                        'children' => $this->getChildKnowledgePoints($masteryRecords, $kpCode),
+                    ];
+                }
+            }
+
+            // 如果没有顶级知识点,尝试构建其他层级的结构
+            if (empty($hierarchy)) {
+                foreach ($masteryRecords as $record) {
+                    $kpCode = $record->kp_code;
+
+                    $kp = $record->knowledgePoint;
+                    if (!$kp) {
+                        continue;
+                    }
+
+                    $hierarchy[] = [
+                        'kp_code' => $kpCode,
+                        'kp_name' => $kp->name ?: $kpCode,
+                        'mastery' => round($record->mastery_level, 4),
+                        'level' => $kp->parent_kp_code ? 'child' : 'top',
+                        'parent_kp_code' => $kp->parent_kp_code,
+                    ];
+                }
+            }
+
+            return response()->json([
+                'success' => true,
+                'data' => [
+                    'student_id' => $studentId,
+                    'hierarchy' => $hierarchy,
+                    'total_count' => count($hierarchy),
+                ],
+            ]);
+
+        } catch (\Exception $e) {
+            Log::error('获取学生知识点层级关系失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return response()->json([
+                'success' => false,
+                'message' => '获取失败:' . $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 获取子知识点
+     */
+    private function getChildKnowledgePoints($masteryRecords, string $parentKpCode): array
+    {
+        $children = [];
+
+        foreach ($masteryRecords as $record) {
+            $kp = $record->knowledgePoint;
+            if ($kp && $kp->parent_kp_code === $parentKpCode) {
+                $children[] = [
+                    'kp_code' => $kp->kp_code,
+                    'kp_name' => $kp->name ?: $kp->kp_code,
+                    'mastery' => round($record->mastery_level, 4),
+                    'level' => 'child',
+                    'children' => $this->getChildKnowledgePoints($masteryRecords, $kp->kp_code),
+                ];
+            }
+        }
+
+        return $children;
+    }
+
+    /**
+     * 【新功能】获取父节点掌握度
+     */
+    private function getParentMasteryLevels(string $studentId, array $knowledgePoints): array
+    {
+        try {
+            $parentMasteryLevels = [];
+
+            // 收集所有父节点代码
+            $parentKpCodes = [];
+            foreach ($knowledgePoints as $kp) {
+                if (!empty($kp['parent_kp_code'])) {
+                    $parentKpCodes[$kp['parent_kp_code']] = true;
+                }
+            }
+
+            if (empty($parentKpCodes)) {
+                return $parentMasteryLevels;
+            }
+
+            // 查询父节点的掌握度
+            $parentRecords = StudentKnowledgeMastery::forStudent($studentId)
+                ->whereIn('kp_code', array_keys($parentKpCodes))
+                ->get();
+
+            foreach ($parentRecords as $record) {
+                // 使用CAST后的原始字段
+                $rawMasteryLevel = $record->getAttributeValue('mastery_level');
+                $rawMasteryChange = $record->getAttributeValue('mastery_change');
+
+                $masteryLevel = is_numeric($rawMasteryLevel) ? (float)$rawMasteryLevel : 0.0;
+                $masteryChange = is_numeric($rawMasteryChange) ? (float)$rawMasteryChange : 0.0;
+
+                // 获取父节点的父节点
+                $kp = $record->knowledgePoint;
+                $grandParentKpCode = $kp?->parent_kp_code;
+
+                $parentMasteryLevels[] = [
+                    'kp_code' => $record->kp_code,
+                    'knowledge_point' => $kp?->name ?: $record->kp_code,
+                    'mastery' => round($masteryLevel, 4),
+                    'mastery_change' => $masteryChange,
+                    'mastery_trend' => $record->mastery_trend,
+                    'trend_label' => $record->trend_label ?? '稳定',
+                    'level' => '父级',
+                    'parent_kp_code' => $grandParentKpCode,
+                    'practice_count' => (int)$record->total_attempts,
+                    'correct_rate' => $record->total_attempts > 0
+                        ? round(($record->correct_attempts / $record->total_attempts), 4)
+                        : 0.0,
+                    'is_calculated' => ($record->mastery_trend === 'calculated'), // 标记为计算得出
+                    'last_updated' => $record->last_mastery_update?->toISOString(),
+                ];
+            }
+
+            return $parentMasteryLevels;
+
+        } catch (\Exception $e) {
+            Log::error('获取父节点掌握度失败', [
+                'student_id' => $studentId,
+                'error' => $e->getMessage(),
+            ]);
+            return [];
+        }
+    }
+}

+ 118 - 29
app/Services/ExamAnswerAnalysisService.php

@@ -58,16 +58,19 @@ class ExamAnswerAnalysisService
         $studentId = $examData['student_id'];
         $questions = $examData['questions'] ?? [];
 
-        // 1. 保存答题记录到数据库
+        // 1. 获取学案基准难度
+        $examBaseDifficulty = $this->getExamBaseDifficulty($examData['paper_id'] ?? '');
+
+        // 2. 保存答题记录到数据库
         $this->saveExamAnswerRecords($examData);
 
-        // 2. 获取题目知识点映射
+        // 3. 获取题目知识点映射
         $questionMappings = $this->getQuestionKnowledgeMappings($questions);
 
-        // 3. 计算每个知识点的加权掌握度
-        $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings);
+        // 4. 计算每个知识点的加权掌握度(传入学案基准难度)
+        $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings, $examBaseDifficulty, $studentId);
 
-        // 4. 更新学生掌握度
+        // 5. 更新学生掌握度
         $updatedMastery = $this->updateStudentMastery($studentId, $knowledgeMasteryVector);
 
         // 5. 生成题目维度分析
@@ -105,6 +108,46 @@ class ExamAnswerAnalysisService
         return $analysisResult;
     }
 
+    /**
+     * 获取学案基准难度(映射为1-4级)
+     */
+    private function getExamBaseDifficulty(string $paperId): ?int
+    {
+        if (empty($paperId)) {
+            return null;
+        }
+
+        try {
+            // 从试卷表获取difficulty_category
+            $paper = DB::table('papers')
+                ->where('paper_id', $paperId)
+                ->first();
+
+            if (!$paper) {
+                Log::warning('未找到试卷,尝试从缓存获取', ['paper_id' => $paperId]);
+                return null;
+            }
+
+            $difficultyCategory = $paper->difficulty_category ?? '中等';
+
+            // 映射为1-4级(筑基、提分、培优、竞赛)
+            return match (strtolower($difficultyCategory)) {
+                '筑基', 'easy', 'foundation', '1' => 1,
+                '提分', 'medium', 'improvement', '2' => 2,
+                '培优', 'hard', 'excellent', 'difficult', '3' => 3,
+                '竞赛', 'competition', 'very hard', 'very difficult', '4' => 4,
+                default => 2, // 默认提分
+            };
+
+        } catch (\Exception $e) {
+            Log::warning('获取学案基准难度失败,使用默认2级', [
+                'paper_id' => $paperId,
+                'error' => $e->getMessage(),
+            ]);
+            return 2; // 默认中等难度
+        }
+    }
+
     /**
      * 获取题目知识点映射
      */
@@ -133,12 +176,13 @@ class ExamAnswerAnalysisService
                     'weight' => 1.0
                 ];
             } else {
-                // 如果没有知识点信息,使用默认的综合知识点
-                $kpMapping[] = [
-                    'kp_id' => 'K-GENERAL',
-                    'kp_name' => '综合',
-                    'weight' => 1.0
-                ];
+                // 【修复】不允许使用默认知识点,必须明确指定
+                Log::warning('ExamAnswerAnalysisService: 题目缺少知识点信息', [
+                    'question_id' => $questionId,
+                    'question' => $question
+                ]);
+                // 不创建默认映射,让后续处理明确报错
+                continue;
             }
 
             $mappings[$questionId] = [
@@ -164,7 +208,7 @@ class ExamAnswerAnalysisService
      * 2. 调用MasteryCalculator计算掌握度(包含:正确率、难度加权、时间效率、遗忘曲线)
      * 3. 返回包含掌握度、置信度、趋势等完整信息的向量
      */
-    private function calculateKnowledgeMasteryVector(array $questions, array $questionMappings): array
+    private function calculateKnowledgeMasteryVector(array $questions, array $questionMappings, ?int $examBaseDifficulty = null, ?string $studentId = null): array
     {
         // 按知识点聚合答题记录
         $knowledgeAttempts = [];
@@ -195,7 +239,14 @@ class ExamAnswerAnalysisService
             // 如果有步骤级分析,使用步骤分析
             if (!empty($steps)) {
                 foreach ($steps as $step) {
-                    $kpId = $step['kp_id'] ?? 'K-GENERAL';
+                    $kpId = $step['kp_id'];
+                    if (empty($kpId)) {
+                        Log::warning('ExamAnswerAnalysisService: 步骤缺少知识点ID', [
+                            'question_id' => $questionId,
+                            'step' => $step
+                        ]);
+                        continue;
+                    }
 
                     if (!isset($knowledgeAttempts[$kpId])) {
                         $knowledgeAttempts[$kpId] = [
@@ -245,12 +296,13 @@ class ExamAnswerAnalysisService
         foreach ($knowledgeAttempts as $kpId => $data) {
             $attempts = $data['attempts'];
 
-            // 调用MasteryCalculator的核心算法
+            // 调用MasteryCalculator的核心算法(传入学案基准难度)
             // 该算法包含:正确率、难度加权、时间效率、技能熟练度、遗忘曲线衰减
             $masteryResult = $this->masteryCalculator->calculateMasteryLevel(
-                '', // studentId在此不需要,因为直接传入attempts
+                $studentId ?? '', // 传递学生ID,用于保存掌握度到数据库
                 $kpId,
-                $attempts
+                $attempts,
+                $examBaseDifficulty
             );
 
             $masteryVector[$kpId] = [
@@ -346,13 +398,24 @@ class ExamAnswerAnalysisService
             $isCorrect = $question['is_correct'] ?? ($score >= $maxScore);
 
             $mapping = $questionMappings[$questionId] ?? ['kp_mapping' => []];
-            $kpCode = $mapping['kp_mapping'][0]['kp_id'] ?? 'K-GENERAL';
+            if (empty($mapping['kp_mapping'])) {
+                Log::warning('ExamAnswerAnalysisService: 题目无知识点映射', ['question_id' => $questionId]);
+                continue;
+            }
+            $kpCode = $mapping['kp_mapping'][0]['kp_id'];
 
             // 步骤分析
             $stepAnalysis = [];
             if (!empty($steps)) {
                 foreach ($steps as $step) {
-                    $kpId = $step['kp_id'] ?? 'K-GENERAL';
+                    $kpId = $step['kp_id'];
+                    if (empty($kpId)) {
+                        Log::warning('ExamAnswerAnalysisService: 步骤缺少知识点ID', [
+                            'question_id' => $questionId,
+                            'step_index' => $step['step_index'] ?? 'unknown'
+                        ]);
+                        continue;
+                    }
                     $stepAnalysis[] = [
                         'step_index' => $step['step_index'],
                         'is_correct' => $step['is_correct'],
@@ -403,7 +466,14 @@ class ExamAnswerAnalysisService
         $isCorrect = $question['is_correct'] ?? false;
         $score = floatval($question['score_obtained'] ?? 0);
         $maxScore = floatval($question['score'] ?? 10);
-        $kpCode = $mapping['kp_mapping'][0]['kp_id'] ?? 'K-GENERAL';
+        $kpCode = $mapping['kp_mapping'][0]['kp_id'] ?? null;
+        if (empty($kpCode)) {
+            Log::warning('ExamAnswerAnalysisService: getQuestionAIAnalysis缺少知识点ID', [
+                'question_id' => $question['question_id'] ?? 'unknown',
+                'mapping' => $mapping
+            ]);
+            $kpCode = 'UNKNOWN_KP';
+        }
 
         // 调用LocalAIAnalysisService进行分析
         try {
@@ -623,12 +693,22 @@ class ExamAnswerAnalysisService
             // 保存步骤级记录
             if (!empty($steps)) {
                 foreach ($steps as $step) {
+                    $kpId = $step['kp_id'] ?? null;
+                    if (empty($kpId)) {
+                        Log::warning('ExamAnswerAnalysisService: 步骤保存缺少知识点ID', [
+                            'student_id' => $studentId,
+                            'exam_id' => $examId,
+                            'question_id' => $questionId,
+                            'step_index' => $step['step_index'] ?? 'unknown'
+                        ]);
+                        continue;
+                    }
                     DB::connection('mysql')->table('student_answer_steps')->insert([
                         'student_id' => $studentId,
                         'exam_id' => $examId,
                         'question_id' => $questionId,
                         'step_index' => $step['step_index'],
-                        'kp_id' => $step['kp_id'] ?? 'K-GENERAL',
+                        'kp_id' => $kpId,
                         'is_correct' => $step['is_correct'],
                         'step_score' => $step['score'] ?? 0,
                         'created_at' => now(),
@@ -637,15 +717,24 @@ class ExamAnswerAnalysisService
                 }
             } else {
                 // 保存题目级记录
-                DB::connection('mysql')->table('student_answer_questions')->insert([
-                    'student_id' => $studentId,
-                    'exam_id' => $examId,
-                    'question_id' => $questionId,
-                    'score_obtained' => $question['score_obtained'] ?? 0,
-                    'max_score' => $question['score'] ?? 0,
-                    'created_at' => now(),
-                    'updated_at' => now(),
-                ]);
+                try {
+                    DB::connection('mysql')->table('student_answer_questions')->insertOrIgnore([
+                        'student_id' => $studentId,
+                        'exam_id' => $examId,
+                        'question_id' => $questionId,
+                        'score_obtained' => $question['score_obtained'] ?? 0,
+                        'max_score' => $question['score'] ?? 0,
+                        'created_at' => now(),
+                        'updated_at' => now(),
+                    ]);
+                } catch (\Exception $e) {
+                    Log::warning('保存答题记录失败', [
+                        'student_id' => $studentId,
+                        'exam_id' => $examId,
+                        'question_id' => $questionId,
+                        'error' => $e->getMessage(),
+                    ]);
+                }
             }
         }
 

+ 435 - 43
app/Services/ExamPdfExportService.php

@@ -8,6 +8,7 @@ use App\Models\Paper;
 use App\Models\PaperQuestion;
 use App\Models\Student;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\File;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
@@ -27,7 +28,8 @@ class ExamPdfExportService
         private readonly LearningAnalyticsService $learningAnalyticsService,
         private readonly QuestionBankService $questionBankService,
         private readonly QuestionServiceApi $questionServiceApi,
-        private readonly PdfStorageService $pdfStorageService
+        private readonly PdfStorageService $pdfStorageService,
+        private readonly MasteryCalculator $masteryCalculator
     ) {}
 
     /**
@@ -72,18 +74,49 @@ class ExamPdfExportService
         }
 
         try {
+            // 【调试】打印输入参数
+            Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+                'record_id' => $recordId,
+            ]);
+
             // 构建分析数据
             $analysisData = $this->buildAnalysisData($paperId, $studentId);
             if (!$analysisData) {
+                Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
+                    'paper_id' => $paperId,
+                    'student_id' => $studentId,
+                ]);
                 return null;
             }
 
+            Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+                'analysisData_keys' => array_keys($analysisData),
+                'mastery_count' => count($analysisData['mastery']['items'] ?? []),
+                'questions_count' => count($analysisData['questions'] ?? []),
+            ]);
+
             // 创建DTO
             $dto = ExamAnalysisDataDto::fromArray($analysisData);
             $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
 
+            // 【调试】打印传给模板的数据
+            $templateData = $payloadDto->toArray();
+            Log::info('ExamPdfExportService: 传给模板的数据', [
+                'paper' => $templateData['paper'] ?? null,
+                'student' => $templateData['student'] ?? null,
+                'mastery' => $templateData['mastery'] ?? null,
+                'parent_mastery_levels' => $templateData['parent_mastery_levels'] ?? null, // 新增:检查父节点掌握度
+                'questions_count' => count($templateData['questions'] ?? []),
+                'insights_count' => count($templateData['question_insights'] ?? []),
+                'recommendations_count' => count($templateData['recommendations'] ?? []),
+            ]);
+
             // 渲染HTML
-            $html = view('exam-analysis.pdf-report', $payloadDto->toArray())->render();
+            $html = view('exam-analysis.pdf-report', $templateData)->render();
             if (!$html) {
                 Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
                 return null;
@@ -262,6 +295,13 @@ class ExamPdfExportService
      */
     private function buildAnalysisData(string $paperId, string $studentId): ?array
     {
+        // 【关键调试】确认方法被调用
+        Log::warning('ExamPdfExportService: buildAnalysisData方法被调用了!', [
+            'paper_id' => $paperId,
+            'student_id' => $studentId,
+            'timestamp' => now()->toISOString()
+        ]);
+
         $paper = Paper::with(['questions' => function ($query) {
             $query->orderBy('question_number')->orderBy('id');
         }])->find($paperId);
@@ -284,6 +324,8 @@ class ExamPdfExportService
 
         // 【修改】直接从本地数据库获取分析数据(不再调用API)
         $analysisData = [];
+
+        // 首先尝试从paper->analysis_id获取
         if (!empty($paper->analysis_id)) {
             Log::info('ExamPdfExportService: 从本地数据库获取试卷分析数据', [
                 'paper_id' => $paperId,
@@ -298,39 +340,221 @@ class ExamPdfExportService
 
             if ($analysisRecord && !empty($analysisRecord->analysis_data)) {
                 $analysisData = json_decode($analysisRecord->analysis_data, true);
-                Log::info('ExamPdfExportService: 成功获取本地分析数据', [
+                Log::info('ExamPdfExportService: 成功获取本地分析数据(通过analysis_id)', [
                     'data_size' => strlen($analysisRecord->analysis_data)
                 ]);
             } else {
-                Log::warning('ExamPdfExportService: 未找到本地分析数据,将使用空数据', [
+                Log::warning('ExamPdfExportService: 未找到本地分析数据,将尝试其他方式', [
                     'paper_id' => $paperId,
                     'student_id' => $studentId,
                     'analysis_id' => $paper->analysis_id
                 ]);
             }
-        } else {
-            Log::warning('ExamPdfExportService: 试卷无analysis_id,将使用空分析数据', [
+        }
+
+        // 如果没有analysis_id或未找到数据,直接从exam_analysis_results表查询
+        if (empty($analysisData)) {
+            Log::info('ExamPdfExportService: 直接从exam_analysis_results表查询分析数据', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId
             ]);
+
+            $analysisRecord = \DB::table('exam_analysis_results')
+                ->where('paper_id', $paperId)
+                ->where('student_id', $studentId)
+                ->first();
+
+            if ($analysisRecord && !empty($analysisRecord->analysis_data)) {
+                $analysisData = json_decode($analysisRecord->analysis_data, true);
+                Log::info('ExamPdfExportService: 成功获取本地分析数据(直接查询)', [
+                    'data_size' => strlen($analysisRecord->analysis_data),
+                    'question_count' => count($analysisData['question_analysis'] ?? [])
+                ]);
+            } else {
+                Log::warning('ExamPdfExportService: 未找到任何分析数据,将使用空数据', [
+                    'paper_id' => $paperId,
+                    'student_id' => $studentId
+                ]);
+            }
         }
 
-        // 【修改】使用本地方法获取掌握度概览(替代API调用)
+        // 【修复】优先使用analysisData中的knowledge_point_analysis数据
         $masteryData = [];
-        try {
-            Log::info('ExamPdfExportService: 获取学生掌握度概览', [
-                'student_id' => $studentId
-            ]);
-            $masteryOverview = $this->learningAnalyticsService->getStudentMasteryOverview($studentId);
-            $masteryData = $masteryOverview['details'] ?? [];
-            Log::info('ExamPdfExportService: 成功获取掌握度数据', [
-                'count' => count($masteryData)
+        $parentMasteryLevels = []; // 新增:父节点掌握度数据
+        Log::info('ExamPdfExportService: 开始处理掌握度数据', [
+            'student_id' => $studentId,
+            'analysisData_keys' => array_keys($analysisData),
+            'has_knowledge_point_analysis' => !empty($analysisData['knowledge_point_analysis']),
+        ]);
+
+        if (!empty($analysisData['knowledge_point_analysis'])) {
+            // 将knowledge_point_analysis转换为buildMasterySummary期望的格式
+            foreach ($analysisData['knowledge_point_analysis'] as $kp) {
+                $masteryData[] = [
+                    'kp_code' => $kp['kp_id'] ?? null,
+                    'kp_name' => $kp['kp_id'] ?? '未知知识点',
+                    'mastery_level' => $kp['mastery_level'] ?? 0,
+                    'mastery_change' => $kp['change'] ?? null,
+                ];
+            }
+
+            // 【修复】基于所有兄弟节点历史数据计算父节点掌握度,并获取掌握度变化
+            try {
+                // 获取本次考试涉及的知识点代码列表
+                $examKpCodes = array_column($masteryData, 'kp_code');
+                Log::info('ExamPdfExportService: 本次考试涉及的知识点', [
+                    'count' => count($examKpCodes),
+                    'kp_codes' => $examKpCodes
+                ]);
+
+                // 获取上一个快照的数据(用于计算变化)
+                // 如果没有其他试卷的记录,使用同一试卷的上一次快照
+                $lastSnapshot = DB::connection('mysql')
+                    ->table('knowledge_point_mastery_snapshots')
+                    ->where('student_id', $studentId)
+                    ->where('paper_id', $paper->paper_id)
+                    ->where('snapshot_id', '!=', "snap_{$paper->paper_id}_" . date('YmdHis'))
+                    ->latest('snapshot_time')
+                    ->first();
+
+                $previousMasteryData = [];
+                if ($lastSnapshot) {
+                    $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
+                    foreach ($previousMasteryJson as $kpCode => $data) {
+                        $previousMasteryData[$kpCode] = [
+                            'current_mastery' => $data['current_mastery'] ?? 0,
+                            'previous_mastery' => $data['previous_mastery'] ?? null,
+                        ];
+                    }
+                    Log::info('ExamPdfExportService: 获取到上一次快照数据', [
+                        'snapshot_time' => $lastSnapshot->snapshot_time,
+                        'kp_count' => count($previousMasteryData)
+                    ]);
+                }
+
+                // 为当前知识点添加变化数据
+                foreach ($masteryData as &$item) {
+                    $kpCode = $item['kp_code'];
+                    if (isset($previousMasteryData[$kpCode])) {
+                        $previous = floatval($previousMasteryData[$kpCode]['previous_mastery'] ?? 0);
+                        $current = floatval($item['mastery_level']);
+                        $item['mastery_change'] = $current - $previous;
+                    }
+                }
+                unset($item); // 解除引用
+
+                // 获取所有父节点掌握度
+                $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
+                $allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? [];
+
+                // 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
+                $parentMasteryLevels = [];
+                foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
+                    // 检查这个父节点是否有子节点在本次考试中出现
+                    $hasRelevantChild = false;
+                    foreach ($examKpCodes as $childKpCode) {
+                        if (str_starts_with($childKpCode, $parentKpCode)) {
+                            $hasRelevantChild = true;
+                            break;
+                        }
+                    }
+                    if ($hasRelevantChild) {
+                        // 【修复】计算父节点变化:基于所有子节点的平均变化
+                        $childChanges = [];
+                        foreach ($examKpCodes as $childKpCode) {
+                            if (str_starts_with($childKpCode, $parentKpCode)) {
+                                $previousChild = $previousMasteryData[$childKpCode]['previous_mastery'] ?? null;
+                                $currentChild = null;
+                                foreach ($masteryData as $item) {
+                                    if ($item['kp_code'] === $childKpCode) {
+                                        $currentChild = $item['mastery_level'];
+                                        break;
+                                    }
+                                }
+                                if ($previousChild !== null && $currentChild !== null) {
+                                    $childChanges[] = floatval($currentChild) - floatval($previousChild);
+                                }
+                            }
+                        }
+                        $avgChange = !empty($childChanges) ? array_sum($childChanges) / count($childChanges) : null;
+
+                        $parentMasteryLevels[$parentKpCode] = [
+                            'mastery_level' => $parentMastery,
+                            'mastery_change' => $avgChange,
+                        ];
+                    }
+                }
+
+                Log::info('ExamPdfExportService: 过滤后的父节点掌握度', [
+                    'all_parent_count' => count($allParentMasteryLevels),
+                    'filtered_parent_count' => count($parentMasteryLevels),
+                    'filtered_codes' => array_keys($parentMasteryLevels)
+                ]);
+            } catch (\Exception $e) {
+                Log::warning('ExamPdfExportService: 获取父节点掌握度失败', [
+                    'error' => $e->getMessage()
+                ]);
+            }
+
+            Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [
+                'count' => count($masteryData),
+                'masteryData_sample' => !empty($masteryData) ? array_slice($masteryData, 0, 2) : []
             ]);
-        } catch (\Exception $e) {
-            Log::error('ExamPdfExportService: 获取掌握度数据失败', [
-                'student_id' => $studentId,
-                'error' => $e->getMessage()
+        } else {
+            // 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览
+            try {
+                Log::info('ExamPdfExportService: 获取学生多层级掌握度概览', [
+                    'student_id' => $studentId
+                ]);
+                $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId);
+                $masteryData = $masteryOverview['details'] ?? [];
+                $parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度
+
+                // 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误)
+                if (!empty($masteryData) && is_array($masteryData)) {
+                    $masteryData = array_map(function($item) {
+                        if (is_object($item)) {
+                            return [
+                                'kp_code' => $item->kp_code ?? null,
+                                'kp_name' => $item->kp_name ?? null,
+                                'mastery_level' => floatval($item->mastery_level ?? 0),
+                                'mastery_change' => $item->mastery_change !== null ? floatval($item->mastery_change) : null,
+                            ];
+                        }
+                        return $item;
+                    }, $masteryData);
+                }
+
+                // 【修复】获取快照数据以计算掌握度变化
+                $lastSnapshot = DB::connection('mysql')
+                    ->table('knowledge_point_mastery_snapshots')
+                    ->where('student_id', $studentId)
+                    ->latest('snapshot_time')
+                    ->first();
+
+                if ($lastSnapshot) {
+                    $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true);
+                    foreach ($masteryData as &$item) {
+                        $kpCode = $item['kp_code'];
+                        if (isset($previousMasteryJson[$kpCode])) {
+                            $previous = floatval($previousMasteryJson[$kpCode]['previous_mastery'] ?? 0);
+                            $current = floatval($item['mastery_level']);
+                            $item['mastery_change'] = $current - $previous;
+                        }
+                    }
+                    unset($item);
+                }
+
+            Log::info('ExamPdfExportService: 成功获取多层级掌握度数据', [
+                'count' => count($masteryData),
+                'parent_count' => count($parentMasteryLevels)
             ]);
+            } catch (\Exception $e) {
+                Log::error('ExamPdfExportService: 获取掌握度数据失败', [
+                    'student_id' => $studentId,
+                    'error' => $e->getMessage()
+                ]);
+            }
         }
 
         // 【修改】使用本地方法获取学习路径推荐(替代API调用)
@@ -353,13 +577,37 @@ class ExamPdfExportService
 
         // 获取知识点名称映射
         $kpNameMap = $this->buildKnowledgePointNameMap();
+        Log::info('ExamPdfExportService: 获取知识点名称映射', [
+            'kpNameMap_count' => count($kpNameMap),
+            'kpNameMap_keys_sample' => !empty($kpNameMap) ? array_slice(array_keys($kpNameMap), 0, 5) : []
+        ]);
 
-        // 获取题目详情
-        $questionDetails = $this->getQuestionDetailsFromPaper($paper);
+        // 【修复】直接从MySQL数据库获取题目详情(不通过API)
+        $questionDetails = $this->getQuestionDetailsFromMySQL($paper);
 
         // 处理题目数据
         $questions = $this->processQuestionsForReport($paper, $questionDetails, $kpNameMap);
 
+        // 【关键调试】查看buildMasterySummary的返回结果
+        $masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap);
+        Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
+            'masteryData_count' => count($masteryData),
+            'kpNameMap_count' => count($kpNameMap),
+            'masterySummary_keys' => array_keys($masterySummary),
+            'masterySummary_items_count' => count($masterySummary['items'] ?? []),
+            'masterySummary_items_sample' => !empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : []
+        ]);
+
+        // 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
+        $examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
+        $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes);
+
+        Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
+            'raw_count' => count($parentMasteryLevels),
+            'processed_count' => count($processedParentMastery),
+            'processed_sample' => !empty($processedParentMastery) ? array_slice($processedParentMastery, 0, 3) : []
+        ]);
+
         return [
             'paper' => [
                 'id' => $paper->paper_id,
@@ -370,7 +618,8 @@ class ExamPdfExportService
             ],
             'student' => $studentInfo,
             'questions' => $questions,
-            'mastery' => $this->buildMasterySummary($masteryData, $kpNameMap),
+            'mastery' => $masterySummary,
+            'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
             'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
             'recommendations' => $recommendations,
             'analysis_data' => $analysisData,
@@ -378,22 +627,39 @@ class ExamPdfExportService
     }
 
     /**
-     * 获取题目详情
+     * 【修复】直接从PaperQuestion表获取题目详情(不通过API)
      */
-    private function getQuestionDetailsFromPaper(Paper $paper): array
+    private function getQuestionDetailsFromMySQL(Paper $paper): array
     {
         $details = [];
-        $questionIds = $paper->questions->pluck('question_id')->filter()->unique()->values();
 
-        foreach ($questionIds as $qid) {
+        Log::info('ExamPdfExportService: 从PaperQuestion表查询题目详情', [
+            'paper_id' => $paper->paper_id,
+            'question_count' => $paper->questions->count()
+        ]);
+
+        foreach ($paper->questions as $pq) {
             try {
-                $detail = $this->questionBankService->getQuestion((string) $qid);
-                if (!empty($detail)) {
-                    $details[(string) $qid] = $detail;
-                }
+                // 【关键修复】直接从PaperQuestion对象获取solution和correct_answer
+                $detail = [
+                    'id' => $pq->question_id,
+                    'content' => $pq->question_text,
+                    'question_type' => $pq->question_type,
+                    'answer' => $pq->correct_answer ?? null,  // 【修复】从PaperQuestion获取正确答案
+                    'solution' => $pq->solution ?? null,  // 【修复】从PaperQuestion获取解题思路
+                ];
+                $details[(string) ($pq->question_id ?? $pq->id)] = $detail;
+
+                Log::debug('ExamPdfExportService: 成功获取题目详情', [
+                    'paper_question_id' => $pq->id,
+                    'question_id' => $pq->question_id,
+                    'has_answer' => !empty($pq->correct_answer),
+                    'has_solution' => !empty($pq->solution),
+                    'answer_preview' => $pq->correct_answer ? substr($pq->correct_answer, 0, 50) : null
+                ]);
             } catch (\Throwable $e) {
-                Log::warning('ExamPdfExportService: 获取题目详情失败', [
-                    'question_id' => $qid,
+                Log::error('ExamPdfExportService: 获取题目详情失败', [
+                    'paper_question_id' => $pq->id,
                     'error' => $e->getMessage(),
                 ]);
             }
@@ -422,8 +688,12 @@ class ExamPdfExportService
         foreach ($sortedQuestions as $idx => $question) {
             $kpCode = $question->knowledge_point ?? '';
             $kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注';
-            $detail = $questionDetails[(string) ($question->question_id ?? '')] ?? [];
-            $solution = $detail['solution'] ?? $detail['解析'] ?? $detail['analysis'] ?? null;
+
+            // 【修复】直接从PaperQuestion对象获取solution和correct_answer
+            $answer = $question->correct_answer ?? null;  // 直接从PaperQuestion获取
+            $solution = $question->solution ?? null;  // 直接从PaperQuestion获取
+
+            $detail = $questionDetails[(string) ($question->question_id ?? $question->id)] ?? [];
             $typeRaw = $question->question_type ?? ($detail['question_type'] ?? $detail['type'] ?? '');
             $normalizedType = $this->normalizeQuestionType($typeRaw);
             $number = $question->question_number ?? ($idx + 1);
@@ -437,10 +707,24 @@ class ExamPdfExportService
                 'knowledge_point' => $kpCode,
                 'knowledge_point_name' => $kpName,
                 'score' => $question->score,
-                'solution' => $solution,
+                'answer' => $answer,  // 正确答案
+                'solution' => $solution,  // 解题思路
+                'student_answer' => $question->student_answer ?? null,  // 【新增】学生答案
+                'correct_answer' => $answer,  // 【新增】正确答案
+                'is_correct' => $question->is_correct ?? null,  // 【新增】判分结果
+                'score_obtained' => $question->score_obtained ?? null,  // 【新增】得分
             ];
 
             $grouped[$normalizedType][] = $payload;
+
+            // 【调试】记录题目数据
+            Log::debug('ExamPdfExportService: 处理题目数据', [
+                'paper_question_id' => $question->id,
+                'question_id' => $question->question_id,
+                'has_answer' => !empty($answer),
+                'has_solution' => !empty($solution),
+                'answer_preview' => $answer ? substr($answer, 0, 50) : null
+            ]);
         }
 
         $ordered = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
@@ -662,18 +946,20 @@ class ExamPdfExportService
      */
     private function buildMasterySummary(array $masteryData, array $kpNameMap): array
     {
+        Log::info('ExamPdfExportService: buildMasterySummary开始处理', [
+            'masteryData_count' => count($masteryData),
+            'kpNameMap_count' => count($kpNameMap)
+        ]);
+
         $items = [];
         $total = 0;
         $count = 0;
-        $hasMap = !empty($kpNameMap);
 
         foreach ($masteryData as $row) {
             $code = $row['kp_code'] ?? null;
-            if ($hasMap && $code && !isset($kpNameMap[$code])) {
-                continue;
-            }
-            $name = $row['kp_name'] ?? ($code ? ($kpNameMap[$code] ?? $code) : '未知知识点');
-            $level = (float) ($row['mastery_level'] ?? 0);
+            // 【修复】使用kpNameMap转换名称为友好显示名
+            $name = $kpNameMap[$code] ?? $row['kp_name'] ?? $code ?: '未知知识点';
+            $level = (float)($row['mastery_level'] ?? 0);
             $delta = $row['mastery_change'] ?? null;
 
             $items[] = [
@@ -690,13 +976,22 @@ class ExamPdfExportService
         $average = $count > 0 ? round($total / $count, 2) : null;
 
         // 按掌握度从低到高排序
-        usort($items, fn($a, $b) => ($a['mastery_level'] <=> $b['mastery_level']));
+        if (!empty($items)) {
+            usort($items, fn($a, $b) => ($a['mastery_level'] <=> $b['mastery_level']));
+        }
 
-        return [
+        $result = [
             'items' => $items,
             'average' => $average,
             'weak_list' => array_slice($items, 0, 5),
         ];
+
+        Log::info('ExamPdfExportService: buildMasterySummary完成', [
+            'total_count' => $count,
+            'items_count' => count($items)
+        ]);
+
+        return $result;
     }
 
     /**
@@ -808,4 +1103,101 @@ class ExamPdfExportService
             ]);
         }
     }
+
+    /**
+     * 【修复】处理父节点掌握度数据
+     * 1. 过滤掉掌握度为0或null的父节点
+     * 2. 将kp_code转换为友好的kp_name
+     * 3. 构建父子层级关系(只显示本次考试相关的子节点)
+     */
+    private function processParentMasteryLevels(array $parentMasteryLevels, array $kpNameMap, array $examKpCodes = []): array
+    {
+        $processed = [];
+
+        foreach ($parentMasteryLevels as $kpCode => $masteryData) {
+            // 兼容不同数据结构:可能是数组或数字
+            $masteryLevel = is_array($masteryData) ? ($masteryData['mastery_level'] ?? 0) : $masteryData;
+            $masteryChange = is_array($masteryData) ? ($masteryData['mastery_change'] ?? null) : null;
+
+            // 过滤零值和空值
+            if ($masteryLevel === null || $masteryLevel === 0.0 || $masteryLevel <= 0.001) {
+                continue;
+            }
+
+            // 获取友好名称
+            $kpName = $kpNameMap[$kpCode] ?? $kpCode;
+
+            // 构建父节点数据,包含子节点信息(只显示本次考试相关的)
+            $processed[$kpCode] = [
+                'kp_code' => $kpCode,
+                'kp_name' => $kpName,
+                'mastery_level' => round(floatval($masteryLevel), 4),
+                'mastery_percentage' => round(floatval($masteryLevel) * 100, 2),
+                'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null,
+                // 【修复】只获取本次考试涉及的子节点
+                'children' => $this->getChildKnowledgePoints($kpCode, $kpNameMap, $examKpCodes),
+                'level' => $this->calculateKnowledgePointLevel($kpCode),
+            ];
+        }
+
+        // 按掌握度降序排序
+        uasort($processed, function($a, $b) {
+            return $b['mastery_level'] <=> $a['mastery_level'];
+        });
+
+        return $processed;
+    }
+
+    /**
+     * 【修复】获取子知识点列表(只返回本次考试涉及的)
+     */
+    private function getChildKnowledgePoints(string $parentKpCode, array $kpNameMap, array $examKpCodes = []): array
+    {
+        $children = [];
+
+        try {
+            $childCodes = DB::connection('mysql')
+                ->table('knowledge_points')
+                ->where('parent_kp_code', $parentKpCode)
+                ->pluck('kp_code')
+                ->toArray();
+
+            foreach ($childCodes as $childCode) {
+                // 只包含本次考试涉及的知识点
+                if (in_array($childCode, $examKpCodes)) {
+                    $children[] = [
+                        'kp_code' => $childCode,
+                        'kp_name' => $kpNameMap[$childCode] ?? $childCode,
+                    ];
+                }
+            }
+        } catch (\Exception $e) {
+            Log::warning('获取子知识点失败', [
+                'parent_kp_code' => $parentKpCode,
+                'error' => $e->getMessage(),
+            ]);
+        }
+
+        return $children;
+    }
+
+    /**
+     * 计算知识点层级深度
+     */
+    private function calculateKnowledgePointLevel(string $kpCode): int
+    {
+        // 根据kp_code前缀判断层级深度
+        // 例如: M (1级) -> M01 (2级) -> M01A (3级)
+        if (preg_match('/^[A-Z]+$/', $kpCode)) {
+            return 1; // 一级分类,如 M, S, E, G
+        } elseif (preg_match('/^[A-Z]+\d+$/', $kpCode)) {
+            return 2; // 二级分类,如 M01, S02
+        } elseif (preg_match('/^[A-Z]+\d+[A-Z]+$/', $kpCode)) {
+            return 3; // 三级分类,如 M01A, S02B
+        } elseif (preg_match('/^[A-Z]+\d+[A-Z]+\d+$/', $kpCode)) {
+            return 4; // 四级分类,如 M01A1
+        }
+
+        return 1; // 默认一级
+    }
 }

+ 311 - 178
app/Services/MasteryCalculator.php

@@ -8,32 +8,16 @@ use Illuminate\Support\Collection;
 
 /**
  * 知识掌握度计算引擎(PHP版本)
- * 基于BKT(Bayesian Knowledge Tracing)模型和多种因素综合计算
+ * 基于学案基准难度的动态加减逻辑计算掌握度
  *
- * 参考LearningAnalytics的Python实现迁移而来
+ * 新算法特点:
+ * 1. 根据学案基准难度(筑基、提分、培优、竞赛)动态调整权重
+ * 2. 难度映射:0.0-1.0 → 1-4级
+ * 3. 权重计算:越级、适应、降级三种情况
+ * 4. 父节点掌握度:子节点平均值
  */
 class MasteryCalculator
 {
-    /**
-     * 难度权重配置
-     * 简单题目权重低,困难题目权重高
-     */
-    private const DIFFICULTY_WEIGHTS = [
-        0.30 => 0.8,   // 简单题目权重
-        0.60 => 1.0,   // 中等题目权重
-        0.85 => 1.3,   // 困难题目权重
-    ];
-
-    /**
-     * 时间效率基准值(秒)
-     * 不同难度题目的平均完成时间
-     */
-    private const TIME_BASELINE = [
-        0.30 => 60,    // 简单题平均用时
-        0.60 => 120,   // 中等题平均用时
-        0.85 => 180,   // 困难题平均用时
-    ];
-
     /**
      * 掌握度阈值配置
      */
@@ -41,11 +25,6 @@ class MasteryCalculator
     private const MASTERY_THRESHOLD_GOOD = 0.70;   // 良好阈值
     private const MASTERY_THRESHOLD_MASTER = 0.85; // 掌握阈值
 
-    /**
-     * 最小练习题目数量
-     */
-    private const MIN_PRACTICE_QUESTIONS = 5;
-
     /**
      * 最小正确率要求
      */
@@ -57,9 +36,10 @@ class MasteryCalculator
      * @param string $studentId 学生ID
      * @param string $kpCode 知识点编码
      * @param array|null $attempts 答题记录(可选,默认从数据库查询)
+     * @param int|null $examBaseDifficulty 学案基准难度(1-4级,1=筑基, 2=提分, 3=培优, 4=竞赛)
      * @return array 返回['mastery' => 掌握度, 'confidence' => 置信度, 'trend' => 趋势]
      */
-    public function calculateMasteryLevel(string $studentId, string $kpCode, ?array $attempts = null): array
+    public function calculateMasteryLevel(string $studentId, string $kpCode, ?array $attempts = null, ?int $examBaseDifficulty = null): array
     {
         // 如果没有提供答题记录,从数据库查询
         if ($attempts === null) {
@@ -77,194 +57,187 @@ class MasteryCalculator
             ];
         }
 
-        // 1. 计算基础正确率
-        $accuracyRate = $this->calculateAccuracyRate($attempts);
-
-        // 2. 计算难度加权分数
-        $difficultyScore = $this->calculateDifficultyScore($attempts);
-
-        // 3. 计算时间效率分数
-        $timeScore = $this->calculateTimeEfficiency($attempts);
-
-        // 4. 计算技能熟练度影响
-        $skillFactor = $this->calculateSkillFactor($attempts);
-
-        // 5. 应用遗忘曲线衰减
-        $decayFactor = $this->calculateDecayFactor($attempts);
-
-        // 6. 综合计算掌握度
-        $baseMastery = $accuracyRate *
-                      $difficultyScore *
-                      $timeScore *
-                      $skillFactor *
-                      $decayFactor;
-
-        // 7. 计算置信度
-        $confidence = $this->calculateConfidence($attempts);
-
-        // 8. 判断趋势
-        $trend = $this->determineTrend($attempts);
+        // 【新算法】使用学案基准难度的动态加减逻辑
+        if ($examBaseDifficulty === null) {
+            Log::warning('缺少学案基准难度,无法计算掌握度', [
+                'student_id' => $studentId,
+                'kp_code' => $kpCode,
+                'exam_base_difficulty' => $examBaseDifficulty,
+            ]);
+            return [
+                'mastery' => 0.0,
+                'confidence' => 0.0,
+                'trend' => 'insufficient',
+                'total_attempts' => count($attempts),
+                'correct_attempts' => 0,
+                'accuracy_rate' => 0.0,
+                'error' => '缺少学案基准难度参数',
+            ];
+        }
 
-        // 9. 计算统计信息
-        $totalAttempts = count($attempts);
-        $correctAttempts = count(array_filter($attempts, fn($a) => $a['is_correct']));
+        $masteryData = $this->calculateMasteryWithExamDifficulty($studentId, $kpCode, $attempts, $examBaseDifficulty);
 
-        Log::info('MasteryCalculator::calculateMasteryLevel', [
+        Log::info('掌握度计算完成', [
             'student_id' => $studentId,
             'kp_code' => $kpCode,
-            'total_attempts' => $totalAttempts,
-            'correct_attempts' => $correctAttempts,
-            'accuracy_rate' => $accuracyRate,
-            'difficulty_score' => $difficultyScore,
-            'time_score' => $timeScore,
-            'skill_factor' => $skillFactor,
-            'decay_factor' => $decayFactor,
-            'final_mastery' => $baseMastery,
-            'confidence' => $confidence,
-            'trend' => $trend,
+            'exam_base_difficulty' => $examBaseDifficulty,
+            'difficulty_name' => $this->getDifficultyName($examBaseDifficulty),
+            'total_attempts' => count($attempts),
+            'correct_attempts' => $masteryData['correct_attempts'],
+            'final_mastery' => $masteryData['mastery'],
+            'confidence' => $masteryData['confidence'],
+            'trend' => $masteryData['trend'],
         ]);
 
-        return [
-            'mastery' => round($baseMastery, 4),
-            'confidence' => round($confidence, 4),
-            'trend' => $trend,
-            'total_attempts' => $totalAttempts,
-            'correct_attempts' => $correctAttempts,
-            'accuracy_rate' => round($accuracyRate * 100, 2),
-            'details' => [
-                'accuracy_rate' => $accuracyRate,
-                'difficulty_score' => $difficultyScore,
-                'time_score' => $timeScore,
-                'skill_factor' => $skillFactor,
-                'decay_factor' => $decayFactor,
-            ],
-        ];
+        return $masteryData;
     }
 
     /**
-     * 计算正确率
+     * 获取难度等级名称
      */
-    private function calculateAccuracyRate(array $attempts): float
+    private function getDifficultyName(int $difficultyLevel): string
     {
-        if (empty($attempts)) {
-            return 0.0;
-        }
-
-        $totalAttempts = count($attempts);
-        $correctAttempts = count(array_filter($attempts, fn($a) => $a['is_correct']));
-        $partialAttempts = count(array_filter($attempts, function($a) {
-            return isset($a['partial_score']) && floatval($a['partial_score']) > 0;
-        }));
-
-        // 部分正确按50%计算
-        $correctScore = $correctAttempts + $partialAttempts * 0.5;
-        $accuracy = $correctScore / $totalAttempts;
-
-        return min($accuracy, 1.0);
+        return match ($difficultyLevel) {
+            1 => '筑基',
+            2 => '提分',
+            3 => '培优',
+            4 => '竞赛',
+            default => '未知',
+        };
     }
 
     /**
-     * 计算难度加权分数
+     * 【新算法】使用学案基准难度的动态加减逻辑计算掌握度
      */
-    private function calculateDifficultyScore(array $attempts): float
+    private function calculateMasteryWithExamDifficulty(string $studentId, string $kpCode, array $attempts, int $examBaseDifficulty): array
     {
-        if (empty($attempts)) {
-            return 0.0;
-        }
+        // 获取历史掌握度
+        $historyMastery = DB::table('student_knowledge_mastery')
+            ->where('student_id', $studentId)
+            ->where('kp_code', $kpCode)
+            ->first();
+
+        $oldMastery = $historyMastery->mastery_level ?? 0.5; // 默认0.5
+
+        // 统计正确和错误次数
+        $totalAttempts = count($attempts);
+        $correctAttempts = 0;
+        $incorrectAttempts = 0;
 
-        $weightedSum = 0.0;
-        $totalWeight = 0.0;
+        // 计算每次答题的权重变化
+        $totalChange = 0.0;
 
         foreach ($attempts as $attempt) {
-            $difficulty = floatval($attempt['question_difficulty'] ?? 0.6);
-            $weight = self::DIFFICULTY_WEIGHTS[$difficulty] ?? 1.0;
-            $score = $attempt['is_correct'] ? 1.0 : 0.0;
+            $isCorrect = boolval($attempt['is_correct'] ?? false);
+            $questionDifficulty = floatval($attempt['question_difficulty'] ?? 0.6);
 
-            $weightedSum += $score * $weight;
-            $totalWeight += $weight;
-        }
+            // 难度映射:将0.0-1.0的浮点数难度映射为1-4等级
+            $questionLevel = $this->mapDifficultyToLevel($questionDifficulty);
 
-        if ($totalWeight == 0) {
-            return 0.0;
-        }
+            // 根据难度关系计算权重变化
+            $change = $this->calculateWeightByDifficultyRelation($questionLevel, $examBaseDifficulty, $isCorrect);
 
-        return $weightedSum / $totalWeight;
-    }
+            $totalChange += $change;
 
-    /**
-     * 计算时间效率分数
-     */
-    private function calculateTimeEfficiency(array $attempts): float
-    {
-        if (empty($attempts)) {
-            return 0.0;
+            if ($isCorrect) {
+                $correctAttempts++;
+            } else {
+                $incorrectAttempts++;
+            }
+
+            Log::debug('掌握度变化计算', [
+                'question_id' => $attempt['question_id'] ?? '',
+                'question_difficulty' => $questionDifficulty,
+                'question_level' => $questionLevel,
+                'exam_base_difficulty' => $examBaseDifficulty,
+                'is_correct' => $isCorrect,
+                'change' => $change,
+                'running_total' => $totalChange
+            ]);
         }
 
-        $efficiencyScores = [];
+        // 计算平均变化(避免单次考试影响过大)
+        $averageChange = $totalAttempts > 0 ? $totalChange / $totalAttempts : 0.0;
 
-        foreach ($attempts as $attempt) {
-            $difficulty = floatval($attempt['question_difficulty'] ?? 0.6);
-            $baseline = self::TIME_BASELINE[$difficulty] ?? 120;
-            $actualTime = floatval($attempt['attempt_time_seconds'] ?? 120);
+        // 应用权重调整(避免单次考试变化过大)
+        $weightedChange = $averageChange * min($totalAttempts / 10.0, 1.0); // 最多10次考试达到满权重
 
-            // 时间效率:基准时间/实际用时,最大不超过1.5
-            $efficiency = min($baseline / max($actualTime, 1), 1.5);
-            $efficiencyScores[] = $efficiency;
-        }
+        // 新掌握度 = 旧掌握度 + 加权变化
+        $newMastery = $oldMastery + $weightedChange;
 
-        // 取最近5次答题的平均效率
-        $recentEfficiency = array_slice($efficiencyScores, -5);
-        $avgEfficiency = array_sum($recentEfficiency) / count($recentEfficiency);
+        // 边界限制:0.0 ~ 1.0
+        $newMastery = max(0.0, min(1.0, $newMastery));
 
-        return min($avgEfficiency, 1.0);
+        // 计算置信度(基于答题次数)
+        $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,
+                    'total_attempts' => ($historyMastery->total_attempts ?? 0) + $totalAttempts,
+                    'correct_attempts' => ($historyMastery->correct_attempts ?? 0) + $correctAttempts,
+                    'mastery_trend' => $trend,
+                    '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),
+            'details' => [
+                'exam_base_difficulty' => $examBaseDifficulty,
+                'total_change' => round($totalChange, 4),
+                'average_change' => round($averageChange, 4),
+                'weighted_change' => round($weightedChange, 4),
+            ],
+        ];
     }
 
     /**
-     * 计算技能熟练度影响
+     * 难度映射:将0.0-1.0的浮点数难度映射为1-4等级
      */
-    private function calculateSkillFactor(array $attempts, ?array $skillProficiency = null): float
+    private function mapDifficultyToLevel(float $difficulty): int
     {
-        // 简化实现:如果有技能熟练度数据,使用它;否则返回1.0
-        if ($skillProficiency && !empty($skillProficiency)) {
-            // 计算平均技能熟练度
-            $avgProficiency = array_sum($skillProficiency) / count($skillProficiency);
-            // 技能熟练度加权:范围0.8-1.2
-            return 0.8 + ($avgProficiency * 0.4);
+        if ($difficulty >= 0.0 && $difficulty < 0.25) {
+            return 1; // 1级
+        } elseif ($difficulty >= 0.25 && $difficulty < 0.5) {
+            return 2; // 2级
+        } elseif ($difficulty >= 0.5 && $difficulty < 0.75) {
+            return 3; // 3级
+        } else {
+            return 4; // 4级
         }
-
-        return 1.0;
     }
 
     /**
-     * 计算遗忘曲线衰减因子
+     * 根据难度关系计算权重变化
      */
-    private function calculateDecayFactor(array $attempts): float
+    private function calculateWeightByDifficultyRelation(int $questionLevel, int $examBaseDifficulty, bool $isCorrect): float
     {
-        if (empty($attempts)) {
-            return 0.0;
-        }
-
-        // 获取最近一次答题时间
-        $lastAttemptTime = null;
-        foreach ($attempts as $attempt) {
-            $attemptTime = strtotime($attempt['completed_at'] ?? $attempt['created_at'] ?? 'now');
-            if ($lastAttemptTime === null || $attemptTime > $lastAttemptTime) {
-                $lastAttemptTime = $attemptTime;
-            }
-        }
-
-        if ($lastAttemptTime === null) {
-            return 1.0;
+        if ($questionLevel > $examBaseDifficulty) {
+            // 越级:题目难度 > 学案基准难度
+            return $isCorrect ? 0.15 : -0.05;
+        } elseif ($questionLevel == $examBaseDifficulty) {
+            // 适应:题目难度 = 学案基准难度
+            return $isCorrect ? 0.10 : -0.10;
+        } else {
+            // 降级:题目难度 < 学案基准难度
+            return $isCorrect ? 0.05 : -0.15;
         }
-
-        // 计算距离现在的天数
-        $daysSinceLastAttempt = (time() - $lastAttemptTime) / 86400; // 86400 = 24*60*60
-
-        // 遗忘曲线:每天衰减2%,最大衰减50%
-        $decayRate = min($daysSinceLastAttempt * 0.02, 0.5);
-        $decayFactor = 1.0 - $decayRate;
-
-        return max($decayFactor, 0.5); // 最低保持50%
     }
 
     /**
@@ -286,8 +259,11 @@ class MasteryCalculator
             $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;
+
         // 正确率也影响置信度
-        $accuracyRate = $this->calculateAccuracyRate($attempts);
         $accuracyFactor = 0.5 + $accuracyRate * 0.5;
 
         // 综合置信度
@@ -317,8 +293,12 @@ class MasteryCalculator
         $firstHalf = array_slice($attempts, 0, $midPoint);
         $secondHalf = array_slice($attempts, $midPoint);
 
-        $firstHalfAccuracy = $this->calculateAccuracyRate($firstHalf);
-        $secondHalfAccuracy = $this->calculateAccuracyRate($secondHalf);
+        // 计算前半部分和后半部分的正确率
+        $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;
 
@@ -429,4 +409,157 @@ class MasteryCalculator
             'details' => $masteryArray,
         ];
     }
+
+    /**
+     * 【新功能】获取知识点层级关系
+     */
+    private function getKnowledgePointHierarchy(string $kpCode): ?array
+    {
+        try {
+            $kp = DB::table('knowledge_points')
+                ->where('kp_code', $kpCode)
+                ->first();
+
+            if (!$kp) {
+                return null;
+            }
+
+            return [
+                'kp_code' => $kp->kp_code,
+                'parent_kp_code' => $kp->parent_kp_code,
+                'level' => $kp->level ?? 1,
+            ];
+        } catch (\Exception $e) {
+            Log::warning('获取知识点层级关系失败', [
+                'kp_code' => $kpCode,
+                'error' => $e->getMessage(),
+            ]);
+            return null;
+        }
+    }
+
+    /**
+     * 【新功能】计算父节点掌握度(子节点平均值)
+     */
+    public function calculateParentMastery(string $studentId, string $parentKpCode): float
+    {
+        try {
+            // 获取所有子节点
+            $childKps = DB::table('knowledge_points')
+                ->where('parent_kp_code', $parentKpCode)
+                ->pluck('kp_code')
+                ->toArray();
+
+            if (empty($childKps)) {
+                // 如果没有子节点,返回0
+                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);
+                }
+            }
+
+            if (empty($masteryLevels)) {
+                return 0.0;
+            }
+
+            // 计算算术平均数
+            $averageMastery = array_sum($masteryLevels) / count($masteryLevels);
+
+            Log::info('父节点掌握度计算', [
+                'student_id' => $studentId,
+                'parent_kp_code' => $parentKpCode,
+                'child_count' => count($childKps),
+                'mastery_count' => count($masteryLevels),
+                'average_mastery' => round($averageMastery, 4),
+                'child_masteries' => $masteryLevels,
+            ]);
+
+            return round($averageMastery, 4);
+
+        } catch (\Exception $e) {
+            Log::error('计算父节点掌握度失败', [
+                'student_id' => $studentId,
+                'parent_kp_code' => $parentKpCode,
+                'error' => $e->getMessage(),
+            ]);
+            return 0.0;
+        }
+    }
+
+    /**
+     * 【增强】获取学生所有知识点的掌握度概览(支持父节点计算)
+     */
+    public function getStudentMasteryOverviewWithHierarchy(string $studentId): array
+    {
+        $masteryList = DB::table('student_knowledge_mastery')
+            ->where('student_id', $studentId)
+            ->get();
+
+        if ($masteryList->isEmpty()) {
+            return [
+                'total_knowledge_points' => 0,
+                'average_mastery_level' => 0.0,
+                'mastered_knowledge_points' => 0,
+                'good_knowledge_points' => 0,
+                'weak_knowledge_points' => 0,
+                'weak_knowledge_points_list' => [],
+                'details' => [],
+                'parent_mastery_levels' => [], // 新增:父节点掌握度
+            ];
+        }
+
+        $masteryArray = $masteryList->toArray();
+
+        $total = count($masteryArray);
+        $average = $masteryArray ? array_sum(array_column($masteryArray, 'mastery_level')) / $total : 0;
+
+        $mastered = [];
+        $good = [];
+        $weak = [];
+
+        foreach ($masteryArray as $item) {
+            $level = floatval($item->mastery_level);
+            if ($level >= self::MASTERY_THRESHOLD_MASTER) {
+                $mastered[] = $item;
+            } elseif ($level >= self::MASTERY_THRESHOLD_GOOD) {
+                $good[] = $item;
+            } else {
+                $weak[] = $item;
+            }
+        }
+
+        // 【新功能】计算父节点掌握度
+        $parentMasteryLevels = [];
+        $parentKpCodes = DB::table('knowledge_points')
+            ->whereNotNull('parent_kp_code')
+            ->distinct()
+            ->pluck('parent_kp_code')
+            ->toArray();
+
+        foreach ($parentKpCodes as $parentKpCode) {
+            $parentMastery = $this->calculateParentMastery($studentId, $parentKpCode);
+            $parentMasteryLevels[$parentKpCode] = $parentMastery;
+        }
+
+        return [
+            'total_knowledge_points' => $total,
+            'average_mastery_level' => round($average, 4),
+            'mastered_knowledge_points' => count($mastered),
+            'good_knowledge_points' => count($good),
+            'weak_knowledge_points' => count($weak),
+            'weak_knowledge_points_list' => $weak,
+            'details' => $masteryArray,
+            'parent_mastery_levels' => $parentMasteryLevels, // 新增:父节点掌握度
+        ];
+    }
 }

+ 47 - 0
app/Services/QuestionBankService.php

@@ -6,6 +6,7 @@ use App\Models\Paper;
 use App\Models\Question;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\DB;
 use App\Services\PaperIdGenerator;
 
 class QuestionBankService
@@ -1472,4 +1473,50 @@ class QuestionBankService
             return [];
         }
     }
+
+    /**
+     * 【通用方法】获取题目的知识点信息
+     * 直接从MySQL的questions表查询
+     */
+    public function getQuestionKnowledgePoint(int $questionBankId): array
+    {
+        try {
+            // 直接从MySQL questions表查询题目
+            $question = \App\Models\Question::where('id', $questionBankId)->first();
+
+            if (!$question) {
+                Log::warning('QuestionBankService: MySQL中未找到题目', ['question_bank_id' => $questionBankId]);
+                return [
+                    'kp_code' => null,
+                    'kp_name' => null,
+                    'question_content' => '',
+                    'question_answer' => '',
+                    'question_type' => 'unknown',
+                ];
+            }
+
+            return [
+                'kp_code' => $question->kp_code,
+                'kp_name' => $question->kp_name ?? $question->kp_code,
+                'question_content' => $question->stem ?? '',
+                'question_answer' => $question->answer ?? '',
+                'question_type' => $question->question_type ?? 'unknown',
+            ];
+
+        } catch (\Exception $e) {
+            Log::error('QuestionBankService: 获取题目知识点失败', [
+                'question_bank_id' => $questionBankId,
+                'error' => $e->getMessage(),
+            ]);
+
+            return [
+                'kp_code' => null,
+                'kp_name' => null,
+                'question_content' => '',
+                'question_answer' => '',
+                'question_type' => 'unknown',
+                'error' => $e->getMessage(),
+            ];
+        }
+    }
 }

+ 265 - 54
resources/views/exam-analysis/pdf-report.blade.php

@@ -1,3 +1,20 @@
+@php
+    // 【参考试卷和判卷格式】学情报告以3开头 + 12位paper_id数字部分
+    $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
+    // 从 paper_id 提取12位数字部分(格式: paper_xxxxxxxxxxxx)
+    if (preg_match('/paper_(\d{12})/', $rawPaperId, $matches)) {
+        $paperIdNum = $matches[1];
+    } else {
+        // 兼容旧格式,取数字部分或生成哈希
+        $paperIdNum = preg_replace('/[^0-9]/', '', $rawPaperId);
+        $paperIdNum = str_pad(substr($paperIdNum, 0, 12), 12, '0', STR_PAD_LEFT);
+    }
+    $reportCode = '3' . $paperIdNum; // 学情报告识别码:3 + 12位数字
+    $averageMastery = isset($mastery['average']) ? number_format($mastery['average'] * 100, 1) . '%' : '无数据';
+
+    // 【修复】从insights中获取AI分析结果(而不是从analysis_data)
+    $questionAnalysis = $insights ?? [];
+@endphp
 <!DOCTYPE html>
 <html lang="zh-CN">
 <head>
@@ -5,10 +22,45 @@
     <title>学情报告 - {{ $paper['name'] ?? '试卷' }}</title>
     <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
     <style>
+        @page {
+            size: A4;
+            margin: 1.5cm 1.5cm 2cm 1.5cm;
+            @top-left {
+                content: "知了数学";
+                font-size: 10px;
+                color: #666;
+            }
+            @top-right {
+                content: "{{ $reportCode }}";
+                font-size: 10px;
+                color: #666;
+            }
+            @bottom-left {
+                content: "{{ $reportCode }}";
+                font-size: 10px;
+                color: #666;
+            }
+            @bottom-right {
+                content: counter(page) "/" counter(pages);
+                font-size: 10px;
+                color: #666;
+            }
+        }
         * { box-sizing: border-box; }
-        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; margin: 24px; color: #1f2937; background: #f9fafb; }
-        h1, h2, h3 { margin: 0; color: #111827; }
-        .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06); }
+        body {
+            font-family: "SimSun", "Songti SC", serif;
+            margin: 0;
+            padding: 0;
+            color: #000;
+            background: #fff;
+            font-size: 12px;
+            line-height: 1.6;
+        }
+        h1, h2, h3 { margin: 0; color: #000; }
+        .card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04); }
+        .header { text-align: center; margin-bottom: 1rem; border-bottom: 2px solid #000; padding-bottom: 0.5rem; }
+        .school-name { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
+        .paper-title { font-size: 20px; font-weight: bold; margin-bottom: 15px; }
         .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
         .tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; color: #374151; background: #e5e7eb; }
         .section-title { font-size: 16px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
@@ -26,51 +78,143 @@
     </style>
 </head>
 <body>
-    <div class="card" style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px;">
-        <div>
-            <h1>学情报告</h1>
-            <div class="muted" style="margin-top:6px;">卷子:{{ $paper['name'] ?? '-' }} | 学生:{{ $student['name'] ?? '-' }}</div>
-            <div class="muted">年级:{{ $student['grade'] ?? '-' }} | 班级:{{ $student['class'] ?? '-' }}</div>
-        </div>
-        <div style="text-align:right;">
-            <div class="pill {{ ($mastery['average'] ?? 0) >= 0.7 ? 'green' : (($mastery['average'] ?? 0) >= 0.5 ? 'amber' : 'red') }}">
-                平均掌握度 {{ isset($mastery['average']) ? number_format($mastery['average'] * 100, 1) . '%' : '无数据' }}
+    <div class="page">
+        <div class="header">
+            <h1 class="paper-title">学情分析报告</h1>
+            <div style="margin-top: 10px; font-size: 14px;">
+                试卷:{{ $paper['name'] ?? '-' }} | 学生:{{ $student['name'] ?? '-' }} | 年级:{{ $student['grade'] ?? '-' }}
+            </div>
+            <div style="margin-top: 6px; font-size: 14px;">
+                题目数:{{ is_array($questions ?? null) ? count($questions) : ($paper['total_questions'] ?? '-') }}
             </div>
-            <div class="muted" style="margin-top:6px;">题目数:{{ is_array($questions ?? null) ? count($questions) : ($paper['total_questions'] ?? '-') }}</div>
         </div>
-    </div>
 
     <div class="card">
         <div class="section-title">知识点掌握度</div>
-        @if(!empty($mastery['items']))
-            @foreach($mastery['items'] as $item)
+        @php
+            // 【修复】过滤掉K-GENERAL等通用知识点,只显示有值的知识点
+            $filteredMasteryItems = [];
+            if (!empty($mastery['items'])) {
+                foreach ($mastery['items'] as $item) {
+                    $kpCode = $item['kp_code'] ?? '';
+                    $masteryLevel = $item['mastery_level'] ?? 0;
+
+                    // 过滤条件:不是通用知识点,且掌握度>0
+                    if (!in_array($kpCode, ['K-GENERAL', 'GENERAL', 'DEFAULT']) && $masteryLevel > 0) {
+                        $filteredMasteryItems[] = $item;
+                    }
+                }
+            }
+        @endphp
+
+        @if(!empty($filteredMasteryItems))
+            @foreach($filteredMasteryItems as $item)
                 @php
-                    $pct = min(100, max(0, $item['mastery_level'] * 100));
+                    $pct = min(100, max(0, ($item['mastery_level'] ?? 0) * 100));
                     $barColor = $pct >= 80 ? '#10b981' : ($pct >= 60 ? '#f59e0b' : '#ef4444');
                     $delta = $item['mastery_change'] ?? null;
+                    // 只有当有变化值时才显示变化信息
+                    $changeText = '';
+                    if ($delta !== null && $delta !== '' && abs($delta) > 0.001) {
+                        $changeText = ($delta > 0 ? '↑ ' : '↓ ') . number_format(abs($delta) * 100, 1) . '%';
+                    }
+                @endphp
+                <div style="margin-bottom:12px; padding: 8px; border: 1px solid #e5e7eb; border-radius: 6px;">
+                    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 6px;">
+                        <div style="font-weight: 600; font-size: 14px;">{{ $item['kp_name'] ?? $item['kp_code'] ?? '未知知识点' }}</div>
+                        <div style="font-weight: 600; color: {{ $barColor }}; font-size: 14px;">
+                            {{ number_format($pct, 1) }}%
+                            <span style="margin-left: 8px; color: #666; font-size: 12px;">{{ $changeText }}</span>
+                        </div>
+                    </div>
+                    <div class="progress-wrap" style="height: 12px;">
+                        <div class="progress-bar" style="width: {{ $pct }}%; background: {{ $barColor }};"></div>
+                    </div>
+                </div>
+            @endforeach
+        @else
+            <div class="muted">暂无有效掌握度数据(已过滤通用知识点和零值)</div>
+        @endif
+    </div>
+
+    <div class="card">
+        <div class="section-title">📊 知识点层级掌握度分析</div>
+        @php
+            $parentMasteryLevels = $parent_mastery_levels ?? [];
+            $hasParentMastery = !empty($parentMasteryLevels);
+        @endphp
+
+        @if($hasParentMastery)
+            @foreach($parentMasteryLevels as $parentData)
+                @php
+                    $pct = $parentData['mastery_percentage'] ?? 0;
+                    $barColor = $pct >= 80 ? '#10b981' : ($pct >= 60 ? '#f59e0b' : '#ef4444');
+                    $parentName = $parentData['kp_name'] ?? $parentData['kp_code'];
+                    $children = $parentData['children'] ?? [];
+                    $level = $parentData['level'] ?? 1;
+                    $delta = $parentData['mastery_change'] ?? null;
+                    // 只有当有变化值时才显示变化信息
+                    $changeText = '';
+                    if ($delta !== null && $delta !== '' && abs($delta) > 0.001) {
+                        $changeText = ($delta > 0 ? '↑ ' : '↓ ') . number_format(abs($delta) * 100, 1) . '%';
+                    }
                 @endphp
-                <div style="margin-bottom:10px;">
-                    <div style="display:flex; justify-content:space-between; align-items:center;">
-                        <div><strong>{{ $item['kp_name'] }}</strong></div>
-                        <div>
+                <div style="margin-bottom: 14px; padding: 10px; border: 1px solid #e0f2fe; border-radius: 6px; background: #f8fafc;">
+                    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px;">
+                        <div style="font-weight: 700; font-size: 15px; color: #0f172a;">
+                            【第{{ $level }}级】{{ $parentName }}
+                            <span style="margin-left: 8px; font-size: 11px; color: #64748b; font-weight: 400;">
+                                ({{ $parentData['kp_code'] }})
+                            </span>
+                        </div>
+                        <div style="font-weight: 700; color: {{ $barColor }}; font-size: 15px;">
                             {{ number_format($pct, 1) }}%
-                            @if($delta !== null)
-                                <span class="muted" style="margin-left:6px;">{{ $delta > 0 ? '↑' : ($delta < 0 ? '↓' : '→') }} {{ number_format(abs($delta) * 100, 1) }}%</span>
+                            @if(!empty($changeText))
+                                <span style="margin-left: 8px; color: #666; font-size: 12px;">{{ $changeText }}</span>
                             @endif
                         </div>
                     </div>
-                    <div class="progress-wrap">
+                    <div class="progress-wrap" style="height: 12px; margin-bottom: 8px;">
                         <div class="progress-bar" style="width: {{ $pct }}%; background: {{ $barColor }};"></div>
                     </div>
+                    @if(!empty($children))
+                        <div style="font-size: 12px; color: #475569;">
+                            <strong>包含子知识点({{ count($children) }}个):</strong>
+                            @foreach($children as $index => $child)
+                                @if($index < 8)
+                                    <span style="display:inline-block; margin: 2px 4px; padding: 2px 6px; background: #e0f2fe; border-radius: 3px; font-size: 11px;">
+                                        {{ $child['kp_name'] }}
+                                    </span>
+                                @endif
+                            @endforeach
+                            @if(count($children) > 8)
+                                <span style="color: #64748b; font-style: italic;">...等{{ count($children) }}个知识点</span>
+                            @endif
+                        </div>
+                    @else
+                        <div style="font-size: 12px; color: #64748b;">
+                            <em>无直接子知识点或子知识点掌握度为0</em>
+                        </div>
+                    @endif
                 </div>
             @endforeach
+
+            <div style="margin-top: 12px; padding: 8px; background: #fefce8; border-left: 4px solid #eab308; border-radius: 4px;">
+                <div style="font-size: 12px; color: #854d0e; line-height: 1.6;">
+                    <strong>学习建议:</strong>
+                    建议重点关注掌握度较低的知识点,通过专项练习提升整体学习水平。优先练习掌握度低于60%的知识点。
+                </div>
+            </div>
         @else
-            <div class="muted">暂无掌握度数据</div>
+            <div class="muted">暂无父节点掌握度数据</div>
+            <div style="margin-top: 8px; font-size: 12px; color: #64748b;">
+                当前分析主要基于具体知识点掌握度。完整的层级掌握度分析需要在系统中建立完整的知识点层级关系。
+            </div>
         @endif
     </div>
 
     <div class="card">
-        <div class="section-title">解题思路与题目表现</div>
+        <div class="section-title">题目表</div>
         @php
             $insightMap = [];
             foreach (($question_insights ?? []) as $insight) {
@@ -82,9 +226,46 @@
         @endphp
         @foreach($questions as $q)
             @php
+                // 【修复】从题目数据中获取学生答案、正确答案和判分结果
+                $studentAnswer = $q['student_answer'] ?? $q['student_answer'] ?? null;
+                $correctAnswer = $q['answer'] ?? $q['correct_answer'] ?? null;
+                $isCorrect = $q['is_correct'] ?? null;  // 1=正确,0=错误,null=未判
+
+                // 如果未判分但有学生答案和正确答案,自动比较判断
+                if ($isCorrect === null && !empty($studentAnswer) && !empty($correctAnswer)) {
+                    $isCorrect = (trim($studentAnswer) === trim($correctAnswer)) ? 1 : 0;
+                }
+
+                // 判分状态显示逻辑
+                $showStatus = false;
+                $statusText = '';
+                $statusColor = '';
+                if (!empty($studentAnswer)) {
+                    // 学生有答题,显示判分结果
+                    $showStatus = true;
+                    if ($isCorrect === 1) {
+                        $statusText = '正确';
+                        $statusColor = '#10b981';
+                    } elseif ($isCorrect === 0) {
+                        $statusText = '错误';
+                        $statusColor = '#ef4444';
+                    }
+                }
+                // 没答题的不显示状态
+
                 $insight = $insightMap[$q['question_number']] ?? ($insightMap[$q['display_number'] ?? null] ?? []);
-                $score = $insight['score'] ?? ($insight['student_score'] ?? null);
+                // 【修复】得分显示:答错显示实际得分,答对显示满分
                 $fullScore = $insight['full_score'] ?? ($q['score'] ?? null);
+                if ($isCorrect === 1) {
+                    // 答对了,显示满分
+                    $score = $fullScore;
+                } elseif ($isCorrect === 0) {
+                    // 答错了,显示实际得分(可能为0分或其他分数)
+                    $score = $q['score_obtained'] ?? 0;
+                } else {
+                    // 未判分或未答题,不显示得分
+                    $score = null;
+                }
                 $analysisRaw = $insight['analysis']
                     ?? $insight['thinking_process']
                     ?? $insight['feedback']
@@ -106,53 +287,83 @@
                 } elseif (is_string($stepsRaw) && trim($stepsRaw) !== '') {
                     $steps = preg_split('/[\r\n]+/', trim($stepsRaw));
                 }
-                $isCorrect = $insight['is_correct'] ?? $insight['correct'] ?? null;
-                $badgeColor = $isCorrect === true ? '#10b981' : ($isCorrect === false ? '#ef4444' : '#6b7280');
-                $badgeText = $isCorrect === true ? '答对' : ($isCorrect === false ? '答错' : '待判');
                 $typeMap = ['choice' => '选择题', 'fill' => '填空题', 'answer' => '解答题'];
                 $typeLabel = $typeMap[$q['question_type'] ?? ''] ?? ($q['question_type'] ?? '题型未标注');
                 $questionText = is_string($q['question_text']) ? $q['question_text'] : json_encode($q['question_text'], JSON_UNESCAPED_UNICODE);
                 $solution = $q['solution'] ?? null;
             @endphp
-            <div style="border:1px solid #e5e7eb; border-radius:10px; padding:12px 14px; margin-bottom:10px; background:#fff; page-break-inside: avoid;">
-                <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:6px;">
+            <div style="border:1px solid #e5e7eb; border-radius:8px; padding:8px 12px; margin-bottom:8px; background:#fff; page-break-inside: avoid;">
+                <div style="display:flex; justify-content:space-between; align-items:center; gap:8px; margin-bottom:4px;">
                     <div style="display:flex; align-items:center; gap:8px; font-weight:600;">
                         <span class="tag">题号 {{ $q['display_number'] ?? $q['question_number'] }}</span>
-                        <span class="tag" style="background: #eef2ff; color:#4338ca;">{{ $q['knowledge_point_name'] ?? $q['knowledge_point'] ?? '-' }}</span>
-                        <span class="tag" style="background: {{ $badgeColor }}; color:#fff;">{{ $badgeText }}</span>
-                    </div>
-                    <div class="muted">
-                        @if($score !== null && $fullScore !== null)
-                            得分 {{ $score }} / {{ $fullScore }}
-                        @else
-                            待评分
+                        @php
+                            $kpName = $q['knowledge_point_name'] ?? $q['knowledge_point'] ?? null;
+                            if (!empty($kpName) && $kpName !== '-' && $kpName !== '未标注') {
+                                echo '<span class="tag" style="background: #eef2ff; color:#4338ca;">' . e($kpName) . '</span>';
+                            }
+                        @endphp
+                        @if($showStatus)
+                            <span class="tag" style="background: {{ $statusColor }}; color:#fff;">{{ $statusText }}</span>
                         @endif
                     </div>
+                    @if($score !== null && $fullScore !== null)
+                        <div class="muted">得分 {{ $score }} / {{ $fullScore }}</div>
+                    @endif
                 </div>
-                <div class="math-content" style="margin-bottom:6px;">{!! $questionText !!}</div>
-                <div class="muted" style="margin-bottom:6px;">题型:{{ $typeLabel }}</div>
-                <div style="font-size:13px; line-height:1.6;">{!! nl2br(e($analysis ?? '暂无解题思路记录')) !!}</div>
+
+                {{-- 【新增】学生答案显示(如果有) --}}
+                @if(!empty($studentAnswer))
+                    <div style="margin-bottom:6px; padding:6px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
+                        <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:3px;">学生答案</div>
+                        <div class="math-content" style="font-size:12px; line-height:1.5; color:#374151;">
+                            {!! nl2br(e($studentAnswer)) !!}
+                        </div>
+                    </div>
+                @endif
+
+                <div class="math-content" style="margin-bottom:6px; font-size:12px;">{!! $questionText !!}</div>
+                <div class="muted" style="margin-bottom:6px; font-size:12px;">题型:{{ $typeLabel }}</div>
+
+                {{-- 【修复】正确答案显示 --}}
+                @if(!empty($correctAnswer))
+                    <div style="margin-bottom:6px; padding:6px; background:#f0fdf4; border-left:3px solid #10b981; border-radius:4px;">
+                        <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:3px;">正确答案</div>
+                        <div class="math-content" style="font-size:12px; line-height:1.5; color:#374151;">
+                            {!! nl2br(e($correctAnswer)) !!}
+                        </div>
+                    </div>
+                @endif
+
+                {{-- 【修改】解题思路显示(优先显示solution,其次显示analysis) --}}
+                @if(!empty($solution))
+                    <div style="margin-top:6px; padding:6px; background:#eff6ff; border-left:3px solid #3b82f6; border-radius:4px;">
+                        <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:4px;">解题思路</div>
+                        <div class="math-content" style="font-size:12px; line-height:1.5; color:#374151;">
+                            {!! is_array($solution) ? json_encode($solution, JSON_UNESCAPED_UNICODE) : nl2br(e($solution)) !!}
+                        </div>
+                    </div>
+                @elseif(!empty($analysis) && $analysis !== '暂无解题思路记录')
+                    <div style="margin-top:6px; padding:6px; background:#eff6ff; border-left:3px solid #3b82f6; border-radius:4px;">
+                        <div style="font-weight:600; font-size:12px; color:#111827; margin-bottom:4px;">解题思路</div>
+                        <div style="font-size:12px; line-height:1.5; color:#374151;">{!! nl2br(e($analysis)) !!}</div>
+                    </div>
+                @endif
+
+                {{-- 解题步骤(如果有) --}}
                 @if(!empty($steps))
-                    <div style="margin-top:6px; font-size:13px;">
-                        <div style="font-weight:600; margin-bottom:4px;">解题步骤</div>
+                    <div style="margin-top:6px; font-size:12px;">
+                        <div style="font-weight:600; margin-bottom:3px;">解题步骤</div>
                         <ol style="margin:0; padding-left:18px;">
                             @foreach($steps as $s)
-                                <li>{!! nl2br(e(is_array($s) ? json_encode($s, JSON_UNESCAPED_UNICODE) : $s)) !!}</li>
+                                <li style="margin-bottom:2px;">{!! nl2br(e(is_array($s) ? json_encode($s, JSON_UNESCAPED_UNICODE) : $s)) !!}</li>
                             @endforeach
                         </ol>
                     </div>
-                @elseif(!empty($solution))
-                    <div style="margin-top:8px; padding:8px; background:#f9fafb; border-left:3px solid #4f46e5; border-radius:4px;">
-                        <div style="font-weight:600; font-size:13px; color:#111827; margin-bottom:6px;">解析</div>
-                        <div class="math-content" style="font-size:13px; line-height:1.6; color:#374151;">
-                            {!! is_array($solution) ? json_encode($solution, JSON_UNESCAPED_UNICODE) : nl2br(e($solution)) !!}
-                        </div>
-                    </div>
                 @endif
             </div>
         @endforeach
     </div>
-
+    </div> {{-- 闭合page div --}}
 
     <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
     <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>

+ 83 - 0
resources/views/filament/pages/student-analysis-simple.blade.php

@@ -167,6 +167,89 @@
                     </div>
                 @endif
             </div>
+
+            @if(!empty($knowledgePointHierarchy['parent_mastery_levels']) || !empty(array_filter($knowledgePointHierarchy['child_knowledge_points'] ?? [])))
+                <!-- 【新增】父子知识点层级关系 -->
+                <div class="bg-white p-6 rounded-lg border shadow-sm">
+                    <h3 class="text-lg font-semibold text-gray-900 mb-4">知识点层级关系</h3>
+                    <p class="text-sm text-gray-600 mb-4">
+                        子知识点掌握度对父知识点掌握度的影响分析
+                    </p>
+
+                    <div class="space-y-6">
+                        @foreach($knowledgePointHierarchy['parent_mastery_levels'] as $parentKpCode => $parentMastery)
+                            <div class="border rounded-lg p-4 bg-blue-50">
+                                <div class="flex items-center justify-between mb-3">
+                                    <div>
+                                        <h4 class="font-medium text-gray-900">{{ $parentKpCode }}</h4>
+                                        <div class="text-xs text-gray-600 mt-1">父知识点(子节点平均值)</div>
+                                    </div>
+                                    <div class="text-right">
+                                        <div class="text-xl font-bold text-blue-600">
+                                            {{ number_format($parentMastery * 100, 1) }}%
+                                        </div>
+                                        <div class="text-xs text-gray-600">父节点掌握度</div>
+                                    </div>
+                                </div>
+
+                                @if(isset($knowledgePointHierarchy['child_knowledge_points'][$parentKpCode]))
+                                    <div class="space-y-2">
+                                        <div class="text-sm font-medium text-gray-700">子知识点详情:</div>
+                                        @foreach($knowledgePointHierarchy['child_knowledge_points'][$parentKpCode] as $childKp)
+                                            <div class="flex items-center justify-between p-2 bg-white rounded border">
+                                                <div class="flex-1">
+                                                    <div class="text-sm font-medium text-gray-900">
+                                                        {{ $childKp['kp_name'] ?? $childKp['kp_code'] }}
+                                                    </div>
+                                                    <div class="text-xs text-gray-600">{{ $childKp['kp_code'] }}</div>
+                                                </div>
+                                                <div class="text-right">
+                                                    <div class="text-sm font-bold" style="color: {{ $childKp['mastery_level'] >= 0.7 ? '#10b981' : ($childKp['mastery_level'] >= 0.5 ? '#f59e0b' : '#ef4444') }}">
+                                                        {{ number_format($childKp['mastery_level'] * 100, 1) }}%
+                                                    </div>
+                                                    <div class="text-xs text-gray-600">
+                                                        练习{{ $childKp['total_attempts'] }}次
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        @endforeach
+                                    </div>
+                                @endif
+                            </div>
+                        @endforeach
+
+                        <!-- 根节点下的子知识点(没有父节点) -->
+                        @if(isset($knowledgePointHierarchy['child_knowledge_points']['ROOT']))
+                            <div class="border rounded-lg p-4 bg-gray-50">
+                                <div class="flex items-center justify-between mb-3">
+                                    <div>
+                                        <h4 class="font-medium text-gray-900">根节点知识点</h4>
+                                        <div class="text-xs text-gray-600 mt-1">无父节点的知识点</div>
+                                    </div>
+                                </div>
+
+                                <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
+                                    @foreach($knowledgePointHierarchy['child_knowledge_points']['ROOT'] as $childKp)
+                                        <div class="p-3 bg-white rounded border">
+                                            <div class="flex items-center justify-between mb-2">
+                                                <div class="text-sm font-medium text-gray-900">
+                                                    {{ $childKp['kp_name'] ?? $childKp['kp_code'] }}
+                                                </div>
+                                                <div class="text-sm font-bold" style="color: {{ $childKp['mastery_level'] >= 0.7 ? '#10b981' : ($childKp['mastery_level'] >= 0.5 ? '#f59e0b' : '#ef4444') }}">
+                                                    {{ number_format($childKp['mastery_level'] * 100, 1) }}%
+                                                </div>
+                                            </div>
+                                            <div class="text-xs text-gray-600">
+                                                {{ $childKp['kp_code'] }} • 练习{{ $childKp['total_attempts'] }}次
+                                            </div>
+                                        </div>
+                                    @endforeach
+                                </div>
+                            </div>
+                        @endif
+                    </div>
+                </div>
+            @endif
         @else
             <!-- 未选择学生的提示 -->
             <div class="bg-white p-6 rounded-lg border shadow-sm text-center py-12">

+ 22 - 0
routes/api.php

@@ -19,6 +19,7 @@ use App\Events\QuestionGenerationFailed;
 use Illuminate\Auth\Middleware\Authenticate;
 use App\Http\Controllers\Api\ExamAnalysisApiController;
 use App\Http\Controllers\Api\StudentAnswerAnalysisController;
+use App\Http\Controllers\Api\StudentKnowledgeController;
 
 /*
 |--------------------------------------------------------------------------
@@ -844,6 +845,27 @@ Route::prefix('mathrecsys/students')->name('api.mathrecsys.students.')->group(fu
     Route::put('{studentId}/mastery', [StudentController::class, 'updateMastery'])
         ->where('studentId', '[0-9]+') // 限制为数字
         ->name('update-mastery');
+
+    // 【新增】获取学生知识点掌握详情(直接查询MySQL)
+    Route::get('{studentId}/knowledge-points/detail', [StudentKnowledgeController::class, 'getKnowledgePointsDetail'])
+        ->where('studentId', '[0-9]+')
+        ->name('knowledge-points.detail');
+
+    // 【新增】获取学生知识点层级关系
+    Route::get('{studentId}/knowledge-points/hierarchy', [StudentKnowledgeController::class, 'getKnowledgeHierarchy'])
+        ->where('studentId', '[0-9]+')
+        ->name('knowledge-points.hierarchy');
+});
+
+// 【前端直连】学生知识点详情API(直接查询MySQL)
+Route::prefix('students')->name('students.')->group(function () {
+    Route::get('{studentId}/knowledge-points/detail', [StudentKnowledgeController::class, 'getKnowledgePointsDetail'])
+        ->where('studentId', '[0-9]+')
+        ->name('knowledge-points.detail');
+
+    Route::get('{studentId}/knowledge-points/hierarchy', [StudentKnowledgeController::class, 'getKnowledgeHierarchy'])
+        ->where('studentId', '[0-9]+')
+        ->name('knowledge-points.hierarchy');
 });
 
 // 班级分析 API