Procházet zdrojové kódy

Merge branch 'main' into feat/question-bank-qc-from-main

# Conflicts:
#	app/Jobs/GenerateAnalysisPdfJob.php
yemeishu před 1 týdnem
rodič
revize
4f6f46d802

+ 367 - 0
app/Console/Commands/BackfillExamAnalysisResultsCommand.php

@@ -0,0 +1,367 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\GenerateAnalysisPdfJob;
+use App\Jobs\ProcessExamAnswerAnalysisJob;
+use App\Services\TaskManager;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class BackfillExamAnalysisResultsCommand extends Command
+{
+    private const ACTION_SKIP = 'skip';
+    private const ACTION_ANALYSIS = 'analysis';
+    private const ACTION_PDF_ONLY = 'pdf_only';
+    private const ACTION_SYNC_PDF_URL = 'sync_pdf_url';
+    private const ACTION_NEEDS_REVIEW = 'needs_review';
+
+    protected $signature = 'exam:backfill-analysis-results
+                            {--since= : papers.completed_at >= 该时间(默认今天 00:00:00)}
+                            {--paper= : 仅处理指定 paper_id}
+                            {--student= : 仅处理指定 student_id}
+                            {--limit=100 : 最多处理多少条待回填目标}
+                            {--force-analysis : 允许重算已有但无效的分析数据(默认不覆盖)}
+                            {--stats-only : 只统计缺失情况,不投递队列、不修改数据、不受 limit 限制}
+                            {--dry-run : 只打印待回填列表,不执行回填}';
+
+    protected $description = '回填 exam_analysis_results 缺失分析数据或缺失 PDF URL 的试卷';
+
+    public function handle(TaskManager $taskManager): int
+    {
+        $sinceRaw = (string) ($this->option('since') ?: Carbon::today()->toDateTimeString());
+        try {
+            $since = Carbon::parse($sinceRaw);
+        } catch (Throwable) {
+            $this->error('无效的 --since 时间:'.$sinceRaw);
+
+            return self::FAILURE;
+        }
+
+        $statsOnly = (bool) $this->option('stats-only');
+        $limit = max(1, (int) $this->option('limit'));
+        $paperFilter = $this->option('paper');
+        $studentFilter = $this->option('student');
+        $forceAnalysis = (bool) $this->option('force-analysis');
+        $isDryRun = (bool) $this->option('dry-run');
+
+        $paperQuery = DB::connection('mysql')
+            ->table('papers')
+            ->whereNotNull('completed_at')
+            ->where('completed_at', '>=', $since->toDateTimeString())
+            ->whereNotNull('student_id')
+            ->where('student_id', '!=', '')
+            ->when($paperFilter, fn ($q) => $q->where('paper_id', $paperFilter))
+            ->when($studentFilter, fn ($q) => $q->where('student_id', $studentFilter))
+            ->orderBy('completed_at');
+
+        $paperRows = $paperQuery->get(['paper_id', 'student_id', 'completed_at']);
+
+        $targets = [];
+        $counts = [
+            self::ACTION_SKIP => 0,
+            self::ACTION_ANALYSIS => 0,
+            self::ACTION_PDF_ONLY => 0,
+            self::ACTION_SYNC_PDF_URL => 0,
+            self::ACTION_NEEDS_REVIEW => 0,
+        ];
+
+        foreach ($paperRows as $paper) {
+            $latest = DB::connection('mysql')
+                ->table('exam_analysis_results')
+                ->where('paper_id', $paper->paper_id)
+                ->where('student_id', $paper->student_id)
+                ->orderByDesc('created_at')
+                ->first(['id', 'analysis_data', 'analysis_pdf_url']);
+
+            $studentReportPdfUrl = DB::connection('mysql')
+                ->table('student_reports')
+                ->where('paper_id', $paper->paper_id)
+                ->where('student_id', $paper->student_id)
+                ->where('report_type', 'exam_analysis')
+                ->whereNotNull('pdf_url')
+                ->where('pdf_url', '!=', '')
+                ->orderByDesc('updated_at')
+                ->value('pdf_url');
+
+            $action = $this->resolveBackfillAction(
+                $latest->analysis_data ?? null,
+                $latest->id ?? null,
+                $latest->analysis_pdf_url ?? null,
+                $studentReportPdfUrl,
+                $forceAnalysis
+            );
+
+            $counts[$action]++;
+            if ($action === self::ACTION_SKIP || $action === self::ACTION_NEEDS_REVIEW) {
+                if ($action === self::ACTION_NEEDS_REVIEW && ! $statsOnly) {
+                    $this->warn(sprintf(
+                        '跳过需人工确认的已有分析记录:paper_id=%s student_id=%s analysis_id=%s(如确认要重算,单张使用 --force-analysis --paper=%s)',
+                        $paper->paper_id,
+                        $paper->student_id,
+                        $latest->id ?? 'null',
+                        $paper->paper_id
+                    ));
+                }
+                continue;
+            }
+
+            if ($statsOnly || count($targets) < $limit) {
+                $targets[] = (object) [
+                    'paper_id' => (string) $paper->paper_id,
+                    'student_id' => (string) $paper->student_id,
+                    'completed_at' => (string) $paper->completed_at,
+                    'action' => $action,
+                    'analysis_id' => $latest->id ?? null,
+                    'student_report_pdf_url' => $studentReportPdfUrl,
+                ];
+            }
+        }
+
+        $pendingCount = $counts[self::ACTION_ANALYSIS]
+            + $counts[self::ACTION_PDF_ONLY]
+            + $counts[self::ACTION_SYNC_PDF_URL]
+            + $counts[self::ACTION_NEEDS_REVIEW];
+
+        $this->info(sprintf(
+            '扫描完成:试卷 %d 条,待处理 %d 条,本次目标 %d 条;需补分析 %d 条,仅补PDF %d 条,同步PDF URL %d 条,需人工确认 %d 条,已完成跳过 %d 条(since=%s%s)',
+            $paperRows->count(),
+            $pendingCount,
+            count($targets),
+            $counts[self::ACTION_ANALYSIS],
+            $counts[self::ACTION_PDF_ONLY],
+            $counts[self::ACTION_SYNC_PDF_URL],
+            $counts[self::ACTION_NEEDS_REVIEW],
+            $counts[self::ACTION_SKIP],
+            $since->toDateTimeString(),
+            $statsOnly ? ', stats-only' : ', limit='.$limit
+        ));
+
+        if ($statsOnly) {
+            return self::SUCCESS;
+        }
+
+        if ($isDryRun) {
+            foreach ($targets as $item) {
+                $this->line(sprintf(
+                    '[dry-run] action=%s paper_id=%s student_id=%s completed_at=%s analysis_id=%s',
+                    $item->action,
+                    $item->paper_id,
+                    $item->student_id,
+                    $item->completed_at,
+                    $item->analysis_id ?? 'null'
+                ));
+            }
+
+            return self::SUCCESS;
+        }
+
+        $ok = 0;
+        $failed = 0;
+        foreach ($targets as $item) {
+            try {
+                if ($item->action === self::ACTION_ANALYSIS) {
+                    $questions = $this->buildQuestionsFromPaperQuestions($item->paper_id);
+                    if ($questions === []) {
+                        $this->warn("回填失败:{$item->paper_id} 无可用 paper_questions");
+                        $failed++;
+                        continue;
+                    }
+
+                    $examData = [
+                        'paper_id' => $item->paper_id,
+                        'student_id' => $item->student_id,
+                        'questions' => $questions,
+                        'force_recalculate' => $forceAnalysis,
+                    ];
+
+                    $taskId = $taskManager->createTask(TaskManager::TASK_TYPE_ANALYSIS, [
+                        'type' => 'exam_answer_analysis_backfill',
+                        'paper_id' => $item->paper_id,
+                        'student_id' => $item->student_id,
+                        'question_count' => count($questions),
+                    ]);
+
+                    dispatch(new ProcessExamAnswerAnalysisJob($taskId, $examData));
+                    $this->info("已投递补分析任务:{$item->paper_id} / {$item->student_id} task_id={$taskId}");
+                    $ok++;
+                    continue;
+                }
+
+                if ($item->action === self::ACTION_SYNC_PDF_URL) {
+                    DB::connection('mysql')
+                        ->table('exam_analysis_results')
+                        ->where('id', $item->analysis_id)
+                        ->update([
+                            'analysis_pdf_url' => $item->student_report_pdf_url,
+                            'updated_at' => now(),
+                        ]);
+
+                    $this->info("已同步PDF URL:{$item->paper_id} / {$item->student_id}");
+                    $ok++;
+                    continue;
+                }
+
+                dispatch(new GenerateAnalysisPdfJob(
+                    $item->paper_id,
+                    $item->student_id,
+                    null
+                ));
+                $this->info("已投递补PDF任务:{$item->paper_id} / {$item->student_id}");
+                $ok++;
+            } catch (Throwable $e) {
+                $this->error("回填失败:{$item->paper_id} / {$item->student_id} -> ".$e->getMessage());
+                Log::error('BackfillExamAnalysisResultsCommand: 回填失败', [
+                    'paper_id' => $item->paper_id,
+                    'student_id' => $item->student_id,
+                    'action' => $item->action,
+                    'error' => $e->getMessage(),
+                    'exception' => get_class($e),
+                ]);
+                $failed++;
+            }
+        }
+
+        $this->info("回填投递完成:成功 {$ok},失败 {$failed}");
+
+        return $failed > 0 ? self::FAILURE : self::SUCCESS;
+    }
+
+    private function resolveBackfillAction(
+        mixed $analysisDataRaw,
+        mixed $analysisId,
+        mixed $analysisPdfUrl,
+        mixed $studentReportPdfUrl,
+        bool $forceAnalysis
+    ): string
+    {
+        $hasAnalysisRow = $analysisId !== null;
+        $hasValidAnalysis = $this->hasValidAnalysisData($analysisDataRaw, $analysisId);
+        $hasAnalysisPdf = $this->hasNonEmptyString($analysisPdfUrl);
+        $hasStudentReportPdf = $this->hasNonEmptyString($studentReportPdfUrl);
+
+        if (! $hasValidAnalysis) {
+            return (! $hasAnalysisRow || $forceAnalysis) ? self::ACTION_ANALYSIS : self::ACTION_NEEDS_REVIEW;
+        }
+
+        if ($hasAnalysisPdf) {
+            return self::ACTION_SKIP;
+        }
+
+        return $hasStudentReportPdf ? self::ACTION_SYNC_PDF_URL : self::ACTION_PDF_ONLY;
+    }
+
+    private function hasValidAnalysisData(mixed $analysisDataRaw, mixed $analysisId): bool
+    {
+        if ($analysisId === null || $analysisDataRaw === null) {
+            return false;
+        }
+
+        $raw = is_string($analysisDataRaw) ? trim($analysisDataRaw) : '';
+        if ($raw === '') {
+            return false;
+        }
+
+        $decoded = json_decode($raw, true);
+        if (! is_array($decoded)) {
+            return false;
+        }
+
+        $hasQuestionAnalysis = isset($decoded['question_analysis']) && is_array($decoded['question_analysis']) && $decoded['question_analysis'] !== [];
+        $hasOverallSummary = isset($decoded['overall_summary']) && is_array($decoded['overall_summary']) && $decoded['overall_summary'] !== [];
+
+        return $hasQuestionAnalysis || $hasOverallSummary;
+    }
+
+    private function hasNonEmptyString(mixed $value): bool
+    {
+        return is_string($value) && trim($value) !== '';
+    }
+
+    /**
+     * @return list<array<string, mixed>>
+     */
+    private function buildQuestionsFromPaperQuestions(string $paperId): array
+    {
+        $rows = DB::connection('mysql')
+            ->table('paper_questions')
+            ->where('paper_id', $paperId)
+            ->orderBy('question_number')
+            ->get([
+                'question_id',
+                'question_bank_id',
+                'score',
+                'score_obtained',
+                'student_answer',
+                'is_correct',
+                'teacher_comment',
+            ]);
+
+        $questions = [];
+        foreach ($rows as $row) {
+            $qid = $row->question_bank_id ?? $row->question_id ?? null;
+            if ($qid === null || $qid === '') {
+                continue;
+            }
+
+            $item = [
+                'question_id' => (string) $qid,
+                'score' => isset($row->score) ? (float) $row->score : 2.0,
+                'is_correct' => $this->normalizeIsCorrect($row->is_correct),
+                'student_answer' => $row->student_answer,
+                'teacher_comment' => $row->teacher_comment,
+            ];
+
+            if ($row->score_obtained !== null) {
+                $item['score_obtained'] = (float) $row->score_obtained;
+            }
+
+            $questions[] = $item;
+        }
+
+        return $questions;
+    }
+
+    /**
+     * @return list<int>
+     */
+    private function normalizeIsCorrect(mixed $value): array
+    {
+        if (is_array($value)) {
+            return $this->flattenToBinary($value);
+        }
+
+        if (is_string($value)) {
+            $trimmed = trim($value);
+            if ($trimmed !== '' && ($trimmed[0] === '[' || $trimmed[0] === '{')) {
+                $decoded = json_decode($trimmed, true);
+                if (is_array($decoded)) {
+                    return $this->flattenToBinary($decoded);
+                }
+            }
+        }
+
+        if ($value === null || $value === '') {
+            return [0];
+        }
+
+        return [((bool) $value) ? 1 : 0];
+    }
+
+    /**
+     * @param  array<int, mixed>  $values
+     * @return list<int>
+     */
+    private function flattenToBinary(array $values): array
+    {
+        $out = [];
+        foreach ($values as $v) {
+            $out[] = ((bool) $v) ? 1 : 0;
+        }
+
+        return $out === [] ? [0] : $out;
+    }
+}

+ 20 - 6
app/Http/Controllers/Api/ExamAnswerAnalysisController.php

@@ -3,7 +3,9 @@
 namespace App\Http\Controllers\Api;
 
 use App\Http\Controllers\Controller;
+use App\Jobs\ProcessExamAnswerAnalysisJob;
 use App\Services\ExamAnswerAnalysisService;
+use App\Services\TaskManager;
 use Illuminate\Http\Request;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\Log;
@@ -16,7 +18,8 @@ use Illuminate\Support\Facades\Validator;
 class ExamAnswerAnalysisController extends Controller
 {
     public function __construct(
-        private readonly ExamAnswerAnalysisService $analysisService
+        private readonly ExamAnswerAnalysisService $analysisService,
+        private readonly TaskManager $taskManager
     ) {}
 
     /**
@@ -78,14 +81,25 @@ class ExamAnswerAnalysisController extends Controller
                 }
             }
 
-            // 调用分析服务
-            $result = $this->analysisService->analyzeExamAnswers($examData);
+            $taskId = $this->taskManager->createTask(
+                TaskManager::TASK_TYPE_ANALYSIS,
+                array_merge($examData, ['type' => 'exam_answer_analysis'])
+            );
+
+            dispatch(new ProcessExamAnswerAnalysisJob($taskId, $examData));
 
             return response()->json([
                 'success' => true,
-                'data' => $result,
-                'message' => '分析完成'
-            ]);
+                'data' => [
+                    'task_id' => $taskId,
+                    'paper_id' => $examData['paper_id'],
+                    'student_id' => (string) $examData['student_id'],
+                    'status' => 'processing',
+                    'status_url' => route('api.tasks.status', ['taskId' => $taskId]),
+                    'created_at' => now()->toISOString(),
+                ],
+                'message' => '分析任务已提交,后台处理中'
+            ], 202);
 
         } catch (\Exception $e) {
             Log::error('考试答题分析失败', [

+ 68 - 11
app/Http/Controllers/ExamAnalysisPdfController.php

@@ -2,32 +2,89 @@
 
 namespace App\Http\Controllers;
 
-use App\Services\ExamPdfExportService;
+use App\Jobs\GenerateAnalysisPdfJob;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 
 class ExamAnalysisPdfController extends Controller
 {
-    public function show(Request $request, ExamPdfExportService $pdfExportService)
+    private const ENQUEUE_LOCK_TTL_SECONDS = 120;
+
+    public function show(Request $request)
     {
         $paperId = $request->query('paperId');
         $studentId = $request->query('studentId');
         $recordId = $request->query('recordId'); // 可选的OCR记录ID
 
         if (!$paperId || !$studentId) {
-            return response('paperId 和 studentId 不能为空', 400);
+            return response()->json([
+                'success' => false,
+                'message' => 'paperId 和 studentId 不能为空',
+            ], 400);
         }
 
-        $pdfUrl = $pdfExportService->generateAnalysisReportPdf($paperId, $studentId, $recordId);
-        if (!$pdfUrl) {
-            Log::error('ExamAnalysisPdfController: 学情报告生成失败', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'record_id' => $recordId,
+        // 已生成则直接返回链接
+        $existingPdfUrl = DB::connection('mysql')
+            ->table('exam_analysis_results')
+            ->where('paper_id', $paperId)
+            ->where('student_id', $studentId)
+            ->whereNotNull('analysis_pdf_url')
+            ->where('analysis_pdf_url', '!=', '')
+            ->orderByDesc('updated_at')
+            ->value('analysis_pdf_url');
+
+        if ($existingPdfUrl) {
+            return response()->json([
+                'success' => true,
+                'message' => '学情报告已生成',
+                'data' => [
+                    'paper_id' => $paperId,
+                    'student_id' => $studentId,
+                    'record_id' => $recordId,
+                    'status' => 'completed',
+                    'pdf_url' => $existingPdfUrl,
+                    'queued' => false,
+                ],
             ]);
-            return response('生成学情报告失败,请稍后重试', 500);
         }
 
-        return redirect($pdfUrl);
+        $lockKey = sprintf('analysis_pdf:enqueue:%s:%s', $paperId, $studentId);
+        $acquired = Cache::add($lockKey, now()->timestamp, now()->addSeconds(self::ENQUEUE_LOCK_TTL_SECONDS));
+        if (! $acquired) {
+            return response()->json([
+                'success' => true,
+                'message' => '学情报告正在生成中,请稍后重试',
+                'data' => [
+                    'paper_id' => $paperId,
+                    'student_id' => $studentId,
+                    'record_id' => $recordId,
+                    'status' => 'processing',
+                    'pdf_url' => null,
+                    'queued' => false,
+                ],
+            ], 202);
+        }
+
+        dispatch(new GenerateAnalysisPdfJob($paperId, $studentId, $recordId));
+        Log::info('ExamAnalysisPdfController: 学情报告生成任务已入队', [
+            'paper_id' => $paperId,
+            'student_id' => $studentId,
+            'record_id' => $recordId,
+        ]);
+
+        return response()->json([
+            'success' => true,
+            'message' => '学情报告任务已入队,正在后台生成',
+            'data' => [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+                'record_id' => $recordId,
+                'status' => 'processing',
+                'pdf_url' => null,
+                'queued' => true,
+            ],
+        ], 202);
     }
 }

+ 28 - 37
app/Jobs/GenerateAnalysisPdfJob.php

@@ -9,6 +9,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Support\Facades\Log;
+use Throwable;
 
 class GenerateAnalysisPdfJob implements ShouldQueue
 {
@@ -20,7 +21,7 @@ class GenerateAnalysisPdfJob implements ShouldQueue
 
     public ?string $recordId;
 
-    public int $maxAttempts = 3;
+    public int $tries = 3;
 
     /**
      * 学情报告 HTML + 数据聚合比「整卷合并 PDF」更重,默认 60s 队列超时容易在 worker 侧被提前杀掉,
@@ -28,6 +29,8 @@ class GenerateAnalysisPdfJob implements ShouldQueue
      */
     public int $timeout = 300;
 
+    public array $backoff = [5, 10, 20];
+
     public function __construct(string $paperId, string $studentId, ?string $recordId = null)
     {
         $this->paperId = $paperId;
@@ -36,6 +39,8 @@ class GenerateAnalysisPdfJob implements ShouldQueue
 
         // 指定使用 pdf 队列,由独立的 pdf-worker 容器处理
         $this->onQueue('pdf');
+        // 避免事务未提交时 worker 提前消费导致读到未提交数据
+        $this->afterCommit();
     }
 
     public function handle(ExamPdfExportService $pdfExportService): void
@@ -55,51 +60,37 @@ class GenerateAnalysisPdfJob implements ShouldQueue
                 $this->recordId
             );
 
-            if ($pdfUrl) {
-                Log::info('学情分析PDF生成成功', [
-                    'paper_id' => $this->paperId,
-                    'student_id' => $this->studentId,
-                    'record_id' => $this->recordId,
-                    'pdf_url' => $pdfUrl,
-                ]);
-            } else {
-                Log::error('学情分析PDF生成失败', [
-                    'paper_id' => $this->paperId,
-                    'student_id' => $this->studentId,
-                    'record_id' => $this->recordId,
-                ]);
-
-                // 如果失败且还有重试次数,则重试
-                if ($this->attempts() < $this->maxAttempts) {
-                    Log::info('将在5秒后重试PDF生成', [
-                        'paper_id' => $this->paperId,
-                        'student_id' => $this->studentId,
-                        'attempt' => $this->attempts(),
-                    ]);
-                    $this->release(5);
-                    return;
-                }
+            if (! $pdfUrl) {
+                throw new \RuntimeException('学情分析PDF生成失败:返回空URL');
             }
 
-        } catch (\Exception $e) {
+            Log::info('学情分析PDF生成成功', [
+                'paper_id' => $this->paperId,
+                'student_id' => $this->studentId,
+                'record_id' => $this->recordId,
+                'pdf_url' => $pdfUrl,
+            ]);
+
+        } catch (\Throwable $e) {
             Log::error('学情分析PDF生成队列任务失败', [
                 'paper_id' => $this->paperId,
                 'student_id' => $this->studentId,
                 'record_id' => $this->recordId,
                 'error' => $e->getMessage(),
                 'trace' => $e->getTraceAsString(),
+                'attempt' => $this->attempts(),
             ]);
-
-            // 如果是第一次失败且可能是临时错误,等待后重试
-            if ($this->attempts() < $this->maxAttempts) {
-                Log::info('检测到临时错误,将在10秒后重试', [
-                    'paper_id' => $this->paperId,
-                    'student_id' => $this->studentId,
-                    'attempt' => $this->attempts(),
-                ]);
-                $this->release(10);
-                return;
-            }
+            throw $e;
         }
     }
+
+    public function failed(Throwable $exception): void
+    {
+        Log::error('学情分析PDF生成队列任务最终失败', [
+            'paper_id' => $this->paperId,
+            'student_id' => $this->studentId,
+            'record_id' => $this->recordId,
+            'error' => $exception->getMessage(),
+        ]);
+    }
 }

+ 91 - 0
app/Jobs/ProcessAnalysisReportTaskJob.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\ExamAnalysisService;
+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 ProcessAnalysisReportTaskJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+    public int $timeout = 300;
+    public array $backoff = [5, 10, 20];
+
+    public function __construct(
+        public string $taskId,
+        public string $paperId,
+        public string $studentId,
+        public ?string $recordId = null
+    ) {
+        // 与 PDF 相关重流程统一走 pdf 队列
+        $this->onQueue('pdf');
+        // 避免事务未提交时 worker 提前消费导致读到旧数据
+        $this->afterCommit();
+    }
+
+    public function handle(ExamAnalysisService $examAnalysisService): void
+    {
+        try {
+            Log::info('ProcessAnalysisReportTaskJob: 开始处理学情报告任务', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'student_id' => $this->studentId,
+                'record_id' => $this->recordId,
+                'attempt' => $this->attempts(),
+            ]);
+
+            $examAnalysisService->processReportGenerationTask(
+                $this->taskId,
+                $this->paperId,
+                $this->studentId,
+                $this->recordId
+            );
+        } catch (Throwable $e) {
+            Log::error('ProcessAnalysisReportTaskJob: 处理失败,将交由队列重试/失败落库', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'student_id' => $this->studentId,
+                'record_id' => $this->recordId,
+                'attempt' => $this->attempts(),
+                'error' => $e->getMessage(),
+            ]);
+            throw $e;
+        }
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        try {
+            $taskManager = app(TaskManager::class);
+            $taskManager->markTaskFailed(
+                $this->taskId,
+                '学情报告生成失败:'.$exception->getMessage()
+            );
+            // 与成功路径保持一致:最终失败也发回调,避免调用方只等待回调而无结果。
+            $taskManager->sendCallback($this->taskId);
+        } catch (Throwable $innerException) {
+            Log::error('ProcessAnalysisReportTaskJob: failed回调更新任务失败', [
+                'task_id' => $this->taskId,
+                'error' => $innerException->getMessage(),
+            ]);
+        }
+
+        Log::error('ProcessAnalysisReportTaskJob: 队列任务最终失败', [
+            'task_id' => $this->taskId,
+            'paper_id' => $this->paperId,
+            'student_id' => $this->studentId,
+            'record_id' => $this->recordId,
+            'error' => $exception->getMessage(),
+        ]);
+    }
+}
+

+ 100 - 0
app/Jobs/ProcessExamAnswerAnalysisJob.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\ExamAnswerAnalysisService;
+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 ProcessExamAnswerAnalysisJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 2;
+    public int $timeout = 300;
+    public array $backoff = [5, 15];
+
+    /**
+     * @param  array<string,mixed>  $examData
+     */
+    public function __construct(
+        public string $taskId,
+        public array $examData
+    ) {
+        $this->onQueue('pdf');
+        $this->afterCommit();
+    }
+
+    public function handle(ExamAnswerAnalysisService $analysisService, TaskManager $taskManager): void
+    {
+        try {
+            $taskManager->updateTaskProgress($this->taskId, 10, '正在分析考试答题...');
+
+            Log::warning('ProcessExamAnswerAnalysisJob: 开始处理考试答题分析', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->examData['paper_id'] ?? null,
+                'student_id' => $this->examData['student_id'] ?? null,
+                'question_count' => count($this->examData['questions'] ?? []),
+                'attempt' => $this->attempts(),
+                'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
+            ]);
+
+            $result = $analysisService->analyzeExamAnswers($this->examData);
+
+            $taskManager->markTaskCompleted($this->taskId, [
+                'paper_id' => $this->examData['paper_id'] ?? null,
+                'student_id' => $this->examData['student_id'] ?? null,
+                'analysis_status' => 'completed',
+                'pdf_status' => 'queued',
+                'analyzed_knowledge_points' => count($result['knowledge_point_analysis'] ?? []),
+                'result_url' => isset($this->examData['student_id'], $this->examData['paper_id'])
+                    ? route('api.exam-answer-analysis.result', [
+                        'student_id' => $this->examData['student_id'],
+                        'paper_id' => $this->examData['paper_id'],
+                    ])
+                    : null,
+                'pdf_lookup_url' => isset($this->examData['paper_id'])
+                    ? route('api.exam-analysis.pdf', ['paper_id' => $this->examData['paper_id']])
+                    : null,
+            ]);
+            $taskManager->sendCallback($this->taskId);
+
+            Log::warning('ProcessExamAnswerAnalysisJob: 考试答题分析完成', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->examData['paper_id'] ?? null,
+                'student_id' => $this->examData['student_id'] ?? null,
+                'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
+            ]);
+        } catch (Throwable $e) {
+            $taskManager->markTaskFailed($this->taskId, $e->getMessage());
+            $taskManager->sendCallback($this->taskId);
+
+            Log::error('ProcessExamAnswerAnalysisJob: 考试答题分析失败', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->examData['paper_id'] ?? null,
+                'student_id' => $this->examData['student_id'] ?? null,
+                'error' => $e->getMessage(),
+                'exception' => get_class($e),
+                'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
+            ]);
+
+            throw $e;
+        }
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        Log::error('ProcessExamAnswerAnalysisJob: 队列任务最终失败', [
+            'task_id' => $this->taskId,
+            'paper_id' => $this->examData['paper_id'] ?? null,
+            'student_id' => $this->examData['student_id'] ?? null,
+            'error' => $exception->getMessage(),
+        ]);
+    }
+}

+ 93 - 0
app/Jobs/ProcessQuestionDifficultyCalibrationJob.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Services\Analytics\QuestionDifficultyCalibrationService;
+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 ProcessQuestionDifficultyCalibrationJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 1;
+    public int $timeout = 300;
+
+    /**
+     * @param  list<array<string,mixed>>  $questions
+     */
+    public function __construct(
+        public string $paperId,
+        public string $studentId,
+        public array $questions
+    ) {
+        $this->questions = $this->compactQuestions($questions);
+
+        // 放在 pdf 队列,且由调用方在 PDF Job 之后入队,保证报告优先生成。
+        $this->onQueue('pdf');
+        $this->afterCommit();
+    }
+
+    public function handle(QuestionDifficultyCalibrationService $calibrationService): void
+    {
+        Log::warning('QuestionDifficultyCalibrationJob: 开始异步在线难度校准', [
+            'paper_id' => $this->paperId,
+            'student_id' => $this->studentId,
+            'question_count' => count($this->questions),
+            'attempt' => $this->attempts(),
+            'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
+        ]);
+
+        $updatedQuestions = $calibrationService->updateOnlineFromPaper($this->paperId, $this->questions);
+
+        Log::warning('QuestionDifficultyCalibrationJob: 异步在线难度校准完成', [
+            'paper_id' => $this->paperId,
+            'student_id' => $this->studentId,
+            'updated_questions' => $updatedQuestions,
+            'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
+        ]);
+    }
+
+    public function failed(Throwable $exception): void
+    {
+        Log::error('QuestionDifficultyCalibrationJob: 异步在线难度校准最终失败', [
+            'paper_id' => $this->paperId,
+            'student_id' => $this->studentId,
+            'question_count' => count($this->questions),
+            'error' => $exception->getMessage(),
+            'exception' => get_class($exception),
+            'memory_peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
+        ]);
+    }
+
+    /**
+     * @param  list<array<string,mixed>>  $questions
+     * @return list<array<string,mixed>>
+     */
+    private function compactQuestions(array $questions): array
+    {
+        $compact = [];
+        foreach ($questions as $question) {
+            if (! is_array($question)) {
+                continue;
+            }
+
+            $questionId = $question['question_id'] ?? $question['question_bank_id'] ?? null;
+            if ($questionId === null || $questionId === '') {
+                continue;
+            }
+
+            $compact[] = [
+                'question_id' => $questionId,
+                'is_correct' => $question['is_correct'] ?? [],
+            ];
+        }
+
+        return $compact;
+    }
+}

+ 2 - 5
app/Services/Analytics/QuestionDifficultyCalibrationService.php

@@ -146,7 +146,6 @@ class QuestionDifficultyCalibrationService
 
         $types = array_values(array_unique(array_values($questionTypeByQid)));
         $baselines = $this->buildGlobalBaselines($types);
-        $healthScaleByType = [];
 
         $now = now();
         $upserts = [];
@@ -179,10 +178,8 @@ class QuestionDifficultyCalibrationService
 
             $questionType = $questionTypeByQid[$canonicalQid] ?? ($questionTypeByQid[$qid] ?? 'unknown');
             $baselineErr = $this->resolveBaselineErrorRate($questionType, $paperDifficultyCategory, $baselines);
-            if (! isset($healthScaleByType[$questionType])) {
-                $healthScaleByType[$questionType] = $this->getHealthScaleForType($questionType);
-            }
-            $healthScale = (float) $healthScaleByType[$questionType];
+            // 不丢弃任何校准样本;仅关闭在线健康监控的 JSON 扫描,避免异步校准长时间占用队列。
+            $healthScale = 1.0;
 
             $estimate = $this->estimateOnlineBySingleOutcome(
                 $originalDifficulty,

+ 32 - 89
app/Services/ExamAnalysisService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\DTO\ExamAnalysisDataDto;
 use App\DTO\ReportPayloadDto;
+use App\Jobs\ProcessAnalysisReportTaskJob;
 use App\Models\Paper;
 use App\Models\PaperQuestion;
 use App\Models\Student;
@@ -26,7 +27,8 @@ class ExamAnalysisService
 
     /**
      * 生成学情报告
-     * 异步模式,立即返回任务ID
+     * 异步模式,立即返回任务ID。
+     * 入队仅创建任务并投递 pdf 队列;构建分析数据、渲染与上传等复杂流程在 worker 内执行。
      */
     public function generateReport(string $paperId, string $studentId, ?string $recordId = null): string
     {
@@ -43,10 +45,13 @@ class ExamAnalysisService
             'record_id' => $recordId,
         ]);
 
-        // 触发后台处理(实际项目中应使用队列)
-        // dispatch(new AnalysisReportJob($taskId));
-        // 目前使用同步调用模拟异步
-        $this->processReportGeneration($taskId, $paperId, $studentId, $recordId);
+        // 复杂流程改为统一进入 pdf 队列异步处理
+        dispatch(new ProcessAnalysisReportTaskJob(
+            $taskId,
+            $paperId,
+            $studentId,
+            $recordId
+        ));
 
         return $taskId;
     }
@@ -114,53 +119,35 @@ class ExamAnalysisService
     /**
      * 处理报告生成(后台任务)
      */
-    private function processReportGeneration(string $taskId, string $paperId, string $studentId, ?string $recordId): void
+    public function processReportGenerationTask(string $taskId, string $paperId, string $studentId, ?string $recordId): void
     {
-        try {
-            $this->taskManager->updateTaskProgress($taskId, 10, '正在获取分析数据...');
-
-            // 获取分析数据
-            $analysisData = $this->getAnalysisData($paperId, $studentId, $recordId);
+        // 分析数据组装、渲染与 exam_analysis_results / student_reports 落库均在
+        // ExamPdfExportService::generateAnalysisReportPdf 内完成;此处不再重复 getAnalysisData,
+        // 避免 pdf worker 内双倍内存与 DB 压力(与 GenerateAnalysisPdfJob 路径一致)。
+        $this->taskManager->updateTaskProgress($taskId, 10, '正在生成学情分析PDF...');
 
-            $this->taskManager->updateTaskProgress($taskId, 50, '正在生成PDF报告...');
-
-            // 生成PDF
-            $pdfUrl = $this->pdfExportService->generateAnalysisReportPdf($paperId, $studentId, $recordId);
-
-            if (!$pdfUrl) {
-                throw new \Exception('PDF生成失败');
-            }
+        $pdfUrl = $this->pdfExportService->generateAnalysisReportPdf($paperId, $studentId, $recordId);
 
-            $this->taskManager->updateTaskProgress($taskId, 90, '正在保存报告...');
-
-            // 保存PDF URL到数据库
-            $this->savePdfUrl($paperId, $studentId, $recordId, $pdfUrl);
+        if (!$pdfUrl) {
+            throw new \RuntimeException('PDF生成失败');
+        }
 
-            // 标记任务完成
-            $this->taskManager->markTaskCompleted($taskId, [
-                'pdf_url' => $pdfUrl,
-            ]);
+        $this->taskManager->updateTaskProgress($taskId, 90, '正在完成任务...');
 
-            Log::info('ExamAnalysisService: 学情报告生成完成', [
-                'task_id' => $taskId,
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'pdf_url' => $pdfUrl,
-            ]);
-
-            // 发送回调通知
-            $this->taskManager->sendCallback($taskId);
+        // 标记任务完成
+        $this->taskManager->markTaskCompleted($taskId, [
+            'pdf_url' => $pdfUrl,
+        ]);
 
-        } catch (\Exception $e) {
-            Log::error('ExamAnalysisService: 报告生成失败', [
-                'task_id' => $taskId,
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'error' => $e->getMessage(),
-            ]);
+        Log::info('ExamAnalysisService: 学情报告生成完成', [
+            'task_id' => $taskId,
+            'paper_id' => $paperId,
+            'student_id' => $studentId,
+            'pdf_url' => $pdfUrl,
+        ]);
 
-            $this->taskManager->markTaskFailed($taskId, $e->getMessage());
-        }
+        // 发送回调通知
+        $this->taskManager->sendCallback($taskId);
     }
 
     /**
@@ -423,50 +410,6 @@ class ExamAnalysisService
         };
     }
 
-    /**
-     * 保存PDF URL到数据库
-     */
-    private function savePdfUrl(string $paperId, string $studentId, ?string $recordId, string $pdfUrl): void
-    {
-        try {
-            if ($recordId) {
-                // OCR记录
-                $ocrRecord = \App\Models\OCRRecord::find($recordId);
-                if ($ocrRecord) {
-                    $ocrRecord->update(['analysis_pdf_url' => $pdfUrl]);
-                }
-            } else {
-                // 学生记录 - 使用新的 student_reports 表
-                \App\Models\StudentReport::updateOrCreate(
-                    [
-                        'student_id' => $studentId,
-                        'report_type' => 'exam_analysis',
-                        'paper_id' => $paperId,
-                    ],
-                    [
-                        'pdf_url' => $pdfUrl,
-                        'generation_status' => 'completed',
-                        'generated_at' => now(),
-                        'updated_at' => now(),
-                    ]
-                );
-
-                Log::info('ExamAnalysisService: PDF URL已保存到student_reports表', [
-                    'paper_id' => $paperId,
-                    'student_id' => $studentId,
-                    'pdf_url' => $pdfUrl,
-                ]);
-            }
-        } catch (\Throwable $e) {
-            Log::error('ExamAnalysisService: 保存PDF URL失败', [
-                'paper_id' => $paperId,
-                'student_id' => $studentId,
-                'record_id' => $recordId,
-                'error' => $e->getMessage(),
-            ]);
-        }
-    }
-
     /**
      * 使用步骤级分析算法分析考试答题数据
      *

+ 54 - 20
app/Services/ExamAnswerAnalysisService.php

@@ -70,18 +70,12 @@ class ExamAnswerAnalysisService
         $hasAnswerChanged = (bool) ($recordChangeState['steps_changed'] ?? false)
             || (bool) ($recordChangeState['questions_changed'] ?? false);
         if ($hasAnswerChanged) {
-            try {
-                $updatedQuestions = $this->difficultyCalibrationService->updateOnlineFromPaper($paperId, $questions);
-                Log::info('ExamAnswerAnalysisService: 判卷后在线更新题目难度完成', [
-                    'paper_id' => $paperId,
-                    'updated_questions' => $updatedQuestions,
-                ]);
-            } catch (\Throwable $e) {
-                Log::warning('ExamAnswerAnalysisService: 判卷后在线更新题目难度失败,已忽略不阻断主流程', [
-                    'paper_id' => $paperId,
-                    'error' => $e->getMessage(),
-                ]);
-            }
+            Log::warning('ExamAnswerAnalysisService: 在线题目难度校准已移至PDF生成后异步执行', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+                'question_count' => count($questions),
+                'queue' => 'pdf',
+            ]);
         } else {
             Log::info('ExamAnswerAnalysisService: 本次答案无变化,跳过在线难度更新', [
                 'paper_id' => $paperId,
@@ -120,7 +114,7 @@ class ExamAnswerAnalysisService
 
         // 【公司要求】4. 计算每个知识点的加权掌握度(传入学案基准难度)
         // 核心算法:难度映射 → 权重计算 → 数值更新(newMastery = oldMastery + change)
-        $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings, $examBaseDifficulty, $studentId);
+        $knowledgeMasteryVector = $this->calculateKnowledgeMasteryVector($questions, $questionMappings, $examBaseDifficulty, $studentId, $paperId);
 
         // 【公司要求】5. 更新学生掌握度(包含多级父节点掌握度计算)
         $updatedMastery = $this->updateStudentMastery($studentId, $knowledgeMasteryVector);
@@ -153,7 +147,12 @@ class ExamAnswerAnalysisService
         $snapshotInfo = $this->createMasterySnapshot($studentId, $paperId, $analysisResult);
         $analysisResult['current_mastery'] = $snapshotInfo['current_mastery'] ?? [];
 
-        $this->saveAnalysisResult($studentId, $examData['paper_id'], $analysisResult);
+        $this->saveAnalysisResult(
+            $studentId,
+            $examData['paper_id'],
+            $analysisResult,
+            $hasAnswerChanged ? $questions : []
+        );
 
         Log::info('考试答题分析完成', [
             'student_id' => $studentId,
@@ -663,18 +662,37 @@ class ExamAnswerAnalysisService
      * @param  array  $questionIds  题目ID数组
      * @return array [questionId => difficulty] 映射
      */
-    private function batchGetQuestionDifficulties(array $questionIds): array
+    private function batchGetQuestionDifficulties(array $questionIds, ?string $paperId = null): array
     {
         if (empty($questionIds)) {
             return [];
         }
 
         $difficulties = [];
+        $questionIds = array_values(array_unique(array_filter($questionIds)));
 
         try {
+            if ($paperId !== null && $paperId !== '') {
+                $paperQuestionRows = DB::connection('mysql')
+                    ->table('paper_questions')
+                    ->where('paper_id', $paperId)
+                    ->whereIn('question_bank_id', $questionIds)
+                    ->whereNotNull('difficulty')
+                    ->get(['question_bank_id', 'difficulty']);
+
+                foreach ($paperQuestionRows as $row) {
+                    $difficulties[$row->question_bank_id] = (float) $row->difficulty;
+                }
+            }
+
+            $missingQuestionIds = array_values(array_diff($questionIds, array_keys($difficulties)));
+            if ($missingQuestionIds === []) {
+                return $difficulties;
+            }
+
             $questions = DB::connection('mysql')
                 ->table('questions')
-                ->whereIn('id', $questionIds)
+                ->whereIn('id', $missingQuestionIds)
                 ->select(['id', 'difficulty'])
                 ->get();
 
@@ -686,6 +704,7 @@ class ExamAnswerAnalysisService
         } catch (\Exception $e) {
             Log::warning('批量获取题目难度失败', [
                 'question_ids' => $questionIds,
+                'paper_id' => $paperId,
                 'error' => $e->getMessage(),
             ]);
         }
@@ -715,14 +734,14 @@ class ExamAnswerAnalysisService
      * @param  string|null  $studentId  学生ID
      * @return array 知识点掌握度向量
      */
-    private function calculateKnowledgeMasteryVector(array $questions, array $questionMappings, ?int $examBaseDifficulty = null, ?string $studentId = null): array
+    private function calculateKnowledgeMasteryVector(array $questions, array $questionMappings, ?int $examBaseDifficulty = null, ?string $studentId = null, ?string $paperId = null): array
     {
         // 按知识点聚合答题记录
         $knowledgeAttempts = [];
 
         // 批量获取所有题目的难度(减少数据库查询次数)
         $questionIds = array_filter(array_map(fn ($q) => $q['question_id'] ?? $q['question_bank_id'] ?? null, $questions));
-        $questionDifficulties = $this->batchGetQuestionDifficulties($questionIds);
+        $questionDifficulties = $this->batchGetQuestionDifficulties($questionIds, $paperId);
 
         foreach ($questions as $question) {
             $questionId = $question['question_id'] ?? $question['question_bank_id'] ?? null;
@@ -1787,7 +1806,7 @@ class ExamAnswerAnalysisService
     /**
      * 保存分析结果并创建掌握度快照
      */
-    private function saveAnalysisResult(string $studentId, string $paperId, array $result): void
+    private function saveAnalysisResult(string $studentId, string $paperId, array $result, array $difficultyCalibrationQuestions = []): void
     {
         // 【修复】支持重复分析:先删除旧的分析结果
         DB::connection('mysql')->table('exam_analysis_results')
@@ -1824,8 +1843,23 @@ class ExamAnswerAnalysisService
                 'student_id' => $studentId,
                 'paper_id' => $paperId,
             ]);
+
+            if (! empty($difficultyCalibrationQuestions)) {
+                dispatch(new \App\Jobs\ProcessQuestionDifficultyCalibrationJob(
+                    $paperId,
+                    $studentId,
+                    $difficultyCalibrationQuestions
+                ));
+
+                Log::warning('在线题目难度校准任务已加入队列(PDF生成后执行)', [
+                    'student_id' => $studentId,
+                    'paper_id' => $paperId,
+                    'question_count' => count($difficultyCalibrationQuestions),
+                    'queue' => 'pdf',
+                ]);
+            }
         } catch (\Exception $e) {
-            Log::error('PDF生成任务加入队列失败', [
+            Log::error('PDF/难度校准任务加入队列失败', [
                 'student_id' => $studentId,
                 'paper_id' => $paperId,
                 'error' => $e->getMessage(),

+ 407 - 116
app/Services/ExamPdfExportService.php

@@ -12,6 +12,7 @@ use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
 use App\Support\GradingStyleQuestionStem;
 use App\Support\PaperNaming;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\File;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
@@ -33,13 +34,15 @@ class ExamPdfExportService
     private ?KatexRenderer $katexRenderer = null;
     private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
     private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
+    private const KNOWLEDGE_POINTS_CACHE_TTL_SECONDS = 86400 * 3;
+    private const KNOWLEDGE_POINTS_VERSION_TTL_SECONDS = 600;
 
     /**
      * @var array<string, array{w:int,h:int}|null>
      */
     private array $pdfImageDimensionCache = [];
     private ?bool $hasPdfImageMetricsTable = null;
-    private ?array $knowledgePointMetaCache = null;
+    private array $knowledgePointMetaCache = [];
     private ?string $lastDebugHtmlPath = null;
 
     public function __construct(
@@ -347,6 +350,10 @@ class ExamPdfExportService
                 'student_id' => $studentId,
                 'record_id' => $recordId,
             ]);
+            Log::warning('ExamPdfExportService: ANALYSIS_PDF_START', [
+                'paper_id' => $paperId,
+                'student_id' => $studentId,
+            ]);
 
             // 构建分析数据
             $analysisData = $this->buildAnalysisData($paperId, $studentId);
@@ -360,12 +367,13 @@ class ExamPdfExportService
                 return null;
             }
 
-            Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
+            Log::info('ExamPdfExportService: buildAnalysisData返回摘要', [
                 'paper_id' => $paperId,
                 'student_id' => $studentId,
-                'analysisData_keys' => array_keys($analysisData),
-                'mastery_count' => count($analysisData['mastery']['items'] ?? []),
+                '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
@@ -373,17 +381,13 @@ class ExamPdfExportService
             $payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
             $mark('build_payload_dto_ms');
 
-            // 打印传给模板的数据
-            $templateData = $payloadDto->toArray();
-            Log::info('ExamPdfExportService: 传给模板的数据', [
-                'paper' => $templateData['paper'] ?? null,
-                'student' => $templateData['student'] ?? null,
-                'mastery' => $templateData['mastery'] ?? null,
-                'parent_mastery_levels' => $templateData['parent_mastery_levels'] ?? null,
-                'questions_count' => count($templateData['questions'] ?? []),
-                'insights_count' => count($templateData['question_insights'] ?? []),
-                'recommendations_count' => count($templateData['recommendations'] ?? []),
-            ]);
+            $templateData = $this->reduceTemplateDataForV3($payloadDto->toArray());
+            unset($analysisData, $dto, $payloadDto);
+            if (function_exists('gc_collect_cycles')) {
+                gc_collect_cycles();
+            }
+
+            Log::info('ExamPdfExportService: 模板数据摘要', $this->summarizeTemplateDataForLog($templateData));
 
             // V3 学情报告不再渲染逐题错题卡,保留题目元数据用于统计即可。
             $mark('prepare_report_data_ms');
@@ -462,13 +466,13 @@ class ExamPdfExportService
         $questionRows = $rawAnalysis['question_analysis'] ?? [];
         $overallSummary = $rawAnalysis['overall_summary'] ?? [];
 
-        $kpMeta = $this->getKnowledgePointMetaMap();
         $grade = (string) ($templateData['student']['grade'] ?? '');
         $stage = $this->resolveRadarStage($grade, $templateData['full_parent_mastery_levels'] ?? []);
         $profile = $this->getRadarProfileByStage($stage);
         $rootCode = (string) ($profile['root_code'] ?? 'M00');
         $moduleCodes = $profile['module_codes'] ?? [];
         $moduleNames = $profile['module_names'] ?? [];
+        $kpMeta = $this->getKnowledgePointMetaMap($rootCode, $moduleCodes);
 
         $moduleAgg = [];
         foreach ($moduleCodes as $moduleCode) {
@@ -668,7 +672,14 @@ class ExamPdfExportService
                 }
             }
         }
-        $stageCodes = $this->collectStageKnowledgePoints($rootCode);
+        $moduleSet = array_fill_keys(array_map(static fn ($code) => (string) $code, $moduleCodes), true);
+        $stageCodes = [];
+        foreach (array_keys($kpMeta) as $kpCode) {
+            if ($kpCode === $rootCode || isset($moduleSet[$kpCode])) {
+                continue;
+            }
+            $stageCodes[] = $kpCode;
+        }
         $childrenByParent = [];
         foreach ($kpMeta as $code => $meta) {
             $p = trim((string) ($meta['parent_kp_code'] ?? ''));
@@ -1472,41 +1483,52 @@ class ExamPdfExportService
             return [];
         }
 
+        $cacheVersion = $this->getKnowledgePointsCacheVersion();
+        $cacheKey = sprintf('exam_pdf:kp_descendants:v1:%s:%s', $rootCode, $cacheVersion);
+
         try {
-            $rows = DB::connection('mysql')
-                ->table('knowledge_points')
-                ->select('kp_code', 'parent_kp_code')
-                ->get();
+            return $this->rememberWithFallback($cacheKey, self::KNOWLEDGE_POINTS_CACHE_TTL_SECONDS, function () use ($rootCode) {
+                $result = [];
+                $currentParents = [$rootCode];
+                $visitedParents = [];
+                $seenCodes = [];
+                while (! empty($currentParents)) {
+                    $batchParents = [];
+                    foreach ($currentParents as $parentCode) {
+                        $parentCode = trim((string) $parentCode);
+                        if ($parentCode === '' || isset($visitedParents[$parentCode])) {
+                            continue;
+                        }
+                        $visitedParents[$parentCode] = true;
+                        $batchParents[] = $parentCode;
+                    }
 
-            $children = [];
-            foreach ($rows as $row) {
-                $code = trim((string) ($row->kp_code ?? ''));
-                $parent = trim((string) ($row->parent_kp_code ?? ''));
-                if ($code === '' || $parent === '') {
-                    continue;
-                }
-                $children[$parent][] = $code;
-            }
+                    if ($batchParents === []) {
+                        break;
+                    }
 
-            $result = [];
-            $queue = [$rootCode];
-            $visited = [];
-            while (! empty($queue)) {
-                $parent = array_shift($queue);
-                if (isset($visited[$parent])) {
-                    continue;
-                }
-                $visited[$parent] = true;
-                foreach (($children[$parent] ?? []) as $childCode) {
-                    if ($childCode === $rootCode) {
-                        continue;
+                    $rows = DB::connection('mysql')
+                        ->table('knowledge_points')
+                        ->whereIn('parent_kp_code', $batchParents)
+                        ->select('kp_code')
+                        ->get();
+
+                    $nextParents = [];
+                    foreach ($rows as $row) {
+                        $childCode = trim((string) ($row->kp_code ?? ''));
+                        if ($childCode === '' || $childCode === $rootCode || isset($seenCodes[$childCode])) {
+                            continue;
+                        }
+                        $seenCodes[$childCode] = true;
+                        $result[] = $childCode;
+                        $nextParents[] = $childCode;
                     }
-                    $result[] = $childCode;
-                    $queue[] = $childCode;
+
+                    $currentParents = $nextParents;
                 }
-            }
 
-            return array_values(array_unique($result));
+                return $result;
+            });
         } catch (\Throwable $e) {
             Log::warning('ExamPdfExportService: collectStageKnowledgePoints failed', [
                 'root_code' => $rootCode,
@@ -1626,32 +1648,273 @@ class ExamPdfExportService
         ];
     }
 
-    private function getKnowledgePointMetaMap(): array
+    private function getKnowledgePointMetaMap(string $rootCode, array $moduleCodes): array
     {
-        if ($this->knowledgePointMetaCache !== null) {
-            return $this->knowledgePointMetaCache;
+        $moduleCodes = array_values(array_unique(array_filter(array_map(static fn ($code) => trim((string) $code), $moduleCodes))));
+        $cacheVersion = $this->getKnowledgePointsCacheVersion();
+        $inMemoryKey = $rootCode.'|'.implode(',', $moduleCodes).'|'.$cacheVersion;
+        if (isset($this->knowledgePointMetaCache[$inMemoryKey])) {
+            return $this->knowledgePointMetaCache[$inMemoryKey];
         }
 
-        $rows = DB::connection('mysql')
-            ->table('knowledge_points')
-            ->select(['kp_code', 'name', 'parent_kp_code'])
-            ->get();
+        $cacheKey = sprintf('exam_pdf:kp_meta_map:v2:%s:%s:%s', $rootCode, md5(implode(',', $moduleCodes)), $cacheVersion);
 
-        $map = [];
-        foreach ($rows as $row) {
-            $code = trim((string) ($row->kp_code ?? ''));
-            if ($code === '') {
+        $map = $this->rememberWithFallback($cacheKey, self::KNOWLEDGE_POINTS_CACHE_TTL_SECONDS, function () use ($rootCode, $moduleCodes) {
+            $seedCodes = array_values(array_unique(array_filter(array_merge([$rootCode], $moduleCodes))));
+            if ($seedCodes === []) {
+                return [];
+            }
+
+            $rows = DB::connection('mysql')
+                ->table('knowledge_points')
+                ->whereIn('kp_code', $seedCodes)
+                ->select(['kp_code', 'name', 'parent_kp_code'])
+                ->get();
+
+            $result = [];
+            foreach ($rows as $row) {
+                $code = trim((string) ($row->kp_code ?? ''));
+                if ($code === '') {
+                    continue;
+                }
+                $result[$code] = [
+                    'name' => (string) ($row->name ?? $code),
+                    'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
+                ];
+            }
+
+            // 从模块出发按层拉取子树,避免从学段根节点全量展开
+            $currentParents = $moduleCodes;
+            $visitedParents = [];
+            $guard = 0;
+            while (! empty($currentParents) && $guard < 40) {
+                $batchParents = [];
+                foreach ($currentParents as $parentCode) {
+                    $parentCode = trim((string) $parentCode);
+                    if ($parentCode === '' || isset($visitedParents[$parentCode])) {
+                        continue;
+                    }
+                    $visitedParents[$parentCode] = true;
+                    $batchParents[] = $parentCode;
+                }
+                if ($batchParents === []) {
+                    break;
+                }
+
+                $childRows = DB::connection('mysql')
+                    ->table('knowledge_points')
+                    ->whereIn('parent_kp_code', $batchParents)
+                    ->select(['kp_code', 'name', 'parent_kp_code'])
+                    ->get();
+
+                $nextParents = [];
+                foreach ($childRows as $row) {
+                    $code = trim((string) ($row->kp_code ?? ''));
+                    if ($code === '' || isset($result[$code])) {
+                        continue;
+                    }
+                    $result[$code] = [
+                        'name' => (string) ($row->name ?? $code),
+                        'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
+                    ];
+                    $nextParents[] = $code;
+                }
+                $currentParents = $nextParents;
+                $guard++;
+            }
+
+            // 补齐祖先链,确保路径与模块归属计算不丢失
+            $pendingParents = [];
+            foreach ($result as $meta) {
+                $parent = trim((string) ($meta['parent_kp_code'] ?? ''));
+                if ($parent !== '' && ! isset($result[$parent])) {
+                    $pendingParents[$parent] = true;
+                }
+            }
+
+            $ancestorGuard = 0;
+            while (! empty($pendingParents) && $ancestorGuard < 24) {
+                $parentBatch = array_keys($pendingParents);
+                $pendingParents = [];
+                foreach (array_chunk($parentBatch, 500) as $chunk) {
+                    $ancestorRows = DB::connection('mysql')
+                        ->table('knowledge_points')
+                        ->whereIn('kp_code', $chunk)
+                        ->select(['kp_code', 'name', 'parent_kp_code'])
+                        ->get();
+
+                    foreach ($ancestorRows as $row) {
+                        $code = trim((string) ($row->kp_code ?? ''));
+                        if ($code === '') {
+                            continue;
+                        }
+                        if (! isset($result[$code])) {
+                            $result[$code] = [
+                                'name' => (string) ($row->name ?? $code),
+                                'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
+                            ];
+                        }
+                        $parent = trim((string) ($result[$code]['parent_kp_code'] ?? ''));
+                        if ($parent !== '' && ! isset($result[$parent])) {
+                            $pendingParents[$parent] = true;
+                        }
+                    }
+                }
+                $ancestorGuard++;
+            }
+
+            return $result;
+        });
+
+        $this->knowledgePointMetaCache[$inMemoryKey] = $map;
+
+        return $map;
+    }
+
+    private function getKnowledgePointsCacheVersion(): string
+    {
+        return $this->rememberWithFallback('exam_pdf:kp_cache_version:v1', self::KNOWLEDGE_POINTS_VERSION_TTL_SECONDS, function () {
+            $row = DB::connection('mysql')
+                ->table('knowledge_points')
+                ->selectRaw('COALESCE(UNIX_TIMESTAMP(MAX(updated_at)), 0) as max_updated_ts, COUNT(*) as total_count')
+                ->first();
+
+            $maxUpdatedTs = (string) (($row->max_updated_ts ?? 0));
+            $totalCount = (string) (($row->total_count ?? 0));
+
+            return $maxUpdatedTs.'_'.$totalCount;
+        });
+    }
+
+    private function rememberWithFallback(string $key, int $ttlSeconds, callable $resolver): mixed
+    {
+        $resolved = false;
+        $resolvedValue = null;
+        try {
+            return Cache::remember($key, now()->addSeconds($ttlSeconds), function () use ($resolver, &$resolved, &$resolvedValue) {
+                $resolvedValue = $resolver();
+                $resolved = true;
+
+                return $resolvedValue;
+            });
+        } catch (\Throwable $e) {
+            Log::warning('ExamPdfExportService: 缓存不可用,回退直查', [
+                'cache_key' => $key,
+                'error' => $e->getMessage(),
+            ]);
+
+            if ($resolved) {
+                return $resolvedValue;
+            }
+
+            return $resolver();
+        }
+    }
+
+    /**
+     * V3 报告仅保留模板和统计所需字段,避免大对象在内存中长期驻留。
+     */
+    private function reduceTemplateDataForV3(array $templateData): array
+    {
+        unset($templateData['question_insights'], $templateData['recommendations']);
+
+        $templateData['questions'] = $this->compactQuestionsForV3($templateData['questions'] ?? []);
+
+        if (isset($templateData['analysis_data']['question_analysis']) && is_array($templateData['analysis_data']['question_analysis'])) {
+            $templateData['analysis_data']['question_analysis'] = array_map(static function ($item) {
+                if (! is_array($item)) {
+                    return [];
+                }
+
+                return [
+                    'question_id' => $item['question_id'] ?? null,
+                    'question_bank_id' => $item['question_bank_id'] ?? null,
+                    'is_correct' => $item['is_correct'] ?? null,
+                    // V3 诊断得分计算依赖这两个字段,不能裁掉
+                    'max_score' => $item['max_score'] ?? null,
+                    'score_obtained' => $item['score_obtained'] ?? null,
+                    // V3 模块归属计算依赖知识点信息,不能裁掉
+                    'knowledge_points' => $item['knowledge_points'] ?? [],
+                ];
+            }, $templateData['analysis_data']['question_analysis']);
+        }
+
+        return $templateData;
+    }
+
+    /**
+     * 仅输出摘要日志,避免将大数组序列化进日志造成额外内存开销。
+     *
+     * @return array<string, mixed>
+     */
+    private function summarizeTemplateDataForLog(array $templateData): array
+    {
+        return [
+            'paper_id' => $templateData['paper']['id'] ?? null,
+            'student_id' => $templateData['student']['id'] ?? null,
+            'questions_count' => count($templateData['questions'] ?? []),
+            'analysis_question_count' => count($templateData['analysis_data']['question_analysis'] ?? []),
+            'mastery_count' => count($templateData['mastery']['items'] ?? []),
+            'exam_hit_kp_count' => count($templateData['exam_hit_kp_codes'] ?? []),
+            'parent_mastery_count' => count($templateData['parent_mastery_levels'] ?? []),
+            'full_parent_mastery_count' => count($templateData['full_parent_mastery_levels'] ?? []),
+        ];
+    }
+
+    /**
+     * 仅保留 PDF V3 模板使用到的题目字段,减少内存占用与序列化开销。
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    private function compactQuestionsForV3(array $questions): array
+    {
+        $compact = [];
+        foreach ($this->flattenQuestionPayloadRows($questions) as $question) {
+            if (! is_array($question)) {
                 continue;
             }
-            $map[$code] = [
-                'name' => (string) ($row->name ?? $code),
-                'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
+            $compact[] = [
+                'question_id' => $question['question_id'] ?? null,
+                'question_bank_id' => $question['question_bank_id'] ?? null,
+                'is_correct' => $question['is_correct'] ?? null,
+                'difficulty' => $question['difficulty'] ?? null,
+                'score' => $question['score'] ?? null,
+                'score_obtained' => $question['score_obtained'] ?? null,
+                'student_answer' => $question['student_answer'] ?? null,
+                'answer' => $question['answer'] ?? null,
+                'correct_answer' => $question['correct_answer'] ?? null,
+                'knowledge_point' => $question['knowledge_point'] ?? null,
+                'knowledge_point_name' => $question['knowledge_point_name'] ?? null,
             ];
         }
 
-        $this->knowledgePointMetaCache = $map;
+        return $compact;
+    }
 
-        return $map;
+    /**
+     * V3 构建阶段只需要题目摘要;这里把 choice/fill/answer 分组结构拍平成题目列表。
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    private function flattenQuestionPayloadRows(array $questions): array
+    {
+        $rows = [];
+        foreach ($questions as $key => $value) {
+            if (is_array($value) && in_array($key, ['choice', 'fill', 'answer'], true)) {
+                foreach ($value as $item) {
+                    if (is_array($item)) {
+                        $rows[] = $item;
+                    }
+                }
+                continue;
+            }
+
+            if (is_array($value)) {
+                $rows[] = $value;
+            }
+        }
+
+        return $rows;
     }
 
     private function mapKpToStageModule(string $kpCode, array $kpMeta, string $rootCode): ?string
@@ -2268,20 +2531,48 @@ class ExamPdfExportService
 
                 // 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
                 $parentMasteryLevels = [];
+                $parentCodes = array_values(array_unique(array_filter(array_map(static fn ($code) => trim((string) $code), array_keys($allParentMasteryLevels)))));
 
-                // 【修复】使用数据库查询正确匹配父子关系,而不是字符串前缀
-                foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
-                    // 查询这个父节点的所有子节点
-                    $childNodes = DB::connection('mysql')
+                if ($parentCodes !== []) {
+                    $childRows = DB::connection('mysql')
                         ->table('knowledge_points')
-                        ->where('parent_kp_code', $parentKpCode)
-                        ->pluck('kp_code')
+                        ->whereIn('parent_kp_code', $parentCodes)
+                        ->select(['parent_kp_code', 'kp_code'])
+                        ->get();
+
+                    $childrenByParent = [];
+                    foreach ($childRows as $row) {
+                        $parentCode = trim((string) ($row->parent_kp_code ?? ''));
+                        $childCode = trim((string) ($row->kp_code ?? ''));
+                        if ($parentCode === '' || $childCode === '') {
+                            continue;
+                        }
+                        $childrenByParent[$parentCode][] = $childCode;
+                    }
+
+                    $parentNameMap = DB::connection('mysql')
+                        ->table('knowledge_points')
+                        ->whereIn('kp_code', $parentCodes)
+                        ->pluck('name', 'kp_code')
                         ->toArray();
 
-                    // 检查是否有子节点在本次考试中出现
-                    $relevantChildren = array_intersect($examKpCodes, $childNodes);
+                    foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
+                        $parentKpCode = trim((string) $parentKpCode);
+                        if ($parentKpCode === '') {
+                            continue;
+                        }
+
+                        $childNodes = $childrenByParent[$parentKpCode] ?? [];
+                        if ($childNodes === []) {
+                            continue;
+                        }
+
+                        // 检查是否有子节点在本次考试中出现
+                        $relevantChildren = array_intersect($examKpCodes, $childNodes);
+                        if (empty($relevantChildren)) {
+                            continue;
+                        }
 
-                    if (! empty($relevantChildren)) {
                         // 口径统一:父节点掌握度 = 全部直接子节点(含未命中,缺失按0)均值
                         $childCurrentLevels = [];
                         $childPreviousLevels = [];
@@ -2303,15 +2594,9 @@ class ExamPdfExportService
                             : $finalParentMastery;
                         $finalParentChange = $finalParentMastery - $previousParentMastery;
 
-                        // 获取父节点中文名称
-                        $parentKpInfo = DB::connection('mysql')
-                            ->table('knowledge_points')
-                            ->where('kp_code', $parentKpCode)
-                            ->first();
-
                         $parentMasteryLevels[$parentKpCode] = [
                             'kp_code' => $parentKpCode,
-                            'kp_name' => $parentKpInfo->name ?? $parentKpCode,
+                            'kp_name' => $parentNameMap[$parentKpCode] ?? $parentKpCode,
                             'mastery_level' => $finalParentMastery,
                             'mastery_percentage' => round($finalParentMastery * 100, 1),
                             'mastery_change' => $finalParentChange,
@@ -2334,7 +2619,7 @@ class ExamPdfExportService
 
             Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [
                 'count' => count($masteryData),
-                'masteryData_sample' => ! empty($masteryData) ? array_slice($masteryData, 0, 2) : [],
+                'has_change_values' => collect($masteryData)->contains(fn ($item) => is_array($item) && array_key_exists('mastery_change', $item)),
             ]);
         } else {
             // 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览
@@ -2447,7 +2732,6 @@ class ExamPdfExportService
         $kpNameMap = $this->buildKnowledgePointNameMap();
         Log::info('ExamPdfExportService: 获取知识点名称映射', [
             'kpNameMap_count' => count($kpNameMap),
-            'kpNameMap_keys_sample' => ! empty($kpNameMap) ? array_slice(array_keys($kpNameMap), 0, 5) : [],
         ]);
 
         // 【修复】直接从MySQL数据库获取题目详情(不通过API)
@@ -2464,9 +2748,9 @@ class ExamPdfExportService
         Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
             'masteryData_count' => count($masteryData),
             'kpNameMap_count' => count($kpNameMap),
-            'masterySummary_keys' => array_keys($masterySummary),
             'masterySummary_items_count' => count($masterySummary['items'] ?? []),
-            'masterySummary_items_sample' => ! empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : [],
+            'masterySummary_avg' => $masterySummary['average'] ?? null,
+            'masterySummary_weak_count' => count($masterySummary['weak_list'] ?? []),
         ]);
 
         // 构建当前学生掌握度映射,供父子影响分析展示使用
@@ -2500,7 +2784,6 @@ class ExamPdfExportService
         Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
             'raw_count' => count($parentMasteryLevels),
             'processed_count' => count($processedParentMastery),
-            'processed_sample' => ! empty($processedParentMastery) ? array_slice($processedParentMastery, 0, 3) : [],
         ]);
 
         return [
@@ -4257,51 +4540,59 @@ class ExamPdfExportService
                         'url' => $url,
                     ]);
                 }
-            } else {
-                // 【修复】同时更新 exam_analysis_results 表和分析报告表
-                $updated = \DB::connection('mysql')->table('exam_analysis_results')
-                    ->where('student_id', $studentId)
-                    ->where('paper_id', $paperId)
-                    ->update([
-                        'analysis_pdf_url' => $url,
-                        'updated_at' => now(),
-                    ]);
 
-                if ($updated) {
-                    Log::info('ExamPdfExportService: 学情分析PDF URL已写入exam_analysis_results表', [
-                        'student_id' => $studentId,
-                        'paper_id' => $paperId,
-                        'url' => $url,
-                        'updated_rows' => $updated,
-                    ]);
-                } else {
-                    Log::warning('ExamPdfExportService: 未找到要更新的学情分析记录', [
+                if (! $ocrRecord) {
+                    Log::warning('ExamPdfExportService: recordId未匹配OCR记录,回退写入学情分析PDF URL', [
+                        'record_id' => $recordId,
                         'student_id' => $studentId,
                         'paper_id' => $paperId,
                     ]);
                 }
+            }
 
-                // 学生记录 - 使用新的 student_reports 表(备用)
-                \App\Models\StudentReport::updateOrCreate(
-                    [
-                        'student_id' => $studentId,
-                        'report_type' => 'exam_analysis',
-                        'paper_id' => $paperId,
-                    ],
-                    [
-                        'pdf_url' => $url,
-                        'generation_status' => 'completed',
-                        'generated_at' => now(),
-                        'updated_at' => now(),
-                    ]
-                );
+            // 【修复】同时更新 exam_analysis_results 表和分析报告表
+            $updated = \DB::connection('mysql')->table('exam_analysis_results')
+                ->where('student_id', $studentId)
+                ->where('paper_id', $paperId)
+                ->update([
+                    'analysis_pdf_url' => $url,
+                    'updated_at' => now(),
+                ]);
 
-                Log::info('ExamPdfExportService: 学生学情报告PDF URL已保存到student_reports表(备用)', [
+            if ($updated) {
+                Log::info('ExamPdfExportService: 学情分析PDF URL已写入exam_analysis_results表', [
                     'student_id' => $studentId,
                     'paper_id' => $paperId,
                     'url' => $url,
+                    'updated_rows' => $updated,
+                ]);
+            } else {
+                Log::warning('ExamPdfExportService: 未找到要更新的学情分析记录', [
+                    'student_id' => $studentId,
+                    'paper_id' => $paperId,
                 ]);
             }
+
+            // 学生记录 - 使用新的 student_reports 表(备用)
+            \App\Models\StudentReport::updateOrCreate(
+                [
+                    'student_id' => $studentId,
+                    'report_type' => 'exam_analysis',
+                    'paper_id' => $paperId,
+                ],
+                [
+                    'pdf_url' => $url,
+                    'generation_status' => 'completed',
+                    'generated_at' => now(),
+                    'updated_at' => now(),
+                ]
+            );
+
+            Log::info('ExamPdfExportService: 学生学情报告PDF URL已保存到student_reports表(备用)', [
+                'student_id' => $studentId,
+                'paper_id' => $paperId,
+                'url' => $url,
+            ]);
         } catch (\Throwable $e) {
             Log::error('ExamPdfExportService: 写入学情分析PDF URL失败', [
                 'paper_id' => $paperId,

+ 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,
-            // 组卷+PDF都异步后,exam 任务时窗适当放宽,避免回调被误判超时
-            'expires_at' => now()->addSeconds($type === self::TASK_TYPE_EXAM ? 240 : 30)->toISOString(),
+            // 分析任务已异步化,给分析/PDF链路留足回调窗口。
+            'expires_at' => now()->addSeconds($type === self::TASK_TYPE_EXAM ? 240 : 600)->toISOString(),
         ];
 
         $this->saveTask($taskId, $taskData);

+ 1 - 0
bootstrap/app.php

@@ -16,6 +16,7 @@ return Application::configure(basePath: dirname(__DIR__))
         \App\Console\Commands\ImportPdfCommand::class,
         \App\Console\Commands\RebuildKnowledgeStatsCommand::class,
         \App\Console\Commands\BackfillQuestionMetaCommand::class,
+        \App\Console\Commands\BackfillExamAnalysisResultsCommand::class,
         \App\Console\Commands\SyncQuestionAssetsCommand::class,
         \App\Console\Commands\SyncQuestionsFromQuestionBank::class,
         \App\Console\Commands\GenerateJudgeCardTemplateCommand::class,

+ 6 - 4
resources/views/exam-analysis/pdf-report-v3.blade.php

@@ -15,7 +15,11 @@
     $scoreRate = $summary['score_rate'] ?? null;
     $averageMastery = $summary['average_mastery'] ?? null;
     $examHitKpSet = array_fill_keys(array_map('strval', $exam_hit_kp_codes ?? []), true);
-    $difficultySummary = $summary['difficulty'] ?? [];
+    $difficultySummary = is_array($summary['difficulty'] ?? null) ? $summary['difficulty'] : [];
+    $difficultyExplain = trim((string) ($difficultySummary['explain'] ?? ''));
+    if ($difficultyExplain === '') {
+        $difficultyExplain = '暂无足够数据评估难度匹配。';
+    }
     $comparisonSummary = $summary['comparison'] ?? [];
     $overallLabelDetail = $summary['overall_label_detail'] ?? [];
     $historySummary = $comparisonSummary['history'] ?? [];
@@ -965,9 +969,7 @@
                         暂无难度匹配数据
                     @endif
                 </li>
-                @if(!empty($difficultySummary['explain']))
-                    <li>难度说明:{{ $difficultySummary['explain'] }}</li>
-                @endif
+                <li>难度说明:{{ $difficultyExplain }}</li>
                 <li>
                     与历史自己对比:
                     @if(!empty($historySummary['is_first_exam']))