BackfillExamAnalysisResultsCommand.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Jobs\GenerateAnalysisPdfJob;
  4. use App\Jobs\ProcessExamAnswerAnalysisJob;
  5. use App\Services\TaskManager;
  6. use Carbon\Carbon;
  7. use Illuminate\Console\Command;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Log;
  10. use Throwable;
  11. class BackfillExamAnalysisResultsCommand extends Command
  12. {
  13. private const ACTION_SKIP = 'skip';
  14. private const ACTION_ANALYSIS = 'analysis';
  15. private const ACTION_PDF_ONLY = 'pdf_only';
  16. private const ACTION_SYNC_PDF_URL = 'sync_pdf_url';
  17. private const ACTION_NEEDS_REVIEW = 'needs_review';
  18. protected $signature = 'exam:backfill-analysis-results
  19. {--since= : papers.completed_at >= 该时间(默认今天 00:00:00)}
  20. {--paper= : 仅处理指定 paper_id}
  21. {--student= : 仅处理指定 student_id}
  22. {--limit=100 : 最多处理多少条候选试卷}
  23. {--force-analysis : 允许重算已有但无效的分析数据(默认不覆盖)}
  24. {--dry-run : 只打印待回填列表,不执行回填}';
  25. protected $description = '回填 exam_analysis_results 缺失分析数据或缺失 PDF URL 的试卷';
  26. public function handle(TaskManager $taskManager): int
  27. {
  28. $sinceRaw = (string) ($this->option('since') ?: Carbon::today()->toDateTimeString());
  29. try {
  30. $since = Carbon::parse($sinceRaw);
  31. } catch (Throwable) {
  32. $this->error('无效的 --since 时间:'.$sinceRaw);
  33. return self::FAILURE;
  34. }
  35. $limit = max(1, (int) $this->option('limit'));
  36. $paperFilter = $this->option('paper');
  37. $studentFilter = $this->option('student');
  38. $forceAnalysis = (bool) $this->option('force-analysis');
  39. $isDryRun = (bool) $this->option('dry-run');
  40. $paperRows = DB::connection('mysql')
  41. ->table('papers')
  42. ->whereNotNull('completed_at')
  43. ->where('completed_at', '>=', $since->toDateTimeString())
  44. ->whereNotNull('student_id')
  45. ->where('student_id', '!=', '')
  46. ->when($paperFilter, fn ($q) => $q->where('paper_id', $paperFilter))
  47. ->when($studentFilter, fn ($q) => $q->where('student_id', $studentFilter))
  48. ->orderBy('completed_at')
  49. ->limit($limit)
  50. ->get(['paper_id', 'student_id', 'completed_at']);
  51. $targets = [];
  52. $counts = [
  53. self::ACTION_SKIP => 0,
  54. self::ACTION_ANALYSIS => 0,
  55. self::ACTION_PDF_ONLY => 0,
  56. self::ACTION_SYNC_PDF_URL => 0,
  57. self::ACTION_NEEDS_REVIEW => 0,
  58. ];
  59. foreach ($paperRows as $paper) {
  60. $latest = DB::connection('mysql')
  61. ->table('exam_analysis_results')
  62. ->where('paper_id', $paper->paper_id)
  63. ->where('student_id', $paper->student_id)
  64. ->orderByDesc('created_at')
  65. ->first(['id', 'analysis_data', 'analysis_pdf_url']);
  66. $studentReportPdfUrl = DB::connection('mysql')
  67. ->table('student_reports')
  68. ->where('paper_id', $paper->paper_id)
  69. ->where('student_id', $paper->student_id)
  70. ->where('report_type', 'exam_analysis')
  71. ->whereNotNull('pdf_url')
  72. ->where('pdf_url', '!=', '')
  73. ->orderByDesc('updated_at')
  74. ->value('pdf_url');
  75. $action = $this->resolveBackfillAction(
  76. $latest->analysis_data ?? null,
  77. $latest->id ?? null,
  78. $latest->analysis_pdf_url ?? null,
  79. $studentReportPdfUrl,
  80. $forceAnalysis
  81. );
  82. $counts[$action]++;
  83. if ($action === self::ACTION_SKIP || $action === self::ACTION_NEEDS_REVIEW) {
  84. if ($action === self::ACTION_NEEDS_REVIEW) {
  85. $this->warn(sprintf(
  86. '跳过需人工确认的已有分析记录:paper_id=%s student_id=%s analysis_id=%s(如确认要重算,单张使用 --force-analysis --paper=%s)',
  87. $paper->paper_id,
  88. $paper->student_id,
  89. $latest->id ?? 'null',
  90. $paper->paper_id
  91. ));
  92. }
  93. continue;
  94. }
  95. $targets[] = (object) [
  96. 'paper_id' => (string) $paper->paper_id,
  97. 'student_id' => (string) $paper->student_id,
  98. 'completed_at' => (string) $paper->completed_at,
  99. 'action' => $action,
  100. 'analysis_id' => $latest->id ?? null,
  101. 'student_report_pdf_url' => $studentReportPdfUrl,
  102. ];
  103. }
  104. $this->info(sprintf(
  105. '扫描完成:已完成试卷 %d 条,需补分析 %d 条,仅补PDF %d 条,同步PDF URL %d 条,需人工确认 %d 条,已跳过 %d 条(since=%s, limit=%d)',
  106. $paperRows->count(),
  107. $counts[self::ACTION_ANALYSIS],
  108. $counts[self::ACTION_PDF_ONLY],
  109. $counts[self::ACTION_SYNC_PDF_URL],
  110. $counts[self::ACTION_NEEDS_REVIEW],
  111. $counts[self::ACTION_SKIP],
  112. $since->toDateTimeString(),
  113. $limit
  114. ));
  115. if ($isDryRun) {
  116. foreach ($targets as $item) {
  117. $this->line(sprintf(
  118. '[dry-run] action=%s paper_id=%s student_id=%s completed_at=%s analysis_id=%s',
  119. $item->action,
  120. $item->paper_id,
  121. $item->student_id,
  122. $item->completed_at,
  123. $item->analysis_id ?? 'null'
  124. ));
  125. }
  126. return self::SUCCESS;
  127. }
  128. $ok = 0;
  129. $failed = 0;
  130. foreach ($targets as $item) {
  131. try {
  132. if ($item->action === self::ACTION_ANALYSIS) {
  133. $questions = $this->buildQuestionsFromPaperQuestions($item->paper_id);
  134. if ($questions === []) {
  135. $this->warn("回填失败:{$item->paper_id} 无可用 paper_questions");
  136. $failed++;
  137. continue;
  138. }
  139. $examData = [
  140. 'paper_id' => $item->paper_id,
  141. 'student_id' => $item->student_id,
  142. 'questions' => $questions,
  143. 'force_recalculate' => $forceAnalysis,
  144. ];
  145. $taskId = $taskManager->createTask(TaskManager::TASK_TYPE_ANALYSIS, [
  146. 'type' => 'exam_answer_analysis_backfill',
  147. 'paper_id' => $item->paper_id,
  148. 'student_id' => $item->student_id,
  149. 'question_count' => count($questions),
  150. ]);
  151. dispatch(new ProcessExamAnswerAnalysisJob($taskId, $examData));
  152. $this->info("已投递补分析任务:{$item->paper_id} / {$item->student_id} task_id={$taskId}");
  153. $ok++;
  154. continue;
  155. }
  156. if ($item->action === self::ACTION_SYNC_PDF_URL) {
  157. DB::connection('mysql')
  158. ->table('exam_analysis_results')
  159. ->where('id', $item->analysis_id)
  160. ->update([
  161. 'analysis_pdf_url' => $item->student_report_pdf_url,
  162. 'updated_at' => now(),
  163. ]);
  164. $this->info("已同步PDF URL:{$item->paper_id} / {$item->student_id}");
  165. $ok++;
  166. continue;
  167. }
  168. dispatch(new GenerateAnalysisPdfJob(
  169. $item->paper_id,
  170. $item->student_id,
  171. null
  172. ));
  173. $this->info("已投递补PDF任务:{$item->paper_id} / {$item->student_id}");
  174. $ok++;
  175. } catch (Throwable $e) {
  176. $this->error("回填失败:{$item->paper_id} / {$item->student_id} -> ".$e->getMessage());
  177. Log::error('BackfillExamAnalysisResultsCommand: 回填失败', [
  178. 'paper_id' => $item->paper_id,
  179. 'student_id' => $item->student_id,
  180. 'action' => $item->action,
  181. 'error' => $e->getMessage(),
  182. 'exception' => get_class($e),
  183. ]);
  184. $failed++;
  185. }
  186. }
  187. $this->info("回填投递完成:成功 {$ok},失败 {$failed}");
  188. return $failed > 0 ? self::FAILURE : self::SUCCESS;
  189. }
  190. private function resolveBackfillAction(
  191. mixed $analysisDataRaw,
  192. mixed $analysisId,
  193. mixed $analysisPdfUrl,
  194. mixed $studentReportPdfUrl,
  195. bool $forceAnalysis
  196. ): string
  197. {
  198. $hasAnalysisRow = $analysisId !== null;
  199. $hasValidAnalysis = $this->hasValidAnalysisData($analysisDataRaw, $analysisId);
  200. $hasAnalysisPdf = $this->hasNonEmptyString($analysisPdfUrl);
  201. $hasStudentReportPdf = $this->hasNonEmptyString($studentReportPdfUrl);
  202. if (! $hasValidAnalysis) {
  203. return (! $hasAnalysisRow || $forceAnalysis) ? self::ACTION_ANALYSIS : self::ACTION_NEEDS_REVIEW;
  204. }
  205. if ($hasAnalysisPdf) {
  206. return self::ACTION_SKIP;
  207. }
  208. return $hasStudentReportPdf ? self::ACTION_SYNC_PDF_URL : self::ACTION_PDF_ONLY;
  209. }
  210. private function hasValidAnalysisData(mixed $analysisDataRaw, mixed $analysisId): bool
  211. {
  212. if ($analysisId === null || $analysisDataRaw === null) {
  213. return false;
  214. }
  215. $raw = is_string($analysisDataRaw) ? trim($analysisDataRaw) : '';
  216. if ($raw === '') {
  217. return false;
  218. }
  219. $decoded = json_decode($raw, true);
  220. if (! is_array($decoded)) {
  221. return false;
  222. }
  223. $hasQuestionAnalysis = isset($decoded['question_analysis']) && is_array($decoded['question_analysis']) && $decoded['question_analysis'] !== [];
  224. $hasOverallSummary = isset($decoded['overall_summary']) && is_array($decoded['overall_summary']) && $decoded['overall_summary'] !== [];
  225. return $hasQuestionAnalysis || $hasOverallSummary;
  226. }
  227. private function hasNonEmptyString(mixed $value): bool
  228. {
  229. return is_string($value) && trim($value) !== '';
  230. }
  231. /**
  232. * @return list<array<string, mixed>>
  233. */
  234. private function buildQuestionsFromPaperQuestions(string $paperId): array
  235. {
  236. $rows = DB::connection('mysql')
  237. ->table('paper_questions')
  238. ->where('paper_id', $paperId)
  239. ->orderBy('question_number')
  240. ->get([
  241. 'question_id',
  242. 'question_bank_id',
  243. 'score',
  244. 'score_obtained',
  245. 'student_answer',
  246. 'is_correct',
  247. 'teacher_comment',
  248. ]);
  249. $questions = [];
  250. foreach ($rows as $row) {
  251. $qid = $row->question_bank_id ?? $row->question_id ?? null;
  252. if ($qid === null || $qid === '') {
  253. continue;
  254. }
  255. $item = [
  256. 'question_id' => (string) $qid,
  257. 'score' => isset($row->score) ? (float) $row->score : 2.0,
  258. 'is_correct' => $this->normalizeIsCorrect($row->is_correct),
  259. 'student_answer' => $row->student_answer,
  260. 'teacher_comment' => $row->teacher_comment,
  261. ];
  262. if ($row->score_obtained !== null) {
  263. $item['score_obtained'] = (float) $row->score_obtained;
  264. }
  265. $questions[] = $item;
  266. }
  267. return $questions;
  268. }
  269. /**
  270. * @return list<int>
  271. */
  272. private function normalizeIsCorrect(mixed $value): array
  273. {
  274. if (is_array($value)) {
  275. return $this->flattenToBinary($value);
  276. }
  277. if (is_string($value)) {
  278. $trimmed = trim($value);
  279. if ($trimmed !== '' && ($trimmed[0] === '[' || $trimmed[0] === '{')) {
  280. $decoded = json_decode($trimmed, true);
  281. if (is_array($decoded)) {
  282. return $this->flattenToBinary($decoded);
  283. }
  284. }
  285. }
  286. if ($value === null || $value === '') {
  287. return [0];
  288. }
  289. return [((bool) $value) ? 1 : 0];
  290. }
  291. /**
  292. * @param array<int, mixed> $values
  293. * @return list<int>
  294. */
  295. private function flattenToBinary(array $values): array
  296. {
  297. $out = [];
  298. foreach ($values as $v) {
  299. $out[] = ((bool) $v) ? 1 : 0;
  300. }
  301. return $out === [] ? [0] : $out;
  302. }
  303. }