|
|
@@ -4,6 +4,7 @@ namespace App\Services;
|
|
|
|
|
|
use App\DTO\ExamAnalysisDataDto;
|
|
|
use App\DTO\ReportPayloadDto;
|
|
|
+use App\Http\Controllers\ExamPdfController;
|
|
|
use App\Models\Paper;
|
|
|
use App\Models\Question;
|
|
|
use App\Models\Student;
|
|
|
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Log;
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
use Illuminate\Support\Facades\URL;
|
|
|
+use Illuminate\Http\Request;
|
|
|
use Illuminate\Support\Str;
|
|
|
use Symfony\Component\Process\Exception\ProcessSignaledException;
|
|
|
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
|
|
@@ -38,6 +40,7 @@ class ExamPdfExportService
|
|
|
private array $pdfImageDimensionCache = [];
|
|
|
private ?bool $hasPdfImageMetricsTable = null;
|
|
|
private ?array $knowledgePointMetaCache = null;
|
|
|
+ private ?string $lastDebugHtmlPath = null;
|
|
|
|
|
|
public function __construct(
|
|
|
private readonly LearningAnalyticsService $learningAnalyticsService,
|
|
|
@@ -141,61 +144,91 @@ class ExamPdfExportService
|
|
|
* 效率提升40-50%,只需生成一次PDF
|
|
|
*
|
|
|
* @param string $paperId 试卷ID
|
|
|
- * @param bool|null $includeKpExplain 是否包含知识点讲解,null则使用配置文件默认值
|
|
|
* @return string|null PDF URL
|
|
|
*/
|
|
|
- public function generateUnifiedPdf(string $paperId, ?bool $includeKpExplain = null): ?string
|
|
|
+ public function generateUnifiedPdf(string $paperId): ?string
|
|
|
{
|
|
|
- // 决定是否包含知识点讲解
|
|
|
- if ($includeKpExplain === null) {
|
|
|
- $includeKpExplain = config('pdf.include_kp_explain_default', false);
|
|
|
- }
|
|
|
+ $flowStart = microtime(true);
|
|
|
+ $lastMark = $flowStart;
|
|
|
+ $timings = [];
|
|
|
+ $mark = static function (string $label) use (&$lastMark, &$timings): void {
|
|
|
+ $now = microtime(true);
|
|
|
+ $timings[$label] = round(($now - $lastMark) * 1000, 1);
|
|
|
+ $lastMark = $now;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 与组卷规则保持一致:仅知识点组卷类型(paper_type=2)包含知识点讲解。
|
|
|
+ $paperType = Paper::query()
|
|
|
+ ->where('paper_id', $paperId)
|
|
|
+ ->value('paper_type');
|
|
|
+ $shouldIncludeKpExplain = ((int) $paperType) === 2;
|
|
|
+ $mark('load_paper_type_ms');
|
|
|
|
|
|
Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
|
|
|
'paper_id' => $paperId,
|
|
|
- 'include_kp_explain' => $includeKpExplain,
|
|
|
+ 'has_kp_explain' => $shouldIncludeKpExplain,
|
|
|
]);
|
|
|
|
|
|
try {
|
|
|
+ $totalStartedAt = microtime(true);
|
|
|
// 步骤0:获取知识点讲解HTML(如需要)
|
|
|
$kpExplainHtml = null;
|
|
|
- if ($includeKpExplain) {
|
|
|
+ if ($shouldIncludeKpExplain) {
|
|
|
+ $kpStartedAt = microtime(true);
|
|
|
Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
|
|
|
$kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
|
|
|
+ $mark('kp_explain_html_ms');
|
|
|
if ($kpExplainHtml) {
|
|
|
// 统一在 mergeHtmlWithPageBreak()->ensureUtf8Html() 阶段处理内联与公式预渲染,
|
|
|
// 避免在此处重复处理导致额外耗时。
|
|
|
Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
|
|
|
'paper_id' => $paperId,
|
|
|
'length' => strlen($kpExplainHtml),
|
|
|
+ 'elapsed_ms' => (int) round((microtime(true) - $kpStartedAt) * 1000),
|
|
|
]);
|
|
|
} else {
|
|
|
Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
|
|
|
}
|
|
|
+ } else {
|
|
|
+ $timings['kp_explain_html_ms'] = 0.0;
|
|
|
}
|
|
|
|
|
|
// 步骤1:同时渲染两个页面的HTML
|
|
|
+ $examRenderStartedAt = microtime(true);
|
|
|
Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
|
|
|
$examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
|
|
|
+ $mark('exam_html_ms');
|
|
|
if (! $examHtml) {
|
|
|
Log::error('ExamPdfExportService: 渲染卷子HTML失败', ['paper_id' => $paperId]);
|
|
|
|
|
|
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]);
|
|
|
$gradingHtml = $this->renderExamHtml($paperId, includeAnswer: true, useGradingView: true);
|
|
|
+ $mark('grading_html_ms');
|
|
|
if (! $gradingHtml) {
|
|
|
Log::error('ExamPdfExportService: 渲染判卷HTML失败', ['paper_id' => $paperId]);
|
|
|
|
|
|
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
|
|
|
+ $mergeStartedAt = microtime(true);
|
|
|
Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
|
|
|
$unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
|
|
|
+ $mark('merge_html_ms');
|
|
|
if (! $unifiedHtml) {
|
|
|
Log::error('ExamPdfExportService: HTML合并失败', ['paper_id' => $paperId]);
|
|
|
|
|
|
@@ -205,17 +238,25 @@ class ExamPdfExportService
|
|
|
'paper_id' => $paperId,
|
|
|
'length' => strlen($unifiedHtml),
|
|
|
'has_kp_explain' => ! empty($kpExplainHtml),
|
|
|
+ 'elapsed_ms' => (int) round((microtime(true) - $mergeStartedAt) * 1000),
|
|
|
]);
|
|
|
|
|
|
// 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
|
|
|
+ $pdfRenderStartedAt = microtime(true);
|
|
|
Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
|
|
|
+ $this->lastDebugHtmlPath = null;
|
|
|
$pdfBinary = $this->buildPdf($unifiedHtml, true, true);
|
|
|
+ $mark('build_pdf_ms');
|
|
|
if (! $pdfBinary) {
|
|
|
Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
|
|
|
|
|
|
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
|
|
|
$paper = Paper::where('paper_id', $paperId)->first();
|
|
|
@@ -226,25 +267,47 @@ class ExamPdfExportService
|
|
|
}
|
|
|
$allPdfName = $this->buildPdfFileName($paper);
|
|
|
$path = "exams/{$allPdfName}";
|
|
|
+ $storageStartedAt = microtime(true);
|
|
|
Log::info('generateUnifiedPdf: 开始保存PDF到云存储', ['paper_id' => $paperId, 'path' => $path]);
|
|
|
$url = $this->pdfStorageService->put($path, $pdfBinary);
|
|
|
+ $mark('upload_pdf_ms');
|
|
|
if (! $url) {
|
|
|
Log::error('ExamPdfExportService: 保存统一PDF失败', ['path' => $path]);
|
|
|
|
|
|
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字段)
|
|
|
Log::info('generateUnifiedPdf: 开始保存URL到数据库', ['paper_id' => $paperId, 'field' => 'all_pdf_url']);
|
|
|
$this->savePdfUrlToDatabase($paperId, 'all_pdf_url', $url);
|
|
|
+ $mark('save_url_ms');
|
|
|
Log::info('generateUnifiedPdf: URL保存完成', ['paper_id' => $paperId]);
|
|
|
|
|
|
+ Log::warning('PDF_GENERATION_TIMING', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'paper_type' => (int) $paperType,
|
|
|
+ 'has_kp_explain' => ! empty($kpExplainHtml),
|
|
|
+ 'exam_html_bytes' => strlen($examHtml),
|
|
|
+ 'grading_html_bytes' => strlen($gradingHtml),
|
|
|
+ 'kp_explain_html_bytes' => $kpExplainHtml ? strlen($kpExplainHtml) : 0,
|
|
|
+ 'unified_html_bytes' => strlen($unifiedHtml),
|
|
|
+ 'pdf_bytes' => strlen($pdfBinary),
|
|
|
+ 'timings_ms' => $timings,
|
|
|
+ 'total_ms' => round((microtime(true) - $flowStart) * 1000, 1),
|
|
|
+ 'debug_html_path' => $this->lastDebugHtmlPath,
|
|
|
+ ]);
|
|
|
+
|
|
|
Log::info('generateUnifiedPdf 全部完成(终极优化:直接HTML合并生成一份PDF)', [
|
|
|
'paper_id' => $paperId,
|
|
|
'url' => $url,
|
|
|
'pdf_size' => strlen($pdfBinary),
|
|
|
'method' => 'direct HTML merge to PDF (no pdfunite)',
|
|
|
+ 'elapsed_ms' => (int) round((microtime(true) - $totalStartedAt) * 1000),
|
|
|
]);
|
|
|
|
|
|
return $url;
|
|
|
@@ -701,6 +764,7 @@ class ExamPdfExportService
|
|
|
if ($ca === $cb) {
|
|
|
return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
|
|
|
}
|
|
|
+
|
|
|
return $cb <=> $ca;
|
|
|
});
|
|
|
$radarChildrenByModule[$moduleCode] = $items;
|
|
|
@@ -774,6 +838,7 @@ class ExamPdfExportService
|
|
|
$keep = array_values(array_filter($highToLow, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? -1) >= 85));
|
|
|
$boost = array_values(array_filter($lowToHigh, function ($i) {
|
|
|
$percent = $this->toPcMasteryPercent($i['mastery_level']) ?? -1;
|
|
|
+
|
|
|
return $percent >= 60 && $percent < 85;
|
|
|
}));
|
|
|
$key = array_values(array_filter($lowToHigh, fn ($i) => ($this->toPcMasteryPercent($i['mastery_level']) ?? 101) < 60));
|
|
|
@@ -1480,6 +1545,7 @@ class ExamPdfExportService
|
|
|
if ($code !== $rootCode) {
|
|
|
$leaves[] = $code;
|
|
|
}
|
|
|
+
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
@@ -1795,10 +1861,15 @@ class ExamPdfExportService
|
|
|
*/
|
|
|
private function fetchKnowledgeExplanationHtml(string $paperId): ?string
|
|
|
{
|
|
|
+ $html = $this->renderKnowledgeExplanationHtmlFromController($paperId);
|
|
|
+ if ($html !== null) {
|
|
|
+ return $html;
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
$url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
|
|
|
-
|
|
|
- $response = Http::get($url);
|
|
|
+ $timeout = max(1, (int) config('pdf.kp_explain_fetch_timeout_seconds', 2));
|
|
|
+ $response = Http::timeout($timeout)->get($url);
|
|
|
if ($response->successful()) {
|
|
|
$html = $response->body();
|
|
|
if (! empty(trim($html))) {
|
|
|
@@ -1817,6 +1888,7 @@ class ExamPdfExportService
|
|
|
Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
|
|
|
'paper_id' => $paperId,
|
|
|
'url' => $url,
|
|
|
+ 'timeout_seconds' => $timeout,
|
|
|
]);
|
|
|
|
|
|
return null;
|
|
|
@@ -1825,25 +1897,48 @@ class ExamPdfExportService
|
|
|
Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
|
|
|
'paper_id' => $paperId,
|
|
|
'error' => $e->getMessage(),
|
|
|
+ 'timeout_seconds' => config('pdf.kp_explain_fetch_timeout_seconds', 2),
|
|
|
]);
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private function renderKpExplainMarkdown(string $html): string
|
|
|
+ private function renderKnowledgeExplanationHtmlFromController(string $paperId): ?string
|
|
|
{
|
|
|
- if (! class_exists(\Michelf\MarkdownExtra::class)) {
|
|
|
- return $html;
|
|
|
- }
|
|
|
+ try {
|
|
|
+ $request = Request::create("/admin/intelligent-exam/knowledge-explanation/{$paperId}", 'GET');
|
|
|
+ $result = app(ExamPdfController::class)->showKnowledgeExplanation($request, $paperId);
|
|
|
+ $html = $this->renderControllerResultToHtml($result);
|
|
|
+ if ($html === null || trim($html) === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
- $parser = new \Michelf\MarkdownExtra;
|
|
|
+ Log::info('ExamPdfExportService: 本地渲染知识点讲解HTML成功', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'length' => strlen($html),
|
|
|
+ ]);
|
|
|
|
|
|
+ $html = $this->ensureUtf8Html($html);
|
|
|
+
|
|
|
+ return $this->renderKpExplainMarkdown($html);
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ Log::warning('ExamPdfExportService: 本地渲染知识点讲解HTML异常,将回退HTTP', [
|
|
|
+ 'paper_id' => $paperId,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private function renderKpExplainMarkdown(string $html): string
|
|
|
+ {
|
|
|
return preg_replace_callback(
|
|
|
'/<div class="kp-markdown-source"[^>]*>([\s\S]*?)<\/div>\s*<div class="kp-markdown-container[^"]*"[^>]*><\/div>/i',
|
|
|
- function ($matches) use ($parser) {
|
|
|
+ function ($matches) {
|
|
|
$markdown = html_entity_decode(trim($matches[1]), ENT_QUOTES, 'UTF-8');
|
|
|
- $rendered = $parser->transform($markdown);
|
|
|
+ $rendered = $this->renderKpMarkdownContent($markdown);
|
|
|
|
|
|
return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
|
|
|
},
|
|
|
@@ -1851,18 +1946,16 @@ class ExamPdfExportService
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * 【新增】渲染试卷HTML(通过HTTP调用路由)
|
|
|
- */
|
|
|
private function renderExamHtml(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
|
|
|
{
|
|
|
- // 判卷部分启用答案详情页时,优先本地渲染,避免跨进程配置不一致。
|
|
|
- if ($useGradingView && config('exam.pdf_grading_append_scan_sheet', false)) {
|
|
|
- return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
|
|
|
+ // PDF worker 已经运行在 Laravel 进程内,优先直接渲染 Blade,避免 HTTP 自调用开销。
|
|
|
+ $html = $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
|
|
|
+ if ($html !== null) {
|
|
|
+ return $html;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
|
|
|
+ // 兜底:保留原 HTTP 路由渲染路径,避免特殊页面上下文下直接视图失败。
|
|
|
$routeName = $useGradingView
|
|
|
? 'filament.admin.auth.intelligent-exam.grading'
|
|
|
: 'filament.admin.auth.intelligent-exam.pdf';
|
|
|
@@ -1877,7 +1970,7 @@ class ExamPdfExportService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
|
|
|
+ Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败', [
|
|
|
'paper_id' => $paperId,
|
|
|
'url' => $url,
|
|
|
]);
|
|
|
@@ -1889,8 +1982,7 @@ class ExamPdfExportService
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
- // 备用方案:直接渲染视图
|
|
|
- return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1899,70 +1991,23 @@ class ExamPdfExportService
|
|
|
private function renderExamHtmlFromView(string $paperId, bool $includeAnswer, bool $useGradingView): ?string
|
|
|
{
|
|
|
try {
|
|
|
- $paper = Paper::with('questions')->find($paperId);
|
|
|
- if (! $paper) {
|
|
|
- Log::error('ExamPdfExportService: 试卷不存在', ['paper_id' => $paperId]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- if ($paper->questions->isEmpty()) {
|
|
|
- Log::error('ExamPdfExportService: 试卷没有题目数据', [
|
|
|
- 'paper_id' => $paperId,
|
|
|
- 'question_count' => 0,
|
|
|
- ]);
|
|
|
-
|
|
|
- return null;
|
|
|
- }
|
|
|
-
|
|
|
- $viewName = $this->resolveExamViewName($useGradingView);
|
|
|
-
|
|
|
- // 构造视图需要的变量
|
|
|
- $questions = ['choice' => [], 'fill' => [], 'answer' => []];
|
|
|
- foreach ($paper->questions as $pq) {
|
|
|
- $qType = $this->normalizeQuestionType($pq->question_type ?? 'answer');
|
|
|
- $questions[$qType][] = $this->normalizeAnswerFieldForPdf($pq);
|
|
|
- }
|
|
|
-
|
|
|
- $studentModel = \App\Models\Student::find($paper->student_id);
|
|
|
- $teacherModel = \App\Models\Teacher::find($paper->teacher_id);
|
|
|
- if (! $teacherModel && ! empty($paper->teacher_id)) {
|
|
|
- $teacherModel = \App\Models\Teacher::query()
|
|
|
- ->where('teacher_id', $paper->teacher_id)
|
|
|
- ->first();
|
|
|
- }
|
|
|
- $student = ['name' => $studentModel->name ?? ($paper->student_id ?? '________'), 'grade' => $studentModel->grade ?? '________'];
|
|
|
- $teacher = ['name' => $teacherModel->name ?? ($paper->teacher_id ?? '________')];
|
|
|
- $examCode = PaperNaming::extractExamCode((string) $paper->paper_id);
|
|
|
- try {
|
|
|
- $assembleTypeLabel = PaperNaming::assembleTypeLabel((int) $paper->paper_type);
|
|
|
- } catch (\Throwable $e) {
|
|
|
- $assembleTypeLabel = '未知类型';
|
|
|
- }
|
|
|
- $pdfMeta = [
|
|
|
- 'student_name' => $student['name'],
|
|
|
- 'exam_code' => $examCode,
|
|
|
- 'assemble_type_label' => $assembleTypeLabel,
|
|
|
- 'header_title' => $examCode,
|
|
|
- 'exam_pdf_title' => '试卷_'.$examCode,
|
|
|
- 'grading_pdf_title' => '判卷_'.$examCode,
|
|
|
- 'knowledge_pdf_title' => '知识点梳理_'.$examCode,
|
|
|
- ];
|
|
|
-
|
|
|
- $html = view($viewName, [
|
|
|
- 'paper' => $paper,
|
|
|
- 'questions' => $questions,
|
|
|
- 'includeAnswer' => $includeAnswer,
|
|
|
- 'student' => $student,
|
|
|
- 'teacher' => $teacher,
|
|
|
- 'pdfMeta' => $pdfMeta,
|
|
|
- ])->render();
|
|
|
+ $request = Request::create(
|
|
|
+ $useGradingView
|
|
|
+ ? "/admin/intelligent-exam/grading/{$paperId}"
|
|
|
+ : "/admin/intelligent-exam/pdf/{$paperId}",
|
|
|
+ 'GET',
|
|
|
+ ['answer' => $includeAnswer ? 'true' : 'false']
|
|
|
+ );
|
|
|
+ $controller = app(ExamPdfController::class);
|
|
|
+ $result = $useGradingView
|
|
|
+ ? $controller->showGrading($request, $paperId)
|
|
|
+ : $controller->show($request, $paperId);
|
|
|
+ $html = $this->renderControllerResultToHtml($result);
|
|
|
|
|
|
- if (empty(trim($html))) {
|
|
|
+ if ($html === null || trim($html) === '') {
|
|
|
Log::error('ExamPdfExportService: 视图渲染结果为空', [
|
|
|
'paper_id' => $paperId,
|
|
|
- 'view_name' => $viewName,
|
|
|
- 'question_count' => $paper->questions->count(),
|
|
|
+ 'use_grading_view' => $useGradingView,
|
|
|
]);
|
|
|
|
|
|
return null;
|
|
|
@@ -1981,6 +2026,23 @@ class ExamPdfExportService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private function renderControllerResultToHtml(mixed $result): ?string
|
|
|
+ {
|
|
|
+ if (is_string($result)) {
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_object($result) && method_exists($result, 'render')) {
|
|
|
+ return $result->render();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_object($result) && method_exists($result, 'getContent')) {
|
|
|
+ return $result->getContent();
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 构建分析数据(重构版)
|
|
|
* 优先使用本地MySQL数据,减少API依赖
|
|
|
@@ -2289,6 +2351,7 @@ class ExamPdfExportService
|
|
|
$masteryData = array_map(function ($item) {
|
|
|
if (is_object($item)) {
|
|
|
$kpCode = $item->kp_code ?? null;
|
|
|
+
|
|
|
return [
|
|
|
'kp_code' => $kpCode,
|
|
|
'kp_name' => $item->kp_name ?? null,
|
|
|
@@ -2719,7 +2782,9 @@ class ExamPdfExportService
|
|
|
*/
|
|
|
private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
|
|
|
{
|
|
|
+ $startedAt = microtime(true);
|
|
|
$tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
|
|
|
+ $prepareStartedAt = microtime(true);
|
|
|
$utf8Html = $this->ensureUtf8Html($html);
|
|
|
if ($applyWideImageSizing) {
|
|
|
$utf8Html = $scopeToExamPart
|
|
|
@@ -2728,24 +2793,167 @@ class ExamPdfExportService
|
|
|
}
|
|
|
$written = file_put_contents($tmpHtml, $utf8Html);
|
|
|
|
|
|
- Log::debug('ExamPdfExportService: HTML文件已创建', [
|
|
|
+ Log::info('ExamPdfExportService: PDF HTML准备完成', [
|
|
|
'path' => $tmpHtml,
|
|
|
'html_length' => strlen($utf8Html),
|
|
|
'written_bytes' => $written,
|
|
|
+ 'elapsed_ms' => (int) round((microtime(true) - $prepareStartedAt) * 1000),
|
|
|
]);
|
|
|
|
|
|
// 【调试】如果启用了HTML保存调试,复制HTML到storage用于分析
|
|
|
if (config('pdf.debug_save_html', false)) {
|
|
|
$debugPath = storage_path('app/debug_pdf_'.date('YmdHis').'.html');
|
|
|
@copy($tmpHtml, $debugPath);
|
|
|
- Log::warning('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]);
|
|
|
+ $this->lastDebugHtmlPath = $debugPath;
|
|
|
+ Log::debug('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]);
|
|
|
}
|
|
|
|
|
|
- // 仅使用Chrome渲染
|
|
|
- $chromePdf = $this->renderWithChrome($tmpHtml);
|
|
|
+ $pdf = $this->renderWithConfiguredBackend($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);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -3047,7 +3255,7 @@ class ExamPdfExportService
|
|
|
$process->setTimeout(180); // 复杂学情报告页允许更长渲染时间,降低超时失败率
|
|
|
$killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
|
|
|
|
|
|
- Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [
|
|
|
+ Log::debug('ExamPdfExportService: [调试] Chrome命令准备执行', [
|
|
|
'chrome_binary' => $chromeBinary,
|
|
|
'html_path' => $htmlPath,
|
|
|
'html_exists' => file_exists($htmlPath),
|
|
|
@@ -3097,6 +3305,7 @@ class ExamPdfExportService
|
|
|
if ($result !== null) {
|
|
|
return $result;
|
|
|
}
|
|
|
+
|
|
|
return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
|
|
|
} catch (\Throwable $e) {
|
|
|
if ($process->isRunning()) {
|
|
|
@@ -3255,8 +3464,7 @@ class ExamPdfExportService
|
|
|
$hasKatexCdn = strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false;
|
|
|
$hasKatexLocal = strpos($html, '/js/katex.min.js') !== false || strpos($html, '/css/katex/katex.min.css') !== false;
|
|
|
|
|
|
- // 【调试】记录HTML内容信息
|
|
|
- Log::warning('ExamPdfExportService: inlineExternalResources', [
|
|
|
+ Log::debug('ExamPdfExportService: inlineExternalResources', [
|
|
|
'html_length' => strlen($html),
|
|
|
'has_katex_cdn' => $hasKatexCdn,
|
|
|
'has_katex_local' => $hasKatexLocal,
|
|
|
@@ -3264,7 +3472,7 @@ class ExamPdfExportService
|
|
|
|
|
|
// 如果既没有 CDN 也没有本地链接,仍尝试注入 KaTeX 关系符通用修复
|
|
|
if (! $hasKatexCdn && ! $hasKatexLocal) {
|
|
|
- Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
|
|
|
+ Log::debug('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联');
|
|
|
|
|
|
return $this->applyKatexRelationGlyphFixes($html);
|
|
|
}
|
|
|
@@ -5041,32 +5249,157 @@ MARKDOWN;
|
|
|
return '';
|
|
|
}
|
|
|
|
|
|
- if ($this->looksLikeHtml($content)) {
|
|
|
+ if ($this->looksLikeRenderedKpHtml($content)) {
|
|
|
return $content;
|
|
|
}
|
|
|
|
|
|
- if (! class_exists(\Michelf\MarkdownExtra::class)) {
|
|
|
- $safe = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
|
|
|
- return '<div class="kp-markdown-container kp-markdown-content">'.nl2br($safe).'</div>';
|
|
|
+ if ($this->looksLikeHtml($content) && ! $this->looksLikeMarkdown($content)) {
|
|
|
+ return $content;
|
|
|
}
|
|
|
|
|
|
- $parser = new \Michelf\MarkdownExtra;
|
|
|
$markdown = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
|
|
|
- $rendered = $parser->transform($markdown);
|
|
|
+ $rendered = $this->renderKpMarkdownContent($markdown);
|
|
|
|
|
|
return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
|
|
|
}
|
|
|
|
|
|
- private function looksLikeHtml(string $content): bool
|
|
|
+ private function renderKpMarkdownContent(string $markdown): string
|
|
|
+ {
|
|
|
+ if (class_exists(\Michelf\MarkdownExtra::class)) {
|
|
|
+ $parser = new \Michelf\MarkdownExtra;
|
|
|
+ [$protectedMarkdown, $mathPlaceholders] = $this->protectLatexBlocksForMarkdown($markdown);
|
|
|
+ $rendered = $parser->transform($protectedMarkdown);
|
|
|
+
|
|
|
+ return strtr($rendered, $mathPlaceholders);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->renderBasicKpMarkdown($markdown);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function renderBasicKpMarkdown(string $markdown): string
|
|
|
+ {
|
|
|
+ $lines = preg_split('/\R/u', trim($markdown));
|
|
|
+ if ($lines === false) {
|
|
|
+ $safe = htmlspecialchars($markdown, ENT_QUOTES, 'UTF-8');
|
|
|
+
|
|
|
+ return nl2br($safe);
|
|
|
+ }
|
|
|
+
|
|
|
+ $html = [];
|
|
|
+ $paragraph = [];
|
|
|
+ $listType = null;
|
|
|
+
|
|
|
+ $flushParagraph = function () use (&$html, &$paragraph): void {
|
|
|
+ if ($paragraph === []) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $html[] = '<p>'.implode('<br>', $paragraph).'</p>';
|
|
|
+ $paragraph = [];
|
|
|
+ };
|
|
|
+ $closeList = function () use (&$html, &$listType): void {
|
|
|
+ if ($listType === null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $html[] = "</{$listType}>";
|
|
|
+ $listType = null;
|
|
|
+ };
|
|
|
+ $openList = function (string $type) use (&$html, &$listType, $closeList): void {
|
|
|
+ if ($listType === $type) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $closeList();
|
|
|
+ $html[] = "<{$type}>";
|
|
|
+ $listType = $type;
|
|
|
+ };
|
|
|
+
|
|
|
+ foreach ($lines as $line) {
|
|
|
+ $line = rtrim($line);
|
|
|
+ if ($line === '') {
|
|
|
+ $flushParagraph();
|
|
|
+ $closeList();
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (preg_match('/^(#{1,6})\s+(.+)$/u', $line, $match)) {
|
|
|
+ $flushParagraph();
|
|
|
+ $closeList();
|
|
|
+ $level = min(6, strlen($match[1]));
|
|
|
+ $html[] = sprintf('<h%d>%s</h%d>', $level, $this->escapeMarkdownLine($match[2]), $level);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (preg_match('/^\s*[-*]\s+(.+)$/u', $line, $match)) {
|
|
|
+ $flushParagraph();
|
|
|
+ $openList('ul');
|
|
|
+ $html[] = '<li>'.$this->escapeMarkdownLine($match[1]).'</li>';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (preg_match('/^\s*\d+\.\s+(.+)$/u', $line, $match)) {
|
|
|
+ $flushParagraph();
|
|
|
+ $openList('ol');
|
|
|
+ $html[] = '<li>'.$this->escapeMarkdownLine($match[1]).'</li>';
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $paragraph[] = $this->escapeMarkdownLine($line);
|
|
|
+ }
|
|
|
+
|
|
|
+ $flushParagraph();
|
|
|
+ $closeList();
|
|
|
+
|
|
|
+ return implode("\n", $html);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function escapeMarkdownLine(string $line): string
|
|
|
+ {
|
|
|
+ return htmlspecialchars($line, ENT_QUOTES, 'UTF-8');
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保护 Markdown 中的数学块,避免 MarkdownExtra 吃掉 LaTeX 反斜杠
|
|
|
+ */
|
|
|
+ private function protectLatexBlocksForMarkdown(string $markdown): array
|
|
|
+ {
|
|
|
+ $placeholders = [];
|
|
|
+ $index = 0;
|
|
|
+ $pattern = '/\$\$[\s\S]*?\$\$|\\\\\[[\s\S]*?\\\\\]|\\\\\([\s\S]*?\\\\\)|(?<!\$)\$[^$\n]+\$(?!\$)/';
|
|
|
+
|
|
|
+ $protected = preg_replace_callback($pattern, function (array $matches) use (&$placeholders, &$index) {
|
|
|
+ // 使用 markdown 安全占位符,避免被 __ 粗体语法吞掉
|
|
|
+ $token = "@@KPMATHBLOCK{$index}@@";
|
|
|
+ $placeholders[$token] = $matches[0];
|
|
|
+ $index++;
|
|
|
+
|
|
|
+ return $token;
|
|
|
+ }, $markdown);
|
|
|
+
|
|
|
+ return [$protected ?? $markdown, $placeholders];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function looksLikeRenderedKpHtml(string $content): bool
|
|
|
{
|
|
|
if (stripos($content, 'kp-markdown-container') !== false ||
|
|
|
stripos($content, 'kp-markdown-content') !== false) {
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function looksLikeHtml(string $content): bool
|
|
|
+ {
|
|
|
return (bool) preg_match('/<\s*(p|div|h[1-6]|ul|ol|li|table|span|blockquote|pre|code|br)\b/i', $content);
|
|
|
}
|
|
|
|
|
|
+ private function looksLikeMarkdown(string $content): bool
|
|
|
+ {
|
|
|
+ return (bool) preg_match('/(^|\R)\s{0,3}#{1,6}\s+\S|(^|\R)\s{0,3}[-*]\s+\S|(^|\R)\s{0,3}\d+\.\s+\S/u', $content);
|
|
|
+ }
|
|
|
+
|
|
|
private function shouldUseDefaultExplanations(): bool
|
|
|
{
|
|
|
if (!Schema::hasTable('knowledge_points')) {
|