= 该时间(默认今天 00:00:00)} {--paper= : 仅处理指定 paper_id} {--student= : 仅处理指定 student_id} {--limit=100 : 最多处理多少条候选试卷} {--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; } $limit = max(1, (int) $this->option('limit')); $paperFilter = $this->option('paper'); $studentFilter = $this->option('student'); $isDryRun = (bool) $this->option('dry-run'); $paperRows = 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') ->limit($limit) ->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, ]; 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 ); $counts[$action]++; if ($action === self::ACTION_SKIP) { continue; } $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, ]; } $this->info(sprintf( '扫描完成:已完成试卷 %d 条,需补分析 %d 条,仅补PDF %d 条,同步PDF URL %d 条,已跳过 %d 条(since=%s, limit=%d)', $paperRows->count(), $counts[self::ACTION_ANALYSIS], $counts[self::ACTION_PDF_ONLY], $counts[self::ACTION_SYNC_PDF_URL], $counts[self::ACTION_SKIP], $since->toDateTimeString(), $limit )); 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' => true, ]; $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 ): string { $hasValidAnalysis = $this->hasValidAnalysisData($analysisDataRaw, $analysisId); $hasAnalysisPdf = $this->hasNonEmptyString($analysisPdfUrl); $hasStudentReportPdf = $this->hasNonEmptyString($studentReportPdfUrl); if (! $hasValidAnalysis) { return self::ACTION_ANALYSIS; } 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> */ 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 */ 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 $values * @return list */ private function flattenToBinary(array $values): array { $out = []; foreach ($values as $v) { $out[] = ((bool) $v) ? 1 : 0; } return $out === [] ? [0] : $out; } }