*/
private array $pdfImageDimensionCache = [];
private ?bool $hasPdfImageMetricsTable = null;
public function __construct(
private readonly LearningAnalyticsService $learningAnalyticsService,
private readonly QuestionBankService $questionBankService,
private readonly QuestionServiceApi $questionServiceApi,
private readonly PdfStorageService $pdfStorageService,
private readonly MasteryCalculator $masteryCalculator,
private readonly PdfMerger $pdfMerger
) {
// 延迟初始化 KatexRenderer(避免循环依赖)
$this->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;
}
/**
* 渲染试卷 HTML → 生成 PDF → 上传存储(generateExamPdf / generateGradingPdf 共用)
*/
private function renderAndStoreExamPdf(string $paperId, bool $includeAnswer, string $suffix, bool $useGradingView = false): ?string
{
$html = $this->renderExamHtml($paperId, $includeAnswer, $useGradingView);
if ($html === null || trim($html) === '') {
Log::error('renderAndStoreExamPdf: HTML 为空', [
'paper_id' => $paperId,
'suffix' => $suffix,
]);
return null;
}
$pdfBinary = $this->buildPdf($html, ! $includeAnswer && ! $useGradingView);
if ($pdfBinary === null || $pdfBinary === '') {
Log::error('renderAndStoreExamPdf: buildPdf 失败', [
'paper_id' => $paperId,
'suffix' => $suffix,
]);
return null;
}
$paper = Paper::query()->where('paper_id', $paperId)->first();
if (! $paper) {
Log::error('renderAndStoreExamPdf: 试卷不存在', ['paper_id' => $paperId]);
return null;
}
$stamp = now()->format('YmdHis').strtoupper(Str::random(4));
$base = $this->buildPaperNamePrefix($paper).'_'.$suffix.'_'.$stamp;
$safe = PaperNaming::toSafeFilename($base).'.pdf';
$path = 'exams/'.$safe;
$url = $this->pdfStorageService->put($path, $pdfBinary);
if (! $url) {
Log::error('renderAndStoreExamPdf: 上传失败', ['paper_id' => $paperId, 'path' => $path]);
return null;
}
Log::info('renderAndStoreExamPdf: 完成', [
'paper_id' => $paperId,
'suffix' => $suffix,
'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, true, true);
if (! $pdfBinary) {
Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
return null;
}
Log::info('generateUnifiedPdf: PDF生成完成', ['paper_id' => $paperId, 'pdf_size' => strlen($pdfBinary)]);
// 步骤4:保存PDF
$paper = Paper::where('paper_id', $paperId)->first();
if (! $paper) {
Log::error('ExamPdfExportService: 生成统一PDF失败,未找到试卷', ['paper_id' => $paperId]);
return null;
}
$allPdfName = $this->buildPdfFileName($paper);
$path = "exams/{$allPdfName}";
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);
}
}
// 组装V3报告展示数据(模块化)
$templateData['v3'] = $this->buildAnalysisReportV3Data($templateData);
// 渲染HTML(V3模板)
$html = view('exam-analysis.pdf-report-v3', $templateData)->render();
if (! $html) {
Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]);
return null;
}
// 生成PDF
$pdfBinary = $this->buildPdf($html);
if (! $pdfBinary) {
return null;
}
// 保存PDF(命名统一:姓名_分析报告_卷子id_卷子类型_时间戳)
$studentName = (string) ($templateData['student']['name'] ?? $studentId);
$paperCode = PaperNaming::extractExamCode((string) ($templateData['paper']['id'] ?? $paperId));
$assembleTypeLabel = (string) ($templateData['paper']['assemble_type_label'] ?? '未知类型');
$stamp = now()->format('YmdHis') . strtoupper(Str::random(4));
$analysisBase = "{$studentName}_分析报告_{$paperCode}_{$assembleTypeLabel}_{$stamp}";
$safeAnalysisFile = PaperNaming::toSafeFilename($analysisBase) . '.pdf';
$path = "analysis_reports/{$safeAnalysisFile}";
$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;
}
}
/**
* 构建学情报告 V3 展示数据(模块化视图)
*/
private function buildAnalysisReportV3Data(array $templateData): array
{
$rawAnalysis = $templateData['analysis_data'] ?? [];
$kpRows = $rawAnalysis['knowledge_point_analysis'] ?? [];
$questionRows = $rawAnalysis['question_analysis'] ?? [];
$overallSummary = $rawAnalysis['overall_summary'] ?? [];
$kpMeta = $this->getKnowledgePointMetaMap();
$grade = (string) ($templateData['student']['grade'] ?? '');
$stage = $this->resolveRadarStage($grade, $templateData['full_parent_mastery_levels'] ?? []);
$profile = $this->getRadarProfileByStage($stage);
$rootCode = (string) ($profile['root_code'] ?? 'M00');
$moduleCodes = $profile['module_codes'] ?? [];
$moduleNames = $profile['module_names'] ?? [];
$dimensionDefs = $profile['dimension_defs'] ?? [];
$moduleAgg = [];
foreach ($moduleCodes as $moduleCode) {
$moduleAgg[$moduleCode] = [
'mastery_sum' => 0.0,
'mastery_count' => 0,
'question_max' => 0.0,
'question_obtained' => 0.0,
];
}
$fullParentMap = $templateData['full_parent_mastery_levels'] ?? [];
if (! is_array($fullParentMap)) {
$fullParentMap = [];
}
foreach ($kpRows as $row) {
$kpId = trim((string) ($row['kp_id'] ?? ''));
if ($kpId === '') {
continue;
}
$moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
continue;
}
$mastery = (float) ($row['mastery_level'] ?? 0);
$moduleAgg[$moduleCode]['mastery_sum'] += $mastery;
$moduleAgg[$moduleCode]['mastery_count']++;
}
foreach ($questionRows as $questionRow) {
$maxScore = (float) ($questionRow['max_score'] ?? 0);
$obtainedScore = (float) ($questionRow['score_obtained'] ?? 0);
if ($maxScore <= 0) {
continue;
}
$kpId = '';
$questionKps = $questionRow['knowledge_points'] ?? [];
if (is_array($questionKps) && ! empty($questionKps)) {
$first = $questionKps[0] ?? [];
if (is_array($first)) {
$kpId = trim((string) ($first['kp_id'] ?? ''));
}
}
if ($kpId === '') {
continue;
}
$moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
continue;
}
$moduleAgg[$moduleCode]['question_max'] += $maxScore;
$moduleAgg[$moduleCode]['question_obtained'] += max(0.0, min($obtainedScore, $maxScore));
}
$moduleRows = [];
foreach ($moduleCodes as $moduleCode) {
$agg = $moduleAgg[$moduleCode] ?? null;
$masteryLevel = null;
$kpCount = 0;
if (isset($fullParentMap[$moduleCode])) {
$parentRow = $fullParentMap[$moduleCode];
$masteryLevel = isset($parentRow['mastery_level']) ? (float) $parentRow['mastery_level'] : null;
$kpCount = (int) ($parentRow['children_total_count'] ?? 0);
}
if ($masteryLevel === null && $agg && $agg['mastery_count'] > 0) {
$masteryLevel = $agg['mastery_sum'] / $agg['mastery_count'];
}
$masteryScore5 = $masteryLevel !== null ? round($masteryLevel * 5, 2) : null;
$examMax = (float) ($agg['question_max'] ?? 0.0);
$examObtained = (float) ($agg['question_obtained'] ?? 0.0);
$examRate = $examMax > 0 ? round($examObtained / $examMax, 4) : null;
if ($kpCount <= 0) {
$kpCount = (int) ($agg['mastery_count'] ?? 0);
}
$moduleRows[] = [
'module_code' => $moduleCode,
'module_name' => $moduleNames[$moduleCode] ?? $moduleCode,
'mastery_level' => $masteryLevel !== null ? round($masteryLevel, 4) : null,
'mastery_score_5' => $masteryScore5,
'status' => $this->resolveMasteryStatus($masteryLevel),
'kp_count' => $kpCount,
'exam_max_score' => round($examMax, 2),
'exam_obtained_score' => round($examObtained, 2),
'exam_score_rate' => $examRate,
'ability_text' => $this->defaultModuleAbilityText($moduleCode, $moduleNames[$moduleCode] ?? $moduleCode),
];
}
$moduleMap = [];
foreach ($moduleRows as $moduleRow) {
$moduleMap[$moduleRow['module_code']] = $moduleRow;
}
$dimensions = [];
foreach ($dimensionDefs as $def) {
$masteryWeightedSum = 0.0;
$weightSum = 0.0;
$scoreMax = 0.0;
$scoreObtained = 0.0;
$kpCount = 0;
foreach ($def['modules'] as $moduleCode) {
$row = $moduleMap[$moduleCode] ?? null;
if (! $row) {
continue;
}
$weight = max(1, (int) ($row['kp_count'] ?? 0));
if ($row['mastery_level'] !== null) {
$masteryWeightedSum += ((float) $row['mastery_level']) * $weight;
$weightSum += $weight;
}
$scoreMax += (float) ($row['exam_max_score'] ?? 0);
$scoreObtained += (float) ($row['exam_obtained_score'] ?? 0);
$kpCount += (int) ($row['kp_count'] ?? 0);
}
$mastery = $weightSum > 0 ? ($masteryWeightedSum / $weightSum) : null;
$score5 = $mastery !== null ? round($mastery * 5, 2) : null;
$scoreRate = $scoreMax > 0 ? round($scoreObtained / $scoreMax, 4) : null;
$dimensions[] = [
'id' => $def['id'],
'name' => $def['name'],
'module_codes' => $def['modules'],
'mastery_level' => $mastery !== null ? round($mastery, 4) : null,
'score_5' => $score5,
'status' => $this->resolveMasteryStatus($mastery),
'kp_count' => $kpCount,
'exam_score_rate' => $scoreRate,
];
}
$allMax = 0.0;
$allObtained = 0.0;
foreach ($moduleRows as $moduleRow) {
$allMax += (float) ($moduleRow['exam_max_score'] ?? 0);
$allObtained += (float) ($moduleRow['exam_obtained_score'] ?? 0);
}
$scoreRate = $allMax > 0 ? round($allObtained / $allMax, 4) : null;
$avgMastery = isset($overallSummary['average_mastery'])
? (float) $overallSummary['average_mastery']
: $this->averageByKey(array_filter($dimensions, fn ($d) => $d['mastery_level'] !== null), 'mastery_level');
$overallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery);
$paths = $this->buildV3LearningPaths($dimensions);
// 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点)
// 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。
$radar = [];
$hitParentMap = $templateData['parent_mastery_levels'] ?? [];
if (! is_array($hitParentMap)) {
$hitParentMap = [];
}
// 第二块雷达图补充:父轴下子知识点扩散(突出本卷掌握度变化)
$radarChildrenByModule = [];
foreach ($kpRows as $row) {
$kpId = trim((string) ($row['kp_id'] ?? ''));
if ($kpId === '') {
continue;
}
$moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
if (! $moduleCode || ! in_array($moduleCode, $moduleCodes, true)) {
continue;
}
$parentCode = (string) ($kpMeta[$kpId]['parent_kp_code'] ?? '');
$depth = $this->resolveDepthToModule($kpId, $moduleCode, $kpMeta);
$change = $row['change'] ?? null;
$radarChildrenByModule[$moduleCode][] = [
'code' => $kpId,
'name' => $kpMeta[$kpId]['name'] ?? $kpId,
'parent_code' => $parentCode,
'depth' => $depth,
'change' => $change !== null ? round((float) $change, 4) : null,
'mastery_level' => isset($row['mastery_level']) ? round((float) $row['mastery_level'], 4) : null,
'changed' => $change !== null && abs((float) $change) > 0.0001,
];
}
foreach ($radarChildrenByModule as $moduleCode => $items) {
usort($items, static function ($a, $b) {
$da = (int) ($a['depth'] ?? 1);
$db = (int) ($b['depth'] ?? 1);
if ($da !== $db) {
return $da <=> $db;
}
$ca = abs((float) ($a['change'] ?? 0));
$cb = abs((float) ($b['change'] ?? 0));
if ($ca === $cb) {
return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
}
return $cb <=> $ca;
});
$radarChildrenByModule[$moduleCode] = $items;
}
foreach ($moduleCodes as $moduleCode) {
$hitParentRow = $hitParentMap[$moduleCode] ?? null;
$hitAvg = null;
if (is_array($hitParentRow) && isset($hitParentRow['children_hit_avg_mastery'])) {
$hitAvg = (float) $hitParentRow['children_hit_avg_mastery'];
}
$fullParentRow = $fullParentMap[$moduleCode] ?? null;
$fullMastery = null;
if (is_array($fullParentRow) && isset($fullParentRow['mastery_level'])) {
$fullMastery = (float) $fullParentRow['mastery_level'];
}
$mastery = $hitAvg ?? $fullMastery;
$hasMastery = $mastery !== null;
$score5 = $hasMastery ? round($mastery * 5, 2) : 0.0;
$status = $hasMastery ? $this->resolveMasteryStatus($mastery) : '未涉及';
$radar[] = [
'code' => $moduleCode,
'name' => $moduleNames[$moduleCode] ?? $moduleCode,
'value' => $score5,
'status' => $status,
'has_mastery' => $hasMastery,
'children' => $radarChildrenByModule[$moduleCode] ?? [],
];
}
$foundation = $paths['keep'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '核心模块');
$breakthrough = $paths['boost'][0]['name'] ?? ($paths['key'][0]['name'] ?? '重点模块');
$minimum = $paths['key'][0]['name'] ?? ($paths['boost'][0]['name'] ?? '薄弱模块');
return [
'summary' => [
'score_obtained' => round($allObtained, 1),
'score_total' => round($allMax, 1),
'score_rate' => $scoreRate,
'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
'overall_label' => $overallLabel,
'stage' => $stage,
],
'radar' => $radar,
'modules' => $moduleRows,
'dimensions' => $dimensions,
'paths' => $paths,
'overall_plan' => [
"保基础(60%):稳住{$foundation}",
"拉中档(30%):突破{$breakthrough}",
"冲压轴(10%):优先补齐{$minimum}",
],
];
}
private function buildV3LearningPaths(array $dimensions): array
{
$items = [];
foreach ($dimensions as $dimension) {
if (! isset($dimension['mastery_level']) || $dimension['mastery_level'] === null) {
continue;
}
$items[] = [
'name' => (string) ($dimension['name'] ?? ''),
'mastery_level' => (float) $dimension['mastery_level'],
'score_5' => (float) ($dimension['score_5'] ?? 0),
'status' => $dimension['status'] ?? '一般',
];
}
usort($items, fn ($a, $b) => $a['mastery_level'] <=> $b['mastery_level']);
$lowToHigh = $items;
$highToLow = array_reverse($items);
$keep = array_values(array_filter($highToLow, fn ($i) => $i['mastery_level'] >= 0.75));
$boost = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] >= 0.5 && $i['mastery_level'] < 0.75));
$key = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] < 0.5));
if (empty($keep) && ! empty($highToLow)) {
$keep[] = $highToLow[0];
}
if (empty($boost) && count($lowToHigh) > 1) {
$boost[] = $lowToHigh[(int) floor((count($lowToHigh) - 1) / 2)];
}
if (empty($key) && ! empty($lowToHigh)) {
$key[] = $lowToHigh[0];
}
return [
'keep' => array_slice($keep, 0, 2),
'boost' => array_slice($boost, 0, 2),
'key' => array_slice($key, 0, 2),
];
}
private function resolveMasteryStatus(?float $mastery): string
{
if ($mastery === null) {
return '暂无';
}
if ($mastery >= 0.8) {
return '良好';
}
if ($mastery >= 0.6) {
return '一般';
}
return '薄弱';
}
private function resolveOverallPerformanceLabel(?float $avgMastery): string
{
if ($avgMastery === null) {
return '待评估';
}
if ($avgMastery >= 0.85) {
return '优秀';
}
if ($avgMastery >= 0.7) {
return '良好';
}
if ($avgMastery >= 0.55) {
return '一般';
}
return '需提升';
}
private function averageByKey(array $rows, string $key): ?float
{
if (empty($rows)) {
return null;
}
$sum = 0.0;
$count = 0;
foreach ($rows as $row) {
if (! isset($row[$key])) {
continue;
}
$sum += (float) $row[$key];
$count++;
}
return $count > 0 ? ($sum / $count) : null;
}
/**
* 收集某学段根节点下的全量后代知识点(不含根节点本身)
*/
private function collectStageKnowledgePoints(string $rootCode): array
{
if ($rootCode === '') {
return [];
}
try {
$rows = DB::connection('mysql')
->table('knowledge_points')
->select('kp_code', 'parent_kp_code')
->get();
$children = [];
foreach ($rows as $row) {
$code = trim((string) ($row->kp_code ?? ''));
$parent = trim((string) ($row->parent_kp_code ?? ''));
if ($code === '' || $parent === '') {
continue;
}
$children[$parent][] = $code;
}
$result = [];
$queue = [$rootCode];
$visited = [];
while (! empty($queue)) {
$parent = array_shift($queue);
if (isset($visited[$parent])) {
continue;
}
$visited[$parent] = true;
foreach (($children[$parent] ?? []) as $childCode) {
if ($childCode === $rootCode) {
continue;
}
$result[] = $childCode;
$queue[] = $childCode;
}
}
return array_values(array_unique($result));
} catch (\Throwable $e) {
Log::warning('ExamPdfExportService: collectStageKnowledgePoints failed', [
'root_code' => $rootCode,
'error' => $e->getMessage(),
]);
return [];
}
}
private function defaultModuleAbilityText(string $moduleCode, ?string $moduleName = null): string
{
return match ($moduleCode) {
'M01' => '数感与运算、代数式处理、基础建模',
'M02' => '方程化归、分类讨论、不等式推理',
'M03' => '图形性质识别、辅助线与逻辑证明',
'M04' => '图形变化、度量计算、坐标与变换',
'M05' => '相似迁移、比例推导、勾股应用',
'M06' => '数据分析、统计图表、概率判断',
'M07' => '函数建模、图像理解、函数性质迁移',
'S01_000' => '集合表示、集合运算、集合关系判断',
'S02_000' => '命题结构、逻辑推理、充分必要条件辨析',
'S03_000' => '不等式变形、比较法、恒成立问题处理',
'S04_000' => '函数模型、图像性质、函数迁移应用',
'S05_000' => '导数概念、单调最值、切线与优化问题',
'S06_000' => '三角函数图像、恒等变换、向量运算',
'S07_000' => '空间想象、立体几何证明与计算',
'S08_000' => '坐标法、轨迹方程、圆锥曲线综合',
'S09_000' => '递推与通项、求和技巧、数列建模',
'S10_000' => '概率模型、统计推断、随机变量分析',
'S11_000' => '复数运算、代数几何意义转换',
default => '综合能力',
};
}
private function resolveRadarStage(string $grade, array $fullParentLevels = []): string
{
$g = trim($grade);
if ($g !== '') {
if (preg_match('/高一|高二|高三/u', $g)) {
return 'high';
}
if (preg_match('/\d+/', $g, $m)) {
$n = (int) $m[0];
if ($n >= 10) {
return 'high';
}
if ($n > 0 && $n <= 9) {
return 'junior';
}
}
}
foreach (array_keys($fullParentLevels) as $kpCode) {
if (str_starts_with((string) $kpCode, 'S')) {
return 'high';
}
}
return 'junior';
}
private function getRadarProfileByStage(string $stage): array
{
if ($stage === 'high') {
$moduleNames = [
'S01_000' => '集合',
'S02_000' => '逻辑用语',
'S03_000' => '不等式',
'S04_000' => '函数',
'S05_000' => '导数',
'S06_000' => '三角函数与向量',
'S07_000' => '立体几何',
'S08_000' => '解析几何',
'S09_000' => '数列',
'S10_000' => '概率统计',
'S11_000' => '复数',
];
$dimensionDefs = [];
foreach ($moduleNames as $code => $name) {
$dimensionDefs[] = [
'id' => strtolower(str_replace('_', '', $code)),
'name' => $name,
'modules' => [$code],
];
}
return [
'root_code' => 'S000_000',
'module_codes' => array_keys($moduleNames),
'module_names' => $moduleNames,
'dimension_defs' => $dimensionDefs,
];
}
return [
'root_code' => 'M00',
'module_codes' => ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07'],
'module_names' => [
'M01' => '数与代数',
'M02' => '方程与不等式',
'M03' => '图形性质',
'M04' => '图形变化与度量',
'M05' => '相似与勾股',
'M06' => '统计与概率',
'M07' => '函数',
],
'dimension_defs' => [
['id' => 'number_expr', 'name' => '数与式', 'modules' => ['M01']],
['id' => 'equation', 'name' => '方程与不等式', 'modules' => ['M02']],
['id' => 'function', 'name' => '函数', 'modules' => ['M07']],
['id' => 'shape_change', 'name' => '图形的变化', 'modules' => ['M04']],
['id' => 'shape_property', 'name' => '图形的性质', 'modules' => ['M03', 'M05']],
['id' => 'stat_prob', 'name' => '统计与概率', 'modules' => ['M06']],
],
];
}
private function getKnowledgePointMetaMap(): array
{
if ($this->knowledgePointMetaCache !== null) {
return $this->knowledgePointMetaCache;
}
$rows = DB::connection('mysql')
->table('knowledge_points')
->select(['kp_code', 'name', 'parent_kp_code'])
->get();
$map = [];
foreach ($rows as $row) {
$code = trim((string) ($row->kp_code ?? ''));
if ($code === '') {
continue;
}
$map[$code] = [
'name' => (string) ($row->name ?? $code),
'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
];
}
$this->knowledgePointMetaCache = $map;
return $map;
}
private function mapKpToJuniorModule(string $kpCode, array $kpMeta): ?string
{
return $this->mapKpToStageModule($kpCode, $kpMeta, 'M00');
}
private function mapKpToStageModule(string $kpCode, array $kpMeta, string $rootCode): ?string
{
$code = trim($kpCode);
if ($code === '' || empty($kpMeta[$code])) {
return null;
}
$cursor = $code;
for ($depth = 0; $depth < 16; $depth++) {
$node = $kpMeta[$cursor] ?? null;
if (! $node) {
return null;
}
$parent = (string) ($node['parent_kp_code'] ?? '');
if ($parent === $rootCode) {
return $cursor;
}
if ($parent === '' || $parent === $cursor) {
return null;
}
$cursor = $parent;
}
return null;
}
private function resolveDepthToModule(string $kpCode, string $moduleCode, array $kpMeta): int
{
$depth = 1;
$cursor = trim($kpCode);
for ($i = 0; $i < 24; $i++) {
if ($cursor === '' || ! isset($kpMeta[$cursor])) {
break;
}
if ($cursor === $moduleCode) {
return max(1, $depth - 1);
}
$parent = trim((string) ($kpMeta[$cursor]['parent_kp_code'] ?? ''));
if ($parent === '' || $parent === $cursor) {
break;
}
$cursor = $parent;
$depth++;
}
return max(1, $depth);
}
/**
* 生成合并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);
$paper = Paper::where('paper_id', $paperId)->first();
if (! $paper) {
Log::error('ExamPdfExportService: 合并PDF失败,未找到试卷', ['paper_id' => $paperId]);
return null;
}
$allPdfName = $this->buildPdfFileName($paper);
$path = "exams/{$allPdfName}";
$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(
'/