Forráskód Böngészése

merge: integrate gotenberg pdf pipeline

yemeishu 2 hete
szülő
commit
647866f03c

+ 33 - 19
Dockerfile

@@ -28,7 +28,7 @@ RUN bun run build
 # ========================================
 # ========================================
 # 第二阶段:PHP 运行时(使用 php-fpm)
 # 第二阶段:PHP 运行时(使用 php-fpm)
 # ========================================
 # ========================================
-FROM php:8.3-fpm-alpine AS runtime
+FROM php:8.3-fpm-alpine AS base-runtime
 
 
 # 安装系统依赖 - 使用阿里云镜像
 # 安装系统依赖 - 使用阿里云镜像
 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
@@ -44,8 +44,6 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
         icu-dev \
         icu-dev \
         libzip-dev \
         libzip-dev \
         sqlite-dev \
         sqlite-dev \
-        # Nginx(高性能 Web 服务器)
-        nginx \
         # Chrome/Chromium 依赖(PDF 生成必须)
         # Chrome/Chromium 依赖(PDF 生成必须)
         chromium \
         chromium \
         nss \
         nss \
@@ -66,7 +64,7 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
         npm \
         npm \
         && rm -rf /var/cache/apk/* \
         && rm -rf /var/cache/apk/* \
         && fc-cache -fv \
         && fc-cache -fv \
-        && mkdir -p /run/dbus /run/nginx /var/log/nginx
+        && mkdir -p /run/dbus
 
 
 # 设置 Chrome 环境变量
 # 设置 Chrome 环境变量
 ENV CHROME_BIN=/usr/bin/chromium-browser \
 ENV CHROME_BIN=/usr/bin/chromium-browser \
@@ -95,20 +93,12 @@ WORKDIR /app
 COPY composer.json composer.lock ./
 COPY composer.json composer.lock ./
 RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
 RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
 
 
-# 复制应用代码(排除 node_modules, vendor 等)
-COPY . .
-
-# 从第一阶段复制构建好的前端资源
-COPY --from=frontend-builder /app/public/build ./public/build
-
 # 安装 KaTeX(服务端公式渲染)
 # 安装 KaTeX(服务端公式渲染)
 RUN npm config set registry https://registry.npmmirror.com && \
 RUN npm config set registry https://registry.npmmirror.com && \
     npm install -g katex@0.16.9
     npm install -g katex@0.16.9
 
 
-# 缓存路由和视图(不缓存配置,配置在运行时从 .env 读取)
-RUN php artisan route:cache && \
-    php artisan view:cache && \
-    php artisan filament:upgrade || true
+# 复制应用代码(排除 node_modules, vendor 等)
+COPY . .
 
 
 # 创建必要目录
 # 创建必要目录
 RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache
 RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache
@@ -118,6 +108,28 @@ RUN chown -R www-data:www-data /app && \
     chmod -R 755 /app && \
     chmod -R 755 /app && \
     chmod -R 777 /app/storage /app/bootstrap/cache
     chmod -R 777 /app/storage /app/bootstrap/cache
 
 
+# 通用入口脚本(queue worker 模式仅执行命令,不启动 nginx)
+COPY docker-entrypoint.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+
+# ========================================
+# 第三阶段:App Runtime(Nginx + PHP-FPM)
+# ========================================
+FROM base-runtime AS app-runtime
+
+# 安装 Nginx(仅 Web API 容器需要)
+RUN apk add --no-cache nginx && \
+    mkdir -p /run/nginx /var/log/nginx
+
+# 从第一阶段复制构建好的前端资源
+COPY --from=frontend-builder /app/public/build ./public/build
+
+# 缓存路由和视图(不缓存配置,配置在运行时从 .env 读取)
+RUN php artisan route:cache && \
+    php artisan view:cache && \
+    php artisan filament:upgrade || true
+
 # 复制 Nginx 和 PHP-FPM 配置
 # 复制 Nginx 和 PHP-FPM 配置
 COPY docker/nginx.conf /etc/nginx/nginx.conf
 COPY docker/nginx.conf /etc/nginx/nginx.conf
 COPY docker/www.conf /usr/local/etc/php-fpm.d/www.conf
 COPY docker/www.conf /usr/local/etc/php-fpm.d/www.conf
@@ -128,9 +140,11 @@ EXPOSE 8000
 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
     CMD curl -f http://127.0.0.1:8000/health || exit 1
     CMD curl -f http://127.0.0.1:8000/health || exit 1
 
 
-# 启动脚本:启动 dbus、php-fpm、nginx
-COPY docker-entrypoint.sh /usr/local/bin/
-RUN chmod +x /usr/local/bin/docker-entrypoint.sh
-
-ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
 CMD ["nginx", "-g", "daemon off;"]
 CMD ["nginx", "-g", "daemon off;"]
+
+# ========================================
+# 第四阶段:PDF Worker Runtime(无 Nginx/无前端构建)
+# ========================================
+FROM base-runtime AS pdfworker
+
+CMD ["php", "artisan", "queue:work", "--queue=pdf", "--sleep=3", "--tries=2", "--max-time=300", "--max-jobs=10"]

+ 190 - 15
app/Services/ExamPdfExportService.php

@@ -170,9 +170,11 @@ class ExamPdfExportService
         ]);
         ]);
 
 
         try {
         try {
+            $totalStartedAt = microtime(true);
             // 步骤0:获取知识点讲解HTML(如需要)
             // 步骤0:获取知识点讲解HTML(如需要)
             $kpExplainHtml = null;
             $kpExplainHtml = null;
             if ($shouldIncludeKpExplain) {
             if ($shouldIncludeKpExplain) {
+                $kpStartedAt = microtime(true);
                 Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
                 Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
                 $kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
                 $kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
                 $mark('kp_explain_html_ms');
                 $mark('kp_explain_html_ms');
@@ -182,6 +184,7 @@ class ExamPdfExportService
                     Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
                     Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
                         'paper_id' => $paperId,
                         'paper_id' => $paperId,
                         'length' => strlen($kpExplainHtml),
                         'length' => strlen($kpExplainHtml),
+                        'elapsed_ms' => (int) round((microtime(true) - $kpStartedAt) * 1000),
                     ]);
                     ]);
                 } else {
                 } else {
                     Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
                     Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
@@ -191,6 +194,7 @@ class ExamPdfExportService
             }
             }
 
 
             // 步骤1:同时渲染两个页面的HTML
             // 步骤1:同时渲染两个页面的HTML
+            $examRenderStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
             Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
             $examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
             $examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
             $mark('exam_html_ms');
             $mark('exam_html_ms');
@@ -199,8 +203,13 @@ class ExamPdfExportService
 
 
                 return null;
                 return null;
             }
             }
-            Log::info('generateUnifiedPdf: 试卷HTML渲染完成', ['paper_id' => $paperId, 'length' => strlen($examHtml)]);
+            Log::info('generateUnifiedPdf: 试卷HTML渲染完成', [
+                'paper_id' => $paperId,
+                'length' => strlen($examHtml),
+                'elapsed_ms' => (int) round((microtime(true) - $examRenderStartedAt) * 1000),
+            ]);
 
 
+            $gradingRenderStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始渲染判卷HTML', ['paper_id' => $paperId]);
             Log::info('generateUnifiedPdf: 开始渲染判卷HTML', ['paper_id' => $paperId]);
             $gradingHtml = $this->renderExamHtml($paperId, includeAnswer: true, useGradingView: true);
             $gradingHtml = $this->renderExamHtml($paperId, includeAnswer: true, useGradingView: true);
             $mark('grading_html_ms');
             $mark('grading_html_ms');
@@ -209,9 +218,14 @@ class ExamPdfExportService
 
 
                 return null;
                 return null;
             }
             }
-            Log::info('generateUnifiedPdf: 判卷HTML渲染完成', ['paper_id' => $paperId, 'length' => strlen($gradingHtml)]);
+            Log::info('generateUnifiedPdf: 判卷HTML渲染完成', [
+                'paper_id' => $paperId,
+                'length' => strlen($gradingHtml),
+                'elapsed_ms' => (int) round((microtime(true) - $gradingRenderStartedAt) * 1000),
+            ]);
 
 
             // 步骤2:插入分页符,合并HTML
             // 步骤2:插入分页符,合并HTML
+            $mergeStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
             Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
             $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
             $unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
             $mark('merge_html_ms');
             $mark('merge_html_ms');
@@ -224,9 +238,11 @@ class ExamPdfExportService
                 'paper_id' => $paperId,
                 'paper_id' => $paperId,
                 'length' => strlen($unifiedHtml),
                 'length' => strlen($unifiedHtml),
                 'has_kp_explain' => ! empty($kpExplainHtml),
                 'has_kp_explain' => ! empty($kpExplainHtml),
+                'elapsed_ms' => (int) round((microtime(true) - $mergeStartedAt) * 1000),
             ]);
             ]);
 
 
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
             // 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
+            $pdfRenderStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
             Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
             $this->lastDebugHtmlPath = null;
             $this->lastDebugHtmlPath = null;
             $pdfBinary = $this->buildPdf($unifiedHtml, true, true);
             $pdfBinary = $this->buildPdf($unifiedHtml, true, true);
@@ -236,7 +252,11 @@ class ExamPdfExportService
 
 
                 return null;
                 return null;
             }
             }
-            Log::info('generateUnifiedPdf: PDF生成完成', ['paper_id' => $paperId, 'pdf_size' => strlen($pdfBinary)]);
+            Log::info('generateUnifiedPdf: PDF生成完成', [
+                'paper_id' => $paperId,
+                'pdf_size' => strlen($pdfBinary),
+                'elapsed_ms' => (int) round((microtime(true) - $pdfRenderStartedAt) * 1000),
+            ]);
 
 
             // 步骤4:保存PDF
             // 步骤4:保存PDF
             $paper = Paper::where('paper_id', $paperId)->first();
             $paper = Paper::where('paper_id', $paperId)->first();
@@ -247,6 +267,7 @@ class ExamPdfExportService
             }
             }
             $allPdfName = $this->buildPdfFileName($paper);
             $allPdfName = $this->buildPdfFileName($paper);
             $path = "exams/{$allPdfName}";
             $path = "exams/{$allPdfName}";
+            $storageStartedAt = microtime(true);
             Log::info('generateUnifiedPdf: 开始保存PDF到云存储', ['paper_id' => $paperId, 'path' => $path]);
             Log::info('generateUnifiedPdf: 开始保存PDF到云存储', ['paper_id' => $paperId, 'path' => $path]);
             $url = $this->pdfStorageService->put($path, $pdfBinary);
             $url = $this->pdfStorageService->put($path, $pdfBinary);
             $mark('upload_pdf_ms');
             $mark('upload_pdf_ms');
@@ -255,7 +276,11 @@ class ExamPdfExportService
 
 
                 return null;
                 return null;
             }
             }
-            Log::info('generateUnifiedPdf: PDF保存完成', ['paper_id' => $paperId, 'url' => $url]);
+            Log::info('generateUnifiedPdf: PDF保存完成', [
+                'paper_id' => $paperId,
+                'url' => $url,
+                'elapsed_ms' => (int) round((microtime(true) - $storageStartedAt) * 1000),
+            ]);
 
 
             // 步骤5:保存URL到数据库(存储到all_pdf_url字段)
             // 步骤5:保存URL到数据库(存储到all_pdf_url字段)
             Log::info('generateUnifiedPdf: 开始保存URL到数据库', ['paper_id' => $paperId, 'field' => 'all_pdf_url']);
             Log::info('generateUnifiedPdf: 开始保存URL到数据库', ['paper_id' => $paperId, 'field' => 'all_pdf_url']);
@@ -282,6 +307,7 @@ class ExamPdfExportService
                 'url' => $url,
                 'url' => $url,
                 'pdf_size' => strlen($pdfBinary),
                 'pdf_size' => strlen($pdfBinary),
                 'method' => 'direct HTML merge to PDF (no pdfunite)',
                 'method' => 'direct HTML merge to PDF (no pdfunite)',
+                'elapsed_ms' => (int) round((microtime(true) - $totalStartedAt) * 1000),
             ]);
             ]);
 
 
             return $url;
             return $url;
@@ -738,6 +764,7 @@ class ExamPdfExportService
                 if ($ca === $cb) {
                 if ($ca === $cb) {
                     return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
                     return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
                 }
                 }
+
                 return $cb <=> $ca;
                 return $cb <=> $ca;
             });
             });
             $radarChildrenByModule[$moduleCode] = $items;
             $radarChildrenByModule[$moduleCode] = $items;
@@ -811,6 +838,7 @@ class ExamPdfExportService
         $keep = array_values(array_filter($highToLow, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? -1) >= 85));
         $keep = array_values(array_filter($highToLow, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? -1) >= 85));
         $boost = array_values(array_filter($lowToHigh, function ($i) {
         $boost = array_values(array_filter($lowToHigh, function ($i) {
             $percent = $this->toPcMasteryPercent($i['mastery_level']) ?? -1;
             $percent = $this->toPcMasteryPercent($i['mastery_level']) ?? -1;
+
             return $percent >= 60 && $percent < 85;
             return $percent >= 60 && $percent < 85;
         }));
         }));
         $key = array_values(array_filter($lowToHigh, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? 101) < 60));
         $key = array_values(array_filter($lowToHigh, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? 101) < 60));
@@ -1517,6 +1545,7 @@ class ExamPdfExportService
                 if ($code !== $rootCode) {
                 if ($code !== $rootCode) {
                     $leaves[] = $code;
                     $leaves[] = $code;
                 }
                 }
+
                 continue;
                 continue;
             }
             }
 
 
@@ -1839,8 +1868,8 @@ class ExamPdfExportService
 
 
         try {
         try {
             $url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
             $url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
-
-            $response = Http::timeout(2)->get($url);
+            $timeout = max(1, (int) config('pdf.kp_explain_fetch_timeout_seconds', 2));
+            $response = Http::timeout($timeout)->get($url);
             if ($response->successful()) {
             if ($response->successful()) {
                 $html = $response->body();
                 $html = $response->body();
                 if (! empty(trim($html))) {
                 if (! empty(trim($html))) {
@@ -1859,6 +1888,7 @@ class ExamPdfExportService
             Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
             Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
                 'paper_id' => $paperId,
                 'paper_id' => $paperId,
                 'url' => $url,
                 'url' => $url,
+                'timeout_seconds' => $timeout,
             ]);
             ]);
 
 
             return null;
             return null;
@@ -1867,6 +1897,7 @@ class ExamPdfExportService
             Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
             Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
                 'paper_id' => $paperId,
                 'paper_id' => $paperId,
                 'error' => $e->getMessage(),
                 'error' => $e->getMessage(),
+                'timeout_seconds' => config('pdf.kp_explain_fetch_timeout_seconds', 2),
             ]);
             ]);
 
 
             return null;
             return null;
@@ -1915,18 +1946,16 @@ class ExamPdfExportService
         );
         );
     }
     }
 
 
-    /**
-     * 渲染试卷HTML(优先直接渲染视图;失败再回退HTTP)
-     */
     private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
     private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
     {
     {
-        // 阶段A:优先本地直渲,降低 HTTP 自调用开销。
+        // PDF worker 已经运行在 Laravel 进程内,优先直接渲染 Blade,避免 HTTP 自调用开销。
         $html = $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
         $html = $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
-        if (! empty($html)) {
+        if ($html !== null) {
             return $html;
             return $html;
         }
         }
 
 
         try {
         try {
+            // 兜底:保留原 HTTP 路由渲染路径,避免特殊页面上下文下直接视图失败。
             $routeName = $useGradingView
             $routeName = $useGradingView
                 ? 'filament.admin.auth.intelligent-exam.grading'
                 ? 'filament.admin.auth.intelligent-exam.grading'
                 : 'filament.admin.auth.intelligent-exam.pdf';
                 : 'filament.admin.auth.intelligent-exam.pdf';
@@ -2322,6 +2351,7 @@ class ExamPdfExportService
                     $masteryData = array_map(function ($item) {
                     $masteryData = array_map(function ($item) {
                         if (is_object($item)) {
                         if (is_object($item)) {
                             $kpCode = $item->kp_code ?? null;
                             $kpCode = $item->kp_code ?? null;
+
                             return [
                             return [
                                 'kp_code' => $kpCode,
                                 'kp_code' => $kpCode,
                                 'kp_name' => $item->kp_name ?? null,
                                 'kp_name' => $item->kp_name ?? null,
@@ -2752,7 +2782,9 @@ class ExamPdfExportService
      */
      */
     private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
     private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
     {
     {
+        $startedAt = microtime(true);
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
+        $prepareStartedAt = microtime(true);
         $utf8Html = $this->ensureUtf8Html($html);
         $utf8Html = $this->ensureUtf8Html($html);
         if ($applyWideImageSizing) {
         if ($applyWideImageSizing) {
             $utf8Html = $scopeToExamPart
             $utf8Html = $scopeToExamPart
@@ -2761,10 +2793,11 @@ class ExamPdfExportService
         }
         }
         $written = file_put_contents($tmpHtml, $utf8Html);
         $written = file_put_contents($tmpHtml, $utf8Html);
 
 
-        Log::debug('ExamPdfExportService: HTML文件已创建', [
+        Log::info('ExamPdfExportService: PDF HTML准备完成', [
             'path' => $tmpHtml,
             'path' => $tmpHtml,
             'html_length' => strlen($utf8Html),
             'html_length' => strlen($utf8Html),
             'written_bytes' => $written,
             'written_bytes' => $written,
+            'elapsed_ms' => (int) round((microtime(true) - $prepareStartedAt) * 1000),
         ]);
         ]);
 
 
         // 【调试】如果启用了HTML保存调试,复制HTML到storage用于分析
         // 【调试】如果启用了HTML保存调试,复制HTML到storage用于分析
@@ -2775,11 +2808,152 @@ class ExamPdfExportService
             Log::debug('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]);
             Log::debug('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]);
         }
         }
 
 
-        // 仅使用Chrome渲染
-        $chromePdf = $this->renderWithChrome($tmpHtml);
+        $pdf = $this->renderWithConfiguredBackend($tmpHtml);
         @unlink($tmpHtml);
         @unlink($tmpHtml);
 
 
-        return $chromePdf;
+        Log::info('ExamPdfExportService: buildPdf完成', [
+            'success' => $pdf !== null,
+            'pdf_size' => $pdf !== null ? strlen($pdf) : null,
+            'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+        ]);
+
+        return $pdf;
+    }
+
+    /**
+     * 根据配置选择 PDF 渲染后端。
+     */
+    private function renderWithConfiguredBackend(string $htmlPath): ?string
+    {
+        $renderer = strtolower(trim((string) config('pdf.renderer', 'gotenberg')));
+
+        if ($renderer === 'gotenberg') {
+            $pdf = $this->renderWithGotenberg($htmlPath);
+            if ($pdf !== null) {
+                return $pdf;
+            }
+
+            if ($this->shouldFallbackToChrome()) {
+                Log::warning('ExamPdfExportService: Gotenberg 渲染失败,回退 Chrome CLI', [
+                    'html_path' => $htmlPath,
+                ]);
+
+                return $this->renderWithChrome($htmlPath);
+            }
+
+            Log::error('ExamPdfExportService: Gotenberg 渲染失败,配置禁止回退 Chrome', [
+                'html_path' => $htmlPath,
+            ]);
+
+            return null;
+        }
+
+        if ($renderer === 'chrome') {
+            return $this->renderWithChrome($htmlPath);
+        }
+
+        Log::error('ExamPdfExportService: 未识别的 PDF_RENDERER,拒绝渲染', [
+            'renderer' => $renderer,
+            'html_path' => $htmlPath,
+            'supported_renderers' => ['gotenberg', 'chrome'],
+        ]);
+
+        return null;
+    }
+
+    /**
+     * 通过 Gotenberg 常驻服务生成 PDF。
+     */
+    private function renderWithGotenberg(string $htmlPath): ?string
+    {
+        $startedAt = microtime(true);
+        $baseUrl = rtrim((string) config('pdf.gotenberg_url', 'http://gotenberg:3000'), '/');
+        $connectTimeout = max(1, (int) config('pdf.gotenberg_connect_timeout_seconds', 3));
+        $timeout = max(5, (int) config('pdf.gotenberg_timeout_seconds', 60));
+        $trace = 'exam-pdf-'.basename($htmlPath).'-'.str_replace('.', '', uniqid('', true));
+
+        if ($baseUrl === '') {
+            Log::error('ExamPdfExportService: Gotenberg URL 为空', [
+                'html_path' => $htmlPath,
+            ]);
+
+            return null;
+        }
+
+        if (! is_file($htmlPath)) {
+            Log::error('ExamPdfExportService: Gotenberg 渲染失败,HTML文件不存在', [
+                'html_path' => $htmlPath,
+            ]);
+
+            return null;
+        }
+
+        try {
+            $htmlContent = file_get_contents($htmlPath);
+            if ($htmlContent === false) {
+                Log::error('ExamPdfExportService: Gotenberg 渲染失败,HTML文件读取失败', [
+                    'trace' => $trace,
+                    'html_path' => $htmlPath,
+                ]);
+
+                return null;
+            }
+
+            $response = Http::timeout($timeout)
+                ->connectTimeout($connectTimeout)
+                ->withHeaders([
+                    'Gotenberg-Trace' => $trace,
+                    'Gotenberg-Output-Filename' => 'exam.pdf',
+                ])
+                ->attach('files', $htmlContent, 'index.html')
+                ->post($baseUrl.'/forms/chromium/convert/html', [
+                    'preferCssPageSize' => 'true',
+                    'printBackground' => 'true',
+                ]);
+
+            $body = $response->body();
+            $contentType = strtolower((string) $response->header('Content-Type', ''));
+            $isPdfContent = str_contains($contentType, 'application/pdf')
+                || str_starts_with($body, '%PDF-');
+
+            if ($response->successful() && $isPdfContent && $body !== '') {
+                Log::info('ExamPdfExportService: Gotenberg 渲染成功', [
+                    'trace' => $trace,
+                    'status' => $response->status(),
+                    'pdf_size' => strlen($body),
+                    'content_type' => $contentType,
+                    'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+                ]);
+
+                return $body;
+            }
+
+            Log::error('ExamPdfExportService: Gotenberg 渲染失败', [
+                'trace' => $trace,
+                'status' => $response->status(),
+                'body_size' => strlen($body),
+                'content_type' => $contentType,
+                'is_pdf_content' => $isPdfContent,
+                'body_preview' => mb_substr($body, 0, 500),
+                'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+            ]);
+        } catch (\Throwable $e) {
+            Log::error('ExamPdfExportService: Gotenberg 请求异常', [
+                'trace' => $trace,
+                'url' => $baseUrl,
+                'error' => $e->getMessage(),
+                'connect_timeout_seconds' => $connectTimeout,
+                'timeout_seconds' => $timeout,
+                'elapsed_ms' => (int) round((microtime(true) - $startedAt) * 1000),
+            ]);
+        }
+
+        return null;
+    }
+
+    private function shouldFallbackToChrome(): bool
+    {
+        return (bool) config('pdf.fallback_to_chrome', true);
     }
     }
 
 
     /**
     /**
@@ -3131,6 +3305,7 @@ class ExamPdfExportService
             if ($result !== null) {
             if ($result !== null) {
                 return $result;
                 return $result;
             }
             }
+
             return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
             return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
         } catch (\Throwable $e) {
         } catch (\Throwable $e) {
             if ($process->isRunning()) {
             if ($process->isRunning()) {

+ 25 - 0
config/pdf.php

@@ -12,6 +12,31 @@ return [
     */
     */
     'debug_save_html' => env('PDF_DEBUG_SAVE_HTML', false),
     'debug_save_html' => env('PDF_DEBUG_SAVE_HTML', false),
 
 
+    /*
+    |--------------------------------------------------------------------------
+    | PDF渲染后端
+    |--------------------------------------------------------------------------
+    |
+    | gotenberg:通过常驻 Gotenberg 服务生成 PDF,减少每次启动 Chrome 的长尾耗时。
+    | chrome:保留原有本机 Chrome CLI 渲染路径,作为兼容/兜底方案。
+    |
+    */
+    'renderer' => env('PDF_RENDERER', 'gotenberg'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Gotenberg 服务配置
+    |--------------------------------------------------------------------------
+    |
+    | PDF worker 通过内网访问 Gotenberg 服务;超时控制单次转换最长等待时间。
+    |
+    */
+    'gotenberg_url' => env('GOTENBERG_URL', 'http://gotenberg:3000'),
+    'gotenberg_connect_timeout_seconds' => (int) env('PDF_GOTENBERG_CONNECT_TIMEOUT_SECONDS', 3),
+    'gotenberg_timeout_seconds' => (int) env('PDF_GOTENBERG_TIMEOUT_SECONDS', 60),
+    'fallback_to_chrome' => env('PDF_FALLBACK_TO_CHROME', true),
+    'kp_explain_fetch_timeout_seconds' => (int) env('PDF_KP_EXPLAIN_FETCH_TIMEOUT_SECONDS', 2),
+
     /*
     /*
     |--------------------------------------------------------------------------
     |--------------------------------------------------------------------------
     | Chrome 轮询超时(秒)
     | Chrome 轮询超时(秒)

+ 40 - 5
docker-compose.pdf.yml

@@ -1,7 +1,9 @@
 services:
 services:
   # 主应用(Web API 服务)- 用于渲染 HTML
   # 主应用(Web API 服务)- 用于渲染 HTML
   app:
   app:
-    build: .
+    build:
+      context: .
+      target: app-runtime
     container_name: math_cms_app
     container_name: math_cms_app
     ports:
     ports:
       - "8000:8000"
       - "8000:8000"
@@ -18,9 +20,28 @@ services:
       retries: 3
       retries: 3
       start_period: 40s
       start_period: 40s
 
 
+  # Gotenberg PDF 转换服务(内网访问,不暴露公网端口)
+  gotenberg:
+    image: gotenberg/gotenberg:8
+    container_name: math_cms_gotenberg
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 20s
+    deploy:
+      resources:
+        limits:
+          cpus: '2'
+          memory: 1536M
+
   # PDF Worker 1
   # PDF Worker 1
   pdf-worker-1:
   pdf-worker-1:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_1
     container_name: pdf_worker_1
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
     env_file:
@@ -33,6 +54,8 @@ services:
     depends_on:
     depends_on:
       app:
       app:
         condition: service_healthy
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
       interval: 30s
@@ -47,7 +70,9 @@ services:
 
 
   # PDF Worker 2
   # PDF Worker 2
   pdf-worker-2:
   pdf-worker-2:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_2
     container_name: pdf_worker_2
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
     env_file:
@@ -60,6 +85,8 @@ services:
     depends_on:
     depends_on:
       app:
       app:
         condition: service_healthy
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
       interval: 30s
@@ -74,7 +101,9 @@ services:
 
 
   # PDF Worker 3
   # PDF Worker 3
   pdf-worker-3:
   pdf-worker-3:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_3
     container_name: pdf_worker_3
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
     env_file:
@@ -87,6 +116,8 @@ services:
     depends_on:
     depends_on:
       app:
       app:
         condition: service_healthy
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
       interval: 30s
@@ -101,7 +132,9 @@ services:
 
 
   # PDF Worker 4
   # PDF Worker 4
   pdf-worker-4:
   pdf-worker-4:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: pdf_worker_4
     container_name: pdf_worker_4
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=3 --max-time=300 --max-jobs=10
     env_file:
     env_file:
@@ -114,6 +147,8 @@ services:
     depends_on:
     depends_on:
       app:
       app:
         condition: service_healthy
         condition: service_healthy
+      gotenberg:
+        condition: service_healthy
     healthcheck:
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       interval: 30s
       interval: 30s

+ 29 - 3
docker-compose.yml

@@ -1,7 +1,9 @@
 services:
 services:
   # 主应用(Web API 服务)- 使用 Nginx + PHP-FPM
   # 主应用(Web API 服务)- 使用 Nginx + PHP-FPM
   app:
   app:
-    build: .
+    build:
+      context: .
+      target: app-runtime
     container_name: math_cms_app
     container_name: math_cms_app
     # 使用 Dockerfile 中的默认 CMD(nginx + php-fpm)
     # 使用 Dockerfile 中的默认 CMD(nginx + php-fpm)
     ports:
     ports:
@@ -21,7 +23,9 @@ services:
 
 
   # 队列 Worker(通用任务队列)
   # 队列 Worker(通用任务队列)
   queue:
   queue:
-    build: .
+    build:
+      context: .
+      target: app-runtime
     container_name: math_cms_queue
     container_name: math_cms_queue
     command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
     command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
     env_file:
     env_file:
@@ -44,9 +48,28 @@ services:
           cpus: '1'
           cpus: '1'
           memory: 512M
           memory: 512M
 
 
+  # Gotenberg PDF 转换服务(内网访问,不暴露公网端口)
+  gotenberg:
+    image: gotenberg/gotenberg:8
+    container_name: math_cms_gotenberg
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 20s
+    deploy:
+      resources:
+        limits:
+          cpus: '2'
+          memory: 1536M
+
   # PDF 生成专用 Worker(资源隔离,防止 Chrome 吃满 CPU)
   # PDF 生成专用 Worker(资源隔离,防止 Chrome 吃满 CPU)
   pdf-worker:
   pdf-worker:
-    build: .
+    build:
+      context: .
+      target: pdfworker
     container_name: math_cms_pdf
     container_name: math_cms_pdf
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=2 --max-time=300 --max-jobs=10
     command: php artisan queue:work --queue=pdf --sleep=3 --tries=2 --max-time=300 --max-jobs=10
     env_file:
     env_file:
@@ -56,6 +79,9 @@ services:
       - ./.env:/app/.env
       - ./.env:/app/.env
     restart: unless-stopped
     restart: unless-stopped
     stop_grace_period: 120s
     stop_grace_period: 120s
+    depends_on:
+      gotenberg:
+        condition: service_healthy
     # PDF worker 健康检查:检查进程是否存在
     # PDF worker 健康检查:检查进程是否存在
     healthcheck:
     healthcheck:
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]
       test: ["CMD-SHELL", "pgrep -f 'queue:work' || exit 1"]

+ 26 - 9
docs/pdf-generation.md

@@ -25,7 +25,7 @@ Blade模板渲染 (exam-paper.blade.php + paper-body.blade.php)
 mergeHtmlWithPageBreak()  合并两个HTML
 mergeHtmlWithPageBreak()  合并两个HTML
-buildPdf() → renderWithChrome()  Chrome Headless 转 PDF
+buildPdf() → Gotenberg(默认)/ Chrome CLI(兜底)转 PDF
 上传到OSS,返回URL
 上传到OSS,返回URL
 ```
 ```
@@ -50,8 +50,9 @@ buildPdf() → renderWithChrome()  Chrome Headless 转 PDF
   - `generateUnifiedPdf()` - 生成统一PDF(试卷+判卷)
   - `generateUnifiedPdf()` - 生成统一PDF(试卷+判卷)
   - `renderExamHtml()` - 通过HTTP请求获取渲染后的HTML
   - `renderExamHtml()` - 通过HTTP请求获取渲染后的HTML
   - `mergeHtmlWithPageBreak()` - 合并两个HTML页面
   - `mergeHtmlWithPageBreak()` - 合并两个HTML页面
-  - `buildPdf()` - 调用Chrome生成PDF
-  - `renderWithChrome()` - Chrome Headless渲染
+  - `buildPdf()` - 按配置选择 PDF 渲染后端
+  - `renderWithGotenberg()` - Gotenberg 常驻 Chromium 服务渲染
+  - `renderWithChrome()` - Chrome CLI 兜底渲染
 
 
 ### 4. HTML渲染控制器
 ### 4. HTML渲染控制器
 
 
@@ -141,15 +142,15 @@ paper-body.blade.php 按类型渲染
 - 选择题因含有`()`被误判为填空题
 - 选择题因含有`()`被误判为填空题
 - 修复方法:优先使用 `question_type` 字段,而非根据内容推断
 - 修复方法:优先使用 `question_type` 字段,而非根据内容推断
 
 
-### 问题3:Chrome渲染问题
-
+### 问题3:PDF渲染问题
 **症状**:PDF生成失败或内容不完整
 **症状**:PDF生成失败或内容不完整
 
 
 **排查点**:
 **排查点**:
-
-1. 检查Chrome是否安装:`which google-chrome` 或 `which chromium`
-2. 检查超时设置(当前90秒)
-3. 检查外部资源加载(KaTeX CDN等)
+1. 默认检查 Gotenberg 是否健康:`curl http://gotenberg:3000/health`(容器内)或 `docker compose ps gotenberg`
+2. 检查渲染后端配置:`PDF_RENDERER=gotenberg|chrome`
+3. 检查 Gotenberg 超时:`PDF_GOTENBERG_TIMEOUT_SECONDS`
+4. 如果启用 Chrome 兜底,检查 Chrome 是否安装:`which google-chrome` 或 `which chromium`
+5. 检查外部资源加载(KaTeX 已内联;远程图片仍需网络可达)
 
 
 ### 问题4:HTML结构问题
 ### 问题4:HTML结构问题
 
 
@@ -219,11 +220,15 @@ preg_match('/^(.+?)(?=(?:^|\s)[A-D][\.、:.:])/su', $content, $match);
 - `math_cms_app` - 主应用 (Nginx + PHP-FPM)
 - `math_cms_app` - 主应用 (Nginx + PHP-FPM)
 - `math_cms_queue` - 默认队列 worker
 - `math_cms_queue` - 默认队列 worker
 - `math_cms_pdf` - PDF队列 worker(带Chrome)
 - `math_cms_pdf` - PDF队列 worker(带Chrome)
+- `math_cms_gotenberg` - 默认 PDF 转换服务(常驻 Chromium)
 
 
 ```bash
 ```bash
 # 重启PDF队列
 # 重启PDF队列
 docker compose restart pdf-worker
 docker compose restart pdf-worker
 
 
+# 重启Gotenberg
+docker compose restart gotenberg
+
 # 查看队列状态
 # 查看队列状态
 docker exec math_cms_app php artisan queue:failed
 docker exec math_cms_app php artisan queue:failed
 ```
 ```
@@ -237,3 +242,15 @@ docker exec math_cms_app php artisan queue:failed
 - 默认行为统一为:仅当 `papers.paper_type = 2`(知识点组题)时,统一 PDF 包含“知识点讲解”。
 - 默认行为统一为:仅当 `papers.paper_type = 2`(知识点组题)时,统一 PDF 包含“知识点讲解”。
 - `paper_type = 3`(教材组题)等其他类型,统一 PDF 不包含“知识点讲解”。
 - `paper_type = 3`(教材组题)等其他类型,统一 PDF 不包含“知识点讲解”。
 - `/api/papers/{paper_id}/regenerate` 与组卷队列保持一致:仅知识点组卷类型关联“知识点讲解”,不再提供外部覆盖参数。
 - `/api/papers/{paper_id}/regenerate` 与组卷队列保持一致:仅知识点组卷类型关联“知识点讲解”,不再提供外部覆盖参数。
+
+关键环境变量:
+
+```env
+PDF_RENDERER=gotenberg
+GOTENBERG_URL=http://gotenberg:3000
+PDF_GOTENBERG_CONNECT_TIMEOUT_SECONDS=3
+PDF_GOTENBERG_TIMEOUT_SECONDS=60
+PDF_FALLBACK_TO_CHROME=true
+PDF_CHROME_POLL_TIMEOUT_SECONDS=40
+PDF_KP_EXPLAIN_FETCH_TIMEOUT_SECONDS=2
+```

+ 9 - 3
docs/pdf-worker-deployment.md

@@ -65,7 +65,7 @@ docker compose -f docker-compose.pdf.yml up -d
 docker compose -f docker-compose.pdf.yml ps
 docker compose -f docker-compose.pdf.yml ps
 ```
 ```
 
 
-预期输出:5 个容器都是 `Up` 状态
+预期输出:6 个容器都是 `Up` 状态(包含 `math_cms_gotenberg`)
 
 
 ### 3.2 检查数据库连接
 ### 3.2 检查数据库连接
 
 
@@ -79,7 +79,13 @@ docker exec pdf_worker_1 php artisan db:show
 docker exec pdf_worker_1 php artisan tinker --execute="dump(Redis::ping());"
 docker exec pdf_worker_1 php artisan tinker --execute="dump(Redis::ping());"
 ```
 ```
 
 
-### 3.4 检查队列消费
+### 3.4 检查 Gotenberg 连接
+
+```bash
+docker exec pdf_worker_1 php artisan tinker --execute="dump(\Illuminate\Support\Facades\Http::get(config('pdf.gotenberg_url').'/health')->json());"
+```
+
+### 3.5 检查队列消费
 
 
 ```bash
 ```bash
 # 查看队列长度
 # 查看队列长度
@@ -89,7 +95,7 @@ docker exec pdf_worker_1 php artisan tinker --execute="echo Redis::llen('queues:
 docker compose -f docker-compose.pdf.yml logs -f pdf-worker-1
 docker compose -f docker-compose.pdf.yml logs -f pdf-worker-1
 ```
 ```
 
 
-### 3.5 端到端测试
+### 3.6 端到端测试
 
 
 在主服务器触发一个 PDF 生成,观察 PDF 机器日志是否有消费记录。
 在主服务器触发一个 PDF 生成,观察 PDF 机器日志是否有消费记录。