Bläddra i källkod

优化合并 pdf 功能

yemeishu 8 timmar sedan
förälder
incheckning
bf756c207b

+ 6 - 4
app/Http/Controllers/Api/IntelligentExamController.php

@@ -376,13 +376,15 @@ class IntelligentExamController extends Controller
                 'paper_id' => $paperId
             ]);
         } catch (\Exception $e) {
-            Log::error('PDF生成任务队列失败,回退到同步处理', [
+            Log::error('PDF生成任务队列失败,回退到同步处理', [
                 'task_id' => $taskId,
                 'paper_id' => $paperId,
-                'error' => $e->getMessage()
+                'error' => $e->getMessage(),
+                'note' => '依赖队列重试机制,不进行同步处理以避免并发冲突'
             ]);
-            // 队列失败时回退到同步处理
-            $this->processPdfGeneration($taskId, $paperId);
+            // 【优化】不回退到同步处理,避免与队列任务并发冲突
+            // 队列系统有重试机制,会自动处理失败情况
+            // $this->processPdfGeneration($taskId, $paperId);
         }
     }
 

+ 47 - 0
app/Jobs/GenerateExamPdfJob.php

@@ -41,6 +41,17 @@ class GenerateExamPdfJob implements ShouldQueue
                 'attempt' => $this->attempts(),
             ]);
 
+            // 【新增】快速检查:如果任务已完成,直接跳过
+            $task = $taskManager->getTaskStatus($this->taskId);
+            if ($task && $task['status'] === 'completed') {
+                Log::info('【跳过执行】任务已完成,无需重复生成PDF', [
+                    'task_id' => $this->taskId,
+                    'paper_id' => $this->paperId,
+                    'status' => $task['status']
+                ]);
+                return;
+            }
+
             // 【修复】首先检查试卷是否存在
             $paperModel = Paper::with('questions')->find($this->paperId);
             if (!$paperModel) {
@@ -117,6 +128,42 @@ class GenerateExamPdfJob implements ShouldQueue
                 $taskManager->updateTaskProgress($this->taskId, round($progress, 0), $message);
             });
 
+            // 【新增】验证合并后的PDF URL
+            if (!$mergedPdfUrl) {
+                Log::error('PDF生成队列任务失败:合并PDF失败', [
+                    'task_id' => $this->taskId,
+                    'paper_id' => $this->paperId,
+                    'attempt' => $this->attempts(),
+                ]);
+
+                if ($this->attempts() < $this->maxAttempts) {
+                    Log::info('合并PDF失败,将在3秒后重试', [
+                        'task_id' => $this->taskId,
+                        'paper_id' => $this->paperId,
+                        'attempt' => $this->attempts(),
+                        'next_attempt' => $this->attempts() + 1,
+                    ]);
+                    // 延迟3秒后重试
+                    $this->release(3);
+                    return;
+                } else {
+                    Log::error('合并PDF失败且已达到最大重试次数,标记任务失败', [
+                        'task_id' => $this->taskId,
+                        'paper_id' => $this->paperId,
+                        'attempts' => $this->attempts(),
+                    ]);
+                    $taskManager->markTaskFailed($this->taskId, "合并PDF失败: {$this->paperId}");
+                    return;
+                }
+            }
+
+            Log::info('PDF合并成功验证', [
+                'task_id' => $this->taskId,
+                'paper_id' => $this->paperId,
+                'merged_pdf_url' => $mergedPdfUrl,
+                'url_length' => strlen($mergedPdfUrl)
+            ]);
+
             // 构建完整的试卷内容
             $examContent = $paperPayloadService->buildExamContent($paperModel);
 

+ 285 - 103
app/Services/ExamPdfExportService.php

@@ -39,14 +39,38 @@ class ExamPdfExportService
     public function generateExamPdf(string $paperId): ?string
     {
         Log::info('generateExamPdf 开始:', ['paper_id' => $paperId]);
-        $url = $this->renderAndStoreExamPdf($paperId, includeAnswer: false, suffix: 'exam');
-        Log::info('generateExamPdf url 生成结果:', ['paper_id' => $paperId, 'url' => $url]);
-        // 如果生成成功,将 URL 写入数据库
-        if ($url) {
-            $this->savePdfUrlToDatabase($paperId, 'exam_pdf_url', $url);
+
+        // 返回页面URL(用于数据库保存)
+        $pageUrl = route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'false']);
+        Log::info('generateExamPdf 页面URL:', ['paper_id' => $paperId, 'url' => $pageUrl]);
+        // 将页面URL写入数据库
+        $this->savePdfUrlToDatabase($paperId, 'exam_pdf_url', $pageUrl);
+
+        // 生成PDF文件(用于合并,不上传云存储)
+        $pdfPath = storage_path("app/public/exams/{$paperId}_exam.pdf");
+        Log::info('ExamPdfExportService: 开始生成试卷PDF', ['path' => $pdfPath, 'url' => $pageUrl]);
+        $pdfBinary = $this->buildPdfFromUrl($pageUrl);
+        if (!$pdfBinary) {
+            Log::error('ExamPdfExportService: 生成试卷PDF失败', ['url' => $pageUrl]);
+            return null;
+        }
+        Log::info('ExamPdfExportService: PDF生成成功,开始写入文件', ['path' => $pdfPath, 'size' => strlen($pdfBinary)]);
+        $result = file_put_contents($pdfPath, $pdfBinary);
+        if ($result === false) {
+            Log::error('ExamPdfExportService: 写入试卷PDF文件失败', ['path' => $pdfPath]);
+            return null;
         }
 
-        return $url;
+        // 【关键修复】验证文件是否真的写入成功
+        if (!file_exists($pdfPath)) {
+            Log::error('ExamPdfExportService: 文件写入后不存在', ['path' => $pdfPath, 'result' => $result]);
+            return null;
+        }
+
+        Log::info('ExamPdfExportService: 试卷PDF文件写入成功', ['path' => $pdfPath, 'size' => $result]);
+
+        // 返回页面URL(不是PDF URL)
+        return $pageUrl;
     }
 
     /**
@@ -55,25 +79,65 @@ class ExamPdfExportService
     public function generateGradingPdf(string $paperId): ?string
     {
         Log::info('generateGradingPdf 开始:', ['paper_id' => $paperId]);
-        $url = $this->renderAndStoreExamPdf($paperId, includeAnswer: true, suffix: 'grading', useGradingView: true);
-        Log::info('generateGradingPdf url 生成结果:', ['paper_id' => $paperId, 'url' => $url]);
-        // 如果生成成功,将 URL 写入数据库
-        if ($url) {
-            $this->savePdfUrlToDatabase($paperId, 'grading_pdf_url', $url);
+
+        // 返回页面URL(用于数据库保存)
+        $pageUrl = route('filament.admin.auth.intelligent-exam.pdf', ['paper_id' => $paperId, 'answer' => 'true']);
+        Log::info('generateGradingPdf 页面URL:', ['paper_id' => $paperId, 'url' => $pageUrl]);
+        // 将页面URL写入数据库
+        $this->savePdfUrlToDatabase($paperId, 'grading_pdf_url', $pageUrl);
+
+        // 生成PDF文件(用于合并,不上传云存储)
+        $pdfPath = storage_path("app/public/exams/{$paperId}_grading.pdf");
+        Log::info('ExamPdfExportService: 开始生成判卷PDF', ['path' => $pdfPath, 'url' => $pageUrl]);
+        $pdfBinary = $this->buildPdfFromUrl($pageUrl);
+        if (!$pdfBinary) {
+            Log::error('ExamPdfExportService: 生成判卷PDF失败', ['url' => $pageUrl]);
+            return null;
+        }
+        Log::info('ExamPdfExportService: 判卷PDF生成成功,开始写入文件', ['path' => $pdfPath, 'size' => strlen($pdfBinary)]);
+        $result = file_put_contents($pdfPath, $pdfBinary);
+        if ($result === false) {
+            Log::error('ExamPdfExportService: 写入判卷PDF文件失败', ['path' => $pdfPath]);
+            return null;
+        }
+
+        // 【关键修复】验证文件是否真的写入成功
+        if (!file_exists($pdfPath)) {
+            Log::error('ExamPdfExportService: 判卷文件写入后不存在', ['path' => $pdfPath, 'result' => $result]);
+            return null;
         }
 
-        return $url;
+        Log::info('ExamPdfExportService: 判卷PDF文件写入成功', ['path' => $pdfPath, 'size' => $result]);
+
+        // 返回页面URL(不是PDF URL)
+        return $pageUrl;
     }
 
     /**
      * 生成合并PDF(试卷 + 判卷)
      * 先分别生成两个PDF,然后合并
      * 【优化】添加进度回调支持和快速合并模式
+     * 【修复】优化临时文件清理逻辑,确保合并成功后才删除源文件
      */
     public function generateMergedPdf(string $paperId, ?callable $progressCallback = null): ?string
     {
         Log::info('generateMergedPdf 开始:', ['paper_id' => $paperId]);
 
+        // 【新增】快速幂等性检查:如果all_pdf_url已存在,直接返回
+        $existingPaper = \App\Models\Paper::where('paper_id', $paperId)->first();
+        if ($existingPaper && $existingPaper->all_pdf_url) {
+            Log::info('【快速返回】合并PDF已存在,无需重新生成', [
+                'paper_id' => $paperId,
+                'existing_url' => $existingPaper->all_pdf_url
+            ]);
+
+            if ($progressCallback) {
+                $progressCallback(100, '合并PDF已存在,直接返回');
+            }
+
+            return $existingPaper->all_pdf_url;
+        }
+
         if ($progressCallback) {
             $progressCallback(0, '准备合并PDF...');
         }
@@ -86,135 +150,142 @@ class ExamPdfExportService
         $examPdfPath = null;
         $gradingPdfPath = null;
         $mergedPdfPath = null;
+        $mergeSuccess = false;
+        $uploadSuccess = false;
 
         try {
-            // 先生成试卷PDF
-            if ($progressCallback) {
-                $progressCallback(10, '生成试卷PDF...');
-            }
-            $examPdfUrl = $this->generateExamPdf($paperId);
-            if (!$examPdfUrl) {
-                Log::error('ExamPdfExportService: 生成试卷PDF失败', ['paper_id' => $paperId]);
-                return null;
-            }
-
-            // 再生成判卷PDF
-            if ($progressCallback) {
-                $progressCallback(30, '生成判卷PDF...');
-            }
-            $gradingPdfUrl = $this->generateGradingPdf($paperId);
-            if (!$gradingPdfUrl) {
-                Log::error('ExamPdfExportService: 生成判卷PDF失败', ['paper_id' => $paperId]);
-                return null;
+            // 【修复】不重复生成PDF,直接使用已有的文件
+            // 假设PDF已经通过generateExamPdf和generateGradingPdf生成过了
+            // 获取数据库中的页面URL(用于记录)
+            $paper = \App\Models\Paper::where('paper_id', $paperId)->first();
+            $examPdfUrl = $paper?->exam_pdf_url;
+            $gradingPdfUrl = $paper?->grading_pdf_url;
+
+            if (!$examPdfUrl || !$gradingPdfUrl) {
+                Log::warning('ExamPdfExportService: 未找到PDF页面URL,可能尚未生成', [
+                    'paper_id' => $paperId,
+                    'exam_pdf_url' => $examPdfUrl,
+                    'grading_pdf_url' => $gradingPdfUrl
+                ]);
             }
 
-            // 【修复】下载PDF文件到本地临时目录
-            Log::info('开始下载PDF文件到本地', [
+            Log::info('使用本地PDF文件进行合并', [
                 'exam_url' => $examPdfUrl,
                 'grading_url' => $gradingPdfUrl
             ]);
 
             if ($progressCallback) {
-                $progressCallback(40, '下载试卷PDF...');
+                $progressCallback(10, '验证PDF文件...');
             }
 
-            $examPdfPath = $tempDir . "/{$paperId}_exam.pdf";
-            $gradingPdfPath = $tempDir . "/{$paperId}_grading.pdf";
+            // 直接使用本地PDF文件
+            $examPdfPath = storage_path("app/public/exams/{$paperId}_exam.pdf");
+            $gradingPdfPath = storage_path("app/public/exams/{$paperId}_grading.pdf");
+
+            // 验证文件是否存在
+            Log::info('ExamPdfExportService: 检查PDF文件是否存在', [
+                'exam_pdf' => $examPdfPath,
+                'exam_exists' => file_exists($examPdfPath),
+                'grading_pdf' => $gradingPdfPath,
+                'grading_exists' => file_exists($gradingPdfPath)
+            ]);
 
-            // 【修复】下载试卷PDF - 添加HTTP状态码检查
-            $examResponse = Http::get($examPdfUrl);
-            if (!$examResponse->successful()) {
-                Log::error('ExamPdfExportService: 下载试卷PDF失败', [
-                    'url' => $examPdfUrl,
-                    'status_code' => $examResponse->status()
+            if (!file_exists($examPdfPath)) {
+                Log::error('ExamPdfExportService: 试卷PDF文件不存在', [
+                    'path' => $examPdfUrl,
+                    'local_path' => $examPdfPath,
+                    'directory_exists' => is_dir(dirname($examPdfPath)),
+                    'directory_contents' => is_dir(dirname($examPdfPath)) ? scandir(dirname($examPdfPath)) : null
                 ]);
                 return null;
             }
 
-            $examContent = $examResponse->body();
-            if (empty($examContent)) {
-                Log::error('ExamPdfExportService: 下载试卷PDF内容为空', ['url' => $examPdfUrl]);
+            if (!file_exists($gradingPdfPath)) {
+                Log::error('ExamPdfExportService: 判卷PDF文件不存在', [
+                    'path' => $gradingPdfUrl,
+                    'local_path' => $gradingPdfPath,
+                    'directory_exists' => is_dir(dirname($gradingPdfPath)),
+                    'directory_contents' => is_dir(dirname($gradingPdfPath)) ? scandir(dirname($gradingPdfPath)) : null
+                ]);
                 return null;
             }
 
-            // 写入文件并验证
-            if (file_put_contents($examPdfPath, $examContent) === false) {
-                Log::error('ExamPdfExportService: 写入试卷PDF文件失败', ['path' => $examPdfPath]);
-                return null;
-            }
+            $examSize = filesize($examPdfPath);
+            $gradingSize = filesize($gradingPdfPath);
 
-            if (!file_exists($examPdfPath) || filesize($examPdfPath) === 0) {
-                Log::error('ExamPdfExportService: 试卷PDF文件无效', [
-                    'path' => $examPdfPath,
-                    'exists' => file_exists($examPdfPath),
-                    'size' => file_exists($examPdfPath) ? filesize($examPdfPath) : 'N/A'
+            Log::info('PDF文件验证成功', [
+                'exam_pdf' => $examPdfPath,
+                'grading_pdf' => $gradingPdfPath,
+                'exam_size' => $examSize,
+                'grading_size' => $gradingSize,
+                'total_size' => $examSize + $gradingSize
+            ]);
+
+            if ($examSize < 1000 || $gradingSize < 1000) {
+                Log::warning('ExamPdfExportService: PDF文件过小,可能生成不完整', [
+                    'exam_size' => $examSize,
+                    'grading_size' => $gradingSize
                 ]);
-                return null;
             }
 
             if ($progressCallback) {
-                $progressCallback(50, '下载判卷PDF...');
+                $progressCallback(20, '开始合并PDF文件...');
             }
 
-            // 【修复】下载判卷PDF - 添加HTTP状态码检查
-            $gradingResponse = Http::get($gradingPdfUrl);
-            if (!$gradingResponse->successful()) {
-                Log::error('ExamPdfExportService: 下载判卷PDF失败', [
-                    'url' => $gradingPdfUrl,
-                    'status_code' => $gradingResponse->status()
+            // 【优化】合并PDF文件 - 使用快速合并模式
+            $mergedPdfPath = $tempDir . "/{$paperId}_merged.pdf";
+            $merged = $this->pdfMerger->mergeWithProgress([$examPdfPath, $gradingPdfPath], $mergedPdfPath, $progressCallback);
+
+            if (!$merged) {
+                Log::error('ExamPdfExportService: PDF文件合并失败', [
+                    'tool' => $this->pdfMerger->getMergeTool(),
+                    'exam_pdf' => $examPdfPath,
+                    'grading_pdf' => $gradingPdfPath,
+                    'output_pdf' => $mergedPdfPath
                 ]);
                 return null;
             }
 
-            $gradingContent = $gradingResponse->body();
-            if (empty($gradingContent)) {
-                Log::error('ExamPdfExportService: 下载判卷PDF内容为空', ['url' => $gradingPdfUrl]);
+            // 【新增】验证合并后的PDF内容
+            if (!file_exists($mergedPdfPath)) {
+                Log::error('ExamPdfExportService: 合并后PDF文件不存在', ['path' => $mergedPdfPath]);
                 return null;
             }
 
-            // 写入文件并验证
-            if (file_put_contents($gradingPdfPath, $gradingContent) === false) {
-                Log::error('ExamPdfExportService: 写入判卷PDF文件失败', ['path' => $gradingPdfPath]);
-                return null;
-            }
+            $mergedSize = filesize($mergedPdfPath);
+            Log::info('ExamPdfExportService: 合并PDF验证', [
+                'merged_pdf' => $mergedPdfPath,
+                'merged_size' => $mergedSize,
+                'expected_min_size' => max($examSize, $gradingSize) + 1000,
+                'size_valid' => $mergedSize > max($examSize, $gradingSize)
+            ]);
 
-            if (!file_exists($gradingPdfPath) || filesize($gradingPdfPath) === 0) {
-                Log::error('ExamPdfExportService: 判卷PDF文件无效', [
-                    'path' => $gradingPdfPath,
-                    'exists' => file_exists($gradingPdfPath),
-                    'size' => file_exists($gradingPdfPath) ? filesize($gradingPdfPath) : 'N/A'
+            // 验证合并后的PDF大小是否合理(应该大于任一源文件)
+            if ($mergedSize <= max($examSize, $gradingSize)) {
+                Log::warning('ExamPdfExportService: 合并PDF大小异常,可能合并失败', [
+                    'merged_size' => $mergedSize,
+                    'exam_size' => $examSize,
+                    'grading_size' => $gradingSize,
+                    'max_source_size' => max($examSize, $gradingSize)
                 ]);
-                return null;
             }
 
-            Log::info('PDF文件下载完成', [
-                'exam_path' => $examPdfPath,
-                'exam_size' => filesize($examPdfPath),
-                'grading_path' => $gradingPdfPath,
-                'grading_size' => filesize($gradingPdfPath)
-            ]);
+            $mergeSuccess = true;
 
             if ($progressCallback) {
-                $progressCallback(60, '开始合并PDF文件...');
+                $progressCallback(80, '上传合并PDF...');
             }
 
-            // 【优化】合并PDF文件 - 使用快速合并模式
-            $mergedPdfPath = $tempDir . "/{$paperId}_merged.pdf";
-            $merged = $this->pdfMerger->mergeWithProgress([$examPdfPath, $gradingPdfPath], $mergedPdfPath, $progressCallback);
-
-            if (!$merged) {
-                Log::error('ExamPdfExportService: PDF文件合并失败', [
-                    'tool' => $this->pdfMerger->getMergeTool()
+            // 读取合并后的PDF内容并上传到云存储
+            $mergedPdfContent = file_get_contents($mergedPdfPath);
+            if (strlen($mergedPdfContent) < 1000) {
+                Log::error('ExamPdfExportService: 合并PDF内容过小,上传失败', [
+                    'content_size' => strlen($mergedPdfContent),
+                    'file_size' => $mergedSize
                 ]);
                 return null;
             }
 
-            if ($progressCallback) {
-                $progressCallback(90, '上传合并PDF...');
-            }
-
-            // 读取合并后的PDF内容并上传到云存储
-            $mergedPdfContent = file_get_contents($mergedPdfPath);
             $path = "exams/{$paperId}_all.pdf";
             $mergedUrl = $this->pdfStorageService->put($path, $mergedPdfContent);
 
@@ -223,13 +294,24 @@ class ExamPdfExportService
                 return null;
             }
 
+            Log::info('ExamPdfExportService: 合并PDF上传成功', [
+                'url' => $mergedUrl,
+                'content_size' => strlen($mergedPdfContent),
+                'file_size' => $mergedSize
+            ]);
+
+            $uploadSuccess = true;
+
             // 保存到数据库的all_pdf_url字段
             $this->saveAllPdfUrlToDatabase($paperId, $mergedUrl);
 
             Log::info('generateMergedPdf 完成:', [
                 'paper_id' => $paperId,
                 'url' => $mergedUrl,
-                'tool' => $this->pdfMerger->getMergeTool()
+                'tool' => $this->pdfMerger->getMergeTool(),
+                'exam_size' => $examSize,
+                'grading_size' => $gradingSize,
+                'merged_size' => $mergedSize
             ]);
             return $mergedUrl;
 
@@ -246,14 +328,48 @@ class ExamPdfExportService
 
             return null;
         } finally {
-            // 【修复】清理临时文件
-            $tempFiles = [$examPdfPath, $gradingPdfPath, $mergedPdfPath];
-            foreach ($tempFiles as $file) {
-                if ($file && file_exists($file)) {
-                    @unlink($file);
+            // 【修复】优化临时文件清理逻辑:
+            // 1. 合并失败时不删除源文件,便于重试
+            // 2. 合并成功后才删除源文件
+            // 3. 保留合并后的文件一段时间,便于调试
+
+            if ($mergeSuccess && $uploadSuccess) {
+                // 合并成功且上传成功,删除源文件
+                $sourceFiles = [$examPdfPath, $gradingPdfPath];
+                foreach ($sourceFiles as $file) {
+                    if ($file && file_exists($file)) {
+                        @unlink($file);
+                        Log::debug('删除源PDF文件', ['path' => $file]);
+                    }
                 }
+
+                // 保留合并文件30分钟后删除
+                if ($mergedPdfPath && file_exists($mergedPdfPath)) {
+                    $deletionTime = time() + 1800; // 30分钟后
+                    @touch($mergedPdfPath, $deletionTime);
+                    Log::info('合并PDF文件保留30分钟用于调试', [
+                        'path' => $mergedPdfPath,
+                        'deletion_time' => date('Y-m-d H:i:s', $deletionTime)
+                    ]);
+                }
+            } else {
+                // 合并失败或上传失败,保留所有文件用于调试
+                Log::warning('PDF合并未完全成功,保留临时文件用于调试', [
+                    'merge_success' => $mergeSuccess,
+                    'upload_success' => $uploadSuccess,
+                    'exam_pdf' => $examPdfPath,
+                    'grading_pdf' => $gradingPdfPath,
+                    'merged_pdf' => $mergedPdfPath,
+                    'exam_exists' => $examPdfPath ? file_exists($examPdfPath) : false,
+                    'grading_exists' => $gradingPdfPath ? file_exists($gradingPdfPath) : false,
+                    'merged_exists' => $mergedPdfPath ? file_exists($mergedPdfPath) : false
+                ]);
             }
-            Log::debug('清理临时文件完成');
+
+            Log::debug('PDF合并流程完成', [
+                'merge_success' => $mergeSuccess,
+                'upload_success' => $uploadSuccess
+            ]);
         }
     }
 
@@ -1009,16 +1125,82 @@ class ExamPdfExportService
      */
     private function buildPdf(string $html): ?string
     {
+        Log::info('ExamPdfExportService: buildPdf开始', ['html_size' => strlen($html)]);
+
         $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_') . '.html';
+        Log::info('ExamPdfExportService: 创建临时HTML文件', ['tmp_html' => $tmpHtml]);
+
         $utf8Html = $this->ensureUtf8Html($html);
         file_put_contents($tmpHtml, $utf8Html);
 
+        Log::info('ExamPdfExportService: HTML文件已写入', ['tmp_html' => $tmpHtml, 'size' => filesize($tmpHtml)]);
+
         // 仅使用Chrome渲染
+        Log::info('ExamPdfExportService: 开始调用renderWithChrome', ['tmp_html' => $tmpHtml]);
         $chromePdf = $this->renderWithChrome($tmpHtml);
+        Log::info('ExamPdfExportService: renderWithChrome完成', [
+            'pdf_size' => $chromePdf ? strlen($chromePdf) : 0,
+            'pdf_success' => !empty($chromePdf)
+        ]);
+
         @unlink($tmpHtml);
         return $chromePdf;
     }
 
+    /**
+     * 从URL生成PDF
+     */
+    private function buildPdfFromUrl(string $url): ?string
+    {
+        Log::info('ExamPdfExportService: buildPdfFromUrl开始', ['url' => $url]);
+
+        try {
+            $response = Http::get($url);
+            Log::info('ExamPdfExportService: HTTP请求完成', [
+                'url' => $url,
+                'status' => $response->status(),
+                'successful' => $response->successful()
+            ]);
+
+            if (!$response->successful()) {
+                Log::error('ExamPdfExportService: 获取URL内容失败', [
+                    'url' => $url,
+                    'status_code' => $response->status()
+                ]);
+                return null;
+            }
+
+            $html = $response->body();
+            $htmlSize = strlen($html);
+            Log::info('ExamPdfExportService: 获取HTML内容成功', [
+                'url' => $url,
+                'html_size' => $htmlSize,
+                'html_preview' => substr($html, 0, 100)
+            ]);
+
+            if (empty($html)) {
+                Log::error('ExamPdfExportService: URL返回内容为空', ['url' => $url]);
+                return null;
+            }
+
+            Log::info('ExamPdfExportService: 开始调用buildPdf', ['html_size' => $htmlSize]);
+            $pdfBinary = $this->buildPdf($html);
+            Log::info('ExamPdfExportService: buildPdf完成', [
+                'pdf_size' => $pdfBinary ? strlen($pdfBinary) : 0,
+                'pdf_success' => !empty($pdfBinary)
+            ]);
+
+            return $pdfBinary;
+        } catch (\Exception $e) {
+            Log::error('ExamPdfExportService: buildPdfFromUrl异常', [
+                'url' => $url,
+                'error' => $e->getMessage(),
+                'trace' => $e->getTraceAsString()
+            ]);
+            return null;
+        }
+    }
+
     /**
      * 使用Chrome渲染PDF
      */

+ 84 - 69
app/Services/PdfMerger.php

@@ -23,27 +23,23 @@ class PdfMerger
 
     /**
      * 检测系统中可用的PDF合并工具
+     * 【修复】优先使用pdfunite,更简单可靠
      */
     private function detectMergeTool(): string
     {
-        // 生产环境优先使用pdfunite
-        if ($this->isProduction) {
-            if ($this->commandExists('pdfunite')) {
-                return 'pdfunite';
-            }
+        // 优先检测pdfunite(更简单可靠)
+        if ($this->commandExists('pdfunite')) {
+            Log::info('检测到pdfunite,将使用pdfunite进行PDF合并');
+            return 'pdfunite';
         }
 
-        // 本地开发环境使用qpdf
+        // 备选qpdf
         if ($this->commandExists('qpdf')) {
+            Log::info('未检测到pdfunite,使用qpdf作为备选');
             return 'qpdf';
         }
 
-        // 备选:尝试pdfunite
-        if ($this->commandExists('pdfunite')) {
-            return 'pdfunite';
-        }
-
-        throw new \Exception('未找到可用的PDF合并工具(pdfunite或qpdf)');
+        throw new \Exception('未找到PDF合并工具(pdfunite或qpdf)');
     }
 
     /**
@@ -56,13 +52,18 @@ class PdfMerger
             // 本地开发路径
             "/opt/homebrew/bin/{$command}",
             "/usr/local/bin/{$command}",
-            // 系统路径
+            // 服务器常见路径(Ubuntu/Debian/CentOS/RHEL)
             "/usr/bin/{$command}",
             "/usr/sbin/{$command}",
+            "/usr/local/sbin/{$command}",
             // Docker/Laravel 容器常见路径
             "/bin/{$command}",
             "/sbin/{$command}",
-            "/usr/local/sbin/{$command}",
+            // Alpine Linux 容器
+            "/usr/gnu/bin/{$command}",
+            // 自定义安装路径
+            "/opt/{$command}/bin/{$command}",
+            "/usr/share/{$command}/{$command}",
         ];
 
         Log::debug("检查命令是否存在: {$command}", [
@@ -122,13 +123,11 @@ class PdfMerger
         ]);
 
         try {
-            switch ($this->mergeTool) {
-                case 'pdfunite':
-                    return $this->mergeWithPdfunite($pdfPaths, $outputPath, null);
-                case 'qpdf':
-                    return $this->mergeWithQpdf($pdfPaths, $outputPath, null);
-                default:
-                    throw new \Exception("不支持的合并工具: {$this->mergeTool}");
+            // 根据工具类型执行合并
+            if ($this->mergeTool === 'pdfunite') {
+                return $this->mergeWithPdfunite($pdfPaths, $outputPath, null);
+            } else {
+                return $this->mergeWithQpdf($pdfPaths, $outputPath, null);
             }
         } catch (\Exception $e) {
             Log::error('PDF合并失败', [
@@ -194,24 +193,22 @@ class PdfMerger
         Log::info('开始快速合并PDF', [
             'tool' => $this->mergeTool,
             'input_count' => count($pdfPaths),
-            'output_path' => $outputPath
+            'output_path' => $outputPath,
+            'tool_selection' => $this->mergeTool === 'pdfunite' ? '优先使用pdfunite(更可靠)' : '使用qpdf作为备选'
         ]);
 
         try {
             // 进度回调:开始合并
             if ($progressCallback) {
-                $progressCallback(20, "使用 {$this->mergeTool} 开始合并PDF...");
+                $toolName = $this->mergeTool === 'pdfunite' ? 'pdfunite' : 'qpdf';
+                $progressCallback(20, "使用{$toolName}开始合并PDF...");
             }
 
-            switch ($this->mergeTool) {
-                case 'pdfunite':
-                    $result = $this->mergeWithPdfunite($pdfPaths, $outputPath, $progressCallback);
-                    break;
-                case 'qpdf':
-                    $result = $this->mergeWithQpdf($pdfPaths, $outputPath, $progressCallback);
-                    break;
-                default:
-                    throw new \Exception("不支持的合并工具: {$this->mergeTool}");
+            // 根据工具类型执行合并
+            if ($this->mergeTool === 'pdfunite') {
+                $result = $this->mergeWithPdfunite($pdfPaths, $outputPath, $progressCallback);
+            } else {
+                $result = $this->mergeWithQpdf($pdfPaths, $outputPath, $progressCallback);
             }
 
             // 进度回调:完成
@@ -237,23 +234,47 @@ class PdfMerger
 
     /**
      * 使用pdfunite合并PDF(带进度回调)
-     * 【优化】添加进度反馈
+     * 【新增】pdfunite更简单可靠
      */
     private function mergeWithPdfunite(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
     {
-        $command = 'pdfunite ' . implode(' ', array_map('escapeshellarg', $pdfPaths)) . ' ' . escapeshellarg($outputPath);
+        // 验证输入文件
+        foreach ($pdfPaths as $index => $path) {
+            if (!file_exists($path)) {
+                Log::error('pdfunite合并失败:PDF文件不存在', [
+                    'file_path' => $path,
+                    'file_index' => $index,
+                    'all_files' => $pdfPaths
+                ]);
 
-        Log::debug('执行pdfunite命令', ['command' => $command]);
+                if ($progressCallback) {
+                    $progressCallback(-1, "PDF文件不存在: " . basename($path));
+                }
 
-        // 【优化】设置超时时间为60秒,避免无限等待
-        $timeout = 60;
+                return false;
+            }
+            Log::debug('pdfunite验证文件存在', ['path' => $path, 'size' => filesize($path)]);
+        }
+
+        // 构建pdfunite命令:pdfunite file1.pdf file2.pdf output.pdf
+        $filesArg = '';
+        foreach ($pdfPaths as $path) {
+            $filesArg .= escapeshellarg($path) . ' ';
+        }
+        $command = 'pdfunite ' . $filesArg . escapeshellarg($outputPath);
+
+        Log::info('使用pdfunite合并PDF', [
+            'command' => $command,
+            'input_count' => count($pdfPaths),
+            'output_path' => $outputPath
+        ]);
 
         if ($progressCallback) {
             $progressCallback(30, '执行pdfunite命令...');
         }
 
         $startTime = microtime(true);
-        $output = Process::timeout($timeout)->run($command);
+        $output = Process::timeout(60)->run($command);
         $duration = round((microtime(true) - $startTime) * 1000, 2);
 
         if ($progressCallback) {
@@ -264,7 +285,8 @@ class PdfMerger
             Log::info('pdfunite合并成功', [
                 'output_path' => $outputPath,
                 'duration_ms' => $duration,
-                'file_count' => count($pdfPaths)
+                'file_count' => count($pdfPaths),
+                'command' => $command
             ]);
 
             if ($progressCallback) {
@@ -279,17 +301,19 @@ class PdfMerger
             'output' => $output->output(),
             'error' => $output->errorOutput(),
             'duration_ms' => $duration,
-            'timeout_seconds' => $timeout
+            'command' => $command
         ]);
 
+        if ($progressCallback) {
+            $progressCallback(-1, 'pdfunite合并失败: ' . $output->errorOutput());
+        }
+
         return false;
     }
 
     /**
      * 使用qpdf合并PDF(带进度回调)
-     * 【修复】强制不使用escapeshhellarg,解决单引号问题
-     * 正确格式:qpdf --empty --pages file1.pdf,file2.pdf -- output.pdf
-     * qpdf会自动合并所有页面,无需手动指定页面范围
+     * 【修复】优化qpdf命令格式
      */
     private function mergeWithQpdf(array $pdfPaths, string $outputPath, ?callable $progressCallback = null): bool
     {
@@ -315,37 +339,34 @@ class PdfMerger
             Log::debug('qpdf验证文件存在', ['path' => $path, 'size' => filesize($path)]);
         }
 
-        // 【关键修复】构建qpdf命令 - 强制不使用任何shell转义
-        // qpdf --empty --pages file1.pdf,file2.pdf -- output.pdf
-        // 注意:qpdf不需要手动指定页面范围,会自动合并所有页面
+        // 【最终修复】qpdf命令格式问题 - 查阅qpdf手册
+        // 正确格式应该是:qpdf --empty --pages file1.pdf,file2.pdf z -- output.pdf
+        // 注意:qpdf需要在最后添加页面范围(如z表示最后一页)
 
-        // 直接拼接路径,不使用任何转义函数(关键修复)
-        $pagesArg = $pdfPaths[0];
-        for ($i = 1; $i < count($pdfPaths); $i++) {
-            $pagesArg .= ',' . $pdfPaths[$i];
-        }
-        $command = "qpdf --empty --pages {$pagesArg} -- -- {$outputPath}";
+        // 【最终修复】qpdf命令格式问题
+        // 正确格式:qpdf --empty --pages file1.pdf,file2.pdf -- output.pdf
+        // 注意:qpdf需要为每个PDF指定页面范围,如1-z表示所有页面
 
-        // 【强制清理OPcache】
-        if (function_exists('opcache_reset')) {
-            opcache_reset();
-            Log::warning('已清理OPcache');
+        // 使用逗号分隔文件,并为每个文件指定页面范围1-z(所有页面)
+        $pagesArg = '';
+        foreach ($pdfPaths as $path) {
+            $pagesArg .= escapeshellarg($path) . '1-z,';
         }
+        // 移除末尾的逗号
+        $pagesArg = rtrim($pagesArg, ',');
+        $command = "qpdf --empty --pages {$pagesArg} -- -- " . escapeshellarg($outputPath);
 
-        Log::debug('【重要】执行qpdf命令(已禁用shell转义)', [
+        Log::info('【最终修复】qpdf命令格式 - 为每个PDF指定页面范围', [
             'command' => $command,
             'pages_arg' => $pagesArg,
-            'output_path' => $outputPath,
-            'pdf_paths' => $pdfPaths,
-            'php_version' => PHP_VERSION,
-            'note' => '此命令已禁用escapeshellarg,如果仍有单引号说明服务器代码未更新'
+            'note' => 'qpdf需要为每个PDF指定页面范围(如1-z),使用逗号分隔文件'
         ]);
 
         // 【优化】设置超时时间为60秒,避免无限等待
         $timeout = 60;
 
         if ($progressCallback) {
-            $progressCallback(30, '执行qpdf命令...');
+            $progressCallback(30, '执行qpdf命令(修复版)...');
         }
 
         $startTime = microtime(true);
@@ -380,13 +401,7 @@ class PdfMerger
             'final_command' => $command,
             'input_files' => $pdfPaths,
             'output_file' => $outputPath,
-            'troubleshooting' => [
-                '1. 检查文件是否存在',
-                '2. 检查文件权限',
-                '3. 检查qpdf是否安装',
-                '4. 如果命令仍有单引号,说明服务器代码未更新,需要重启PHP-FPM'
-            ],
-            'note' => 'qpdf会自动合并所有页面,不需要指定页面范围'
+            'note' => 'qpdf需要为每个PDF指定页面范围(如1-z),用逗号分隔文件'
         ]);
 
         if ($progressCallback) {

+ 74 - 21
resources/views/components/exam/paper-body.blade.php

@@ -4,6 +4,38 @@
     $answerQuestions = $questions['answer'] ?? [];
     $gradingMode = $grading ?? false;
 
+    // 【新增】动态计算大题号 - 根据有题目的题型分配序号
+    $sectionNumbers = [
+        'choice' => null,
+        'fill' => null,
+        'answer' => null
+    ];
+    $currentSectionNumber = 1;
+
+    // 只给有题目的题型分配序号
+    if (!empty($choiceQuestions)) {
+        $sectionNumbers['choice'] = $currentSectionNumber++;
+    }
+    if (!empty($fillQuestions)) {
+        $sectionNumbers['fill'] = $currentSectionNumber++;
+    }
+    if (!empty($answerQuestions)) {
+        $sectionNumbers['answer'] = $currentSectionNumber++;
+    }
+
+    // 获取题型名称的辅助函数
+    $getSectionTitle = function($type, $sectionNumber) {
+        $typeNames = [
+            'choice' => '选择题',
+            'fill' => '填空题',
+            'answer' => '解答题'
+        ];
+        // 将数字转换为中文数字
+        $chineseNumbers = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
+        $chineseNumber = $chineseNumbers[$sectionNumber] ?? (string)$sectionNumber;
+        return $chineseNumber . '、' . $typeNames[$type];
+    };
+
     // 检查是否有数学公式处理标记,避免重复处理
     $mathProcessed = false;
     // 检查所有题型中是否有任何题目包含 math_processed 标记
@@ -70,8 +102,9 @@
 }
 </style>
 
-<!-- 一、选择题 -->
-<div class="section-title">一、选择题
+<!-- 动态大题号 - 选择题 -->
+@if($sectionNumbers['choice'] !== null)
+<div class="section-title">{{ $getSectionTitle('choice', $sectionNumbers['choice']) }}
     @if(count($choiceQuestions) > 0)
         @php
             $choiceTotal = array_sum(array_map(fn($q) => $q->score ?? 5, $choiceQuestions));
@@ -84,7 +117,8 @@
 @if(count($choiceQuestions) > 0)
     @foreach($choiceQuestions as $index => $q)
         @php
-            $questionNumber = $index + 1;
+            // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
+            $questionNumber = $q->question_number ?? ($index + 1);
             $cleanContent = preg_replace('/^\d+[\.、]\s*/', '', $q->content);
             $cleanContent = trim($cleanContent);
             $options = $q->options ?? [];
@@ -107,7 +141,13 @@
             }
             // 将题干中的空括号/下划线替换为短波浪线;如无占位符,则在末尾追加短波浪线
             $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-            $renderedStem = preg_replace(['/((\s*))/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $stemLine);
+            // 【修复】扩展下划线转换规则,支持LaTeX格式和多种占位符
+            $renderedStem = $stemLine;
+            // 先处理LaTeX格式的underline命令
+            $renderedStem = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedStem);
+            $renderedStem = preg_replace('/\\\qquad+/', $blankSpan, $renderedStem);
+            // 再处理普通占位符
+            $renderedStem = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedStem);
             if ($renderedStem === $stemLine) {
                 $renderedStem .= ' ' . $blankSpan;
             }
@@ -192,9 +232,11 @@
         </div>
     </div>
 @endif
+@endif
 
-<!-- 二、填空题 -->
-<div class="section-title">二、填空题
+<!-- 动态大题号 - 填空题 -->
+@if($sectionNumbers['fill'] !== null)
+<div class="section-title">{{ $getSectionTitle('fill', $sectionNumbers['fill']) }}
     @if(count($fillQuestions) > 0)
         @php
             $fillTotal = array_sum(array_map(fn($q) => $q->score ?? 5, $fillQuestions));
@@ -207,9 +249,16 @@
 @if(count($fillQuestions) > 0)
     @foreach($fillQuestions as $index => $q)
         @php
-            $questionNumber = count($choiceQuestions) + $index + 1;
+            // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
+            $questionNumber = $q->question_number ?? (count($choiceQuestions) + $index + 1);
             $blankSpan = '<span style="display:inline-block; min-width:80px; border-bottom:1.2px dashed #444; vertical-align:bottom;">&nbsp;</span>';
-            $renderedContent = preg_replace(['/((\s*))/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $q->content);
+            // 【修复】扩展下划线转换规则,支持LaTeX格式和多种占位符
+            $renderedContent = $q->content;
+            // 先处理LaTeX格式的underline命令
+            $renderedContent = preg_replace('/\\\underline\{[^}]*\}/', $blankSpan, $renderedContent);
+            $renderedContent = preg_replace('/\\\qquad+/', $blankSpan, $renderedContent);
+            // 再处理普通占位符
+            $renderedContent = preg_replace(['/(\s*)/u', '/\(\s*\)/', '/_{2,}/'], $blankSpan, $renderedContent);
             if ($renderedContent === $q->content) {
                 $renderedContent .= ' ' . $blankSpan;
             }
@@ -251,9 +300,11 @@
         </div>
     </div>
 @endif
+@endif
 
-<!-- 三、解答题 -->
-<div class="section-title">三、解答题
+<!-- 动态大题号 - 解答题 -->
+@if($sectionNumbers['answer'] !== null)
+<div class="section-title">{{ $getSectionTitle('answer', $sectionNumbers['answer']) }}
     @if(count($answerQuestions) > 0)
         (本大题共 {{ count($answerQuestions) }} 小题,共 {{ array_sum(array_column($answerQuestions, 'score')) }} 分。解答应写出文字说明、证明过程或演算步骤)
     @else
@@ -263,7 +314,8 @@
 @if(count($answerQuestions) > 0)
     @foreach($answerQuestions as $index => $q)
         @php
-            $questionNumber = count($choiceQuestions) + count($fillQuestions) + $index + 1;
+            // 【修复】使用question_number字段作为显示序号,确保全局序号一致性
+            $questionNumber = $q->question_number ?? (count($choiceQuestions) + count($fillQuestions) + $index + 1);
         @endphp
         <div class="question">
             <div class="question-grid">
@@ -296,8 +348,8 @@
                         // 先处理【】格式
                         $solutionProcessed = preg_replace('/【(解题思路|详细解答|最终答案)】/u', "\n\n===SECTION_START===\n【$1】\n===SECTION_END===\n\n", $solutionProcessed);
 
-                        // 再处理"解题过程:"格式
-                        $solutionProcessed = preg_replace('/(解题过程\s*:)/u', "\n\n===SECTION_START===\n【解题过程】\n===SECTION_END===\n\n", $solutionProcessed);
+                        // 【扩展】处理多种"解题过程"格式,包括带括号的内容
+                        $solutionProcessed = preg_replace('/(解题过程\s*[^:\n]*:)/u', "\n\n===SECTION_START===\n【解题过程】\n===SECTION_END===\n\n", $solutionProcessed);
 
                         // 按section分割内容
                         $sections = explode('===SECTION_START===', $solutionProcessed);
@@ -315,20 +367,20 @@
                                 $sectionContent = preg_replace('/【(解题思路|详细解答|最终答案|解题过程)】/u', '', $section);
 
                                 // 【修复】处理步骤 - 在每个"步骤N"或"第N步"前添加方框
-                                // 【简化】使用split分割步骤,然后在每个步骤前添加方框
+                                // 【优化】使用split分割步骤,为所有步骤添加方框(包括第一个)
                                 if (preg_match('/(步骤\s*\d+|第\s*\d+\s*步)/u', $sectionContent)) {
                                     // 使用前瞻断言分割,保留分隔符
                                     $allSteps = preg_split('/(?=步骤\s*\d+|第\s*\d+\s*步)/u', $sectionContent, -1, PREG_SPLIT_NO_EMPTY);
 
-                                    if (count($allSteps) > 1) {
-                                        // 第一部分通常不是步骤,直接保留
-                                        $processed = trim($allSteps[0]);
-                                        // 从第二个元素开始,每个都是步骤
-                                        for ($i = 1; $i < count($allSteps); $i++) {
+                                    if (count($allSteps) > 0) {
+                                        $processed = '';
+                                        // 为每个步骤添加方框(包括第一个)
+                                        for ($i = 0; $i < count($allSteps); $i++) {
                                             $stepText = trim($allSteps[$i]);
                                             if (!empty($stepText)) {
-                                                // 在步骤前面添加方框和换行
-                                                $processed .= '<br><span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">' . $stepText . '</span></span>';
+                                                // 为每个步骤添加方框和换行(第一个步骤前面不加<br>)
+                                                $prefix = ($i > 0) ? '<br>' : '';
+                                                $processed .= $prefix . '<span class="solution-step"><span class="step-box">' . $renderBoxes(1) . '</span><span class="step-label">' . $stepText . '</span></span>';
                                             }
                                         }
                                         $sectionContent = $processed;
@@ -369,3 +421,4 @@
         </div>
     </div>
 @endif
+@endif

+ 1 - 1
resources/views/pdf/exam-grading.blade.php

@@ -181,7 +181,7 @@
         }
     </style>
 </head>
-<body>
+<body style="page-break-before: always;">
     <div class="page">
     <div class="header">
         <div style="font-size:22px;font-weight:bold;">判卷专用</div>