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

fix textbook fallback matching and persist raw request payload

Made-with: Cursor
yemeishu 2 недель назад
Родитель
Сommit
70292781bb

+ 14 - 42
app/Http/Controllers/Api/IntelligentExamController.php

@@ -56,44 +56,13 @@ class IntelligentExamController extends Controller
     {
         $requestStartedAt = microtime(true);
         $requestTraceId = 'exam_req_' . substr(md5(uniqid('', true)), 0, 10);
-        Log::info('IntelligentExamController: request started', [
-            'trace_id' => $requestTraceId,
-            'path' => $request->path(),
-            'method' => $request->method(),
-        ]);
-
-        // 优先从body获取数据,不使用query params(入口仍记录 query,便于对照网关是否拼错)
+        // 优先从body获取数据,不使用query params
         $jsonPayload = $request->json()->all();
-        $bodySource = 'json';
         $payload = $jsonPayload;
         if (empty($payload)) {
             $payload = $request->all();
-            $bodySource = 'request_all';
-        }
-        $queryParams = $request->query();
-        $rawBody = $request->getContent();
-        $rawBodyLen = strlen($rawBody);
-        $rawBodyLog = null;
-        if ($rawBodyLen > 0) {
-            $rawCap = 65536;
-            if ($rawBodyLen <= $rawCap) {
-                $rawBodyLog = $rawBody;
-            } else {
-                $rawBodyLog = substr($rawBody, 0, $rawCap);
-            }
         }
 
-        Log::info('IntelligentExamController: 组卷API原始请求参数(入口,未经 normalizePayload)', [
-            'trace_id' => $requestTraceId,
-            'content_type' => $request->header('Content-Type'),
-            'body_source' => $bodySource,
-            'body_payload' => $payload,
-            'query_params' => $queryParams,
-            'raw_body_length' => $rawBodyLen,
-            'raw_body' => $rawBodyLog,
-            'raw_body_truncated' => $rawBodyLen > 65536,
-        ]);
-
         $normalized = $this->normalizePayload($payload);
 
         $validator = validator($normalized, [
@@ -165,6 +134,7 @@ class IntelligentExamController extends Controller
         }
 
         $data = $validator->validated();
+        $requestPayloadSnapshotRaw = $payload;
 
         $assembleType = (int) ($data['assemble_type'] ?? 4);
         if ($assembleType === 15 && empty($data['paper_ids'] ?? [])) {
@@ -197,12 +167,21 @@ class IntelligentExamController extends Controller
             'paper_id' => $reservedPaperId,
             'request_trace_id' => $requestTraceId,
             'request_started_at' => now()->toISOString(),
+            'request_payload_snapshot_raw' => $requestPayloadSnapshotRaw,
         ]);
 
-        Log::info('IntelligentExamController: 组卷API请求参数(校验并补全后,即将入队)', [
+        Log::info('assemble.request', [
             'trace_id' => $requestTraceId,
-            'assemble_type_resolved' => $assembleType,
-            'params' => $taskPayload,
+            'student_id' => $taskPayload['student_id'] ?? null,
+            'teacher_id' => $taskPayload['teacher_id'] ?? null,
+            'grade' => $taskPayload['grade'] ?? null,
+            'assemble_type' => $assembleType,
+            'paper_id' => $reservedPaperId,
+            'textbook_id' => $taskPayload['textbook_id'] ?? null,
+            'chapter_id_list' => $taskPayload['chapter_id_list'] ?? [],
+            'kp_code_list' => $taskPayload['kp_code_list'] ?? [],
+            'kp_codes' => $taskPayload['kp_codes'] ?? [],
+            'total_questions' => $taskPayload['total_questions'] ?? null,
         ]);
 
         try {
@@ -238,13 +217,6 @@ class IntelligentExamController extends Controller
                 ],
             ];
 
-            Log::info('IntelligentExamController: async task dispatched', [
-                'trace_id' => $requestTraceId,
-                'task_id' => $taskId,
-                'paper_id' => $reservedPaperId,
-                'sync_elapsed_ms_total' => (int) round((microtime(true) - $requestStartedAt) * 1000),
-            ]);
-
             $this->taskManager->updateTaskStatus($taskId, [
                 'request_trace_id' => $requestTraceId,
                 'sync_elapsed_ms_total' => (int) round((microtime(true) - $requestStartedAt) * 1000),

+ 30 - 1
app/Jobs/AssembleExamTaskJob.php

@@ -45,6 +45,7 @@ class AssembleExamTaskJob implements ShouldQueue
 
         $data = $task['data'];
         $assembleStartedAt = microtime(true);
+        $phaseStartedAt = $assembleStartedAt;
 
         try {
             $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...');
@@ -154,6 +155,13 @@ class AssembleExamTaskJob implements ShouldQueue
                 $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
                 $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes'] ?? []);
             }
+            Log::info('assemble.job.timing', [
+                'task_id' => $this->taskId,
+                'phase' => 'select_and_prepare_questions',
+                'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
+                'assemble_type' => $assembleType,
+                'question_count' => count($questions),
+            ]);
 
             if (empty($questions)) {
                 $taskManager->markTaskFailed($this->taskId, '未能生成有效题目');
@@ -168,11 +176,15 @@ class AssembleExamTaskJob implements ShouldQueue
             $totalScore = array_sum(array_column($questions, 'score'));
 
             $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
+            $requestPayloadParams = $data['request_payload_snapshot_raw'] ?? null;
+            $phaseStartedAt = microtime(true);
             $paperId = $questionBankService->saveExamToDatabase([
                 'paper_id' => $data['paper_id'] ?? null,
                 'paper_name' => $paperName,
                 'student_id' => $data['student_id'],
                 'teacher_id' => $data['teacher_id'] ?? null,
+                'subject' => $data['subject'] ?? null,
+                'params' => $requestPayloadParams,
                 'assembleType' => $finalAssembleType,
                 'difficulty_category' => $difficultyCategory,
                 'total_score' => $totalScore,
@@ -185,6 +197,12 @@ class AssembleExamTaskJob implements ShouldQueue
                 $taskManager->markTaskFailed($this->taskId, '试卷保存失败');
                 return;
             }
+            Log::info('assemble.job.timing', [
+                'task_id' => $this->taskId,
+                'phase' => 'save_exam_to_database',
+                'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
+                'paper_id' => $paperId,
+            ]);
 
             $finalStats = $result['stats'] ?? [
                 'total_selected' => count($questions),
@@ -201,11 +219,22 @@ class AssembleExamTaskJob implements ShouldQueue
             ]);
             $taskManager->updateTaskProgress($this->taskId, 40, '组卷完成,开始生成PDF...');
 
+            $phaseStartedAt = microtime(true);
             dispatch(new GenerateExamPdfJob($this->taskId, $paperId));
-            Log::info('AssembleExamTaskJob: 组卷任务完成并已触发PDF任务', [
+            Log::info('assemble.job.timing', [
                 'task_id' => $this->taskId,
+                'phase' => 'dispatch_pdf_job',
+                'elapsed_ms' => (int) round((microtime(true) - $phaseStartedAt) * 1000),
                 'paper_id' => $paperId,
             ]);
+            Log::info('assemble.success', [
+                'task_id' => $this->taskId,
+                'paper_id' => $paperId,
+                'assemble_type' => $finalAssembleType,
+                'question_count' => count($questions),
+                'total_score' => $totalScore,
+                'elapsed_ms' => (int) round((microtime(true) - $assembleStartedAt) * 1000),
+            ]);
         } catch (\Exception $e) {
             Log::error('AssembleExamTaskJob: 异常', [
                 'task_id' => $this->taskId,

+ 4 - 0
app/Models/Paper.php

@@ -19,6 +19,8 @@ class Paper extends Model
         'paper_id',
         'student_id',
         'teacher_id',
+        'subject',
+        'params',
         'paper_name',
         'paper_type',
         'diagnostic_chapter_id', // 摸底的章节ID(章节摸底时记录)
@@ -38,6 +40,8 @@ class Paper extends Model
         'paper_id' => 'string',
         'student_id' => 'string',
         'teacher_id' => 'string',
+        'subject' => 'string',
+        'params' => 'array',
         'total_questions' => 'integer',
         'total_score' => 'float',
         'status' => 'string',

+ 8 - 0
app/Services/ExamTypeStrategy.php

@@ -1287,9 +1287,17 @@ class ExamTypeStrategy
             ]);
         }
 
+        // 教材组卷复用「父节点整树补题」能力:主池不足时,先按同父知识点补题(兄弟节点)
+        $kpSupplementSubtreeCodes = [];
+        if (!empty($kpCodes)) {
+            $expandedKpCodes = $this->expandKpCodesWithParentChapterSubtrees($kpCodes);
+            $kpSupplementSubtreeCodes = array_values(array_diff($expandedKpCodes, $kpCodes));
+        }
+
         // 组装增强参数
         $enhanced = array_merge($params, [
             'kp_codes' => $kpCodes, // 可能为空,但仍保留
+            'kp_supplement_subtree_codes' => $kpSupplementSubtreeCodes,
             'chapter_id_list' => $chapterIdList,
             'textbook_id' => $textbookId ?: ($params['textbook_id'] ?? null), // 保证智能补充有教材范围
             'grade' => $grade,

+ 359 - 210
app/Services/LearningAnalyticsService.php

@@ -1216,24 +1216,15 @@ class LearningAnalyticsService
             $assembleType = (int) ($params['assemble_type'] ?? 4); // 默认为通用类型(4)
             $examTypeLegacy = $params['exam_type'] ?? 'general'; // 兼容旧版参数
 
-            Log::debug('LearningAnalyticsService: 检查组卷策略', [
-                'assemble_type' => $assembleType
-            ]);
-
             // 如果有 assemble_type 参数,优先使用新的参数系统
             if (isset($params['assemble_type'])) {
                 try {
                     // 确保QuestionExpansionService和QuestionLocalService可用
                     $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
                     $questionLocalService = app(QuestionLocalService::class);
-                    Log::debug('LearningAnalyticsService: 从容器获取服务实例');
 
                     $strategy = new ExamTypeStrategy($questionExpansionService, $questionLocalService);
                     $params = $strategy->buildParams($params, $assembleType);
-
-                    Log::debug('LearningAnalyticsService: 已应用组卷策略', [
-                        'assemble_type' => $assembleType
-                    ]);
                 } catch (Exception $e) {
                     Log::warning('LearningAnalyticsService: 组卷策略应用失败,使用默认策略', [
                         'assemble_type' => $assembleType,
@@ -1246,14 +1237,9 @@ class LearningAnalyticsService
                 try {
                     $questionExpansionService = $this->questionExpansionService ?? app(QuestionExpansionService::class);
                     $questionLocalService = app(QuestionLocalService::class);
-                    Log::info('LearningAnalyticsService: 从容器获取服务实例(兼容模式)');
 
                     $strategy = new ExamTypeStrategy($questionExpansionService, $questionLocalService);
                     $params = $strategy->buildParamsLegacy($params, $examTypeLegacy);
-
-                    Log::debug('LearningAnalyticsService: 已应用组卷策略(兼容模式)', [
-                        'exam_type' => $examTypeLegacy
-                    ]);
                 } catch (Exception $e) {
                     Log::warning('LearningAnalyticsService: 组卷策略应用失败,使用默认策略', [
                         'exam_type' => $examTypeLegacy,
@@ -1261,10 +1247,6 @@ class LearningAnalyticsService
                         'trace' => $e->getTraceAsString()
                     ]);
                 }
-            } else {
-                Log::info('LearningAnalyticsService: 跳过组卷策略', [
-                    'reason' => '通用类型不需要特殊策略'
-                ]);
             }
 
             $studentId = $params['student_id'] ?? null;
@@ -1289,43 +1271,24 @@ class LearningAnalyticsService
             $difficultyLevels = $params['difficulty_levels'] ?? [];
             // 如果用户没有选择任何难度,difficultyLevels 为空数组,表示随机难度
 
-            Log::info("generateIntelligentExam 开始", [
-                'student_id' => $studentId,
-                'total_questions' => $totalQuestions,
-                'assemble_type' => $assembleType,
-                'kp_count' => count($kpCodes),
-            ]);
-
             // 1. 如果指定了学生,获取学生的薄弱点
             $weaknessFilter = [];
             if ($studentId) {
-                Log::debug("获取学生薄弱点", ['student_id' => $studentId]);
-
                 $weaknesses = $this->getStudentWeaknesses($studentId, 20);
-                Log::debug("薄弱点数量", ['count' => count($weaknesses)]);
 
                 $weaknessFilter = array_column($weaknesses, 'kp_code');
 
                 // 【修复】教材出卷(assemble_type=3)不使用薄弱点,严格按章节获取知识点
                 if ($assembleType == 3) {
-                    Log::debug("LearningAnalyticsService: 教材出卷不使用薄弱点");
                     // 教材组卷不使用薄弱点
                 } else {
                     // 如果用户没有指定知识点,使用学生的薄弱点(非教材组卷)
                     if (empty($kpCodes)) {
                         $kpCodes = $weaknessFilter;
-                        Log::info("用户未选择知识点,使用薄弱点作为kp_codes", [
-                            '最终kp_codes' => $kpCodes,
-                        ]);
                     }
                 }
             }
 
-            Log::debug("准备调用 getQuestionsFromBank", [
-                'kp_codes_count' => count($kpCodes),
-                'skills_count' => count($skills),
-            ]);
-
             // 2. 优先使用学生错题(如果存在)
             $mistakeQuestionIds = $params['mistake_question_ids'] ?? [];
             $priorityQuestions = [];
@@ -1333,39 +1296,14 @@ class LearningAnalyticsService
             $poolLimit = 0; // 题库池不设上限,0 表示不限制
 
             if (!empty($mistakeQuestionIds)) {
-                Log::info('LearningAnalyticsService: 优先获取学生错题', [
-                    'mistake_question_ids' => $mistakeQuestionIds,
-                    'count' => count($mistakeQuestionIds),
-                    'max_limit' => $maxQuestions
-                ]);
-
                 // 如果错题超过最大值,截取到最大值
                 $truncatedMistakeIds = $mistakeQuestionIds;
                 if (count($mistakeQuestionIds) > $maxQuestions) {
-                    Log::warning('LearningAnalyticsService: 错题数量超过最大值限制,已截取', [
-                        'mistake_count' => count($mistakeQuestionIds),
-                        'max_limit' => $maxQuestions,
-                        'truncated_count' => $maxQuestions
-                    ]);
                     $truncatedMistakeIds = array_slice($mistakeQuestionIds, 0, $maxQuestions);
                 }
 
                 // 获取学生错题的详细信息(错题获取不需要智能补充,不传入 grade/textbook_id)
                 $priorityQuestions = $this->getQuestionsFromBank([], [], $studentId, $questionTypeRatio, $maxQuestions, $truncatedMistakeIds, [], null, null, null, null, 1);
-
-                Log::info('LearningAnalyticsService: 错题获取完成', [
-                    'priority_questions_count' => count($priorityQuestions),
-                    'expected_count' => count($truncatedMistakeIds)
-                ]);
-
-                // 如果获取的错题数量少于预期,记录警告
-                if (count($priorityQuestions) < count($truncatedMistakeIds)) {
-                    Log::warning('LearningAnalyticsService: 错题获取不完整', [
-                        'expected' => count($truncatedMistakeIds),
-                        'actual' => count($priorityQuestions),
-                        'missing_ids' => array_diff($truncatedMistakeIds, array_column($priorityQuestions, 'id'))
-                    ]);
-                }
             }
 
             // 3. 处理错题本逻辑
@@ -1377,62 +1315,31 @@ class LearningAnalyticsService
             // 【修改】错题本类型严格按错题组卷,不补充题目
             if ($isMistakeBook) {
                 $mistakeKpSource = (bool) ($params['mistake_kp_source'] ?? false);
-                Log::info('LearningAnalyticsService: 错题本严格按错题组卷,不补充题目', [
-                    'mistake_count' => count($priorityQuestions),
-                    'assemble_type' => $assembleType,
-                    'mistake_kp_source' => $mistakeKpSource,
-                    'action' => '只使用学生错题,不从原卷子补充'
-                ]);
-
                 // 如果完全没有错题,回退到知识点/题库组卷
                 if (!$hasMistakePriority && !$mistakeKpSource) {
-                    Log::warning('LearningAnalyticsService: 错题本无错题', [
-                        'student_id' => $studentId,
-                        'paper_ids' => $params['paper_ids'] ?? []
-                    ]);
-                    Log::info('LearningAnalyticsService: 错题本无错题,允许补充题目', [
-                        'assemble_type' => $assembleType,
-                        'action' => 'fallback_to_kp_or_bank'
-                    ]);
                 } elseif (!$hasMistakePriority && $mistakeKpSource) {
-                    Log::info('LearningAnalyticsService: 错题本使用错题知识点组卷', [
-                        'assemble_type' => $assembleType,
-                        'action' => 'use_mistake_kp_codes'
-                    ]);
                 }
             }
 
             if (!$strictMistakeBook && count($priorityQuestions) < $totalQuestions) {
                 try {
-                    // 【优化】教材出卷(assemble_type=3)保留知识点筛选,但额外使用章节筛选
-                    if ($assembleType == 3) {
-                    Log::debug('LearningAnalyticsService: 教材出卷模式,保留知识点筛选', [
-                        'kp_codes_count' => count($kpCodes),
-                    ]);
-                    }
-
                     // 【优化】获取textbook_catalog_node_ids参数(教材组卷时使用)
                     $textbookCatalogNodeIds = $params['textbook_catalog_node_ids'] ?? null;
                     // 【修复超纲问题】获取 textbook_id 和 difficulty_category,用于智能补充时限制范围
                     $textbookId = isset($params['textbook_id']) ? (int) $params['textbook_id'] : null;
                     $difficultyCategory = (int) ($params['difficulty_category'] ?? 1);
 
-                    Log::debug('开始调用 getQuestionsFromBank 补充题目', [
-                        'need_more' => $totalQuestions - count($priorityQuestions),
-                        'assemble_type' => $assembleType,
-                        'grade' => $grade,
-                    ]);
-
                     // 【修复超纲问题】传入 grade 和 textbook_id,用于智能补充时限制范围
                     // 【修复】传入实际需要数量,否则 totalNeeded=0 会导致智能补充不触发
                     $excludeQuestionIds = $params['exclude_question_ids'] ?? [];
                     $needCount = $totalQuestions - count($priorityQuestions);
+                    $baseNeedCount = in_array($assembleType, [2, 3], true) ? 0 : $needCount;
                     $additionalQuestions = $this->getQuestionsFromBank(
                         $kpCodes,
                         $skills,
                         $studentId,
                         $questionTypeRatio,
-                        $needCount,
+                        $baseNeedCount,
                         [],
                         $excludeQuestionIds,
                         $questionCategory,
@@ -1443,40 +1350,134 @@ class LearningAnalyticsService
                     );
                     $allQuestions = $this->dedupeQuestionsByBankId(array_merge($priorityQuestions, $additionalQuestions));
 
-                    // assemble_type=2:本源 KP 合并后仍不足,用父树补题 KP 拉题(与 getQuestionsFromBank 主条件一致,走专用列表,不混入本源查询
+                    // assemble_type=2/3:本源池不足,先用父树补题 KP 拉题(兄弟节点优先
                     if (
-                        $assembleType === 2
+                        in_array($assembleType, [2, 3], true)
                         && count($allQuestions) < $totalQuestions
                         && ! empty($params['kp_supplement_subtree_codes'] ?? [])
                         && $grade !== null
                     ) {
+                        $before = count($allQuestions);
                         $excludeForSupp = array_values(array_unique(array_filter(array_merge(
                             $excludeQuestionIds,
                             array_column($allQuestions, 'id')
                         ))));
+                        Log::info('assemble.supplement', [
+                            'stage' => 'kp_subtree',
+                            'status' => 'start',
+                            'before_count' => $before,
+                            'target_count' => $totalQuestions,
+                            'assemble_type' => $assembleType,
+                        ]);
                         $supp = $this->fetchQuestionsForKpAssembleSupplement(
                             $params['kp_supplement_subtree_codes'],
                             $excludeForSupp,
                             (int) $grade,
                             $skills,
                             $questionCategory,
-                            $textbookCatalogNodeIds
+                            $assembleType === 3 ? null : $textbookCatalogNodeIds
                         );
-                        $before = count($allQuestions);
+                        $supplementStats = $this->buildSupplementQuestionStats($supp);
                         $allQuestions = $this->dedupeQuestionsByBankId(array_merge($allQuestions, $supp));
-                        Log::info('LearningAnalyticsService: 知识点组卷父树补题(本源池不足后)', [
-                            'source_merged_count' => $before,
-                            'supplement_fetched' => count($supp),
-                            'after_merge_count' => count($allQuestions),
-                            'target_total' => $totalQuestions,
-                            'supplement_kp_sample' => array_slice($params['kp_supplement_subtree_codes'], 0, 12),
+                        Log::info('assemble.supplement', [
+                            'stage' => 'kp_subtree',
+                            'status' => empty($supp) ? 'empty' : 'done',
+                            'before_count' => $before,
+                            'supplement_count' => count($supp),
+                            'after_count' => count($allQuestions),
+                            'target_count' => $totalQuestions,
+                            'assemble_type' => $assembleType,
+                            'supplement_kp_distribution_topn' => $supplementStats['supplement_kp_distribution_topn'],
+                            'supplement_kp_sample' => $supplementStats['supplement_kp_sample'],
+                        ]);
+                    }
+
+                    // assemble_type=3:兄弟补题后仍不足,再走教材前章节补题兜底
+                    if (
+                        $assembleType === 3
+                        && count($allQuestions) < $totalQuestions
+                        && $grade !== null
+                    ) {
+                        $before = count($allQuestions);
+                        $deficit = $totalQuestions - count($allQuestions);
+                        $excludeForChapterSupplement = array_values(array_unique(array_filter(array_merge(
+                            $excludeQuestionIds,
+                            array_column($allQuestions, 'id')
+                        ))));
+                        Log::info('assemble.supplement', [
+                            'stage' => 'chapter_fallback',
+                            'status' => 'start',
+                            'before_count' => $before,
+                            'target_count' => $totalQuestions,
+                            'assemble_type' => $assembleType,
+                        ]);
+                        $chapterSupplement = $this->getSupplementaryQuestionsForGrade(
+                            (int) $grade,
+                            array_column($allQuestions, 'kp_code'),
+                            $deficit,
+                            $difficultyCategory,
+                            $textbookId,
+                            $excludeForChapterSupplement,
+                            $textbookCatalogNodeIds ?? null,
+                            $studentId
+                        );
+
+                        $chapterSupplementStats = $this->buildSupplementQuestionStats($chapterSupplement);
+                        $allQuestions = $this->dedupeQuestionsByBankId(array_merge($allQuestions, $chapterSupplement));
+                        Log::info('assemble.supplement', [
+                            'stage' => 'chapter_fallback',
+                            'status' => empty($chapterSupplement) ? 'empty' : 'done',
+                            'before_count' => $before,
+                            'supplement_count' => count($chapterSupplement),
+                            'after_count' => count($allQuestions),
+                            'target_count' => $totalQuestions,
+                            'assemble_type' => $assembleType,
+                            'supplement_kp_distribution_topn' => $chapterSupplementStats['supplement_kp_distribution_topn'],
+                            'supplement_kp_sample' => $chapterSupplementStats['supplement_kp_sample'],
+                        ]);
+                    }
+
+                    // 通用兜底:无论何种组卷类型,前序补题后仍不足时,最终都走常规补题策略
+                    if (count($allQuestions) < $totalQuestions && $grade !== null) {
+                        $before = count($allQuestions);
+                        $deficit = $totalQuestions - count($allQuestions);
+                        $excludeForCommonFallback = array_values(array_unique(array_filter(array_merge(
+                            $excludeQuestionIds,
+                            array_column($allQuestions, 'id')
+                        ))));
+                        Log::info('assemble.supplement', [
+                            'stage' => 'common_fallback',
+                            'status' => 'start',
+                            'before_count' => $before,
+                            'target_count' => $totalQuestions,
+                            'assemble_type' => $assembleType,
+                        ]);
+                        $commonFallback = $this->getSupplementaryQuestionsForGrade(
+                            (int) $grade,
+                            array_column($allQuestions, 'kp_code'),
+                            $deficit,
+                            $difficultyCategory,
+                            $textbookId,
+                            $excludeForCommonFallback,
+                            $assembleType === 3 ? null : ($textbookCatalogNodeIds ?? null),
+                            $studentId
+                        );
+
+                        $commonFallbackStats = $this->buildSupplementQuestionStats($commonFallback);
+                        $allQuestions = $this->dedupeQuestionsByBankId(array_merge($allQuestions, $commonFallback));
+                        Log::info('assemble.supplement', [
+                            'stage' => 'common_fallback',
+                            'status' => empty($commonFallback) ? 'empty' : 'done',
+                            'before_count' => $before,
+                            'supplement_count' => count($commonFallback),
+                            'after_count' => count($allQuestions),
+                            'target_count' => $totalQuestions,
+                            'assemble_type' => $assembleType,
+                            'supplement_kp_distribution_topn' => $commonFallbackStats['supplement_kp_distribution_topn'],
+                            'supplement_kp_sample' => $commonFallbackStats['supplement_kp_sample'],
                         ]);
                     }
 
-                    Log::info('getQuestionsFromBank 完成', [
-                        'questions_count' => count($allQuestions),
-                        'time_ms' => round((microtime(true) - $startTime) * 1000, 2)
-                    ]);
                 } catch (\Exception $e) {
                     Log::error('getQuestionsFromBank 调用失败', [
                         'error' => $e->getMessage(),
@@ -1485,13 +1486,6 @@ class LearningAnalyticsService
 
                     throw $e;
                 }
-            } elseif ($strictMistakeBook) {
-                // 错题本类型:不补充题目,只使用错题
-                Log::info('错题本类型:不补充题目,只使用错题', [
-                    'assemble_type' => $assembleType,
-                    'mistake_questions_count' => count($priorityQuestions),
-                    'total_questions_requested' => $totalQuestions
-                ]);
             }
 
             if (empty($allQuestions)) {
@@ -1504,14 +1498,6 @@ class LearningAnalyticsService
                     $message = '题库为空,请先添加题目到题库。您可以点击"生成练习题"按钮或手动上传题目。';
                 }
 
-                Log::warning('智能出卷失败 - 未找到题目', [
-                    'student_id' => $studentId,
-                    'selected_kp_codes' => $kpCodes,
-                    'kp_codes_count' => count($kpCodes),
-                    'message' => $message,
-                    'hint' => '如果选择了知识点但题库为空,请检查知识点代码是否正确,或尝试取消知识点选择'
-                ]);
-
                 return [
                     'success' => false,
                     'message' => $message,
@@ -1530,17 +1516,9 @@ class LearningAnalyticsService
                 $questionTypeRatio,
                 $difficultyLevels,
                 $weaknessFilter,
-                $assembleType  // 新增assembleType参数
+                $assembleType,  // 新增assembleType参数
+                $params['kp_code_list_original'] ?? $params['kp_codes'] ?? []
             );
-            $selectTime = (microtime(true) - $startTime) * 1000;
-
-            Log::debug('题目筛选结果', [
-                'input_count' => count($allQuestions),
-                'selected_count' => count($selectedQuestions),
-                'target_count' => $targetQuestionCount,
-                'select_time_ms' => round($selectTime, 2)
-            ]);
-
             if (empty($selectedQuestions)) {
                 return [
                     'success' => false,
@@ -1552,7 +1530,8 @@ class LearningAnalyticsService
             // 【恢复】简化难度分布检查
             $difficultyCategory = $params['difficulty_category'] ?? 1;
             $enableDistribution = $params['enable_difficulty_distribution'] ?? false;
-            $isExcludedType = false; // 统一允许应用难度分布(错题本类型也允许)
+            // assemble_type=2(知识点组卷)优先保证请求知识点覆盖,避免二次难度重排把次要 KP 挤没
+            $isExcludedType = ($assembleType === 2);
 
             if ($enableDistribution && !$isExcludedType) {
                 try {
@@ -1564,17 +1543,24 @@ class LearningAnalyticsService
                         $questionTypeRatio
                     );
 
-                    Log::debug('LearningAnalyticsService: 题型感知难度分布完成', [
-                        'after_count' => count($selectedQuestions),
-                        'type_breakdown' => $this->countByType($selectedQuestions),
-                    ]);
                 } catch (\Exception $e) {
-                    Log::warning('LearningAnalyticsService: 题型感知难度分布失败,继续使用原结果', [
-                        'error' => $e->getMessage(),
-                    ]);
+                    // 保持组卷主流程稳定,难度分布失败时继续返回原结果
                 }
             }
 
+            $requestedKpSelectionStats = $this->buildRequestedKpSelectionStats(
+                $selectedQuestions,
+                $params['kp_code_list_original'] ?? $params['kp_codes'] ?? []
+            );
+
+            Log::info('LearningAnalyticsService: 最终选题分布', [
+                'assemble_type' => $assembleType,
+                'question_count' => count($selectedQuestions),
+                'type_distribution' => $this->countByType($selectedQuestions),
+                'kp_distribution' => array_count_values(array_column($selectedQuestions, 'kp_code')),
+                'requested_kp_selection' => $assembleType === 2 ? $requestedKpSelectionStats : null,
+            ]);
+
             return [
                 'success' => true,
                 'message' => '智能出卷成功',
@@ -1597,6 +1583,7 @@ class LearningAnalyticsService
                         (int) $difficultyCategory,
                         (int) $totalQuestions
                     ),
+                    'requested_kp_selection' => $assembleType === 2 ? $requestedKpSelectionStats : null,
                     // 【新增】章节知识点数量统计(教材组卷时)
                     'chapter_knowledge_point_stats' => $params['chapter_knowledge_point_stats'] ?? null,
                     'textbook_catalog_node_ids' => $params['textbook_catalog_node_ids'] ?? null
@@ -1738,21 +1725,12 @@ class LearningAnalyticsService
         try {
             // 错题回顾:优先获取指定的学生错题
             if (!empty($priorityQuestionIds)) {
-                Log::info('getQuestionsFromBank: 优先获取学生错题', [
-                    'priority_count' => count($priorityQuestionIds),
-                    'priority_ids' => $priorityQuestionIds
-                ]);
 
                 $priorityQuestions = $this->getLocalQuestionsByIds($priorityQuestionIds);
 
                 if (!empty($priorityQuestions)) {
-                    Log::info('getQuestionsFromBank: 优先错题获取成功', [
-                        'count' => count($priorityQuestions)
-                    ]);
-
                     return $priorityQuestions;
                 } else {
-                    Log::warning('getQuestionsFromBank: 优先错题获取失败,返回空数组让上层处理');
                     // 错题本类型获取不到错题时,返回空数组,不回退到题库随机选题
                     return [];
                 }
@@ -1809,13 +1787,6 @@ class LearningAnalyticsService
 
             $questions = $query->get();
 
-            Log::info('getQuestionsFromBank: 查询完成', [
-                'raw_count' => $questions->count(),
-                'total_needed' => $totalNeeded,
-                'exclude_count' => count($excludeQuestionIds),
-                'time_ms' => round((microtime(true) - $startTime) * 1000, 2)
-            ]);
-
             // 转换为标准格式
             $formattedQuestions = $questions->map(function ($q) {
                 return [
@@ -1844,13 +1815,15 @@ class LearningAnalyticsService
             // 【修复】重新启用智能补充功能,增加 textbook_id 限制避免超纲
             if ($totalNeeded > 0 && count($selectedQuestions) < $totalNeeded && $grade !== null) {
                 $deficit = $totalNeeded - count($selectedQuestions);
-                Log::warning('getQuestionsFromBank: 指定知识点题目不足,尝试智能补充', [
+                Log::info('assemble.supplement', [
+                    'stage' => 'start',
                     'deficit' => $deficit,
-                    'available_count' => count($selectedQuestions),
+                    'current_count' => count($selectedQuestions),
+                    'target_count' => $totalNeeded,
                     'grade' => $grade,
                     'textbook_id' => $textbookId,
-                    'student_id' => $studentId ? '(有)' : '(无)',
-                    'strategy' => $textbookId ? '从同教材前章节补充' : ($studentId ? '从学生已学知识点补充' : '无教材且无学生ID,不补充')
+                    'has_student_id' => ! empty($studentId),
+                    'has_chapter_scope' => ! empty($textbookCatalogNodeIds),
                 ]);
 
                 // 【修复超纲问题】补充策略:只从「学过的」内容补充
@@ -1869,28 +1842,26 @@ class LearningAnalyticsService
 
                 if (!empty($supplementaryQuestions)) {
                     $selectedQuestions = array_merge($selectedQuestions, $supplementaryQuestions);
-                    Log::info('getQuestionsFromBank: 智能补充完成', [
-                        'supplementary_count' => count($supplementaryQuestions),
-                        'total_after_supplement' => count($selectedQuestions),
-                        'textbook_id' => $textbookId
+                    Log::info('assemble.supplement', [
+                        'stage' => 'done',
+                        'supplement_count' => count($supplementaryQuestions),
+                        'after_count' => count($selectedQuestions),
+                        'target_count' => $totalNeeded,
+                        'grade' => $grade,
+                        'textbook_id' => $textbookId,
                     ]);
                 } else {
-                    Log::warning('getQuestionsFromBank: 智能补充失败,未找到合适的题目', [
+                    Log::info('assemble.supplement', [
+                        'stage' => 'empty',
+                        'supplement_count' => 0,
+                        'after_count' => count($selectedQuestions),
+                        'target_count' => $totalNeeded,
                         'grade' => $grade,
                         'textbook_id' => $textbookId,
-                        'note' => $textbookId ? '可能是该教材知识点题目不足' : '该年级题目不足'
                     ]);
                 }
             }
 
-            Log::info('getQuestionsFromBank 完成', [
-                'final_count' => count($selectedQuestions),
-                'raw_database_count' => $questions->count(),
-                'total_needed' => $totalNeeded,
-                'success' => $totalNeeded > 0 ? count($selectedQuestions) >= $totalNeeded : true,
-                'time_ms' => round((microtime(true) - $startTime) * 1000, 2)
-            ]);
-
             return $selectedQuestions;
 
         } catch (\Exception $e) {
@@ -2101,7 +2072,8 @@ class LearningAnalyticsService
         array $questionTypeRatio,
         array $difficultyLevels,
         array $weaknessFilter,
-        int $assembleType  // 新增assembleType参数
+        int $assembleType,  // 新增assembleType参数
+        array $requestedKpCodes = []
     ): array {
         // 【修复】题目数量处理逻辑:无论题目数量多少,都要进行权重分配和筛选
         // 如果题目数量超过目标,则截取到目标数量
@@ -2123,6 +2095,35 @@ class LearningAnalyticsService
         // 题目本身就有难度系数,QuestionLocalService的难度分布系统会处理题目分布
         // 不需要额外的难度筛选,让题目保持原始的难度分布
 
+        $requestedKpCodes = array_values(array_unique(array_filter($requestedKpCodes)));
+        $requestedKpSet = array_fill_keys($requestedKpCodes, true);
+        $shouldRestrictToRequestedKps = ($assembleType === 2 && ! empty($requestedKpSet));
+
+        if ($shouldRestrictToRequestedKps) {
+            $requestedQuestions = array_values(array_filter($questions, function ($question) use ($requestedKpSet) {
+                $kpCode = $question['kp_code'] ?? '';
+                return isset($requestedKpSet[$kpCode]);
+            }));
+            $fallbackQuestions = array_values(array_filter($questions, function ($question) use ($requestedKpSet) {
+                $kpCode = $question['kp_code'] ?? '';
+                return ! isset($requestedKpSet[$kpCode]);
+            }));
+
+            Log::info('selectQuestionsByMastery: assemble_type=2 请求知识点约束', [
+                'requested_kp_codes' => $requestedKpCodes,
+                'requested_question_count' => count($requestedQuestions),
+                'fallback_question_count' => count($fallbackQuestions),
+                'target_question_count' => $totalQuestions,
+            ]);
+
+            // 原始请求知识点足够时,最终选题不应被补题池中的兄弟/父树知识点带偏。
+            if (count($requestedQuestions) >= $totalQuestions) {
+                $questions = $requestedQuestions;
+            } else {
+                $questions = array_merge($requestedQuestions, $fallbackQuestions);
+            }
+        }
+
         // 1. 按知识点分组
         $groupStartTime = microtime(true);
         $questionsByKp = [];
@@ -2232,8 +2233,10 @@ class LearningAnalyticsService
         // ========== 步骤1:按题型分配题目 ==========
         $selectedQuestions = [];
 
-        // 【区分】根据assembleType决定是否使用知识点优先机制
-        $useKnowledgePointPriority = ($assembleType === 0); // 摸底测试需要知识点优先
+        // 【区分】根据 assembleType 决定是否使用知识点覆盖优先机制
+        // - 0:摸底测试,优先扩大知识点覆盖
+        // - 2:知识点组卷,也应尽量覆盖请求中的多个知识点,避免被单一 KP 吞掉
+        $useKnowledgePointPriority = in_array($assembleType, [0, 2], true);
         $kpSelected = []; // 已选知识点记录
 
         // 【新增】非摸底类型:按比例计算每种题型的目标数量
@@ -2299,24 +2302,45 @@ class LearningAnalyticsService
 
             // 根据策略选择题目
             if ($useKnowledgePointPriority) {
-                // 摸底测试:选择第一个未选过知识点的题目
+                // 知识点优先:assemble_type=2 先从原始请求知识点里选;摸底则扩大覆盖
                 $selectedInThisType = 0;
+                $candidate = null;
+
                 foreach ($questionsByType[$type] as $q) {
                     $kpCode = $q['kp_code'] ?? '';
+                    if ($shouldRestrictToRequestedKps && ! isset($requestedKpSet[$kpCode])) {
+                        continue;
+                    }
                     if (!isset($kpSelected[$kpCode])) {
-                        $selectedQuestions[] = $q;
-                        $kpSelected[$kpCode] = true;
-                        $selectedInThisType++;
-                        Log::debug('题型基础分配(知识点优先)', [
-                            'type' => $type,
-                            'kp' => $kpCode,
-                            'question_id' => $q['id'] ?? 'unknown',
-                            'selected_in_type' => $selectedInThisType
-                        ]);
+                        $candidate = $q;
                         break; // 只选1题
                     }
                 }
-                Log::debug('摸底测试题型基础分配完成', [
+
+                if ($candidate === null && $shouldRestrictToRequestedKps) {
+                    foreach ($questionsByType[$type] as $q) {
+                        $kpCode = $q['kp_code'] ?? '';
+                        if (isset($requestedKpSet[$kpCode])) {
+                            $candidate = $q;
+                            break;
+                        }
+                    }
+                }
+
+                if ($candidate !== null) {
+                    $kpCode = $candidate['kp_code'] ?? '';
+                    $selectedQuestions[] = $candidate;
+                    $kpSelected[$kpCode] = true;
+                    $selectedInThisType++;
+                    Log::debug('题型基础分配(知识点优先)', [
+                        'type' => $type,
+                        'kp' => $kpCode,
+                        'question_id' => $candidate['id'] ?? 'unknown',
+                        'selected_in_type' => $selectedInThisType
+                    ]);
+                }
+
+                Log::debug('知识点优先题型基础分配完成', [
                     'type' => $type,
                     'selected_count' => $selectedInThisType
                 ]);
@@ -2406,27 +2430,56 @@ class LearningAnalyticsService
             'total_questions' => $totalQuestions,
             'selected_kp_codes' => array_keys($kpSelected),
             'available_kp_count' => count($preSortKpDistribution),
-            'strategy' => $useKnowledgePointPriority ? '知识点优先' : '无知识点限制'
+            'strategy' => $useKnowledgePointPriority ? '知识点覆盖优先' : '无知识点限制'
         ]);
 
         if ($useKnowledgePointPriority) {
-            // 摸底测试:优先选择未选过知识点的题目
+            // 知识点优先:assemble_type=2 先补齐原始请求知识点,再允许请求知识点重复,最后才使用 fallback KP
             $initialSelectedCount = count($selectedQuestions);
             $prioritySelectedCount = 0;
-            foreach ($allQuestions as $q) {
-                if (count($selectedQuestions) >= $totalQuestions) break;
+            $selectedIds = array_values(array_filter(array_column($selectedQuestions, 'id')));
 
-                $kpCode = $q['kp_code'] ?? '';
-                if (!isset($kpSelected[$kpCode])) {
-                    $selectedQuestions[] = $q;
-                    $kpSelected[$kpCode] = true;
-                    $prioritySelectedCount++;
-                    Log::debug('继续选择题目(知识点优先)', [
-                        'kp' => $kpCode,
-                        'id' => $q['id'] ?? 'unknown',
-                        'priority_selected_count' => $prioritySelectedCount,
-                        'total_selected' => count($selectedQuestions)
-                    ]);
+            if ($shouldRestrictToRequestedKps) {
+                foreach ($allQuestions as $q) {
+                    if (count($selectedQuestions) >= $totalQuestions) {
+                        break;
+                    }
+
+                    $kpCode = $q['kp_code'] ?? '';
+                    $qid = $q['id'] ?? null;
+                    if (!isset($requestedKpSet[$kpCode]) || $qid === null || in_array($qid, $selectedIds)) {
+                        continue;
+                    }
+
+                    if (!isset($kpSelected[$kpCode])) {
+                        $selectedQuestions[] = $q;
+                        $selectedIds[] = $qid;
+                        $kpSelected[$kpCode] = true;
+                        $prioritySelectedCount++;
+                        Log::debug('继续选择题目(请求知识点优先)', [
+                            'kp' => $kpCode,
+                            'id' => $qid,
+                            'priority_selected_count' => $prioritySelectedCount,
+                            'total_selected' => count($selectedQuestions)
+                        ]);
+                    }
+                }
+            } else {
+                foreach ($allQuestions as $q) {
+                    if (count($selectedQuestions) >= $totalQuestions) break;
+
+                    $kpCode = $q['kp_code'] ?? '';
+                    if (!isset($kpSelected[$kpCode])) {
+                        $selectedQuestions[] = $q;
+                        $kpSelected[$kpCode] = true;
+                        $prioritySelectedCount++;
+                        Log::debug('继续选择题目(知识点优先)', [
+                            'kp' => $kpCode,
+                            'id' => $q['id'] ?? 'unknown',
+                            'priority_selected_count' => $prioritySelectedCount,
+                            'total_selected' => count($selectedQuestions)
+                        ]);
+                    }
                 }
             }
 
@@ -2449,7 +2502,32 @@ class LearningAnalyticsService
                 ]);
 
                 $fallbackSelectedCount = 0;
-                $selectedIds = array_column($selectedQuestions, 'id');
+                $selectedIds = array_values(array_filter(array_column($selectedQuestions, 'id')));
+
+                if ($shouldRestrictToRequestedKps) {
+                    foreach ($allQuestions as $q) {
+                        if (count($selectedQuestions) >= $totalQuestions) {
+                            break;
+                        }
+
+                        $kpCode = $q['kp_code'] ?? '';
+                        $qid = $q['id'] ?? null;
+                        if ($qid === null || in_array($qid, $selectedIds) || !isset($requestedKpSet[$kpCode])) {
+                            continue;
+                        }
+
+                        $selectedQuestions[] = $q;
+                        $selectedIds[] = $qid;
+                        $fallbackSelectedCount++;
+                        Log::debug('降级选择题目(请求知识点重复)', [
+                            'kp' => $kpCode,
+                            'id' => $qid,
+                            'fallback_selected_count' => $fallbackSelectedCount,
+                            'current_count' => count($selectedQuestions)
+                        ]);
+                    }
+                }
+
                 foreach ($allQuestions as $q) {
                     if (count($selectedQuestions) >= $totalQuestions) break;
 
@@ -2518,7 +2596,8 @@ class LearningAnalyticsService
             'selected_count' => count($selectedQuestions),
             'success' => count($selectedQuestions) === $totalQuestions,
             'assemble_type' => $assembleType,
-            'strategy' => $useKnowledgePointPriority ? '知识点优先' : '无知识点限制',
+            'strategy' => $useKnowledgePointPriority ? '知识点覆盖优先' : '无知识点限制',
+            'requested_kp_selection' => $this->buildRequestedKpSelectionStats($selectedQuestions, $requestedKpCodes),
             'type_distribution' => array_count_values(array_map(function($q) {
                 $qid = $q['id'] ?? $q['question_id'] ?? null;
                 if ($qid && isset($questionTypeCache[$qid])) {
@@ -2641,6 +2720,68 @@ class LearningAnalyticsService
         return $finalQuestions;
     }
 
+    /**
+     * @param  array<int, array<string, mixed>>  $questions
+     * @param  array<int, string>  $requestedKpCodes
+     * @return array<string, mixed>
+     */
+    private function buildRequestedKpSelectionStats(array $questions, array $requestedKpCodes): array
+    {
+        $requestedKpCodes = array_values(array_unique(array_filter($requestedKpCodes)));
+        if ($requestedKpCodes === []) {
+            return [
+                'requested_kp_codes' => [],
+                'requested_kp_selected_count' => 0,
+                'related_kp_selected_count' => 0,
+                'requested_kp_distribution' => [],
+                'related_kp_distribution' => [],
+            ];
+        }
+
+        $requestedKpSet = array_fill_keys($requestedKpCodes, true);
+        $requestedDistribution = [];
+        $relatedDistribution = [];
+
+        foreach ($questions as $question) {
+            $kpCode = (string) ($question['kp_code'] ?? '');
+            if ($kpCode === '') {
+                continue;
+            }
+
+            if (isset($requestedKpSet[$kpCode])) {
+                $requestedDistribution[$kpCode] = ($requestedDistribution[$kpCode] ?? 0) + 1;
+            } else {
+                $relatedDistribution[$kpCode] = ($relatedDistribution[$kpCode] ?? 0) + 1;
+            }
+        }
+
+        return [
+            'requested_kp_codes' => $requestedKpCodes,
+            'requested_kp_selected_count' => array_sum($requestedDistribution),
+            'related_kp_selected_count' => array_sum($relatedDistribution),
+            'requested_kp_distribution' => $requestedDistribution,
+            'related_kp_distribution' => $relatedDistribution,
+        ];
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $questions
+     * @return array<string, mixed>
+     */
+    private function buildSupplementQuestionStats(array $questions): array
+    {
+        $kpDistribution = array_count_values(array_filter(array_map(
+            static fn ($question) => (string) ($question['kp_code'] ?? ''),
+            $questions
+        )));
+        arsort($kpDistribution);
+
+        return [
+            'supplement_kp_distribution_topn' => array_slice($kpDistribution, 0, 10, true),
+            'supplement_kp_sample' => array_slice(array_keys($kpDistribution), 0, 10),
+        ];
+    }
+
     /**
      * 获取学生对特定知识点的掌握度
      */
@@ -3052,15 +3193,23 @@ class LearningAnalyticsService
             ]);
 
             // 【核心】补充范围:只从「学过的」内容补充,不能从未学知识点或年级对应章节中选
-            // - 有 textbookId:从 getGradeKnowledgePoints(grade, textbookId) + 前章节(若有 textbookCatalogNodeIds
+            // - 有 textbookId:从 textbook_id -> 章节 -> 章节知识点关联表 取 kp_code(若有 chapter scope 再收缩到前章节
             // - 无 textbookId 且有 studentId:从学生做过的题目对应知识点(getStudentLearnedKpCodes)补充
             // - 无 textbookId 且无 studentId:无法确定学过的内容,不补充
             $gradeKpCodes = [];
             if ($textbookId) {
-                $gradeKpCodes = $this->getGradeKnowledgePoints($grade, $textbookId);
-                Log::info('getSupplementaryQuestionsForGrade: 教材模式,使用教材知识点', [
+                $allCatalogNodeIds = DB::table('textbook_catalog_nodes')
+                    ->where('textbook_id', $textbookId)
+                    ->pluck('id')
+                    ->map(static fn ($id) => (int) $id)
+                    ->toArray();
+
+                $gradeKpCodes = $this->getKpCodesForCatalogChapterIds($allCatalogNodeIds);
+
+                Log::info('getSupplementaryQuestionsForGrade: 教材模式,按章节关联知识点补题', [
                     'kp_count' => count($gradeKpCodes),
                     'textbook_id' => $textbookId,
+                    'catalog_node_count' => count($allCatalogNodeIds),
                 ]);
             } elseif ($studentId) {
                 $learnedKps = $this->getStudentLearnedKpCodes($studentId);

+ 5 - 0
app/Services/QuestionBankService.php

@@ -585,6 +585,8 @@ class QuestionBankService
                     'paper_id' => $paperId,
                     'student_id' => $studentId,
                     'teacher_id' => $examData['teacher_id'] ?? '',
+                    'subject' => $examData['subject'] ?? null,
+                    'params' => $examData['params'] ?? null,
                     'paper_name' => $paperDisplayName,
                     'paper_type' => $assembleType, // assemble_type 唯一来源
                     'diagnostic_chapter_id' => $examData['diagnostic_chapter_id'] ?? null, // 摸底的章节ID(章节摸底时记录)
@@ -623,6 +625,7 @@ class QuestionBankService
                     'paper_id' => $paperId,
                     'total_questions' => count($examData['questions']),
                 ]);
+                $now = now();
 
                 foreach ($examData['questions'] as $index => $question) {
                     // 验证题目基本数据
@@ -696,6 +699,8 @@ class QuestionBankService
                         'score' => $question['score'] ?? 5, // 默认5分
                         'estimated_time' => $question['estimated_time'] ?? 300,
                         'question_number' => $question['question_number'] ?? ($index + 1),
+                        'created_at' => $now,
+                        'updated_at' => $now,
                     ];
                 }