BackfillExamAnalysisResultsCommand.php 13 KB

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