ExamPdfExportService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. <?php
  2. namespace App\Services;
  3. use App\Http\Controllers\ExamPdfController;
  4. use Illuminate\Http\Request;
  5. use Illuminate\Support\Facades\File;
  6. use Illuminate\Support\Facades\Log;
  7. use Illuminate\Support\Facades\Storage;
  8. use Illuminate\Support\Facades\URL;
  9. use Symfony\Component\Process\Exception\ProcessSignaledException;
  10. use Symfony\Component\Process\Exception\ProcessTimedOutException;
  11. use Symfony\Component\Process\Process;
  12. class ExamPdfExportService
  13. {
  14. private ExamPdfController $controller;
  15. public function __construct(ExamPdfController $controller)
  16. {
  17. $this->controller = $controller;
  18. }
  19. /**
  20. 生成试卷 PDF(不含答案)
  21. */
  22. public function generateExamPdf(string $paperId): ?string
  23. {
  24. return $this->renderAndStore($paperId, includeAnswer: false, suffix: 'exam');
  25. }
  26. /**
  27. 生成判卷 PDF(含答案与解析)
  28. */
  29. public function generateGradingPdf(string $paperId): ?string
  30. {
  31. return $this->renderAndStore($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
  32. }
  33. private function renderAndStore(
  34. string $paperId,
  35. bool $includeAnswer,
  36. string $suffix,
  37. bool $useGradingView = false
  38. ): ?string {
  39. // 放宽脚本执行时间,避免长耗时渲染被 PHP 全局超时打断
  40. if (function_exists('set_time_limit')) {
  41. @set_time_limit(240);
  42. }
  43. try {
  44. $html = $this->renderHtml($paperId, $includeAnswer, $useGradingView);
  45. if (!$html) {
  46. Log::error('ExamPdfExportService: 渲染 HTML 为空', [
  47. 'paper_id' => $paperId,
  48. 'include_answer' => $includeAnswer,
  49. 'use_grading_view' => $useGradingView,
  50. ]);
  51. return null;
  52. }
  53. $pdfBinary = $this->buildPdf($html);
  54. if (!$pdfBinary) {
  55. return null;
  56. }
  57. $path = "exams/{$paperId}_{$suffix}.pdf";
  58. Storage::disk('public')->put($path, $pdfBinary);
  59. return URL::to(Storage::url($path));
  60. } catch (\Throwable $e) {
  61. Log::error('ExamPdfExportService: 生成 PDF 失败', [
  62. 'paper_id' => $paperId,
  63. 'suffix' => $suffix,
  64. 'error' => $e->getMessage(),
  65. 'exception' => get_class($e),
  66. 'trace' => $e->getTraceAsString(),
  67. ]);
  68. return null;
  69. }
  70. }
  71. private function renderHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
  72. {
  73. // 复用已有控制器的渲染逻辑,保证版式一致
  74. $request = Request::create(
  75. '/admin/intelligent-exam/' . ($useGradingView ? 'grading' : 'pdf') . '/' . $paperId,
  76. 'GET',
  77. ['answer' => $includeAnswer ? 'true' : 'false']
  78. );
  79. $view = $useGradingView
  80. ? $this->controller->showGrading($request, $paperId)
  81. : $this->controller->show($request, $paperId);
  82. if (is_object($view) && method_exists($view, 'render')) {
  83. return $this->ensureUtf8Html($view->render());
  84. }
  85. return null;
  86. }
  87. private function buildPdf(string $html): ?string
  88. {
  89. $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
  90. $utf8Html = $this->ensureUtf8Html($html);
  91. file_put_contents($tmpHtml, $utf8Html);
  92. // 仅使用 Chrome 渲染,去掉 wkhtmltopdf 兜底以暴露真实问题
  93. $chromePdf = $this->renderWithChrome($tmpHtml);
  94. @unlink($tmpHtml);
  95. return $chromePdf;
  96. }
  97. private function renderWithChrome(string $htmlPath): ?string
  98. {
  99. $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
  100. // 固定用户目录,减少 Chrome 首次初始化开销;允许多进程并发时可按需加锁
  101. $userDataDir = sys_get_temp_dir() . '/chrome-pdf-profile';
  102. $chromeBinary = env('PDF_CHROME_BINARY');
  103. if (!$chromeBinary) {
  104. $candidates = [
  105. '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  106. '/usr/bin/google-chrome-stable',
  107. '/usr/bin/google-chrome',
  108. '/usr/bin/chromium-browser',
  109. '/usr/bin/chromium',
  110. ];
  111. foreach ($candidates as $path) {
  112. if (is_file($path) && is_executable($path)) {
  113. $chromeBinary = $path;
  114. break;
  115. }
  116. }
  117. }
  118. if (!$chromeBinary) {
  119. Log::error('ExamPdfExportService: 未找到可用的 Chrome/Chromium,已停止导出', [
  120. 'html_path' => $htmlPath,
  121. 'path_env' => env('PATH'),
  122. 'candidates_checked' => $candidates ?? [],
  123. ]);
  124. return null;
  125. }
  126. $process = new Process([
  127. $chromeBinary,
  128. '--headless',
  129. '--disable-gpu',
  130. '--no-sandbox',
  131. '--disable-setuid-sandbox',
  132. '--disable-dev-shm-usage',
  133. '--no-zygote',
  134. '--disable-features=VizDisplayCompositor',
  135. '--disable-software-rasterizer',
  136. '--disable-extensions',
  137. '--disable-background-networking',
  138. '--disable-component-update',
  139. '--disable-client-side-phishing-detection',
  140. '--disable-default-apps',
  141. '--disable-domain-reliability',
  142. '--disable-sync',
  143. '--safebrowsing-disable-auto-update',
  144. '--no-first-run',
  145. '--no-default-browser-check',
  146. '--disable-crash-reporter',
  147. '--disable-print-preview',
  148. '--user-data-dir=' . $userDataDir,
  149. '--print-to-pdf=' . $tmpPdf,
  150. '--print-to-pdf-no-header',
  151. '--allow-file-access-from-files',
  152. 'file://' . $htmlPath,
  153. ]);
  154. $process->setTimeout(60);
  155. try {
  156. $startedAt = microtime(true);
  157. Log::info('ExamPdfExportService: Chrome 渲染启动', [
  158. 'cmd' => $process->getCommandLine(),
  159. 'html_path' => $htmlPath,
  160. 'tmp_pdf' => $tmpPdf,
  161. 'user_data_dir' => $userDataDir,
  162. 'html_exists' => file_exists($htmlPath),
  163. 'html_size' => file_exists($htmlPath) ? filesize($htmlPath) : null,
  164. 'cwd' => $process->getWorkingDirectory(),
  165. ]);
  166. $process->start();
  167. $pdfGenerated = false;
  168. // 轮询检测 PDF 是否生成,尽快返回,避免等待 Chrome 完整退出
  169. $pollStart = microtime(true);
  170. $maxPollSeconds = 45;
  171. while ($process->isRunning() && (microtime(true) - $pollStart) < $maxPollSeconds) {
  172. if (file_exists($tmpPdf) && filesize($tmpPdf) > 0) {
  173. $pdfGenerated = true;
  174. Log::info('ExamPdfExportService: 发现 PDF 已生成,提前结束 Chrome', [
  175. 'duration_sec' => round(microtime(true) - $startedAt, 3),
  176. 'tmp_pdf_size' => filesize($tmpPdf),
  177. ]);
  178. $process->stop(5, SIGKILL);
  179. break;
  180. }
  181. usleep(200_000); // 200ms
  182. }
  183. // 如果仍在运行且超过轮询窗口,则强制结束
  184. if ($process->isRunning()) {
  185. Log::warning('ExamPdfExportService: Chrome 轮询超时,强制结束', [
  186. 'duration_sec' => round(microtime(true) - $startedAt, 3),
  187. ]);
  188. $process->stop(5, SIGKILL);
  189. }
  190. $process->wait();
  191. Log::info('ExamPdfExportService: Chrome 渲染完成', [
  192. 'duration_sec' => round(microtime(true) - $startedAt, 3),
  193. 'exit_code' => $process->getExitCode(),
  194. 'tmp_pdf_exists' => file_exists($tmpPdf),
  195. 'tmp_pdf_size' => file_exists($tmpPdf) ? filesize($tmpPdf) : null,
  196. 'stderr' => $process->getErrorOutput(),
  197. 'stdout' => $process->getOutput(),
  198. 'pdf_generated_during_poll' => $pdfGenerated,
  199. ]);
  200. } catch (ProcessTimedOutException|ProcessSignaledException $e) {
  201. Log::error('ExamPdfExportService: Chrome 进程异常', [
  202. 'cmd' => $process->getCommandLine(),
  203. 'signal' => method_exists($process, 'getTermSignal') ? $process->getTermSignal() : null,
  204. 'error' => $process->getErrorOutput(),
  205. 'output' => $process->getOutput(),
  206. 'exit_code' => $process->getExitCode(),
  207. 'exception' => $e->getMessage(),
  208. 'trace' => $e->getTraceAsString(),
  209. ]);
  210. if ($process->isRunning()) {
  211. $process->stop(5, SIGKILL);
  212. }
  213. $pdfExists = file_exists($tmpPdf);
  214. $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
  215. if ($pdfExists && $pdfSize > 0) {
  216. Log::warning('ExamPdfExportService: Chrome 异常但产生了 PDF,尝试继续返回', [
  217. 'tmp_pdf_exists' => $pdfExists,
  218. 'tmp_pdf_size' => $pdfSize,
  219. 'duration_sec' => isset($startedAt) ? round(microtime(true) - $startedAt, 3) : null,
  220. ]);
  221. $pdfBinary = file_get_contents($tmpPdf);
  222. @unlink($tmpPdf);
  223. File::deleteDirectory($userDataDir);
  224. return $pdfBinary ?: null;
  225. }
  226. @unlink($tmpPdf);
  227. File::deleteDirectory($userDataDir);
  228. return null;
  229. } catch (\Throwable $e) {
  230. Log::error('ExamPdfExportService: Chrome 调用异常', [
  231. 'cmd' => $process->getCommandLine(),
  232. 'error' => $e->getMessage(),
  233. 'exit_code' => $process->getExitCode(),
  234. 'stderr' => $process->getErrorOutput(),
  235. 'stdout' => $process->getOutput(),
  236. 'trace' => $e->getTraceAsString(),
  237. ]);
  238. if ($process->isRunning()) {
  239. $process->stop(5, SIGKILL);
  240. }
  241. $pdfExists = file_exists($tmpPdf);
  242. $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
  243. if ($pdfExists && $pdfSize > 0) {
  244. Log::warning('ExamPdfExportService: Chrome 调用异常但产生了 PDF,尝试继续返回', [
  245. 'tmp_pdf_exists' => $pdfExists,
  246. 'tmp_pdf_size' => $pdfSize,
  247. 'duration_sec' => isset($startedAt) ? round(microtime(true) - $startedAt, 3) : null,
  248. ]);
  249. $pdfBinary = file_get_contents($tmpPdf);
  250. @unlink($tmpPdf);
  251. File::deleteDirectory($userDataDir);
  252. return $pdfBinary ?: null;
  253. }
  254. @unlink($tmpPdf);
  255. File::deleteDirectory($userDataDir);
  256. return null;
  257. }
  258. $pdfExists = file_exists($tmpPdf);
  259. $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
  260. if (!$process->isSuccessful()) {
  261. if ($pdfExists && $pdfSize > 0) {
  262. Log::warning('ExamPdfExportService: Chrome 进程异常但生成了 PDF,继续使用', [
  263. 'cmd' => implode(' ', (array) $process->getCommandLine()),
  264. 'exit_code' => $process->getExitCode(),
  265. 'error' => $process->getErrorOutput(),
  266. 'output' => $process->getOutput(),
  267. 'tmp_pdf_exists' => $pdfExists,
  268. 'tmp_pdf_size' => $pdfSize,
  269. 'html_path' => $htmlPath,
  270. 'user_data_dir' => $userDataDir,
  271. ]);
  272. } else {
  273. Log::error('ExamPdfExportService: Chrome 渲染失败', [
  274. 'cmd' => implode(' ', (array) $process->getCommandLine()),
  275. 'exit_code' => $process->getExitCode(),
  276. 'error' => $process->getErrorOutput(),
  277. 'output' => $process->getOutput(),
  278. 'tmp_pdf_exists' => $pdfExists,
  279. 'tmp_pdf_size' => $pdfSize,
  280. 'html_path' => $htmlPath,
  281. 'user_data_dir' => $userDataDir,
  282. ]);
  283. @unlink($tmpPdf);
  284. File::deleteDirectory($userDataDir);
  285. return null;
  286. }
  287. }
  288. $pdfBinary = $pdfExists ? file_get_contents($tmpPdf) : null;
  289. @unlink($tmpPdf);
  290. File::deleteDirectory($userDataDir);
  291. return $pdfBinary ?: null;
  292. }
  293. private function ensureUtf8Html(string $html): string
  294. {
  295. $meta = '<meta charset="UTF-8">';
  296. if (stripos($html, '<head>') !== false) {
  297. return preg_replace('/<head>/i', "<head>{$meta}", $html, 1);
  298. }
  299. return $meta . $html;
  300. }
  301. }