BackfillExamAnalysisResultsCommand.php 12 KB

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