GenerateExamPdfJob.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. <?php
  2. namespace App\Jobs;
  3. use App\Models\Paper;
  4. use App\Services\ExamPdfExportService;
  5. use App\Services\PaperPayloadService;
  6. use App\Services\QuestionBankService;
  7. use App\Services\TaskManager;
  8. use Illuminate\Bus\Queueable;
  9. use Illuminate\Contracts\Queue\ShouldQueue;
  10. use Illuminate\Foundation\Bus\Dispatchable;
  11. use Illuminate\Queue\InteractsWithQueue;
  12. use Illuminate\Queue\SerializesModels;
  13. use Illuminate\Support\Facades\Log;
  14. use Throwable;
  15. class GenerateExamPdfJob implements ShouldQueue
  16. {
  17. use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  18. public string $taskId;
  19. public string $paperId;
  20. public int $tries = 3;
  21. public int $timeout = 300;
  22. public function __construct(string $taskId, string $paperId)
  23. {
  24. $this->taskId = $taskId;
  25. $this->paperId = $paperId;
  26. // 指定使用 pdf 队列,由独立的 pdf-worker 容器处理
  27. $this->onQueue('pdf');
  28. // 避免事务未提交时 worker 提前消费导致“试卷不存在”
  29. $this->afterCommit();
  30. }
  31. public function handle(
  32. ExamPdfExportService $pdfExportService,
  33. QuestionBankService $questionBankService,
  34. PaperPayloadService $paperPayloadService,
  35. TaskManager $taskManager
  36. ): void {
  37. $jobStartedAt = microtime(true);
  38. $taskCreatedAt = null;
  39. $queueWaitMs = null;
  40. $taskSnapshot = $taskManager->getTaskStatus($this->taskId);
  41. if (is_array($taskSnapshot) && !empty($taskSnapshot['created_at'])) {
  42. try {
  43. $taskCreatedAt = \Illuminate\Support\Carbon::parse($taskSnapshot['created_at']);
  44. $queueWaitMs = $taskCreatedAt
  45. ? now()->diffInMilliseconds($taskCreatedAt, false) * -1
  46. : null;
  47. } catch (\Throwable $e) {
  48. $taskCreatedAt = null;
  49. $queueWaitMs = null;
  50. }
  51. }
  52. try {
  53. Log::info('开始处理PDF生成队列任务', [
  54. 'task_id' => $this->taskId,
  55. 'paper_id' => $this->paperId,
  56. 'attempt' => $this->attempts(),
  57. 'queue_wait_ms' => $queueWaitMs,
  58. ]);
  59. $taskManager->updateTaskStatus($this->taskId, [
  60. 'queue_wait_ms' => $queueWaitMs,
  61. ]);
  62. // 【修复】首先检查试卷是否存在
  63. // 强制走主库读取,避免读写分离下新建试卷短时不可见导致“试卷不存在”
  64. $paperModel = Paper::query()
  65. ->useWritePdo()
  66. ->with('questions')
  67. ->find($this->paperId);
  68. if (! $paperModel) {
  69. Log::error('PDF生成队列任务失败:试卷不存在', [
  70. 'task_id' => $this->taskId,
  71. 'paper_id' => $this->paperId,
  72. 'attempt' => $this->attempts(),
  73. ]);
  74. // 如果试卷不存在,判断是否需要重试
  75. if ($this->attempts() < $this->tries) {
  76. Log::info('试卷不存在,将在2秒后重试', [
  77. 'task_id' => $this->taskId,
  78. 'paper_id' => $this->paperId,
  79. 'attempt' => $this->attempts(),
  80. 'next_attempt' => $this->attempts() + 1,
  81. ]);
  82. // 延迟2秒后重试(缩短间隔,减少对回调的影响)
  83. $this->release(2);
  84. return;
  85. } else {
  86. Log::error('试卷不存在且已达到最大重试次数,标记任务失败', [
  87. 'task_id' => $this->taskId,
  88. 'paper_id' => $this->paperId,
  89. 'attempts' => $this->attempts(),
  90. ]);
  91. $taskManager->markTaskFailed($this->taskId, "试卷不存在: {$this->paperId}");
  92. return;
  93. }
  94. }
  95. // 检查试卷是否有题目
  96. if ($paperModel->questions->isEmpty()) {
  97. Log::error('PDF生成队列任务失败:试卷没有题目数据', [
  98. 'task_id' => $this->taskId,
  99. 'paper_id' => $this->paperId,
  100. 'question_count' => 0,
  101. ]);
  102. if ($this->attempts() < $this->tries) {
  103. Log::info('试卷没有题目,将在1秒后重试', [
  104. 'task_id' => $this->taskId,
  105. 'paper_id' => $this->paperId,
  106. 'attempt' => $this->attempts(),
  107. ]);
  108. // 延迟1秒后重试(更短间隔)
  109. $this->release(1);
  110. return;
  111. } else {
  112. $taskManager->markTaskFailed($this->taskId, "试卷没有题目数据: {$this->paperId}");
  113. return;
  114. }
  115. }
  116. $taskManager->updateTaskProgress($this->taskId, 10, '开始生成统一PDF(直接合并两个页面,效率最高)...');
  117. // 根据 config 或 env 配置决定是否包含知识点讲解
  118. // 还需要判断如果摸底(paper_type =0)的时候也是不需要插入知识点讲解内容
  119. $includeKpExplain = null;
  120. if($paperModel->paper_type === 0) {
  121. $includeKpExplain = false;
  122. }
  123. info("includekpexplain", [$includeKpExplain, $paperModel->paper_type]);
  124. $unifiedPdfUrl = $pdfExportService->generateUnifiedPdf($this->paperId, $includeKpExplain);
  125. $taskManager->updateTaskProgress($this->taskId, 90, 'PDF生成完成,准备返回结果...');
  126. $examContent = $paperPayloadService->buildExamContent($paperModel);
  127. // 标记任务完成(完整PDF存储到all_pdf_url字段)
  128. $taskManager->markTaskCompleted($this->taskId, [
  129. 'exam_content' => $examContent,
  130. 'pdfs' => [
  131. 'all_pdf' => $unifiedPdfUrl, // 【完整PDF】包含试卷和判卷,存储到all_pdf_url字段
  132. ],
  133. ]);
  134. $beforeCallbackAt = microtime(true);
  135. $queueProcessingMs = (int) round((microtime(true) - $jobStartedAt) * 1000);
  136. $taskTotalMs = null;
  137. if ($taskCreatedAt) {
  138. $taskTotalMs = $taskCreatedAt->diffInMilliseconds(now());
  139. }
  140. Log::info('PDF生成队列任务完成(终极优化:直接合并HTML生成一份完整PDF)', [
  141. 'task_id' => $this->taskId,
  142. 'paper_id' => $this->paperId,
  143. 'all_pdf_url' => $unifiedPdfUrl,
  144. 'question_count' => $paperModel->questions->count(),
  145. 'method' => 'generateUnifiedPdf (direct merge, fastest)',
  146. 'queue_processing_ms' => $queueProcessingMs,
  147. 'task_total_ms_before_callback' => $taskTotalMs,
  148. ]);
  149. $taskManager->updateTaskStatus($this->taskId, [
  150. 'queue_processing_ms' => $queueProcessingMs,
  151. 'task_total_ms_before_callback' => $taskTotalMs,
  152. ]);
  153. // 发送回调通知(在合并PDF完成后)
  154. $taskManager->sendCallback($this->taskId);
  155. $callbackMs = (int) round((microtime(true) - $beforeCallbackAt) * 1000);
  156. $taskTotalAfterCallbackMs = null;
  157. if ($taskCreatedAt) {
  158. $taskTotalAfterCallbackMs = $taskCreatedAt->diffInMilliseconds(now());
  159. }
  160. Log::info('PDF生成队列任务回调完成', [
  161. 'task_id' => $this->taskId,
  162. 'paper_id' => $this->paperId,
  163. 'callback_elapsed_ms' => $callbackMs,
  164. 'task_total_ms_after_callback' => $taskTotalAfterCallbackMs,
  165. ]);
  166. $taskManager->updateTaskStatus($this->taskId, [
  167. 'callback_elapsed_ms' => $callbackMs,
  168. 'task_total_ms_after_callback' => $taskTotalAfterCallbackMs,
  169. ]);
  170. $taskSummary = $taskManager->getTaskStatus($this->taskId);
  171. Log::info('EXAM_TASK_TIMELINE_SUMMARY', [
  172. 'task_id' => $this->taskId,
  173. 'paper_id' => $this->paperId,
  174. 'request_trace_id' => $taskSummary['request_trace_id'] ?? null,
  175. 'sync_elapsed_sec_total' => isset($taskSummary['sync_elapsed_ms_total'])
  176. ? round(((float) $taskSummary['sync_elapsed_ms_total']) / 1000, 3)
  177. : null,
  178. 'queue_wait_sec' => round(((float) ($taskSummary['queue_wait_ms'] ?? $queueWaitMs ?? 0)) / 1000, 3),
  179. 'queue_processing_sec' => round(((float) ($taskSummary['queue_processing_ms'] ?? $queueProcessingMs ?? 0)) / 1000, 3),
  180. 'callback_sec' => round(((float) ($taskSummary['callback_elapsed_ms'] ?? $callbackMs ?? 0)) / 1000, 3),
  181. 'task_total_sec_after_callback' => round(((float) ($taskSummary['task_total_ms_after_callback'] ?? $taskTotalAfterCallbackMs ?? 0)) / 1000, 3),
  182. 'status' => $taskSummary['status'] ?? null,
  183. ]);
  184. } catch (\Exception $e) {
  185. Log::error('PDF生成队列任务失败', [
  186. 'task_id' => $this->taskId,
  187. 'paper_id' => $this->paperId,
  188. 'error' => $e->getMessage(),
  189. 'trace' => $e->getTraceAsString(),
  190. 'queue_elapsed_ms_before_error' => (int) round((microtime(true) - $jobStartedAt) * 1000),
  191. ]);
  192. // 如果是第一次失败且试卷可能还在创建中,等待后重试
  193. if ($this->attempts() < $this->tries && strpos($e->getMessage(), '不存在') !== false) {
  194. Log::info('检测到试卷不存在错误,将在2秒后重试', [
  195. 'task_id' => $this->taskId,
  196. 'paper_id' => $this->paperId,
  197. 'attempt' => $this->attempts(),
  198. ]);
  199. $this->release(2);
  200. return;
  201. }
  202. $taskManager->markTaskFailed($this->taskId, $e->getMessage());
  203. }
  204. }
  205. public function failed(Throwable $exception): void
  206. {
  207. try {
  208. app(TaskManager::class)->markTaskFailed($this->taskId, $exception->getMessage());
  209. } catch (Throwable $innerException) {
  210. Log::error('PDF生成队列任务失败回调异常', [
  211. 'task_id' => $this->taskId,
  212. 'paper_id' => $this->paperId,
  213. 'error' => $innerException->getMessage(),
  214. ]);
  215. }
  216. Log::error('PDF生成队列任务最终失败', [
  217. 'task_id' => $this->taskId,
  218. 'paper_id' => $this->paperId,
  219. 'error' => $exception->getMessage(),
  220. ]);
  221. }
  222. }