| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- <?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;
- }
- }
|