|
|
@@ -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;
|
|
|
@@ -141,25 +143,25 @@ 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);
|
|
|
- }
|
|
|
+ // 与组卷规则保持一致:仅知识点组卷类型(paper_type=2)包含知识点讲解。
|
|
|
+ $paperType = Paper::query()
|
|
|
+ ->where('paper_id', $paperId)
|
|
|
+ ->value('paper_type');
|
|
|
+ $shouldIncludeKpExplain = ((int) $paperType) === 2;
|
|
|
|
|
|
Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
|
|
|
'paper_id' => $paperId,
|
|
|
- 'include_kp_explain' => $includeKpExplain,
|
|
|
+ 'has_kp_explain' => $shouldIncludeKpExplain,
|
|
|
]);
|
|
|
|
|
|
try {
|
|
|
// 步骤0:获取知识点讲解HTML(如需要)
|
|
|
$kpExplainHtml = null;
|
|
|
- if ($includeKpExplain) {
|
|
|
+ if ($shouldIncludeKpExplain) {
|
|
|
Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
|
|
|
$kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
|
|
|
if ($kpExplainHtml) {
|
|
|
@@ -1795,10 +1797,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);
|
|
|
+ $response = Http::timeout(2)->get($url);
|
|
|
if ($response->successful()) {
|
|
|
$html = $response->body();
|
|
|
if (! empty(trim($html))) {
|
|
|
@@ -1831,6 +1838,34 @@ class ExamPdfExportService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private function renderKnowledgeExplanationHtmlFromController(string $paperId): ?string
|
|
|
+ {
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
{
|
|
|
if (! class_exists(\Michelf\MarkdownExtra::class)) {
|
|
|
@@ -1843,7 +1878,9 @@ class ExamPdfExportService
|
|
|
'/<div class="kp-markdown-source"[^>]*>([\s\S]*?)<\/div>\s*<div class="kp-markdown-container[^"]*"[^>]*><\/div>/i',
|
|
|
function ($matches) use ($parser) {
|
|
|
$markdown = html_entity_decode(trim($matches[1]), ENT_QUOTES, 'UTF-8');
|
|
|
- $rendered = $parser->transform($markdown);
|
|
|
+ [$protectedMarkdown, $mathPlaceholders] = $this->protectLatexBlocksForMarkdown($markdown);
|
|
|
+ $rendered = $parser->transform($protectedMarkdown);
|
|
|
+ $rendered = strtr($rendered, $mathPlaceholders);
|
|
|
|
|
|
return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
|
|
|
},
|
|
|
@@ -1852,17 +1889,17 @@ class ExamPdfExportService
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 【新增】渲染试卷HTML(通过HTTP调用路由)
|
|
|
+ * 渲染试卷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);
|
|
|
+ // 阶段A:优先本地直渲,降低 HTTP 自调用开销。
|
|
|
+ $html = $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
|
|
|
+ if (! empty($html)) {
|
|
|
+ return $html;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑)
|
|
|
$routeName = $useGradingView
|
|
|
? 'filament.admin.auth.intelligent-exam.grading'
|
|
|
: 'filament.admin.auth.intelligent-exam.pdf';
|
|
|
@@ -1877,7 +1914,7 @@ class ExamPdfExportService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [
|
|
|
+ Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败', [
|
|
|
'paper_id' => $paperId,
|
|
|
'url' => $url,
|
|
|
]);
|
|
|
@@ -1889,8 +1926,7 @@ class ExamPdfExportService
|
|
|
]);
|
|
|
}
|
|
|
|
|
|
- // 备用方案:直接渲染视图
|
|
|
- return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView);
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1899,70 +1935,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 +1970,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依赖
|
|
|
@@ -5052,11 +5058,34 @@ MARKDOWN;
|
|
|
|
|
|
$parser = new \Michelf\MarkdownExtra;
|
|
|
$markdown = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
|
|
|
- $rendered = $parser->transform($markdown);
|
|
|
+ [$protectedMarkdown, $mathPlaceholders] = $this->protectLatexBlocksForMarkdown($markdown);
|
|
|
+ $rendered = $parser->transform($protectedMarkdown);
|
|
|
+ $rendered = strtr($rendered, $mathPlaceholders);
|
|
|
|
|
|
return '<div class="kp-markdown-container kp-markdown-content">'.$rendered.'</div>';
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 保护 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 looksLikeHtml(string $content): bool
|
|
|
{
|
|
|
if (stripos($content, 'kp-markdown-container') !== false ||
|