GenerateExamPdfJob.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <?php
  2. namespace App\Jobs;
  3. use App\Models\Paper;
  4. use App\Services\ExamPdfExportService;
  5. use App\Services\QuestionBankService;
  6. use App\Services\PaperPayloadService;
  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. class GenerateExamPdfJob implements ShouldQueue
  15. {
  16. use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  17. public string $taskId;
  18. public string $paperId;
  19. public int $maxAttempts = 3;
  20. /**
  21. * 【优化】增加任务超时时间,给Chrome充足渲染时间
  22. */
  23. public $timeout = 180; // 从90秒增加到180秒(3分钟)
  24. /**
  25. * 【保持】重试次数,配合并发控制
  26. */
  27. public $tries = 3;
  28. public function __construct(string $taskId, string $paperId)
  29. {
  30. $this->taskId = $taskId;
  31. $this->paperId = $paperId;
  32. $this->onQueue('pdf_generation');
  33. }
  34. public function handle(
  35. ExamPdfExportService $pdfExportService,
  36. QuestionBankService $questionBankService,
  37. PaperPayloadService $paperPayloadService,
  38. TaskManager $taskManager
  39. ): void {
  40. $jobId = $this->uuid ?? 'unknown';
  41. $startTime = microtime(true);
  42. try {
  43. Log::info('开始处理PDF生成队列任务', [
  44. 'job_id' => $jobId,
  45. 'task_id' => $this->taskId,
  46. 'paper_id' => $this->paperId,
  47. 'attempt' => $this->attempts(),
  48. ]);
  49. // 【并发控制】检查当前并发数量
  50. if (!$this->acquireLock()) {
  51. $waitTime = rand(5, 15); // 等待5-15秒后重试
  52. Log::warning('GenerateExamPdfJob: 并发限制,等待后重试', [
  53. 'job_id' => $jobId,
  54. 'wait_time' => $waitTime,
  55. 'current_concurrent' => $this->getConcurrentCount()
  56. ]);
  57. // 释放队列槽位,等待后重试
  58. $this->release($waitTime);
  59. return;
  60. }
  61. Log::info('GenerateExamPdfJob: 获得执行权限', [
  62. 'job_id' => $jobId,
  63. 'concurrent_count' => $this->getConcurrentCount()
  64. ]);
  65. // 【新增】快速检查:如果任务已完成,直接跳过
  66. $task = $taskManager->getTaskStatus($this->taskId);
  67. if ($task && $task['status'] === 'completed') {
  68. Log::info('【跳过执行】任务已完成,无需重复生成PDF', [
  69. 'task_id' => $this->taskId,
  70. 'paper_id' => $this->paperId,
  71. 'status' => $task['status']
  72. ]);
  73. return;
  74. }
  75. // 【修复】首先检查试卷是否存在
  76. $paperModel = Paper::with('questions')->find($this->paperId);
  77. if (!$paperModel) {
  78. Log::error('PDF生成队列任务失败:试卷不存在', [
  79. 'task_id' => $this->taskId,
  80. 'paper_id' => $this->paperId,
  81. 'attempt' => $this->attempts(),
  82. ]);
  83. // 如果试卷不存在,判断是否需要重试
  84. if ($this->attempts() < $this->maxAttempts) {
  85. Log::info('试卷不存在,将在2秒后重试', [
  86. 'task_id' => $this->taskId,
  87. 'paper_id' => $this->paperId,
  88. 'attempt' => $this->attempts(),
  89. 'next_attempt' => $this->attempts() + 1,
  90. ]);
  91. // 延迟2秒后重试(缩短间隔,减少对回调的影响)
  92. $this->release(2);
  93. return;
  94. } else {
  95. Log::error('试卷不存在且已达到最大重试次数,标记任务失败', [
  96. 'task_id' => $this->taskId,
  97. 'paper_id' => $this->paperId,
  98. 'attempts' => $this->attempts(),
  99. ]);
  100. $taskManager->markTaskFailed($this->taskId, "试卷不存在: {$this->paperId}");
  101. return;
  102. }
  103. }
  104. // 检查试卷是否有题目
  105. if ($paperModel->questions->isEmpty()) {
  106. Log::error('PDF生成队列任务失败:试卷没有题目数据', [
  107. 'task_id' => $this->taskId,
  108. 'paper_id' => $this->paperId,
  109. 'question_count' => 0,
  110. ]);
  111. if ($this->attempts() < $this->maxAttempts) {
  112. Log::info('试卷没有题目,将在1秒后重试', [
  113. 'task_id' => $this->taskId,
  114. 'paper_id' => $this->paperId,
  115. 'attempt' => $this->attempts(),
  116. ]);
  117. // 延迟1秒后重试(更短间隔)
  118. $this->release(1);
  119. return;
  120. } else {
  121. $taskManager->markTaskFailed($this->taskId, "试卷没有题目数据: {$this->paperId}");
  122. return;
  123. }
  124. }
  125. $taskManager->updateTaskProgress($this->taskId, 10, '开始生成试卷PDF...');
  126. // 生成试卷PDF
  127. $pdfUrl = $pdfExportService->generateExamPdf($this->paperId)
  128. ?? $questionBankService->exportExamToPdf($this->paperId)
  129. ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $this->paperId, 'answer' => 'false']);
  130. $taskManager->updateTaskProgress($this->taskId, 50, '试卷PDF生成完成,开始生成判卷PDF...');
  131. // 生成判卷PDF
  132. $gradingPdfUrl = $pdfExportService->generateGradingPdf($this->paperId)
  133. ?? route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $this->paperId, 'answer' => 'true']);
  134. $taskManager->updateTaskProgress($this->taskId, 70, '判卷PDF生成完成,开始合并PDF...');
  135. // 【优化】生成合并PDF(试卷 + 判卷) - 使用快速合并模式
  136. $mergedPdfUrl = $pdfExportService->generateMergedPdf($this->paperId, function($percentage, $message) use ($taskManager) {
  137. // 进度更新:70% 开始,最高到 95%
  138. $progress = 70 + ($percentage / 100) * 25;
  139. $taskManager->updateTaskProgress($this->taskId, round($progress, 0), $message);
  140. });
  141. // 【新增】验证合并后的PDF URL
  142. if (!$mergedPdfUrl) {
  143. Log::error('PDF生成队列任务失败:合并PDF失败', [
  144. 'task_id' => $this->taskId,
  145. 'paper_id' => $this->paperId,
  146. 'attempt' => $this->attempts(),
  147. ]);
  148. if ($this->attempts() < $this->maxAttempts) {
  149. Log::info('合并PDF失败,将在3秒后重试', [
  150. 'task_id' => $this->taskId,
  151. 'paper_id' => $this->paperId,
  152. 'attempt' => $this->attempts(),
  153. 'next_attempt' => $this->attempts() + 1,
  154. ]);
  155. // 延迟3秒后重试
  156. $this->release(3);
  157. return;
  158. } else {
  159. Log::error('合并PDF失败且已达到最大重试次数,标记任务失败', [
  160. 'task_id' => $this->taskId,
  161. 'paper_id' => $this->paperId,
  162. 'attempts' => $this->attempts(),
  163. ]);
  164. $taskManager->markTaskFailed($this->taskId, "合并PDF失败: {$this->paperId}");
  165. return;
  166. }
  167. }
  168. Log::info('PDF合并成功验证', [
  169. 'task_id' => $this->taskId,
  170. 'paper_id' => $this->paperId,
  171. 'merged_pdf_url' => $mergedPdfUrl,
  172. 'url_length' => strlen($mergedPdfUrl)
  173. ]);
  174. // 构建完整的试卷内容
  175. $examContent = $paperPayloadService->buildExamContent($paperModel);
  176. // 标记任务完成(包含合并后的PDF URL)
  177. $taskManager->markTaskCompleted($this->taskId, [
  178. 'exam_content' => $examContent,
  179. 'pdfs' => [
  180. 'exam_paper_pdf' => $pdfUrl,
  181. 'grading_pdf' => $gradingPdfUrl,
  182. 'all_pdf' => $mergedPdfUrl, // 【新增】合并后的完整PDF
  183. ],
  184. ]);
  185. Log::info('PDF生成队列任务完成', [
  186. 'task_id' => $this->taskId,
  187. 'paper_id' => $this->paperId,
  188. 'pdf_url' => $pdfUrl,
  189. 'grading_pdf_url' => $gradingPdfUrl,
  190. 'merged_pdf_url' => $mergedPdfUrl,
  191. 'question_count' => $paperModel->questions->count(),
  192. ]);
  193. // 发送回调通知(在合并PDF完成后)
  194. $taskManager->sendCallback($this->taskId);
  195. } catch (\Exception $e) {
  196. $elapsed = microtime(true) - $startTime;
  197. Log::error('PDF生成队列任务失败', [
  198. 'job_id' => $jobId,
  199. 'task_id' => $this->taskId,
  200. 'paper_id' => $this->paperId,
  201. 'attempt' => $this->attempts(),
  202. 'max_attempts' => $this->maxAttempts,
  203. 'elapsed' => round($elapsed, 2) . 's',
  204. 'error' => $e->getMessage(),
  205. 'trace' => $e->getTraceAsString(),
  206. ]);
  207. // 如果是第一次失败且试卷可能还在创建中,等待后重试
  208. if ($this->attempts() < $this->maxAttempts && strpos($e->getMessage(), '不存在') !== false) {
  209. Log::info('检测到试卷不存在错误,将在2秒后重试', [
  210. 'task_id' => $this->taskId,
  211. 'paper_id' => $this->paperId,
  212. 'attempt' => $this->attempts(),
  213. ]);
  214. $this->release(2);
  215. return;
  216. }
  217. $taskManager->markTaskFailed($this->taskId, $e->getMessage());
  218. } finally {
  219. // 【并发控制】释放锁
  220. $this->releaseLock();
  221. $currentCount = $this->getConcurrentCount();
  222. Log::info('GenerateExamPdfJob: 任务完成', [
  223. 'job_id' => $jobId,
  224. 'total_elapsed' => round(microtime(true) - $startTime, 2) . 's',
  225. 'remaining_concurrent' => $currentCount
  226. ]);
  227. }
  228. }
  229. /**
  230. * 【并发控制】获取执行锁
  231. */
  232. private function acquireLock(): bool
  233. {
  234. $currentCount = $this->getConcurrentCount();
  235. if ($currentCount >= self::MAX_CONCURRENT_JOBS) {
  236. return false;
  237. }
  238. // 增加计数器
  239. Redis::incr(self::REDIS_KEY_CONCURRENT);
  240. // 设置锁过期时间(2小时)
  241. $lockKey = self::REDIS_KEY_LOCK_PREFIX . $this->taskId;
  242. Redis::setex($lockKey, 7200, 1);
  243. return true;
  244. }
  245. /**
  246. * 【并发控制】释放执行锁
  247. */
  248. private function releaseLock(): void
  249. {
  250. // 减少计数器
  251. $currentCount = Redis::decr(self::REDIS_KEY_CONCURRENT);
  252. if ($currentCount < 0) {
  253. Redis::set(self::REDIS_KEY_CONCURRENT, 0);
  254. }
  255. // 删除锁
  256. $lockKey = self::REDIS_KEY_LOCK_PREFIX . $this->taskId;
  257. Redis::del($lockKey);
  258. }
  259. /**
  260. * 【并发控制】获取当前并发数量
  261. */
  262. private function getConcurrentCount(): int
  263. {
  264. $count = Redis::get(self::REDIS_KEY_CONCURRENT);
  265. return $count ? (int)$count : 0;
  266. }
  267. }