katexRenderer = new KatexRenderer;
}
/**
* 生成试卷 PDF(不含答案)
*/
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);
}
return $url;
}
/**
* 生成判卷 PDF(含答案与解析)
*/
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);
}
return $url;
}
/**
* 【优化方案】生成统一PDF(卷子 + 判卷一页完成)
* 效率提升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
{
// 决定是否包含知识点讲解
if ($includeKpExplain === null) {
$includeKpExplain = config('pdf.include_kp_explain_default', false);
}
Log::info('generateUnifiedPdf 开始(终极优化版本,直接HTML合并生成PDF):', [
'paper_id' => $paperId,
'include_kp_explain' => $includeKpExplain,
]);
try {
// 步骤0:获取知识点讲解HTML(如需要)
$kpExplainHtml = null;
if ($includeKpExplain) {
Log::info('generateUnifiedPdf: 开始获取知识点讲解HTML', ['paper_id' => $paperId]);
$kpExplainHtml = $this->fetchKnowledgeExplanationHtml($paperId);
if ($kpExplainHtml) {
// 对知识点讲解HTML进行内联资源处理(与服务端公式渲染)
$kpExplainHtml = $this->inlineExternalResources($kpExplainHtml);
Log::info('generateUnifiedPdf: 知识点讲解HTML获取并处理成功', [
'paper_id' => $paperId,
'length' => strlen($kpExplainHtml),
]);
} else {
Log::warning('generateUnifiedPdf: 知识点讲解HTML获取失败,将跳过', ['paper_id' => $paperId]);
}
}
// 步骤1:同时渲染两个页面的HTML
Log::info('generateUnifiedPdf: 开始渲染试卷HTML', ['paper_id' => $paperId]);
$examHtml = $this->renderExamHtml($paperId, includeAnswer: false, useGradingView: false);
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]);
$gradingHtml = $this->renderExamHtml($paperId, includeAnswer: true, useGradingView: true);
if (! $gradingHtml) {
Log::error('ExamPdfExportService: 渲染判卷HTML失败', ['paper_id' => $paperId]);
return null;
}
Log::info('generateUnifiedPdf: 判卷HTML渲染完成', ['paper_id' => $paperId, 'length' => strlen($gradingHtml)]);
// 步骤2:插入分页符,合并HTML
Log::info('generateUnifiedPdf: 开始合并HTML(保留原始样式)', ['paper_id' => $paperId]);
$unifiedHtml = $this->mergeHtmlWithPageBreak($examHtml, $gradingHtml, $kpExplainHtml);
if (! $unifiedHtml) {
Log::error('ExamPdfExportService: HTML合并失败', ['paper_id' => $paperId]);
return null;
}
Log::info('generateUnifiedPdf: HTML合并完成(将直接生成PDF,不使用pdfunite)', [
'paper_id' => $paperId,
'length' => strlen($unifiedHtml),
'has_kp_explain' => ! empty($kpExplainHtml),
]);
// 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
$pdfBinary = $this->buildPdf($unifiedHtml);
if (! $pdfBinary) {
Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
return null;
}
Log::info('generateUnifiedPdf: PDF生成完成', ['paper_id' => $paperId, 'pdf_size' => strlen($pdfBinary)]);
// 步骤4:保存PDF
$path = "exams/{$paperId}_all.pdf";
Log::info('generateUnifiedPdf: 开始保存PDF到云存储', ['paper_id' => $paperId, 'path' => $path]);
$url = $this->pdfStorageService->put($path, $pdfBinary);
if (! $url) {
Log::error('ExamPdfExportService: 保存统一PDF失败', ['path' => $path]);
return null;
}
Log::info('generateUnifiedPdf: PDF保存完成', ['paper_id' => $paperId, 'url' => $url]);
// 步骤5:保存URL到数据库(存储到all_pdf_url字段)
Log::info('generateUnifiedPdf: 开始保存URL到数据库', ['paper_id' => $paperId, 'field' => 'all_pdf_url']);
$this->savePdfUrlToDatabase($paperId, 'all_pdf_url', $url);
Log::info('generateUnifiedPdf: URL保存完成', ['paper_id' => $paperId]);
Log::info('generateUnifiedPdf 全部完成(终极优化:直接HTML合并生成一份PDF)', [
'paper_id' => $paperId,
'url' => $url,
'pdf_size' => strlen($pdfBinary),
'method' => 'direct HTML merge to PDF (no pdfunite)',
]);
return $url;
} catch (\Throwable $e) {
Log::error('generateUnifiedPdf 失败', [
'paper_id' => $paperId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return null;
}
}
/**
* 生成学情分析 PDF
*/
public function generateAnalysisReportPdf(string $paperId, string $studentId, ?string $recordId = null): ?string
{
if (function_exists('set_time_limit')) {
@set_time_limit(240);
}
try {
Log::info('ExamPdfExportService: 开始生成学情分析PDF', [
'paper_id' => $paperId,
'student_id' => $studentId,
'record_id' => $recordId,
]);
// 构建分析数据
$analysisData = $this->buildAnalysisData($paperId, $studentId);
if (! $analysisData) {
Log::warning('ExamPdfExportService: buildAnalysisData返回空数据', [
'paper_id' => $paperId,
'student_id' => $studentId,
]);
return null;
}
Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
'paper_id' => $paperId,
'student_id' => $studentId,
'analysisData_keys' => array_keys($analysisData),
'mastery_count' => count($analysisData['mastery']['items'] ?? []),
'questions_count' => count($analysisData['questions'] ?? []),
]);
// 创建DTO
$dto = ExamAnalysisDataDto::fromArray($analysisData);
$payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
// 打印传给模板的数据
$templateData = $payloadDto->toArray();
Log::info('ExamPdfExportService: 传给模板的数据', [
'paper' => $templateData['paper'] ?? null,
'student' => $templateData['student'] ?? null,
'mastery' => $templateData['mastery'] ?? null,
'parent_mastery_levels' => $templateData['parent_mastery_levels'] ?? null,
'questions_count' => count($templateData['questions'] ?? []),
'insights_count' => count($templateData['question_insights'] ?? []),
'recommendations_count' => count($templateData['recommendations'] ?? []),
]);
// 【重要】处理题目数据中的图片标签和公式
// 将 ,并处理公式
if (! empty($templateData['questions'])) {
foreach ($templateData['questions'] as $idx => $question) {
$templateData['questions'][$idx] = MathFormulaProcessor::processQuestionData($question);
}
}
if (! empty($templateData['question_insights'])) {
foreach ($templateData['question_insights'] as $idx => $insight) {
$templateData['question_insights'][$idx] = MathFormulaProcessor::processQuestionData($insight);
}
}
// 渲染HTML
$html = view('exam-analysis.pdf-report', $templateData)->render();
if (! $html) {
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
return null;
}
// 生成PDF
$pdfBinary = $this->buildPdf($html);
if (! $pdfBinary) {
return null;
}
// 保存PDF
$version = time();
$path = "analysis_reports/{$paperId}_{$studentId}_{$version}.pdf";
$url = $this->pdfStorageService->put($path, $pdfBinary);
if (! $url) {
Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]);
return null;
}
// 保存URL到数据库
$this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url);
return $url;
} catch (\Throwable $e) {
Log::error('ExamPdfExportService: 生成学情分析PDF失败', [
'paper_id' => $paperId,
'student_id' => $studentId,
'record_id' => $recordId,
'error' => $e->getMessage(),
'exception' => get_class($e),
'trace' => $e->getTraceAsString(),
]);
return null;
}
}
/**
* 生成合并PDF(试卷 + 判卷)
* 先分别生成两个PDF,然后合并
*/
public function generateMergedPdf(string $paperId): ?string
{
Log::info('generateMergedPdf 开始:', ['paper_id' => $paperId]);
$tempDir = storage_path('app/temp');
if (! is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$examPdfPath = null;
$gradingPdfPath = null;
$mergedPdfPath = null;
try {
// 先生成试卷PDF
$examPdfUrl = $this->generateExamPdf($paperId);
if (! $examPdfUrl) {
Log::error('ExamPdfExportService: 生成试卷PDF失败', ['paper_id' => $paperId]);
return null;
}
// 再生成判卷PDF
$gradingPdfUrl = $this->generateGradingPdf($paperId);
if (! $gradingPdfUrl) {
Log::error('ExamPdfExportService: 生成判卷PDF失败', ['paper_id' => $paperId]);
return null;
}
// 【修复】下载PDF文件到本地临时目录
Log::info('开始下载PDF文件到本地', [
'exam_url' => $examPdfUrl,
'grading_url' => $gradingPdfUrl,
]);
$examPdfPath = $tempDir."/{$paperId}_exam.pdf";
$gradingPdfPath = $tempDir."/{$paperId}_grading.pdf";
// 下载试卷PDF
$examContent = Http::get($examPdfUrl)->body();
if (empty($examContent)) {
Log::error('ExamPdfExportService: 下载试卷PDF失败', ['url' => $examPdfUrl]);
return null;
}
file_put_contents($examPdfPath, $examContent);
// 下载判卷PDF
$gradingContent = Http::get($gradingPdfUrl)->body();
if (empty($gradingContent)) {
Log::error('ExamPdfExportService: 下载判卷PDF失败', ['url' => $gradingPdfUrl]);
return null;
}
file_put_contents($gradingPdfPath, $gradingContent);
Log::info('PDF文件下载完成', [
'exam_size' => filesize($examPdfPath),
'grading_size' => filesize($gradingPdfPath),
]);
// 合并PDF文件
$mergedPdfPath = $tempDir."/{$paperId}_merged.pdf";
$merged = $this->pdfMerger->merge([$examPdfPath, $gradingPdfPath], $mergedPdfPath);
if (! $merged) {
Log::error('ExamPdfExportService: PDF文件合并失败', [
'tool' => $this->pdfMerger->getMergeTool(),
]);
return null;
}
// 读取合并后的PDF内容并上传到云存储
$mergedPdfContent = file_get_contents($mergedPdfPath);
$path = "exams/{$paperId}_all.pdf";
$mergedUrl = $this->pdfStorageService->put($path, $mergedPdfContent);
if (! $mergedUrl) {
Log::error('ExamPdfExportService: 保存合并PDF失败', ['path' => $path]);
return null;
}
// 保存到数据库的all_pdf_url字段
$this->saveAllPdfUrlToDatabase($paperId, $mergedUrl);
Log::info('generateMergedPdf 完成:', [
'paper_id' => $paperId,
'url' => $mergedUrl,
'tool' => $this->pdfMerger->getMergeTool(),
]);
return $mergedUrl;
} catch (\Throwable $e) {
Log::error('ExamPdfExportService: 生成合并PDF失败', [
'paper_id' => $paperId,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return null;
} finally {
// 【修复】清理临时文件
$tempFiles = [$examPdfPath, $gradingPdfPath, $mergedPdfPath];
foreach ($tempFiles as $file) {
if ($file && file_exists($file)) {
@unlink($file);
}
}
Log::debug('清理临时文件完成');
}
}
/**
* 将URL转换为本地文件路径
*/
private function convertUrlToPath(string $url): ?string
{
// 如果是本地存储,URL格式类似:/storage/exams/paper_id_exam.pdf
// 需要转换为绝对路径
if (strpos($url, '/storage/') === 0) {
return public_path(ltrim($url, '/'));
}
// 如果是完整路径,直接返回
if (strpos($url, '/') === 0 && file_exists($url)) {
return $url;
}
// 如果是相对路径,转换为绝对路径
$path = public_path($url);
if (file_exists($path)) {
return $path;
}
return null;
}
/**
* 【新增】获取知识点讲解HTML
*/
private function fetchKnowledgeExplanationHtml(string $paperId): ?string
{
try {
$url = route('filament.admin.auth.intelligent-exam.knowledge-explanation', ['paper_id' => $paperId]);
$response = Http::get($url);
if ($response->successful()) {
$html = $response->body();
if (! empty(trim($html))) {
Log::info('ExamPdfExportService: 成功获取知识点讲解HTML', [
'paper_id' => $paperId,
'length' => strlen($html),
]);
$html = $this->ensureUtf8Html($html);
$html = $this->renderKpExplainMarkdown($html);
return $html;
}
}
Log::warning('ExamPdfExportService: 获取知识点讲解HTML失败', [
'paper_id' => $paperId,
'url' => $url,
]);
return null;
} catch (\Exception $e) {
Log::warning('ExamPdfExportService: 获取知识点讲解HTML异常', [
'paper_id' => $paperId,
'error' => $e->getMessage(),
]);
return null;
}
}
private function renderKpExplainMarkdown(string $html): string
{
if (! class_exists(\Michelf\MarkdownExtra::class)) {
return $html;
}
$parser = new \Michelf\MarkdownExtra;
return preg_replace_callback(
'/