|
@@ -4,9 +4,12 @@ namespace App\Services;
|
|
|
|
|
|
|
|
use App\Http\Controllers\ExamPdfController;
|
|
use App\Http\Controllers\ExamPdfController;
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Request;
|
|
|
|
|
+use Illuminate\Support\Facades\File;
|
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use Illuminate\Support\Facades\URL;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
|
+use Symfony\Component\Process\Exception\ProcessSignaledException;
|
|
|
|
|
+use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
|
|
use Symfony\Component\Process\Process;
|
|
use Symfony\Component\Process\Process;
|
|
|
|
|
|
|
|
class ExamPdfExportService
|
|
class ExamPdfExportService
|
|
@@ -40,9 +43,19 @@ class ExamPdfExportService
|
|
|
string $suffix,
|
|
string $suffix,
|
|
|
bool $useGradingView = false
|
|
bool $useGradingView = false
|
|
|
): ?string {
|
|
): ?string {
|
|
|
|
|
+ // 放宽脚本执行时间,避免长耗时渲染被 PHP 全局超时打断
|
|
|
|
|
+ if (function_exists('set_time_limit')) {
|
|
|
|
|
+ @set_time_limit(240);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
$html = $this->renderHtml($paperId, $includeAnswer, $useGradingView);
|
|
$html = $this->renderHtml($paperId, $includeAnswer, $useGradingView);
|
|
|
if (!$html) {
|
|
if (!$html) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: 渲染 HTML 为空', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'include_answer' => $includeAnswer,
|
|
|
|
|
+ 'use_grading_view' => $useGradingView,
|
|
|
|
|
+ ]);
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -59,7 +72,9 @@ class ExamPdfExportService
|
|
|
Log::error('ExamPdfExportService: 生成 PDF 失败', [
|
|
Log::error('ExamPdfExportService: 生成 PDF 失败', [
|
|
|
'paper_id' => $paperId,
|
|
'paper_id' => $paperId,
|
|
|
'suffix' => $suffix,
|
|
'suffix' => $suffix,
|
|
|
- 'error' => $e->getMessage()
|
|
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'exception' => get_class($e),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString(),
|
|
|
]);
|
|
]);
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
@@ -88,24 +103,20 @@ class ExamPdfExportService
|
|
|
private function buildPdf(string $html): ?string
|
|
private function buildPdf(string $html): ?string
|
|
|
{
|
|
{
|
|
|
$tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
|
|
$tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
|
|
|
- file_put_contents($tmpHtml, $this->ensureUtf8Html($html));
|
|
|
|
|
|
|
+ $utf8Html = $this->ensureUtf8Html($html);
|
|
|
|
|
+ file_put_contents($tmpHtml, $utf8Html);
|
|
|
|
|
|
|
|
- // 先尝试 Chrome
|
|
|
|
|
|
|
+ // 仅使用 Chrome 渲染,去掉 wkhtmltopdf 兜底以暴露真实问题
|
|
|
$chromePdf = $this->renderWithChrome($tmpHtml);
|
|
$chromePdf = $this->renderWithChrome($tmpHtml);
|
|
|
- if ($chromePdf !== null) {
|
|
|
|
|
- @unlink($tmpHtml);
|
|
|
|
|
- return $chromePdf;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Chrome 失败则降级 wkhtmltopdf,尽量保证有输出
|
|
|
|
|
- $wkPdf = $this->renderWithWkhtml($tmpHtml);
|
|
|
|
|
@unlink($tmpHtml);
|
|
@unlink($tmpHtml);
|
|
|
- return $wkPdf;
|
|
|
|
|
|
|
+ return $chromePdf;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private function renderWithChrome(string $htmlPath): ?string
|
|
private function renderWithChrome(string $htmlPath): ?string
|
|
|
{
|
|
{
|
|
|
$tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
|
|
$tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_') . '.pdf';
|
|
|
|
|
+ // 固定用户目录,减少 Chrome 首次初始化开销;允许多进程并发时可按需加锁
|
|
|
|
|
+ $userDataDir = sys_get_temp_dir() . '/chrome-pdf-profile';
|
|
|
|
|
|
|
|
$chromeBinary = env('PDF_CHROME_BINARY');
|
|
$chromeBinary = env('PDF_CHROME_BINARY');
|
|
|
if (!$chromeBinary) {
|
|
if (!$chromeBinary) {
|
|
@@ -125,7 +136,11 @@ class ExamPdfExportService
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (!$chromeBinary) {
|
|
if (!$chromeBinary) {
|
|
|
- Log::warning('ExamPdfExportService: 未找到可用的 Chrome/Chromium,可尝试 wkhtmltopdf');
|
|
|
|
|
|
|
+ Log::error('ExamPdfExportService: 未找到可用的 Chrome/Chromium,已停止导出', [
|
|
|
|
|
+ 'html_path' => $htmlPath,
|
|
|
|
|
+ 'path_env' => env('PATH'),
|
|
|
|
|
+ 'candidates_checked' => $candidates ?? [],
|
|
|
|
|
+ ]);
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -137,79 +152,170 @@ class ExamPdfExportService
|
|
|
'--disable-setuid-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
|
'--disable-dev-shm-usage',
|
|
'--disable-dev-shm-usage',
|
|
|
'--no-zygote',
|
|
'--no-zygote',
|
|
|
- '--single-process',
|
|
|
|
|
'--disable-features=VizDisplayCompositor',
|
|
'--disable-features=VizDisplayCompositor',
|
|
|
- '--user-data-dir=' . sys_get_temp_dir() . '/chrome-user-data',
|
|
|
|
|
|
|
+ '--disable-software-rasterizer',
|
|
|
|
|
+ '--disable-extensions',
|
|
|
|
|
+ '--disable-background-networking',
|
|
|
|
|
+ '--disable-component-update',
|
|
|
|
|
+ '--disable-client-side-phishing-detection',
|
|
|
|
|
+ '--disable-default-apps',
|
|
|
|
|
+ '--disable-domain-reliability',
|
|
|
|
|
+ '--disable-sync',
|
|
|
|
|
+ '--safebrowsing-disable-auto-update',
|
|
|
|
|
+ '--no-first-run',
|
|
|
|
|
+ '--no-default-browser-check',
|
|
|
|
|
+ '--disable-crash-reporter',
|
|
|
|
|
+ '--disable-print-preview',
|
|
|
|
|
+ '--user-data-dir=' . $userDataDir,
|
|
|
'--print-to-pdf=' . $tmpPdf,
|
|
'--print-to-pdf=' . $tmpPdf,
|
|
|
'--print-to-pdf-no-header',
|
|
'--print-to-pdf-no-header',
|
|
|
'--allow-file-access-from-files',
|
|
'--allow-file-access-from-files',
|
|
|
'file://' . $htmlPath,
|
|
'file://' . $htmlPath,
|
|
|
]);
|
|
]);
|
|
|
- $process->setTimeout(40);
|
|
|
|
|
- $process->run();
|
|
|
|
|
|
|
+ $process->setTimeout(60);
|
|
|
|
|
|
|
|
- if (!$process->isSuccessful() || !file_exists($tmpPdf)) {
|
|
|
|
|
- Log::error('ExamPdfExportService: Chrome 渲染失败', [
|
|
|
|
|
- 'cmd' => implode(' ', (array) $process->getCommandLine()),
|
|
|
|
|
- 'exit_code' => $process->getExitCode(),
|
|
|
|
|
- 'error' => $process->getErrorOutput(),
|
|
|
|
|
- 'output' => $process->getOutput(),
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ $startedAt = microtime(true);
|
|
|
|
|
+ Log::info('ExamPdfExportService: Chrome 渲染启动', [
|
|
|
|
|
+ 'cmd' => $process->getCommandLine(),
|
|
|
|
|
+ 'html_path' => $htmlPath,
|
|
|
|
|
+ 'tmp_pdf' => $tmpPdf,
|
|
|
|
|
+ 'user_data_dir' => $userDataDir,
|
|
|
|
|
+ 'html_exists' => file_exists($htmlPath),
|
|
|
|
|
+ 'html_size' => file_exists($htmlPath) ? filesize($htmlPath) : null,
|
|
|
|
|
+ 'cwd' => $process->getWorkingDirectory(),
|
|
|
]);
|
|
]);
|
|
|
- @unlink($tmpPdf);
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ $process->start();
|
|
|
|
|
+ $pdfGenerated = false;
|
|
|
|
|
|
|
|
- $pdfBinary = file_get_contents($tmpPdf);
|
|
|
|
|
- @unlink($tmpPdf);
|
|
|
|
|
- return $pdfBinary ?: null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private function renderWithWkhtml(string $htmlPath): ?string
|
|
|
|
|
- {
|
|
|
|
|
- $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_wk_') . '.pdf';
|
|
|
|
|
-
|
|
|
|
|
- $wkBinary = env('PDF_WKHTML_BINARY');
|
|
|
|
|
- if (!$wkBinary) {
|
|
|
|
|
- $candidates = [
|
|
|
|
|
- '/usr/bin/wkhtmltopdf',
|
|
|
|
|
- '/usr/local/bin/wkhtmltopdf',
|
|
|
|
|
- ];
|
|
|
|
|
- foreach ($candidates as $path) {
|
|
|
|
|
- if (is_file($path) && is_executable($path)) {
|
|
|
|
|
- $wkBinary = $path;
|
|
|
|
|
|
|
+ // 轮询检测 PDF 是否生成,尽快返回,避免等待 Chrome 完整退出
|
|
|
|
|
+ $pollStart = microtime(true);
|
|
|
|
|
+ $maxPollSeconds = 45;
|
|
|
|
|
+ while ($process->isRunning() && (microtime(true) - $pollStart) < $maxPollSeconds) {
|
|
|
|
|
+ if (file_exists($tmpPdf) && filesize($tmpPdf) > 0) {
|
|
|
|
|
+ $pdfGenerated = true;
|
|
|
|
|
+ Log::info('ExamPdfExportService: 发现 PDF 已生成,提前结束 Chrome', [
|
|
|
|
|
+ 'duration_sec' => round(microtime(true) - $startedAt, 3),
|
|
|
|
|
+ 'tmp_pdf_size' => filesize($tmpPdf),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $process->stop(5, SIGKILL);
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
|
|
+ usleep(200_000); // 200ms
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- if (!$wkBinary) {
|
|
|
|
|
- Log::error('ExamPdfExportService: 未找到可用的 Chrome,且 wkhtmltopdf 未安装,无法导出');
|
|
|
|
|
- return null;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- $process = new Process([
|
|
|
|
|
- $wkBinary,
|
|
|
|
|
- '--disable-smart-shrinking',
|
|
|
|
|
- '--encoding', 'utf-8',
|
|
|
|
|
- $htmlPath,
|
|
|
|
|
- $tmpPdf,
|
|
|
|
|
- ]);
|
|
|
|
|
- $process->setTimeout(40);
|
|
|
|
|
- $process->run();
|
|
|
|
|
|
|
+ // 如果仍在运行且超过轮询窗口,则强制结束
|
|
|
|
|
+ if ($process->isRunning()) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: Chrome 轮询超时,强制结束', [
|
|
|
|
|
+ 'duration_sec' => round(microtime(true) - $startedAt, 3),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $process->stop(5, SIGKILL);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (!$process->isSuccessful() || !file_exists($tmpPdf)) {
|
|
|
|
|
- Log::error('ExamPdfExportService: wkhtmltopdf 渲染失败', [
|
|
|
|
|
- 'cmd' => implode(' ', (array) $process->getCommandLine()),
|
|
|
|
|
|
|
+ $process->wait();
|
|
|
|
|
+ Log::info('ExamPdfExportService: Chrome 渲染完成', [
|
|
|
|
|
+ 'duration_sec' => round(microtime(true) - $startedAt, 3),
|
|
|
'exit_code' => $process->getExitCode(),
|
|
'exit_code' => $process->getExitCode(),
|
|
|
|
|
+ 'tmp_pdf_exists' => file_exists($tmpPdf),
|
|
|
|
|
+ 'tmp_pdf_size' => file_exists($tmpPdf) ? filesize($tmpPdf) : null,
|
|
|
|
|
+ 'stderr' => $process->getErrorOutput(),
|
|
|
|
|
+ 'stdout' => $process->getOutput(),
|
|
|
|
|
+ 'pdf_generated_during_poll' => $pdfGenerated,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } catch (ProcessTimedOutException|ProcessSignaledException $e) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: Chrome 进程异常', [
|
|
|
|
|
+ 'cmd' => $process->getCommandLine(),
|
|
|
|
|
+ 'signal' => method_exists($process, 'getTermSignal') ? $process->getTermSignal() : null,
|
|
|
'error' => $process->getErrorOutput(),
|
|
'error' => $process->getErrorOutput(),
|
|
|
'output' => $process->getOutput(),
|
|
'output' => $process->getOutput(),
|
|
|
|
|
+ 'exit_code' => $process->getExitCode(),
|
|
|
|
|
+ 'exception' => $e->getMessage(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ if ($process->isRunning()) {
|
|
|
|
|
+ $process->stop(5, SIGKILL);
|
|
|
|
|
+ }
|
|
|
|
|
+ $pdfExists = file_exists($tmpPdf);
|
|
|
|
|
+ $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
|
|
|
|
|
+ if ($pdfExists && $pdfSize > 0) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: Chrome 异常但产生了 PDF,尝试继续返回', [
|
|
|
|
|
+ 'tmp_pdf_exists' => $pdfExists,
|
|
|
|
|
+ 'tmp_pdf_size' => $pdfSize,
|
|
|
|
|
+ 'duration_sec' => isset($startedAt) ? round(microtime(true) - $startedAt, 3) : null,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $pdfBinary = file_get_contents($tmpPdf);
|
|
|
|
|
+ @unlink($tmpPdf);
|
|
|
|
|
+ File::deleteDirectory($userDataDir);
|
|
|
|
|
+ return $pdfBinary ?: null;
|
|
|
|
|
+ }
|
|
|
|
|
+ @unlink($tmpPdf);
|
|
|
|
|
+ File::deleteDirectory($userDataDir);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::error('ExamPdfExportService: Chrome 调用异常', [
|
|
|
|
|
+ 'cmd' => $process->getCommandLine(),
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ 'exit_code' => $process->getExitCode(),
|
|
|
|
|
+ 'stderr' => $process->getErrorOutput(),
|
|
|
|
|
+ 'stdout' => $process->getOutput(),
|
|
|
|
|
+ 'trace' => $e->getTraceAsString(),
|
|
|
]);
|
|
]);
|
|
|
|
|
+ if ($process->isRunning()) {
|
|
|
|
|
+ $process->stop(5, SIGKILL);
|
|
|
|
|
+ }
|
|
|
|
|
+ $pdfExists = file_exists($tmpPdf);
|
|
|
|
|
+ $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
|
|
|
|
|
+ if ($pdfExists && $pdfSize > 0) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: Chrome 调用异常但产生了 PDF,尝试继续返回', [
|
|
|
|
|
+ 'tmp_pdf_exists' => $pdfExists,
|
|
|
|
|
+ 'tmp_pdf_size' => $pdfSize,
|
|
|
|
|
+ 'duration_sec' => isset($startedAt) ? round(microtime(true) - $startedAt, 3) : null,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $pdfBinary = file_get_contents($tmpPdf);
|
|
|
|
|
+ @unlink($tmpPdf);
|
|
|
|
|
+ File::deleteDirectory($userDataDir);
|
|
|
|
|
+ return $pdfBinary ?: null;
|
|
|
|
|
+ }
|
|
|
@unlink($tmpPdf);
|
|
@unlink($tmpPdf);
|
|
|
|
|
+ File::deleteDirectory($userDataDir);
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $pdfBinary = file_get_contents($tmpPdf);
|
|
|
|
|
|
|
+ $pdfExists = file_exists($tmpPdf);
|
|
|
|
|
+ $pdfSize = $pdfExists ? filesize($tmpPdf) : null;
|
|
|
|
|
+
|
|
|
|
|
+ if (!$process->isSuccessful()) {
|
|
|
|
|
+ if ($pdfExists && $pdfSize > 0) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: Chrome 进程异常但生成了 PDF,继续使用', [
|
|
|
|
|
+ 'cmd' => implode(' ', (array) $process->getCommandLine()),
|
|
|
|
|
+ 'exit_code' => $process->getExitCode(),
|
|
|
|
|
+ 'error' => $process->getErrorOutput(),
|
|
|
|
|
+ 'output' => $process->getOutput(),
|
|
|
|
|
+ 'tmp_pdf_exists' => $pdfExists,
|
|
|
|
|
+ 'tmp_pdf_size' => $pdfSize,
|
|
|
|
|
+ 'html_path' => $htmlPath,
|
|
|
|
|
+ 'user_data_dir' => $userDataDir,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Log::error('ExamPdfExportService: Chrome 渲染失败', [
|
|
|
|
|
+ 'cmd' => implode(' ', (array) $process->getCommandLine()),
|
|
|
|
|
+ 'exit_code' => $process->getExitCode(),
|
|
|
|
|
+ 'error' => $process->getErrorOutput(),
|
|
|
|
|
+ 'output' => $process->getOutput(),
|
|
|
|
|
+ 'tmp_pdf_exists' => $pdfExists,
|
|
|
|
|
+ 'tmp_pdf_size' => $pdfSize,
|
|
|
|
|
+ 'html_path' => $htmlPath,
|
|
|
|
|
+ 'user_data_dir' => $userDataDir,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ @unlink($tmpPdf);
|
|
|
|
|
+ File::deleteDirectory($userDataDir);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $pdfBinary = $pdfExists ? file_get_contents($tmpPdf) : null;
|
|
|
@unlink($tmpPdf);
|
|
@unlink($tmpPdf);
|
|
|
|
|
+ File::deleteDirectory($userDataDir);
|
|
|
return $pdfBinary ?: null;
|
|
return $pdfBinary ?: null;
|
|
|
}
|
|
}
|
|
|
|
|
|