Procházet zdrojové kódy

merge: optimize analysis report generation

yemeishu před 1 týdnem
rodič
revize
b13fac622e

+ 2 - 8
app/Http/Controllers/Api/ExamAnalysisApiController.php

@@ -66,6 +66,7 @@ class ExamAnalysisApiController extends Controller
                         'recordId' => $recordId,
                     ]),
                     'pdf_url' => null,  // 稍后生成
+                    'pdf_poll_interval_seconds' => 2,
                     'created_at' => now()->toISOString(),
                 ],
             ];
@@ -133,11 +134,6 @@ class ExamAnalysisApiController extends Controller
                 ->first();
 
             if ($report && !empty($report->analysis_pdf_url)) {
-                Log::info('学情报告PDF URL查询成功(从数据库)', [
-                    'paper_id' => $paperId,
-                    'pdf_url' => $report->analysis_pdf_url,
-                ]);
-
                 return response()->json([
                     'success' => true,
                     'data' => [
@@ -150,15 +146,13 @@ class ExamAnalysisApiController extends Controller
                 ], 200, [], JSON_UNESCAPED_SLASHES);
             }
 
-            // 如果数据库中没有找到报告
-            Log::info('未找到学情报告', ['paper_id' => $paperId]);
-
             return response()->json([
                 'success' => true,
                 'data' => [
                     'paper_id' => $paperId,
                     'status' => 'not_found',
                     'pdf_url' => null,
+                    'retry_after_seconds' => 2,
                     'message' => '报告尚未生成,请先提交试卷进行分析',
                 ]
             ], 200, [], JSON_UNESCAPED_SLASHES);

+ 1 - 1
app/Jobs/GenerateAnalysisPdfJob.php

@@ -38,7 +38,7 @@ class GenerateAnalysisPdfJob implements ShouldQueue
         $this->recordId = $recordId;
 
         // 指定使用 pdf 队列,由独立的 pdf-worker 容器处理
-        $this->onQueue('pdf');
+        $this->onQueue((string) config('queue.workloads.pdf', 'pdf'));
         // 避免事务未提交时 worker 提前消费导致读到未提交数据
         $this->afterCommit();
     }

+ 1 - 2
app/Jobs/ProcessAnalysisReportTaskJob.php

@@ -27,7 +27,7 @@ class ProcessAnalysisReportTaskJob implements ShouldQueue
         public ?string $recordId = null
     ) {
         // 与 PDF 相关重流程统一走 pdf 队列
-        $this->onQueue('pdf');
+        $this->onQueue((string) config('queue.workloads.pdf', 'pdf'));
         // 避免事务未提交时 worker 提前消费导致读到旧数据
         $this->afterCommit();
     }
@@ -88,4 +88,3 @@ class ProcessAnalysisReportTaskJob implements ShouldQueue
         ]);
     }
 }
-

+ 3 - 1
app/Jobs/ProcessExamAnswerAnalysisJob.php

@@ -27,7 +27,9 @@ class ProcessExamAnswerAnalysisJob implements ShouldQueue
         public string $taskId,
         public array $examData
     ) {
-        $this->onQueue('pdf');
+        // Keep the default on the scaled worker fleet. In production today the
+        // PDF nodes are the only horizontally scaled queue consumers.
+        $this->onQueue((string) config('queue.workloads.exam_answer_analysis', 'pdf'));
         $this->afterCommit();
     }
 

+ 3 - 2
app/Jobs/ProcessQuestionDifficultyCalibrationJob.php

@@ -28,8 +28,9 @@ class ProcessQuestionDifficultyCalibrationJob implements ShouldQueue
     ) {
         $this->questions = $this->compactQuestions($questions);
 
-        // 放在 pdf 队列,且由调用方在 PDF Job 之后入队,保证报告优先生成。
-        $this->onQueue('pdf');
+        // Online difficulty calibration is not on the user-visible critical
+        // path. Keep it away from PDF workers so it cannot race report output.
+        $this->onQueue((string) config('queue.workloads.question_difficulty_calibration', 'default'));
         $this->afterCommit();
     }
 

+ 86 - 12
app/Services/ExamAnswerAnalysisService.php

@@ -41,6 +41,15 @@ class ExamAnswerAnalysisService
      */
     public function analyzeExamAnswers(array $examData): array
     {
+        $flowStart = microtime(true);
+        $lastMark = $flowStart;
+        $timings = [];
+        $mark = static function (string $label) use (&$lastMark, &$timings): void {
+            $now = microtime(true);
+            $timings[$label] = round(($now - $lastMark) * 1000, 1);
+            $lastMark = $now;
+        };
+
         Log::info('开始分析考试答题', [
             'paper_id' => $examData['paper_id'] ?? 'unknown',
             'student_id' => $examData['student_id'] ?? 'unknown',
@@ -53,28 +62,33 @@ class ExamAnswerAnalysisService
 
         // 0. 自动计算分数并补充未提交的题目
         $questions = $this->autoCalculateScores($questions, $paperId);
+        $mark('auto_calculate_scores_ms');
 
         // 更新 examData 中的 questions(包含补充的题目)
         $examData['questions'] = $questions;
 
         // 【公司要求】1. 获取学案基准难度(取自学案的difficulty_category)
         $examBaseDifficulty = $this->getExamBaseDifficulty($examData['paper_id'] ?? '');
+        $mark('get_exam_base_difficulty_ms');
 
         // 2. 获取题目知识点映射(批量查询,避免N+1)
         $questionMappings = $this->getQuestionKnowledgeMappings($questions);
+        $mark('get_question_knowledge_mappings_ms');
 
         // 3. 保存答题记录到数据库(复用已查询的知识点映射)
         $recordChangeState = $this->saveExamAnswerRecords($examData, $questionMappings);
+        $mark('save_exam_answer_records_ms');
         // 同步回写 paper_questions 判分结果,保证 PDF 与分析链路一致
         $this->syncPaperQuestionGrading($examData);
+        $mark('sync_paper_question_grading_ms');
         $hasAnswerChanged = (bool) ($recordChangeState['steps_changed'] ?? false)
             || (bool) ($recordChangeState['questions_changed'] ?? false);
         if ($hasAnswerChanged) {
-            Log::warning('ExamAnswerAnalysisService: 在线题目难度校准已移至PDF生成后异步执行', [
+            Log::warning('ExamAnswerAnalysisService: 在线题目难度校准已移至非关键路径异步执行', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId,
                 'question_count' => count($questions),
-                'queue' => 'pdf',
+                'queue' => config('queue.workloads.question_difficulty_calibration', 'default'),
             ]);
         } else {
             Log::info('ExamAnswerAnalysisService: 本次答案无变化,跳过在线难度更新', [
@@ -107,29 +121,45 @@ class ExamAnswerAnalysisService
                     'paper_id' => $examData['paper_id'],
                     'analysis_id' => $existing->id,
                 ]);
+                $mark('reuse_existing_analysis_ms');
+                $timings['total_ms'] = round((microtime(true) - $flowStart) * 1000, 1);
+                Log::warning('ExamAnswerAnalysisService: 答题分析耗时明细', [
+                    'paper_id' => $paperId,
+                    'student_id' => $studentId,
+                    'question_count' => count($questions),
+                    'reused_existing_analysis' => true,
+                    'timing' => $timings,
+                ]);
 
                 return $existingData;
             }
         }
+        $mark('reuse_check_ms');
 
         // 【公司要求】4. 计算每个知识点的加权掌握度(传入学案基准难度)
         // 核心算法:难度映射 → 权重计算 → 数值更新(newMastery = oldMastery + change)
         $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings, $examBaseDifficulty, $studentId, $paperId);
+        $mark('calculate_knowledge_mastery_vector_ms');
 
         // 【公司要求】5. 更新学生掌握度(包含多级父节点掌握度计算)
         $updatedMastery = $this->updateStudentMastery($studentId, $knowledgeMasteryVector);
+        $mark('update_student_mastery_ms');
 
         // 5. 生成题目维度分析
         $questionAnalysis = $this->analyzeQuestions($questions, $questionMappings);
+        $mark('analyze_questions_ms');
 
         // 6. 生成知识点维度分析
         $knowledgePointAnalysis = $this->analyzeKnowledgePoints($knowledgeMasteryVector, $questionMappings);
+        $mark('analyze_knowledge_points_ms');
 
         // 7. 生成整体掌握度总结
         $overallSummary = $this->generateOverallSummary($updatedMastery);
+        $mark('generate_overall_summary_ms');
 
         // 8. 生成智能出卷推荐依据
         $smartQuizRecommendation = $this->generateSmartQuizRecommendation($updatedMastery);
+        $mark('generate_smart_quiz_recommendation_ms');
 
         // 9. 保存分析结果
         $analysisResult = [
@@ -146,6 +176,7 @@ class ExamAnswerAnalysisService
         // 【新增】创建掌握度快照,并返回 current_mastery(仅kp->mastery)
         $snapshotInfo = $this->createMasterySnapshot($studentId, $paperId, $analysisResult);
         $analysisResult['current_mastery'] = $snapshotInfo['current_mastery'] ?? [];
+        $mark('create_mastery_snapshot_ms');
 
         $this->saveAnalysisResult(
             $studentId,
@@ -153,12 +184,23 @@ class ExamAnswerAnalysisService
             $analysisResult,
             $hasAnswerChanged ? $questions : []
         );
+        $mark('save_analysis_result_ms');
 
         Log::info('考试答题分析完成', [
             'student_id' => $studentId,
             'paper_id' => $examData['paper_id'],
             'analyzed_knowledge_points' => count($knowledgeMasteryVector),
         ]);
+        $timings['total_ms'] = round((microtime(true) - $flowStart) * 1000, 1);
+        Log::warning('ExamAnswerAnalysisService: 答题分析耗时明细', [
+            'paper_id' => $paperId,
+            'student_id' => $studentId,
+            'question_count' => count($questions),
+            'submitted_question_count' => count($examData['questions'] ?? []),
+            'knowledge_point_count' => count($knowledgeMasteryVector),
+            'has_answer_changed' => $hasAnswerChanged,
+            'timing' => $timings,
+        ]);
 
         return $analysisResult;
     }
@@ -1669,6 +1711,7 @@ class ExamAnswerAnalysisService
         $now = now();
         $updated = 0;
         $processedQuestionIds = [];
+        $payloadsByQuestionId = [];
         foreach ($examData['questions'] as $question) {
             $questionId = $question['question_id'] ?? $question['question_bank_id'] ?? null;
             if (empty($questionId)) {
@@ -1710,16 +1753,40 @@ class ExamAnswerAnalysisService
                 $payload['teacher_comment'] = $question['teacher_comment'];
             }
 
-            // 关键:不再依赖 paper_questions.id(部分库该字段为空),改为业务键匹配更新
-            $affected = DB::connection('mysql')->table('paper_questions')
+            $payloadsByQuestionId[$questionId] = $payload;
+        }
+
+        if (! empty($payloadsByQuestionId)) {
+            $questionIds = array_keys($payloadsByQuestionId);
+            $paperQuestionRows = DB::connection('mysql')->table('paper_questions')
                 ->where('paper_id', $paperId)
-                ->where(function ($q) use ($questionId) {
-                    $q->where('question_bank_id', $questionId)
-                        ->orWhere('question_id', $questionId);
+                ->where(function ($q) use ($questionIds) {
+                    $q->whereIn('question_bank_id', $questionIds)
+                        ->orWhereIn('question_id', $questionIds);
                 })
-                ->update($payload);
+                ->get(['id', 'question_bank_id', 'question_id']);
+
+            $idsByQuestionId = [];
+            foreach ($paperQuestionRows as $row) {
+                foreach ([$row->question_bank_id ?? null, $row->question_id ?? null] as $candidateId) {
+                    $candidateId = (string) $candidateId;
+                    if ($candidateId === '' || ! isset($payloadsByQuestionId[$candidateId])) {
+                        continue;
+                    }
+                    $idsByQuestionId[$candidateId][] = $row->id;
+                }
+            }
+
+            foreach ($payloadsByQuestionId as $questionId => $payload) {
+                $rowIds = array_values(array_unique(array_filter($idsByQuestionId[$questionId] ?? [])));
+                if ($rowIds === []) {
+                    continue;
+                }
 
-            $updated += $affected;
+                $updated += DB::connection('mysql')->table('paper_questions')
+                    ->whereIn('id', $rowIds)
+                    ->update($payload);
+            }
         }
 
         if ($updated > 0) {
@@ -1737,6 +1804,7 @@ class ExamAnswerAnalysisService
             'input_question_count' => count($examData['questions']),
             'processed_question_count' => count($processedQuestionIds),
             'updated_rows' => $updated,
+            'matched_rows' => isset($paperQuestionRows) ? $paperQuestionRows->count() : 0,
         ]);
     }
 
@@ -1845,17 +1913,23 @@ class ExamAnswerAnalysisService
             ]);
 
             if (! empty($difficultyCalibrationQuestions)) {
+                $calibrationDelaySeconds = max(
+                    0,
+                    (int) config('queue.workloads.question_difficulty_calibration_delay_seconds', 30)
+                );
+
                 dispatch(new \App\Jobs\ProcessQuestionDifficultyCalibrationJob(
                     $paperId,
                     $studentId,
                     $difficultyCalibrationQuestions
-                ));
+                ))->delay(now()->addSeconds($calibrationDelaySeconds));
 
-                Log::warning('在线题目难度校准任务已加入队列(PDF生成后执行)', [
+                Log::warning('在线题目难度校准任务已加入延迟队列', [
                     'student_id' => $studentId,
                     'paper_id' => $paperId,
                     'question_count' => count($difficultyCalibrationQuestions),
-                    'queue' => 'pdf',
+                    'queue' => config('queue.workloads.question_difficulty_calibration', 'default'),
+                    'delay_seconds' => $calibrationDelaySeconds,
                 ]);
             }
         } catch (\Exception $e) {

+ 2 - 116
app/Services/ExamPdfExportService.php

@@ -345,16 +345,6 @@ class ExamPdfExportService
                 $lastMark = $now;
             };
 
-            Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'record_id' => $recordId,
-            ]);
-            Log::warning('ExamPdfExportService: ANALYSIS_PDF_START', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-            ]);
-
             // 构建分析数据
             $analysisData = $this->buildAnalysisData($paperId, $studentId);
             $mark('build_analysis_data_ms');
@@ -367,15 +357,6 @@ class ExamPdfExportService
                 return null;
             }
 
-            Log::info('ExamPdfExportService: buildAnalysisData返回摘要', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'analysisData_keys_count' => count(array_keys($analysisData)),
-                'analysis_question_count' => count($analysisData['analysis_data']['question_analysis'] ?? []),
-                'questions_count' => count($analysisData['questions'] ?? []),
-                'mastery_count' => count($analysisData['mastery']['items'] ?? []),
-            ]);
-
             // 创建DTO
             $dto = ExamAnalysisDataDto::fromArray($analysisData);
             $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
@@ -386,9 +367,6 @@ class ExamPdfExportService
             if (function_exists('gc_collect_cycles')) {
                 gc_collect_cycles();
             }
-
-            Log::info('ExamPdfExportService: 模板数据摘要', $this->summarizeTemplateDataForLog($templateData));
-
             // V3 学情报告不再渲染逐题错题卡,保留题目元数据用于统计即可。
             $mark('prepare_report_data_ms');
 
@@ -433,7 +411,7 @@ class ExamPdfExportService
             $mark('save_pdf_url_ms');
             $marks['total_ms'] = round((microtime(true) - $flowStart) * 1000, 1);
 
-            Log::info('ExamPdfExportService: 学情分析PDF耗时明细', [
+            Log::warning('ExamPdfExportService: 学情分析PDF耗时明细', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId,
                 'record_id' => $recordId,
@@ -2312,13 +2290,6 @@ 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);
@@ -2361,12 +2332,6 @@ class ExamPdfExportService
 
         // 首先尝试从paper->analysis_id获取
         if (! empty($paper->analysis_id)) {
-            Log::info('ExamPdfExportService: 从本地数据库获取试卷分析数据', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'analysis_id' => $paper->analysis_id,
-            ]);
-
             $analysisRecord = \DB::table('exam_analysis_results')
                 ->where('id', $paper->analysis_id)
                 ->where('student_id', $studentId)
@@ -2374,9 +2339,6 @@ class ExamPdfExportService
 
             if ($analysisRecord && ! empty($analysisRecord->analysis_data)) {
                 $analysisData = json_decode($analysisRecord->analysis_data, true);
-                Log::info('ExamPdfExportService: 成功获取本地分析数据(通过analysis_id)', [
-                    'data_size' => strlen($analysisRecord->analysis_data),
-                ]);
             } else {
                 Log::warning('ExamPdfExportService: 未找到本地分析数据,将尝试其他方式', [
                     'paper_id' => $paperId,
@@ -2388,11 +2350,6 @@ class ExamPdfExportService
 
         // 如果没有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)
@@ -2401,10 +2358,6 @@ class ExamPdfExportService
 
             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,
@@ -2416,11 +2369,6 @@ class ExamPdfExportService
         // 【修复】优先使用analysisData中的knowledge_point_analysis数据
         $masteryData = [];
         $parentMasteryLevels = []; // 新增:父节点掌握度数据
-        Log::info('ExamPdfExportService: 开始处理掌握度数据', [
-            'student_id' => $studentId,
-            'analysisData_keys' => array_keys($analysisData),
-            'has_knowledge_point_analysis' => ! empty($analysisData['knowledge_point_analysis']),
-        ]);
 
         $fullMasteryMap = [];
         $snapshotMasteryData = [];
@@ -2441,10 +2389,6 @@ class ExamPdfExportService
             try {
                 // 获取本次考试涉及的知识点代码列表
                 $examKpCodes = array_column($masteryData, 'kp_code');
-                Log::info('ExamPdfExportService: 本次考试涉及的知识点', [
-                    'count' => count($examKpCodes),
-                    'kp_codes' => $examKpCodes,
-                ]);
 
                 // 获取最新快照的数据(mastery_data 内已包含 current_mastery 和 previous_mastery)
                 $lastSnapshot = DB::connection('mysql')
@@ -2606,27 +2550,14 @@ class ExamPdfExportService
                     }
                 }
 
-                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),
-                'has_change_values' => collect($masteryData)->contains(fn ($item) => is_array($item) && array_key_exists('mastery_change', $item)),
-            ]);
         } 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'] ?? []; // 获取父节点掌握度
@@ -2697,11 +2628,6 @@ class ExamPdfExportService
                         unset($item);
                     }
                 }
-
-                Log::info('ExamPdfExportService: 成功获取多层级掌握度数据', [
-                    'count' => count($masteryData),
-                    'parent_count' => count($parentMasteryLevels),
-                ]);
             } catch (\Exception $e) {
                 Log::error('ExamPdfExportService: 获取掌握度数据失败', [
                     'student_id' => $studentId,
@@ -2710,29 +2636,11 @@ class ExamPdfExportService
             }
         }
 
-        // 【修改】使用本地方法获取学习路径推荐(替代API调用)
+        // V3 PDF 会在 reduceTemplateDataForV3 中丢弃 recommendations,这里不再计算未展示的推荐数据。
         $recommendations = [];
-        try {
-            Log::info('ExamPdfExportService: 获取学习路径推荐', [
-                'student_id' => $studentId,
-            ]);
-            $learningPaths = $this->learningAnalyticsService->recommendLearningPaths($studentId, 3);
-            $recommendations = $learningPaths['recommendations'] ?? [];
-            Log::info('ExamPdfExportService: 成功获取学习路径推荐', [
-                'count' => count($recommendations),
-            ]);
-        } catch (\Exception $e) {
-            Log::error('ExamPdfExportService: 获取学习路径推荐失败', [
-                'student_id' => $studentId,
-                'error' => $e->getMessage(),
-            ]);
-        }
 
         // 获取知识点名称映射
         $kpNameMap = $this->buildKnowledgePointNameMap();
-        Log::info('ExamPdfExportService: 获取知识点名称映射', [
-            'kpNameMap_count' => count($kpNameMap),
-        ]);
 
         // 【修复】直接从MySQL数据库获取题目详情(不通过API)
         // 只有当 $paper 是 Paper 模型时才查询题目详情
@@ -2745,13 +2653,6 @@ class ExamPdfExportService
 
         // 【关键调试】查看buildMasterySummary的返回结果
         $masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap);
-        Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
-            'masteryData_count' => count($masteryData),
-            'kpNameMap_count' => count($kpNameMap),
-            'masterySummary_items_count' => count($masterySummary['items'] ?? []),
-            'masterySummary_avg' => $masterySummary['average'] ?? null,
-            'masterySummary_weak_count' => count($masterySummary['weak_list'] ?? []),
-        ]);
 
         // 构建当前学生掌握度映射,供父子影响分析展示使用
         // 与 PC 端保持一致:优先使用当前报告/学案对应快照的 current_mastery。
@@ -2781,11 +2682,6 @@ class ExamPdfExportService
             $snapshotMasteryData
         );
 
-        Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
-            'raw_count' => count($parentMasteryLevels),
-            'processed_count' => count($processedParentMastery),
-        ]);
-
         return [
             'paper' => [
                 'id' => $paper->paper_id,
@@ -4375,11 +4271,6 @@ 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;
@@ -4415,11 +4306,6 @@ class ExamPdfExportService
             'weak_list' => array_slice($items, 0, 5),
         ];
 
-        Log::info('ExamPdfExportService: buildMasterySummary完成', [
-            'total_count' => $count,
-            'items_count' => count($items),
-        ]);
-
         return $result;
     }
 

+ 24 - 0
config/queue.php

@@ -15,6 +15,30 @@ return [
 
     'default' => env('QUEUE_CONNECTION', 'database'),
 
+    /*
+    |--------------------------------------------------------------------------
+    | Workload Queue Names
+    |--------------------------------------------------------------------------
+    |
+    | Production currently has only two consumed queues:
+    | - default: one worker on the main server for non-PDF background work.
+    | - pdf: nine workers across main/pdf-1/pdf-2 for PDFs and heavy analysis.
+    |
+    | Keep report-critical work on "pdf"; move only non-critical follow-up work
+    | away from the PDF worker fleet.
+    |
+    */
+
+    'workloads' => [
+        'pdf' => env('QUEUE_PDF', 'pdf'),
+        // Keep answer analysis on the scaled pdf worker fleet. Do not point
+        // this at default unless default workers are scaled out.
+        'exam_answer_analysis' => env('QUEUE_EXAM_ANSWER_ANALYSIS', 'pdf'),
+        // This follow-up is not on the visible report-generation path.
+        'question_difficulty_calibration' => env('QUEUE_QUESTION_DIFFICULTY_CALIBRATION', 'default'),
+        'question_difficulty_calibration_delay_seconds' => (int) env('QUEUE_QUESTION_DIFFICULTY_CALIBRATION_DELAY_SECONDS', 30),
+    ],
+
     /*
     |--------------------------------------------------------------------------
     | Queue Connections

+ 2 - 2
docker-compose.pdf.mount.yml

@@ -31,7 +31,7 @@ services:
       - ./storage:/app/storage
       - ./.env:/app/.env
 
-  pdf-worker-3:
+  logic-worker-1:
     volumes:
       - .:/app
       - /app/vendor
@@ -40,7 +40,7 @@ services:
       - ./storage:/app/storage
       - ./.env:/app/.env
 
-  pdf-worker-4:
+  logic-worker-2:
     volumes:
       - .:/app
       - /app/vendor

+ 8 - 8
docker-compose.pdf.yml

@@ -99,13 +99,13 @@ services:
           cpus: '1'
           memory: 1536M
 
-  # PDF Worker 3
-  pdf-worker-3:
+  # Logic Worker 1(非 PDF 重逻辑:答题分析、难度校准等)
+  logic-worker-1:
     build:
       context: .
       target: pdfworker
-    container_name: pdf_worker_3
-    command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
+    container_name: logic_worker_1
+    command: php artisan queue:work --queue=logic --sleep=1 --tries=2 --max-time=600 --max-jobs=50
     env_file:
       - .env
     volumes:
@@ -130,13 +130,13 @@ services:
           cpus: '1'
           memory: 1536M
 
-  # PDF Worker 4
-  pdf-worker-4:
+  # Logic Worker 2(非 PDF 重逻辑:答题分析、难度校准等)
+  logic-worker-2:
     build:
       context: .
       target: pdfworker
-    container_name: pdf_worker_4
-    command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
+    container_name: logic_worker_2
+    command: php artisan queue:work --queue=logic --sleep=1 --tries=2 --max-time=600 --max-jobs=50
     env_file:
       - .env
     volumes: