|
|
@@ -0,0 +1,335 @@
|
|
|
+<?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';
|
|
|
+
|
|
|
+ protected $signature = 'exam:backfill-analysis-results
|
|
|
+ {--since= : papers.completed_at >= 该时间(默认今天 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,
|
|
|
+ $item->analysis_id ? (string) $item->analysis_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<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;
|
|
|
+ }
|
|
|
+}
|