Explorar el Código

fix(exam-analysis): queue exam answer analysis

yemeishu hace 2 semanas
padre
commit
b5565ca4db

+ 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('考试答题分析失败', [

+ 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;
+    }
+}

+ 29 - 15
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,
@@ -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,
@@ -1787,7 +1786,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 +1823,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(),

+ 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);