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

feat(async-exam): decouple assembly from request path

Keep paper_id/task_id returned synchronously while moving heavy exam assembly into queue jobs, add end-to-end timing telemetry, and harden paper_id 15-digit normalization for stable downstream rendering and callbacks.

Made-with: Cursor
yemeishu пре 1 месец
родитељ
комит
a35705cceb

+ 44 - 214
app/Http/Controllers/Api/IntelligentExamController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\Api;
 
+use App\Jobs\AssembleExamTaskJob;
 use App\Http\Controllers\Controller;
 use App\Models\MistakeRecord;
 use App\Models\Paper;
@@ -53,6 +54,14 @@ class IntelligentExamController extends Controller
      */
     public function store(Request $request): JsonResponse
     {
+        $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
         $payload = $request->json()->all();
         if (empty($payload)) {
@@ -127,6 +136,8 @@ class IntelligentExamController extends Controller
         $assembleType = (int) ($data['assemble_type'] ?? 4);
         // API 固定题量:含追练(assemble_type=5),一律 default_total_questions,不使用请求题量参数
         $data['total_questions'] = (int) config('question_bank.default_total_questions');
+        // 预分配 paper_id,保证接口语义稳定(后续异步化时也可继续同步返回)
+        $reservedPaperId = $this->questionBankService->generatePaperId();
         $this->ensureStudentTeacherRelation($data);
 
         // 【修改】使用series_id、semester_code和grade获取textbook_id
@@ -141,244 +152,63 @@ class IntelligentExamController extends Controller
             $data['kp_codes'] = [];
         }
 
-        $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
-        // 注意: difficulty_ratio 参数已废弃,使用 difficulty_category 控制难度分布
-        $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
-        $difficultyCategory = $data['difficulty_category'] ?? 1; // 直接使用数字,不转换
-        $mistakeIds = $data['mistake_ids'] ?? [];
-        $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
-        $paperIds = $data['paper_ids'] ?? [];
-        $assembleType = (int) ($data['assemble_type'] ?? 4); // 默认为通用类型(4)
-
         try {
-            $questions = [];
-            $result = null;
-            // 【新增】初始化章节摸底和智能组卷的关键字段
-            $diagnosticChapterId = null;
-            $explanationKpCodes = null;
-
-            if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
-                $questionIds = $this->resolveMistakeQuestionIds(
-                    $data['student_id'],
-                    $mistakeIds,
-                    $mistakeQuestionIds
-                );
-
-                if (empty($questionIds)) {
-                    return response()->json([
-                        'success' => false,
-                        'message' => '未找到可用的错题题目,请检查错题ID或学生ID',
-                    ], 400);
-                }
-
-                $bankQuestions = $this->questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
-                if (empty($bankQuestions)) {
-                    return response()->json([
-                        'success' => false,
-                        'message' => '错题对应的题库题目不存在或不可用',
-                    ], 400);
-                }
-
-                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes']);
-                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
-                $paperName = $data['paper_name'] ?? ('错题复习_'.$data['student_id'].'_'.now()->format('Ymd_His'));
-            } else {
-                // 第一步:生成智能试卷(同步)
-                $params = [
-                    'student_id' => $data['student_id'],
-                    'grade' => $data['grade'] ?? null,
-                    'total_questions' => $data['total_questions'],
-                    // 【修复】教材组卷时不使用用户传入的kp_codes,只使用章节关联的知识点
-                    'kp_codes' => $assembleType == 3 ? null : ($data['kp_codes'] ?? null),
-                    'skills' => $data['skills'] ?? [],
-                    'question_type_ratio' => $questionTypeRatio,
-                    'difficulty_category' => $difficultyCategory, // 传递难度分类(数字)
-                    'assemble_type' => $assembleType, // 新版组卷类型
-                    'exam_type' => $data['exam_type'] ?? 'general', // 兼容旧版参数
-                    'paper_ids' => $paperIds, // 错题本类型专用参数
-                    'textbook_id' => $data['textbook_id'] ?? null, // 摸底和智能组卷专用
-                    'end_catalog_id' => $data['end_catalog_id'] ?? null, // 摸底专用:截止章节ID
-                    'chapter_id_list' => $data['chapter_id_list'] ?? null, // 教材组卷专用
-                    'kp_code_list' => $assembleType == 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []), // 知识点组卷专用
-                    'practice_options' => $data['practice_options'] ?? null, // 传递专项练习选项
-                    'mistake_options' => $data['mistake_options'] ?? null, // 传递错题选项
-                ];
-
-                $result = $this->learningAnalyticsService->generateIntelligentExam($params);
-
-                if (empty($result['success'])) {
-                    $errorMsg = $result['message'] ?? '智能出卷失败';
-                    Log::error('智能出卷失败', [
-                        'student_id' => $data['student_id'],
-                        'error' => $result,
-                    ]);
-
-                    // 提供更详细的错误信息
-                    if (strpos($errorMsg, '超时') !== false) {
-                        $errorMsg = '服务响应超时,请稍后重试';
-                    } elseif (strpos($errorMsg, '连接') !== false) {
-                        $errorMsg = '依赖服务连接失败,请检查服务状态';
-                    }
-
-                    return response()->json([
-                        'success' => false,
-                        'message' => $errorMsg,
-                        'details' => $result['details'] ?? null,
-                    ], 400);
-                }
-
-                if (isset($result['stats']['difficulty_category'])) {
-                    $difficultyCategory = $result['stats']['difficulty_category'];
-                }
-
-                // 【新增】提取章节摸底和智能组卷的关键字段
-                $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
-                $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
-
-                $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes']);
-            }
-
-            if (empty($questions)) {
-                return response()->json([
-                    'success' => false,
-                    'message' => '未能生成有效题目,请检查知识点或题库数据',
-                ], 400);
-            }
-
-            // 含追练在内:统一截断为 default_total_questions(已写入 $data['total_questions'])
-            $totalQuestions = min($data['total_questions'], count($questions));
-            $questions = array_slice($questions, 0, $totalQuestions);
-
-            // 每个题型内按难度升序排序(由易到难),并重排题号
-            $questions = $this->sortQuestionsWithinTypeByDifficulty($questions);
-
-            // 调整题目分值:统一按目标总分凑整(默认100),不再按20题特殊分支处理
-            $targetTotalScore = (float) ($data['total_score'] ?? 100.0);
-            $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
-
-            // 计算总分
-            $totalScore = array_sum(array_column($questions, 'score'));
-
-            // 第二步:保存试卷到数据库(同步)
-            // 【修复】策略可能修改assembleType(如章节智能组卷检测到全部掌握后转为下一章节摸底)
-            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
-            $paperId = $this->questionBankService->saveExamToDatabase([
-                'paper_name' => $paperName,
-                'student_id' => $data['student_id'],
-                'teacher_id' => $data['teacher_id'] ?? null,
-                'assembleType' => $finalAssembleType,
-                'difficulty_category' => $difficultyCategory,
-                'total_score' => $totalScore, // 使用计算后的实际总分
-                'questions' => $questions,
-                // 【新增】章节摸底和智能组卷的关键字段
-                'diagnostic_chapter_id' => $diagnosticChapterId ?? null,
-                'explanation_kp_codes' => $explanationKpCodes ?? null,
-            ]);
-
-            if (! $paperId) {
-                return response()->json([
-                    'success' => false,
-                    'message' => '试卷保存失败',
-                ], 500);
-            }
-
-            // 第三步:创建异步任务(使用TaskManager)
-            // 注意:callback_url会在TaskManager中被提取并保存
-            $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, array_merge($data, ['paper_id' => $paperId]));
-
-            // 生成识别码
-            $codes = $this->paperPayloadService->generatePaperCodes($paperId);
-
-            // 立即返回完整的试卷数据(不等待PDF生成)
-            $paperModel = Paper::with('questions')->find($paperId);
-            $examContent = $paperModel
-                ? $this->paperPayloadService->buildExamContent($paperModel)
-                : [];
-
-            $finalStats = $result['stats'] ?? [
-                'total_selected' => count($questions),
-                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
-            ];
-            if (! isset($finalStats['difficulty_category'])) {
-                $finalStats['difficulty_category'] = $difficultyCategory;
-            }
-            if (! isset($finalStats['final_distribution'])) {
-                $distributionService = app(\App\Services\DifficultyDistributionService::class);
-                $finalBuckets = $distributionService->groupQuestionsByDifficultyRange($questions, (int) $difficultyCategory);
-                $finalTotal = max(1, count($questions));
-                $finalStats['final_distribution'] = array_map(static function ($bucket) use ($finalTotal) {
-                    $count = count($bucket);
-                    return [
-                        'count' => $count,
-                        'ratio' => round(($count / $finalTotal) * 100, 2),
-                    ];
-                }, $finalBuckets);
-            }
-            if (! isset($finalStats['final_distribution_shortage'])) {
-                $distributionService = app(\App\Services\DifficultyDistributionService::class);
-                $distribution = $distributionService->calculateDistribution((int) $difficultyCategory, (int) ($data['total_questions'] ?? count($questions)));
-                $buckets = $distributionService->groupQuestionsByDifficultyRange($questions, (int) $difficultyCategory);
-                $expected = [
-                    'primary_low' => 0,
-                    'primary_medium' => 0,
-                    'primary_high' => 0,
-                    'secondary' => 0,
-                    'other' => 0,
-                ];
-                foreach ($distribution as $level => $config) {
-                    $bucketKey = $distributionService->mapDifficultyLevelToRangeKey($level, (int) $difficultyCategory);
-                    $expected[$bucketKey] += (int) ($config['count'] ?? 0);
-                }
-                $actual = array_map(static fn($bucket) => count($bucket), $buckets);
-                $finalStats['final_distribution_shortage'] = array_map(static function ($count, $bucketKey) use ($actual) {
-                    $actualCount = $actual[$bucketKey] ?? 0;
-                    return [
-                        'expected' => $count,
-                        'actual' => $actualCount,
-                        'short' => max(0, $count - $actualCount),
-                    ];
-                }, $expected, array_keys($expected));
-            }
-
-            $this->taskManager->updateTaskStatus($taskId, [
-                'stats' => $finalStats,
-            ]);
+            // 异步优化:同步仅返回 task_id/paper_id,重型组卷逻辑下沉到队列
+            $taskId = $this->taskManager->createTask(TaskManager::TASK_TYPE_EXAM, array_merge($data, [
+                'paper_id' => $reservedPaperId,
+                'request_trace_id' => $requestTraceId,
+                'request_started_at' => now()->toISOString(),
+            ]));
 
-            // 触发后台PDF生成
-            $this->triggerPdfGeneration($taskId, $paperId);
+            dispatch(new AssembleExamTaskJob($taskId));
 
+            $codes = $this->paperPayloadService->generatePaperCodes($reservedPaperId);
             $payload = [
                 'success' => true,
-                'message' => '智能试卷创建成功PDF正在后台生成...',
+                'message' => '智能试卷任务已创建,正在后台组卷并生成PDF...',
                 'data' => [
                     'task_id' => $taskId,
-                    'paper_id' => $paperId,
+                    'paper_id' => $reservedPaperId,
                     'status' => 'processing',
-                    // 识别码
-                    'exam_code' => $codes['exam_code'],       // 试卷识别码 (1+12位)
-                    'grading_code' => $codes['grading_code'], // 判卷识别码 (2+12位)
-                    'paper_id_num' => $codes['paper_id_num'], // 12位数字ID
-                    'exam_content' => $examContent,
+                    'exam_code' => $codes['exam_code'],
+                    'grading_code' => $codes['grading_code'],
+                    'paper_id_num' => $codes['paper_id_num'],
+                    'exam_content' => [],
                     'urls' => [
-                        'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $paperId]),
-                        'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']),
-                        'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]),
+                        'grading_url' => route('filament.admin.auth.intelligent-exam.grading', ['paper_id' => $reservedPaperId]),
+                        'student_exam_url' => route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $reservedPaperId, 'answer' => 'false']),
+                        'knowledge_explanation_url' => route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $reservedPaperId]),
                     ],
                     'pdfs' => [
                         'exam_paper_pdf' => null,
                         'grading_pdf' => null,
+                        'all_pdf' => null,
                     ],
-                    'stats' => $finalStats,
+                    'stats' => null,
                     'created_at' => now()->toISOString(),
                 ],
             ];
 
+            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),
+            ]);
+
             return response()->json($payload, 200, [], JSON_UNESCAPED_SLASHES);
 
         } catch (\Exception $e) {
             Log::error('Intelligent exam API failed', [
+                'trace_id' => $requestTraceId,
                 'error' => $e->getMessage(),
                 'trace' => $e->getTraceAsString(),
+                'sync_elapsed_ms_before_error' => (int) round((microtime(true) - $requestStartedAt) * 1000),
             ]);
 
             // 返回更具体的错误信息

+ 11 - 1
app/Http/Controllers/ExamPdfController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Jobs\RegeneratePdfJob;
 use App\Models\Paper;
+use App\Services\PaperIdGenerator;
 use App\Support\PaperNaming;
 use App\Services\QuestionBankService;
 use Illuminate\Http\Request;
@@ -499,7 +500,7 @@ class ExamPdfController extends Controller
             'header_title' => $headerTitle,
             'exam_pdf_title' => "试卷_{$headerTitle}",
             'grading_pdf_title' => "判卷_{$headerTitle}",
-            'knowledge_pdf_title' => "知识点讲解_{$headerTitle}",
+            'knowledge_pdf_title' => "知识点梳理_{$headerTitle}",
         ];
     }
 
@@ -1150,6 +1151,9 @@ class ExamPdfController extends Controller
                 'paper_id' => $paper_id,
             ], 400);
         }
+        if (! PaperIdGenerator::validatePaperId((string) $paper_id)) {
+            Log::warning('RegeneratePdf: 非标准15位 paper_id', ['paper_id' => $paper_id]);
+        }
 
         try {
             // 【修复】首先检查试卷是否存在
@@ -1237,6 +1241,9 @@ class ExamPdfController extends Controller
                 'paper_id' => $paper_id,
             ], 400);
         }
+        if (! PaperIdGenerator::validatePaperId((string) $paper_id)) {
+            Log::warning('RegenerateExamPdf: 非标准15位 paper_id', ['paper_id' => $paper_id]);
+        }
 
         try {
             $pdfService = app(\App\Services\ExamPdfExportService::class);
@@ -1288,6 +1295,9 @@ class ExamPdfController extends Controller
                 'paper_id' => $paper_id,
             ], 400);
         }
+        if (! PaperIdGenerator::validatePaperId((string) $paper_id)) {
+            Log::warning('RegenerateGradingPdf: 非标准15位 paper_id', ['paper_id' => $paper_id]);
+        }
 
         try {
             $pdfService = app(\App\Services\ExamPdfExportService::class);

+ 533 - 0
app/Jobs/AssembleExamTaskJob.php

@@ -0,0 +1,533 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\MistakeRecord;
+use App\Services\LearningAnalyticsService;
+use App\Services\QuestionBankService;
+use App\Services\TaskManager;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class AssembleExamTaskJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public string $taskId;
+
+    public int $tries = 2;
+
+    public int $timeout = 180;
+
+    public function __construct(string $taskId)
+    {
+        $this->taskId = $taskId;
+        // 复用现有 pdf-worker,避免 default 队列无人消费
+        $this->onQueue('pdf');
+        $this->afterCommit();
+    }
+
+    public function handle(
+        LearningAnalyticsService $learningAnalyticsService,
+        QuestionBankService $questionBankService,
+        TaskManager $taskManager
+    ): void {
+        $task = $taskManager->getTaskStatus($this->taskId);
+        if (!is_array($task) || empty($task['data']) || !is_array($task['data'])) {
+            $taskManager->markTaskFailed($this->taskId, '任务数据不存在');
+            return;
+        }
+
+        $data = $task['data'];
+        $assembleStartedAt = microtime(true);
+
+        try {
+            $taskManager->updateTaskProgress($this->taskId, 5, '开始异步组卷...');
+
+            $assembleType = (int) ($data['assemble_type'] ?? 4);
+            $difficultyCategory = $data['difficulty_category'] ?? 1;
+            $paperName = $data['paper_name'] ?? ('智能试卷_'.now()->format('Ymd_His'));
+            $mistakeIds = $data['mistake_ids'] ?? [];
+            $mistakeQuestionIds = $data['mistake_question_ids'] ?? [];
+            $paperIds = $data['paper_ids'] ?? [];
+            $questionTypeRatio = $this->normalizeQuestionTypeRatio($data['question_type_ratio'] ?? []);
+
+            $questions = [];
+            $result = null;
+            $diagnosticChapterId = null;
+            $explanationKpCodes = null;
+
+            if (! empty($mistakeIds) || ! empty($mistakeQuestionIds)) {
+                $questionIds = $this->resolveMistakeQuestionIds((string) $data['student_id'], $mistakeIds, $mistakeQuestionIds);
+                if (empty($questionIds)) {
+                    $taskManager->markTaskFailed($this->taskId, '未找到可用的错题题目');
+                    return;
+                }
+
+                $bankQuestions = $questionBankService->getQuestionsByIds($questionIds)['data'] ?? [];
+                if (empty($bankQuestions)) {
+                    $taskManager->markTaskFailed($this->taskId, '错题对应题库题目不可用');
+                    return;
+                }
+
+                $questions = $this->hydrateQuestions($bankQuestions, $data['kp_codes'] ?? []);
+                $questions = $this->sortQuestionsByRequestedIds($questions, $questionIds);
+                $paperName = $data['paper_name'] ?? ('错题复习_'.$data['student_id'].'_'.now()->format('Ymd_His'));
+            } else {
+                $params = [
+                    'student_id' => $data['student_id'],
+                    'grade' => $data['grade'] ?? null,
+                    'total_questions' => $data['total_questions'],
+                    'kp_codes' => $assembleType === 3 ? null : ($data['kp_codes'] ?? null),
+                    'skills' => $data['skills'] ?? [],
+                    'question_type_ratio' => $questionTypeRatio,
+                    'difficulty_category' => $difficultyCategory,
+                    'assemble_type' => $assembleType,
+                    'exam_type' => $data['exam_type'] ?? 'general',
+                    'paper_ids' => $paperIds,
+                    'textbook_id' => $data['textbook_id'] ?? null,
+                    'end_catalog_id' => $data['end_catalog_id'] ?? null,
+                    'chapter_id_list' => $data['chapter_id_list'] ?? null,
+                    'kp_code_list' => $assembleType === 3 ? null : ($data['kp_code_list'] ?? $data['kp_codes'] ?? []),
+                    'practice_options' => $data['practice_options'] ?? null,
+                    'mistake_options' => $data['mistake_options'] ?? null,
+                ];
+
+                $result = $learningAnalyticsService->generateIntelligentExam($params);
+                if (empty($result['success'])) {
+                    $taskManager->markTaskFailed($this->taskId, $result['message'] ?? '智能出卷失败');
+                    return;
+                }
+
+                if (isset($result['stats']['difficulty_category'])) {
+                    $difficultyCategory = $result['stats']['difficulty_category'];
+                }
+                $diagnosticChapterId = $result['diagnostic_chapter_id'] ?? null;
+                $explanationKpCodes = $result['explanation_kp_codes'] ?? null;
+                $questions = $this->hydrateQuestions($result['questions'] ?? [], $data['kp_codes'] ?? []);
+            }
+
+            if (empty($questions)) {
+                $taskManager->markTaskFailed($this->taskId, '未能生成有效题目');
+                return;
+            }
+
+            $totalQuestions = min((int) ($data['total_questions'] ?? 10), count($questions));
+            $questions = array_slice($questions, 0, $totalQuestions);
+            $questions = $this->sortQuestionsWithinTypeByDifficulty($questions);
+            $targetTotalScore = (float) ($data['total_score'] ?? 100.0);
+            $questions = $this->adjustQuestionScores($questions, $targetTotalScore);
+            $totalScore = array_sum(array_column($questions, 'score'));
+
+            $finalAssembleType = ($result !== null && isset($result['assemble_type'])) ? $result['assemble_type'] : $assembleType;
+            $paperId = $questionBankService->saveExamToDatabase([
+                'paper_id' => $data['paper_id'] ?? null,
+                'paper_name' => $paperName,
+                'student_id' => $data['student_id'],
+                'teacher_id' => $data['teacher_id'] ?? null,
+                'assembleType' => $finalAssembleType,
+                'difficulty_category' => $difficultyCategory,
+                'total_score' => $totalScore,
+                'questions' => $questions,
+                'diagnostic_chapter_id' => $diagnosticChapterId,
+                'explanation_kp_codes' => $explanationKpCodes,
+            ]);
+
+            if (! $paperId) {
+                $taskManager->markTaskFailed($this->taskId, '试卷保存失败');
+                return;
+            }
+
+            $finalStats = $result['stats'] ?? [
+                'total_selected' => count($questions),
+                'mistake_based' => ! empty($mistakeIds) || ! empty($mistakeQuestionIds),
+            ];
+            if (! isset($finalStats['difficulty_category'])) {
+                $finalStats['difficulty_category'] = $difficultyCategory;
+            }
+
+            $taskManager->updateTaskStatus($this->taskId, [
+                'paper_id' => $paperId,
+                'stats' => $finalStats,
+                'assemble_elapsed_ms' => (int) round((microtime(true) - $assembleStartedAt) * 1000),
+            ]);
+            $taskManager->updateTaskProgress($this->taskId, 40, '组卷完成,开始生成PDF...');
+
+            dispatch(new GenerateExamPdfJob($this->taskId, $paperId));
+            Log::info('AssembleExamTaskJob: 组卷任务完成并已触发PDF任务', [
+                'task_id' => $this->taskId,
+                'paper_id' => $paperId,
+            ]);
+        } catch (\Exception $e) {
+            Log::error('AssembleExamTaskJob: 异常', [
+                'task_id' => $this->taskId,
+                'error' => $e->getMessage(),
+            ]);
+            $taskManager->markTaskFailed($this->taskId, $e->getMessage());
+        }
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage());
+    }
+
+    private function normalizeQuestionTypeRatio(array $input): array
+    {
+        $defaults = ['选择题' => 40, '填空题' => 20, '解答题' => 40];
+        $normalized = [];
+        foreach ($input as $key => $value) {
+            if (! is_numeric($value)) {
+                continue;
+            }
+            $type = $this->normalizeQuestionTypeKey((string) $key);
+            if ($type) {
+                $normalized[$type] = (float) $value;
+            }
+        }
+        $merged = array_merge($defaults, $normalized);
+        $sum = array_sum($merged);
+        if ($sum > 0) {
+            foreach ($merged as $k => $v) {
+                $merged[$k] = round(($v / $sum) * 100, 2);
+            }
+        }
+        return $merged;
+    }
+
+    private function normalizeQuestionTypeKey(string $key): ?string
+    {
+        $key = trim($key);
+        if (in_array($key, ['choice', '选择题', 'single_choice', 'multiple_choice', 'CHOICE', 'SINGLE_CHOICE', 'MULTIPLE_CHOICE'], true)) {
+            return '选择题';
+        }
+        if (in_array($key, ['fill', '填空题', 'blank', 'FILL_IN_THE_BLANK', 'FILL'], true)) {
+            return '填空题';
+        }
+        if (in_array($key, ['answer', '解答题', '计算题', 'CALCULATION', 'WORD_PROBLEM', 'PROOF'], true)) {
+            return '解答题';
+        }
+        return null;
+    }
+
+    private function resolveMistakeQuestionIds(string $studentId, array $mistakeIds, array $mistakeQuestionIds): array
+    {
+        $questionIds = [];
+        if (! empty($mistakeQuestionIds)) {
+            $questionIds = array_merge($questionIds, $mistakeQuestionIds);
+        }
+        if (! empty($mistakeIds)) {
+            $fromDb = MistakeRecord::query()->where('student_id', $studentId)->whereIn('id', $mistakeIds)->pluck('question_id')->filter()->values()->all();
+            $questionIds = array_merge($questionIds, $fromDb);
+        }
+        return array_values(array_unique(array_filter($questionIds)));
+    }
+
+    private function hydrateQuestions(array $questions, array $kpCodes): array
+    {
+        $normalized = [];
+        foreach ($questions as $question) {
+            $type = $this->normalizeQuestionTypeKey($question['question_type'] ?? $question['type'] ?? '') ?? $this->guessType($question);
+            $score = $question['score'] ?? $this->defaultScore($type);
+            $normalized[] = [
+                'id' => $question['id'] ?? $question['question_id'] ?? null,
+                'question_id' => $question['question_id'] ?? null,
+                'question_type' => $type === '选择题' ? 'choice' : ($type === '填空题' ? 'fill' : 'answer'),
+                'stem' => $question['stem'] ?? $question['content'] ?? ($question['question_text'] ?? ''),
+                'content' => $question['content'] ?? $question['stem'] ?? '',
+                'options' => $question['options'] ?? ($question['choices'] ?? []),
+                'answer' => $question['answer'] ?? $question['correct_answer'] ?? '',
+                'solution' => $question['solution'] ?? '',
+                'difficulty' => isset($question['difficulty']) ? (float) $question['difficulty'] : 0.5,
+                'score' => $score,
+                'estimated_time' => $question['estimated_time'] ?? 300,
+                'kp' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
+                'kp_code' => $question['kp_code'] ?? $question['kp'] ?? $question['knowledge_point'] ?? ($kpCodes[0] ?? ''),
+            ];
+        }
+        return array_values(array_filter($normalized, fn ($q) => ! empty($q['id'])));
+    }
+
+    private function sortQuestionsByRequestedIds(array $questions, array $requestedIds): array
+    {
+        if (empty($requestedIds)) {
+            return $questions;
+        }
+        $order = array_flip($requestedIds);
+        usort($questions, function ($a, $b) use ($order) {
+            $aPos = $order[(string) ($a['id'] ?? '')] ?? PHP_INT_MAX;
+            $bPos = $order[(string) ($b['id'] ?? '')] ?? PHP_INT_MAX;
+            return $aPos <=> $bPos;
+        });
+        return $questions;
+    }
+
+    private function guessType(array $question): string
+    {
+        if (! empty($question['options']) && is_array($question['options'])) {
+            return '选择题';
+        }
+        $content = $question['stem'] ?? $question['content'] ?? '';
+        if (is_string($content) && (strpos($content, '____') !== false || strpos($content, '()') !== false)) {
+            return '填空题';
+        }
+        return '解答题';
+    }
+
+    private function defaultScore(string $type): int
+    {
+        return match ($type) {
+            '选择题' => 5,
+            '填空题' => 5,
+            '解答题' => 10,
+            default => 5,
+        };
+    }
+
+    private function sortQuestionsWithinTypeByDifficulty(array $questions): array
+    {
+        $grouped = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($questions as $question) {
+            $type = $this->normalizeQuestionType((string) ($question['question_type'] ?? 'answer'));
+            $grouped[$type][] = $question;
+        }
+        $sortFn = function (array $a, array $b): int {
+            $ad = (float) ($a['difficulty'] ?? 0.5);
+            $bd = (float) ($b['difficulty'] ?? 0.5);
+            if ($ad !== $bd) {
+                return $ad <=> $bd;
+            }
+            return ((int) ($a['id'] ?? $a['question_id'] ?? 0)) <=> ((int) ($b['id'] ?? $b['question_id'] ?? 0));
+        };
+        usort($grouped['choice'], $sortFn);
+        usort($grouped['fill'], $sortFn);
+        usort($grouped['answer'], $sortFn);
+        $sorted = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']);
+        foreach ($sorted as $idx => &$question) {
+            $question['question_number'] = $idx + 1;
+        }
+        unset($question);
+        return $sorted;
+    }
+
+    private function normalizeQuestionType(string $type): string
+    {
+        $type = strtolower(trim($type));
+        if (in_array($type, ['choice', 'single_choice', 'multiple_choice', '选择题', '单选', '多选'], true)) {
+            return 'choice';
+        }
+        if (in_array($type, ['fill', 'fill_in_the_blank', 'blank', '填空题', '填空'], true)) {
+            return 'fill';
+        }
+        return 'answer';
+    }
+
+    private function adjustQuestionScores(array $questions, float $targetTotalScore = 100.0): array
+    {
+        if (empty($questions)) {
+            return $questions;
+        }
+
+        // 第一步:按题型排序
+        $sortedQuestions = [];
+        $choiceQuestions = [];
+        $fillQuestions = [];
+        $answerQuestions = [];
+
+        foreach ($questions as $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            if ($type === 'choice') {
+                $choiceQuestions[] = $question;
+            } elseif ($type === 'fill') {
+                $fillQuestions[] = $question;
+            } else {
+                $answerQuestions[] = $question;
+            }
+        }
+
+        $sortedQuestions = array_merge($choiceQuestions, $fillQuestions, $answerQuestions);
+
+        Log::debug('adjustQuestionScores 开始', [
+            'choice_count' => count($choiceQuestions),
+            'fill_count' => count($fillQuestions),
+            'answer_count' => count($answerQuestions),
+        ]);
+
+        foreach ($sortedQuestions as $idx => &$question) {
+            $question['question_number'] = $idx + 1;
+        }
+        unset($question);
+
+        $typeCounts = [
+            'choice' => count($choiceQuestions),
+            'fill' => count($fillQuestions),
+            'answer' => count($answerQuestions),
+        ];
+
+        $typeIndexes = ['choice' => [], 'fill' => [], 'answer' => []];
+        foreach ($sortedQuestions as $index => $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            $typeIndexes[$type][] = $index;
+        }
+
+        $questionScores = [];
+        $totalQuestions = $typeCounts['choice'] + $typeCounts['fill'] + $typeCounts['answer'];
+        $globalBaseScore = floor($targetTotalScore / $totalQuestions);
+        $globalBaseScore = max(1, $globalBaseScore);
+
+        $typeOrder = [];
+        foreach ($sortedQuestions as $question) {
+            $type = $this->normalizeQuestionType($question['question_type'] ?? 'answer');
+            if (! in_array($type, $typeOrder)) {
+                $typeOrder[] = $type;
+            }
+        }
+
+        $remainingBudget = $targetTotalScore;
+        foreach ($typeOrder as $typeIndex => $type) {
+            $count = $typeCounts[$type];
+            if ($count === 0) {
+                continue;
+            }
+
+            if ($typeIndex === 0) {
+                $thisBase = $globalBaseScore;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = max(1, $questionScores[$idx] - 1);
+                }
+                $allocated = 0;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $allocated += $questionScores[$idx];
+                }
+                $remainingBudget -= $allocated;
+            } elseif ($typeIndex === count($typeOrder) - 1) {
+                $thisBase = floor($remainingBudget / $count);
+                $thisBase = max(1, $thisBase);
+
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+
+                $total = $thisBase * $count;
+                $remainder = $remainingBudget - $total;
+                if ($remainder > 0) {
+                    $answerIndexes = array_values($typeIndexes[$type]);
+                    $startIdx = max(0, count($answerIndexes) - $remainder);
+                    for ($i = $startIdx; $i < count($answerIndexes); $i++) {
+                        $questionScores[$answerIndexes[$i]] += 1;
+                    }
+                }
+            } else {
+                $thisBase = $globalBaseScore;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $questionScores[$idx] = $thisBase;
+                }
+                $allocated = 0;
+                foreach ($typeIndexes[$type] as $idx) {
+                    $allocated += $questionScores[$idx];
+                }
+                $remainingBudget -= $allocated;
+            }
+        }
+
+        if (count($typeOrder) > 1) {
+            $lastType = end($typeOrder);
+            $otherTypes = array_slice($typeOrder, 0, -1);
+            $maxOtherScore = 0;
+            foreach ($otherTypes as $type) {
+                foreach ($typeIndexes[$type] as $idx) {
+                    $maxOtherScore = max($maxOtherScore, $questionScores[$idx]);
+                }
+            }
+
+            $minLastScore = PHP_INT_MAX;
+            foreach ($typeIndexes[$lastType] as $idx) {
+                $minLastScore = min($minLastScore, $questionScores[$idx]);
+            }
+
+            if ($minLastScore <= $maxOtherScore) {
+                $diff = $maxOtherScore - $minLastScore + 1;
+                $reductionPerQuestion = min($diff, 2);
+                foreach ($otherTypes as $type) {
+                    foreach ($typeIndexes[$type] as $idx) {
+                        $questionScores[$idx] = max(1, $questionScores[$idx] - $reductionPerQuestion);
+                    }
+                }
+
+                $reallocated = $targetTotalScore;
+                foreach ($typeIndexes[$lastType] as $idx) {
+                    $reallocated -= $questionScores[$idx];
+                }
+                foreach ($otherTypes as $type) {
+                    foreach ($typeIndexes[$type] as $idx) {
+                        $reallocated -= $questionScores[$idx];
+                    }
+                }
+
+                if ($reallocated > 0) {
+                    $newBase = floor($reallocated / $typeCounts[$lastType]);
+                    foreach ($typeIndexes[$lastType] as $idx) {
+                        $questionScores[$idx] = $newBase;
+                    }
+
+                    $total = $newBase * $typeCounts[$lastType];
+                    $remainder = $reallocated - $total;
+                    if ($remainder > 0) {
+                        $lastIndexes = array_values($typeIndexes[$lastType]);
+                        $startIdx = max(0, count($lastIndexes) - $remainder);
+                        for ($i = $startIdx; $i < count($lastIndexes); $i++) {
+                            $questionScores[$lastIndexes[$i]] += 1;
+                        }
+                    }
+                }
+            }
+        }
+
+        $adjustedQuestions = [];
+        foreach ($sortedQuestions as $index => $question) {
+            $adjustedQuestions[$index] = $question;
+            $adjustedQuestions[$index]['score'] = $questionScores[$index] ?? 5;
+        }
+
+        $total = array_sum(array_column($adjustedQuestions, 'score'));
+        $diff = (int) $targetTotalScore - (int) $total;
+        if ($diff !== 0 && ! empty($adjustedQuestions)) {
+            $count = count($adjustedQuestions);
+            $i = $count - 1;
+            while ($diff !== 0) {
+                $score = $adjustedQuestions[$i]['score'];
+                if ($diff > 0) {
+                    $adjustedQuestions[$i]['score'] = $score + 1;
+                    $diff--;
+                } else {
+                    if ($score > 1) {
+                        $adjustedQuestions[$i]['score'] = $score - 1;
+                        $diff++;
+                    }
+                }
+
+                $i--;
+                if ($i < 0) {
+                    $i = $count - 1;
+                    if ($diff < 0) {
+                        $minScore = min(array_column($adjustedQuestions, 'score'));
+                        if ($minScore <= 1) {
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        return $adjustedQuestions;
+    }
+}
+

+ 66 - 0
app/Jobs/GenerateExamPdfJob.php

@@ -44,11 +44,32 @@ class GenerateExamPdfJob implements ShouldQueue
         PaperPayloadService $paperPayloadService,
         TaskManager $taskManager
     ): void {
+        $jobStartedAt = microtime(true);
+        $taskCreatedAt = null;
+        $queueWaitMs = null;
+
+        $taskSnapshot = $taskManager->getTaskStatus($this->taskId);
+        if (is_array($taskSnapshot) && !empty($taskSnapshot['created_at'])) {
+            try {
+                $taskCreatedAt = \Illuminate\Support\Carbon::parse($taskSnapshot['created_at']);
+                $queueWaitMs = $taskCreatedAt
+                    ? now()->diffInMilliseconds($taskCreatedAt, false) * -1
+                    : null;
+            } catch (\Throwable $e) {
+                $taskCreatedAt = null;
+                $queueWaitMs = null;
+            }
+        }
+
         try {
             Log::info('开始处理PDF生成队列任务', [
                 'task_id' => $this->taskId,
                 'paper_id' => $this->paperId,
                 'attempt' => $this->attempts(),
+                'queue_wait_ms' => $queueWaitMs,
+            ]);
+            $taskManager->updateTaskStatus($this->taskId, [
+                'queue_wait_ms' => $queueWaitMs,
             ]);
 
             // 【修复】首先检查试卷是否存在
@@ -136,23 +157,68 @@ class GenerateExamPdfJob implements ShouldQueue
                 ],
             ]);
 
+            $beforeCallbackAt = microtime(true);
+            $queueProcessingMs = (int) round((microtime(true) - $jobStartedAt) * 1000);
+            $taskTotalMs = null;
+            if ($taskCreatedAt) {
+                $taskTotalMs = $taskCreatedAt->diffInMilliseconds(now());
+            }
+
             Log::info('PDF生成队列任务完成(终极优化:直接合并HTML生成一份完整PDF)', [
                 'task_id' => $this->taskId,
                 'paper_id' => $this->paperId,
                 'all_pdf_url' => $unifiedPdfUrl,
                 'question_count' => $paperModel->questions->count(),
                 'method' => 'generateUnifiedPdf (direct merge, fastest)',
+                'queue_processing_ms' => $queueProcessingMs,
+                'task_total_ms_before_callback' => $taskTotalMs,
+            ]);
+            $taskManager->updateTaskStatus($this->taskId, [
+                'queue_processing_ms' => $queueProcessingMs,
+                'task_total_ms_before_callback' => $taskTotalMs,
             ]);
 
             // 发送回调通知(在合并PDF完成后)
             $taskManager->sendCallback($this->taskId);
 
+            $callbackMs = (int) round((microtime(true) - $beforeCallbackAt) * 1000);
+            $taskTotalAfterCallbackMs = null;
+            if ($taskCreatedAt) {
+                $taskTotalAfterCallbackMs = $taskCreatedAt->diffInMilliseconds(now());
+            }
+            Log::info('PDF生成队列任务回调完成', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'callback_elapsed_ms' => $callbackMs,
+                'task_total_ms_after_callback' => $taskTotalAfterCallbackMs,
+            ]);
+            $taskManager->updateTaskStatus($this->taskId, [
+                'callback_elapsed_ms' => $callbackMs,
+                'task_total_ms_after_callback' => $taskTotalAfterCallbackMs,
+            ]);
+
+            $taskSummary = $taskManager->getTaskStatus($this->taskId);
+            Log::info('EXAM_TASK_TIMELINE_SUMMARY', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'request_trace_id' => $taskSummary['request_trace_id'] ?? null,
+                'sync_elapsed_sec_total' => isset($taskSummary['sync_elapsed_ms_total'])
+                    ? round(((float) $taskSummary['sync_elapsed_ms_total']) / 1000, 3)
+                    : null,
+                'queue_wait_sec' => round(((float) ($taskSummary['queue_wait_ms'] ?? $queueWaitMs ?? 0)) / 1000, 3),
+                'queue_processing_sec' => round(((float) ($taskSummary['queue_processing_ms'] ?? $queueProcessingMs ?? 0)) / 1000, 3),
+                'callback_sec' => round(((float) ($taskSummary['callback_elapsed_ms'] ?? $callbackMs ?? 0)) / 1000, 3),
+                'task_total_sec_after_callback' => round(((float) ($taskSummary['task_total_ms_after_callback'] ?? $taskTotalAfterCallbackMs ?? 0)) / 1000, 3),
+                'status' => $taskSummary['status'] ?? null,
+            ]);
+
         } catch (\Exception $e) {
             Log::error('PDF生成队列任务失败', [
                 'task_id' => $this->taskId,
                 'paper_id' => $this->paperId,
                 'error' => $e->getMessage(),
                 'trace' => $e->getTraceAsString(),
+                'queue_elapsed_ms_before_error' => (int) round((microtime(true) - $jobStartedAt) * 1000),
             ]);
 
             // 如果是第一次失败且试卷可能还在创建中,等待后重试

+ 1 - 1
app/Services/ExamPdfExportService.php

@@ -670,7 +670,7 @@ class ExamPdfExportService
                 'header_title' => $examCode,
                 'exam_pdf_title' => '试卷_'.$examCode,
                 'grading_pdf_title' => '判卷_'.$examCode,
-                'knowledge_pdf_title' => '知识点讲解_'.$examCode,
+                'knowledge_pdf_title' => '知识点梳理_'.$examCode,
             ];
 
             $html = view($viewName, [

+ 13 - 0
app/Services/PaperIdGenerator.php

@@ -70,6 +70,11 @@ class PaperIdGenerator
             $id[0] = '1';
         }
 
+        // 防御式校验:必须是15位且首位非0
+        if (! self::validate($id)) {
+            throw new \RuntimeException('生成的 paper 数字ID不合法');
+        }
+
         return $id;
     }
 
@@ -84,6 +89,14 @@ class PaperIdGenerator
         return preg_match('/^[1-9]\d{14}$/', $id) === 1;
     }
 
+    /**
+     * 校验完整 paper_id(paper_ + 15位数字)
+     */
+    public static function validatePaperId(string $paperId): bool
+    {
+        return preg_match('/^paper_[1-9]\d{14}$/', $paperId) === 1;
+    }
+
     /**
      * 从ID提取时间戳
      *

+ 11 - 1
app/Services/PaperPayloadService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\Paper;
 use App\Models\PaperQuestion;
+use Illuminate\Support\Facades\Log;
 
 class PaperPayloadService
 {
@@ -115,7 +116,16 @@ class PaperPayloadService
     {
         // 提取15位数字ID作为统一学案编号
         preg_match('/paper_(\d{15})/', $paperId, $matches);
-        $paperIdNum = $matches[1] ?? preg_replace('/[^0-9]/', '', $paperId);
+        $paperIdNum = $matches[1] ?? '';
+
+        if ($paperIdNum === '') {
+            $digits = preg_replace('/[^0-9]/', '', $paperId);
+            $paperIdNum = substr(str_pad($digits, 15, '0', STR_PAD_LEFT), -15);
+            Log::warning('PaperPayloadService: paper_id 不符合标准格式,已执行15位归一化', [
+                'paper_id' => $paperId,
+                'normalized_paper_id_num' => $paperIdNum,
+            ]);
+        }
 
         return [
             'paper_id_num' => $paperIdNum,

+ 23 - 3
app/Services/QuestionBankService.php

@@ -556,9 +556,13 @@ class QuestionBankService
 
                 $assembleType = (int) $examData['assembleType'];
 
-                // 使用行业标准的Snowflake ID生成12位唯一数字ID
-                $uniqueId = PaperIdGenerator::generate();
-                $paperId = 'paper_'.$uniqueId;
+                // 允许外部预分配 paper_id,以保证接口可以同步返回稳定 ID
+                $paperId = (string) ($examData['paper_id'] ?? '');
+                if ($paperId === '') {
+                    $paperId = $this->generatePaperId();
+                } elseif (!PaperIdGenerator::validatePaperId($paperId)) {
+                    throw new \InvalidArgumentException("paper_id 格式不合法: {$paperId}");
+                }
                 $studentId = $examData['student_id'] ?? '';
                 $studentName = '________';
                 if (! empty($studentId)) {
@@ -776,6 +780,22 @@ class QuestionBankService
         return null;
     }
 
+    /**
+     * 生成试卷ID(paper_ + 15位数字)
+     */
+    public function generatePaperId(): string
+    {
+        // 防御式重试,确保始终产出合法 15 位数字部分
+        for ($i = 0; $i < 3; $i++) {
+            $uniqueId = PaperIdGenerator::generate();
+            if (PaperIdGenerator::validate($uniqueId)) {
+                return 'paper_'.$uniqueId;
+            }
+        }
+
+        throw new \RuntimeException('无法生成合法的 paper_id');
+    }
+
     private function useLocal(): bool
     {
         return true;

+ 2 - 2
app/Services/TaskManager.php

@@ -44,8 +44,8 @@ class TaskManager
             'created_at' => now()->toISOString(),
             'updated_at' => now()->toISOString(),
             'callback_url' => $data['callback_url'] ?? null,
-            // 【优化】根据任务类型设置不同的超时时间
-            'expires_at' => now()->addSeconds($type === self::TASK_TYPE_EXAM ? 45 : 30)->toISOString(),
+            // 组卷+PDF都异步后,exam 任务时窗适当放宽,避免回调被误判超时
+            'expires_at' => now()->addSeconds($type === self::TASK_TYPE_EXAM ? 240 : 30)->toISOString(),
         ];
 
         $this->saveTask($taskId, $taskData);

+ 11 - 1
app/Support/PaperNaming.php

@@ -2,6 +2,7 @@
 
 namespace App\Support;
 
+use App\Services\PaperIdGenerator;
 use InvalidArgumentException;
 
 class PaperNaming
@@ -21,7 +22,16 @@ class PaperNaming
     public static function extractExamCode(string $paperId): string
     {
         preg_match('/paper_(\d{15})/', $paperId, $matches);
-        $examCode = $matches[1] ?? preg_replace('/[^0-9]/', '', $paperId);
+        $examCode = $matches[1] ?? '';
+
+        if ($examCode === '') {
+            $digits = preg_replace('/[^0-9]/', '', $paperId);
+            $examCode = substr(str_pad($digits, 15, '0', STR_PAD_LEFT), -15);
+        }
+
+        if (!PaperIdGenerator::validate($examCode)) {
+            throw new InvalidArgumentException("无效的考试编码: {$paperId}");
+        }
 
         return $examCode !== '' ? $examCode : $paperId;
     }

+ 5 - 1
resources/views/exam-analysis/pdf-report.blade.php

@@ -2,7 +2,11 @@
     // 提取15位paper_id数字部分作为学案编号
     $rawPaperId = $paper['id'] ?? $paper['paper_id'] ?? 'unknown';
     preg_match('/paper_(\d{15})/', $rawPaperId, $matches);
-    $reportCode = $matches[1] ?? preg_replace('/[^0-9]/', '', $rawPaperId);
+    $reportCode = $matches[1] ?? '';
+    if ($reportCode === '') {
+        $digits = preg_replace('/[^0-9]/', '', $rawPaperId);
+        $reportCode = substr(str_pad($digits, 15, '0', STR_PAD_LEFT), -15);
+    }
     $averageMastery = isset($mastery['average']) ? number_format($mastery['average'] * 100, 1) . '%' : '无数据';
 
     // 【修复】从insights中获取AI分析结果(而不是从analysis_data)

+ 3 - 3
resources/views/pdf/exam-knowledge-explanation.blade.php

@@ -1,16 +1,16 @@
-{{-- 知识点讲解模板 --}}
+{{-- 知识点梳理模板 --}}
 <!DOCTYPE html>
 <html lang="zh-CN">
 <head>
     <meta charset="UTF-8">
-    <title>知识点讲解</title>
+    <title>知识点梳理</title>
     <link rel="stylesheet" href="/css/katex/katex.min.css">
     @include('pdf.partials.kp-explain-styles')
 </head>
 <body>
     <div class="page">
         <div class="kp-explain-header">
-            <div class="kp-explain-title">知识点讲解</div>
+            <div class="kp-explain-title">知识点梳理</div>
             <div class="kp-explain-subtitle">本章节用于梳理本卷涉及的知识点,帮助学生在做题前完成预习/复盘。</div>
         </div>