*/ 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 { $flowStart = microtime(true); $lastMark = $flowStart; $marks = []; $mark = static function (string $label) use (&$lastMark, &$marks): void { $now = microtime(true); $marks[$label] = round(($now - $lastMark) * 1000, 1); $lastMark = $now; }; Log::info('ExamPdfExportService: 开始生成学情分析PDF', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'record_id' => $recordId, ]); // 构建分析数据 $analysisData = $this->buildAnalysisData($paperId, $studentId); $mark('build_analysis_data_ms'); 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); $mark('build_payload_dto_ms'); // 打印传给模板的数据 $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'] ?? []), ]); // V3 学情报告不再渲染逐题错题卡,保留题目元数据用于统计即可。 $mark('prepare_report_data_ms'); // 组装V3报告展示数据(模块化) $templateData['v3'] = $this->buildAnalysisReportV3Data($templateData); $mark('build_v3_data_ms'); // 渲染HTML(V3模板) $html = view('exam-analysis.pdf-report-v3', $templateData)->render(); $mark('render_html_ms'); if (! $html) { Log::error('ExamPdfExportService: 渲染HTML为空', ['paper_id' => $paperId]); return null; } // 生成PDF $pdfBinary = $this->buildPdf($html); $mark('build_pdf_ms'); 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); $mark('upload_pdf_ms'); if (! $url) { Log::error('ExamPdfExportService: 保存学情PDF失败', ['path' => $path]); return null; } // 保存URL到数据库 $this->saveAnalysisPdfUrl($paperId, $studentId, $recordId, $url); $mark('save_pdf_url_ms'); $marks['total_ms'] = round((microtime(true) - $flowStart) * 1000, 1); Log::info('ExamPdfExportService: 学情分析PDF耗时明细', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'record_id' => $recordId, 'timing' => $marks, ]); 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'] ?? []; $moduleAgg = []; foreach ($moduleCodes as $moduleCode) { $moduleAgg[$moduleCode] = [ 'mastery_sum' => 0.0, 'mastery_count' => 0, 'question_max' => 0.0, 'question_obtained' => 0.0, 'question_count' => 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)); $moduleAgg[$moduleCode]['question_count']++; } $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, 'question_count' => (int) ($agg['question_count'] ?? 0), 'exam_max_score' => round($examMax, 2), 'exam_obtained_score' => round($examObtained, 2), 'exam_score_rate' => $examRate, ]; } $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($moduleRows, fn ($m) => $m['mastery_level'] !== null), 'mastery_level'); $paths = $this->buildV3LearningPaths($moduleRows); $difficultyInsight = $this->buildV3DifficultyInsight($templateData); $comparisonInsight = $this->buildV3ComparisonInsight( $templateData, $scoreRate, $avgMastery ); $fallbackOverallLabel = $overallSummary['overall_performance'] ?? $this->resolveOverallPerformanceLabel($avgMastery); $overallComposite = $this->buildV3OverallCompositeEvaluation( $scoreRate, $avgMastery, $difficultyInsight, $comparisonInsight, $fallbackOverallLabel ); $overallLabel = (string) ($overallComposite['label'] ?? $fallbackOverallLabel); // 雷达图:轴固定为学段根节点的“第一层父知识点”(根节点的直接子节点) // 掌握度计算口径:严格沿用上一版父节点掌握度口径(full_parent_mastery_levels / parent_mastery_levels)。 $radar = []; $hitParentMap = $templateData['parent_mastery_levels'] ?? []; if (! is_array($hitParentMap)) { $hitParentMap = []; } // 第二块聚类视图数据:严格按学段知识树叶子节点分组(与 math.client-pc 层级一致) $radarChildrenByModule = []; $examHitCodes = $templateData['exam_hit_kp_codes'] ?? []; if (! is_array($examHitCodes)) { $examHitCodes = []; } $examHitSet = array_fill_keys(array_map(static fn ($v) => (string) $v, $examHitCodes), true); $masteryMapForCluster = $templateData['mastery_map'] ?? []; if (! is_array($masteryMapForCluster)) { $masteryMapForCluster = []; } $kpChangeMap = []; foreach ($kpRows as $row) { $code = trim((string) ($row['kp_code'] ?? $row['knowledge_point_code'] ?? $row['code'] ?? '')); if ($code === '') { continue; } $changeRaw = $row['mastery_change'] ?? $row['change'] ?? null; if ($changeRaw === null || $changeRaw === '') { continue; } $kpChangeMap[$code] = (float) $changeRaw; } if (empty($kpChangeMap)) { $studentId = (string) ($templateData['student']['id'] ?? ''); $paperId = (string) ($templateData['paper']['id'] ?? ''); if ($studentId !== '' && $paperId !== '') { try { $snapshot = DB::connection('mysql') ->table('knowledge_point_mastery_snapshots') ->where('student_id', $studentId) ->where('paper_id', $paperId) ->latest('snapshot_time') ->first(); if ($snapshot && ! empty($snapshot->mastery_data)) { $snapshotData = json_decode((string) $snapshot->mastery_data, true); if (is_array($snapshotData)) { foreach ($snapshotData as $kpCode => $node) { if (! is_array($node)) { continue; } $change = $node['change'] ?? null; if ($change === null && isset($node['current_mastery'], $node['previous_mastery'])) { $change = floatval($node['current_mastery']) - floatval($node['previous_mastery']); } if ($change === null || $change === '') { continue; } $code = trim((string) $kpCode); if ($code === '') { continue; } $kpChangeMap[$code] = (float) $change; } } } } catch (\Throwable $e) { Log::warning('ExamPdfExportService: 聚类变化值快照回填失败', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } } } $stageCodes = $this->collectStageKnowledgePoints($rootCode); $childrenByParent = []; foreach ($kpMeta as $code => $meta) { $p = trim((string) ($meta['parent_kp_code'] ?? '')); if ($p !== '') { $childrenByParent[$p][] = (string) $code; } } $resolveHierarchyForCluster = function (string $kpId) use ($kpMeta, $rootCode): array { $rootName = trim((string) ($kpMeta[$rootCode]['name'] ?? $rootCode)); $lineage = []; $cursor = $kpId; $guard = 0; while ($cursor !== '' && isset($kpMeta[$cursor]) && $guard < 24) { $nodeName = trim((string) ($kpMeta[$cursor]['name'] ?? $cursor)); if ($nodeName !== '') { $lineage[] = $nodeName; } $parent = trim((string) ($kpMeta[$cursor]['parent_kp_code'] ?? '')); if ($parent === '' || $parent === $cursor || $parent === $rootCode) { break; } $cursor = $parent; $guard++; } $nonRootPath = array_reverse($lineage); // 等价前端去 root 后的路径 $safePath = !empty($nonRootPath) ? $nonRootPath : [trim((string) ($kpMeta[$kpId]['name'] ?? $kpId))]; $pathCount = count($safePath); $parentName = $safePath[$pathCount - 2] ?? $safePath[$pathCount - 1] ?? $rootName; $grandParentName = $safePath[$pathCount - 3] ?? $safePath[0] ?? $parentName; $greatGrandParentName = $safePath[$pathCount - 4] ?? $safePath[0] ?? $grandParentName; $path = implode(' > ', array_filter(array_merge([$rootName], $safePath))); return [ 'parent_name' => $parentName, 'grand_parent_name' => $grandParentName, 'great_grand_parent_name' => $greatGrandParentName, 'path' => $path, ]; }; foreach ($stageCodes as $kpId) { $kpId = trim((string) $kpId); if ($kpId === '' || ! isset($kpMeta[$kpId])) { continue; } // 仅纳入叶子知识点,保证与前端 flattenKnowledgePoints 一致 if (! empty($childrenByParent[$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); $hierarchy = $resolveHierarchyForCluster($kpId); $masteryValue = array_key_exists($kpId, $masteryMapForCluster) ? floatval($masteryMapForCluster[$kpId]) : null; $changeValue = array_key_exists($kpId, $kpChangeMap) ? (float) $kpChangeMap[$kpId] : null; $isHit = isset($examHitSet[$kpId]); $radarChildrenByModule[$moduleCode][] = [ 'code' => $kpId, 'name' => $kpMeta[$kpId]['name'] ?? $kpId, 'parent_code' => $parentCode, 'parent_name' => $hierarchy['parent_name'], 'grand_parent_name' => $hierarchy['grand_parent_name'], 'great_grand_parent_name' => $hierarchy['great_grand_parent_name'], 'path' => $hierarchy['path'], 'depth' => $depth, 'change' => $changeValue !== null ? round($changeValue, 4) : null, 'mastery_level' => $masteryValue !== null ? round($masteryValue, 4) : null, 'changed' => $changeValue !== null, 'is_hit' => $isHit, ]; } 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] ?? [], ]; } 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, 'overall_label_detail' => $overallComposite, 'stage' => $stage, 'difficulty' => $difficultyInsight, 'comparison' => $comparisonInsight, ], 'radar' => $radar, 'modules' => $moduleRows, 'paths' => $paths, ]; } private function buildV3LearningPaths(array $moduleRows): array { $items = []; foreach ($moduleRows as $moduleRow) { if (! isset($moduleRow['mastery_level']) || $moduleRow['mastery_level'] === null) { continue; } $items[] = [ 'name' => (string) ($moduleRow['module_name'] ?? ''), 'module_code' => (string) ($moduleRow['module_code'] ?? ''), 'mastery_level' => (float) $moduleRow['mastery_level'], 'status' => $moduleRow['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.85)); $boost = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] >= 0.6 && $i['mastery_level'] < 0.85)); $key = array_values(array_filter($lowToHigh, fn ($i) => $i['mastery_level'] < 0.6)); 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.85) { 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 buildV3DifficultyInsight(array $templateData): array { $paper = $templateData['paper'] ?? []; $questions = $templateData['questions'] ?? []; $rawCategory = (string) ($paper['difficulty_category'] ?? ''); $level = QuestionDifficultyCalibrationAnalyzer::parsePaperDifficultyCategory($rawCategory); $levelInt = $level !== null ? (int) round($level) : null; $range = $this->difficultyRangeByLevel($levelInt); $difficulties = []; foreach ($questions as $q) { $d = $q['difficulty'] ?? null; if ($d !== null && is_numeric($d)) { $dv = (float) $d; if ($dv >= 0.0 && $dv <= 1.0) { $difficulties[] = $dv; } } } $actualAvg = ! empty($difficulties) ? (array_sum($difficulties) / count($difficulties)) : null; $actualLevel = $actualAvg !== null ? $this->mapDifficultyValueToLevel($actualAvg) : null; $targetCenter = $range !== null ? (($range['min'] + $range['max']) / 2.0) : null; $deviation = ($actualAvg !== null && $targetCenter !== null) ? ($actualAvg - $targetCenter) : null; $matchStatus = '暂无'; if ($actualAvg !== null && $range !== null) { if ($actualAvg > $range['max']) { $matchStatus = '偏难'; } elseif ($actualAvg < $range['min']) { $matchStatus = '偏易'; } else { $matchStatus = '匹配'; } } $scoreRate = $this->resolveCurrentScoreRateForDifficultyInsight($templateData); $scoreBand = $this->resolveScoreRateBandForDifficultyInsight($scoreRate); $seed = sprintf( '%s|%s|%s|%s', (string) ($paper['id'] ?? ''), (string) (($templateData['student'] ?? [])['id'] ?? ''), $matchStatus, $scoreBand ); $explain = $this->buildDifficultyExplainByContext($matchStatus, $scoreBand, $seed); return [ 'target_category_raw' => $rawCategory, 'target_level' => $levelInt, 'target_label' => $this->difficultyLevelLabel($levelInt, $rawCategory), 'target_range' => $range, 'actual_average_difficulty' => $actualAvg !== null ? round($actualAvg, 4) : null, 'actual_level' => $actualLevel, 'actual_label' => $actualLevel !== null ? $this->difficultyLevelLabel($actualLevel) : null, 'deviation' => $deviation !== null ? round($deviation, 4) : null, 'status' => $matchStatus, 'question_count' => count($difficulties), 'score_rate' => $scoreRate !== null ? round($scoreRate, 4) : null, 'score_band' => $scoreBand, 'explain' => $explain, ]; } private function resolveCurrentScoreRateForDifficultyInsight(array $templateData): ?float { $rawOverall = (array) (($templateData['analysis_data'] ?? [])['overall_summary'] ?? []); if (isset($rawOverall['score_rate']) && is_numeric($rawOverall['score_rate'])) { $v = (float) $rawOverall['score_rate']; if ($v >= 0.0 && $v <= 1.0) { return $v; } if ($v > 1.0 && $v <= 100.0) { return $v / 100.0; } } $questions = $templateData['questions'] ?? []; $total = 0.0; $obtained = 0.0; foreach ($questions as $q) { $max = $q['score'] ?? $q['max_score'] ?? null; $got = $q['score_obtained'] ?? null; if (! is_numeric($max) || ! is_numeric($got)) { continue; } $maxVal = (float) $max; if ($maxVal <= 0) { continue; } $gotVal = max(0.0, min((float) $got, $maxVal)); $total += $maxVal; $obtained += $gotVal; } return $total > 0 ? ($obtained / $total) : null; } private function resolveScoreRateBandForDifficultyInsight(?float $scoreRate): string { if ($scoreRate === null) { return 'unknown'; } if ($scoreRate >= 0.8) { return 'high'; } if ($scoreRate >= 0.6) { return 'mid'; } return 'low'; } private function buildDifficultyExplainByContext(string $matchStatus, string $scoreBand, string $seed): string { $messageMap = config('exam.analysis_report_v3.difficulty_explain_messages', []); if (! is_array($messageMap) || empty($messageMap)) { $messageMap = [ '暂无' => [ 'unknown' => [ '暂无足够数据评估难度匹配。', ], ], ]; } $statusMap = $messageMap[$matchStatus] ?? $messageMap['暂无']; $candidates = $statusMap[$scoreBand] ?? $statusMap['unknown'] ?? $messageMap['暂无']['unknown']; return $this->pickStableVariantMessage($candidates, $seed); } private function pickStableVariantMessage(array $messages, string $seed): string { if (empty($messages)) { return '暂无足够数据评估难度匹配。'; } $idx = abs(crc32($seed)) % count($messages); return (string) $messages[$idx]; } private function buildV3ComparisonInsight(array $templateData, ?float $currentScoreRate, ?float $currentMastery): array { $paper = $templateData['paper'] ?? []; $student = $templateData['student'] ?? []; $studentId = (string) ($student['id'] ?? ''); $grade = (string) ($student['grade'] ?? ''); $paperId = (string) ($paper['id'] ?? ''); $paperType = isset($paper['paper_type']) ? (int) $paper['paper_type'] : null; $history = [ 'has_data' => false, 'is_first_exam' => false, 'low_baseline_guard' => false, 'sample_size' => 0, 'baseline_score_rate' => null, 'delta_score_rate' => null, 'trend' => '暂无', 'message' => '历史样本不足,暂无法形成稳定趋势。', ]; $peers = [ 'has_data' => false, 'sample_size' => 0, 'raw_sample_size' => 0, 'peer_avg_score_rate' => null, 'delta_score_rate' => null, 'percentile' => null, 'band' => '暂无', 'display_mode' => 'none', 'show_line' => false, 'band_icon' => '•', 'band_color' => '#64748b', 'message' => '同群体样本不足,暂无法做稳健对比。', ]; if ($studentId === '') { return ['history' => $history, 'peers' => $peers]; } try { $historyRows = DB::connection('mysql') ->table('papers as p') ->join('paper_questions as pq', 'pq.paper_id', '=', 'p.paper_id') ->where('p.student_id', $studentId) ->when($paperId !== '', fn ($q) => $q->where('p.paper_id', '!=', $paperId)) ->groupBy('p.paper_id', 'p.created_at') ->selectRaw('p.paper_id, p.created_at, SUM(COALESCE(pq.score, 0)) as total_score, SUM(COALESCE(pq.score_obtained, 0)) as obtained_score') ->havingRaw('SUM(COALESCE(pq.score, 0)) > 0') ->orderByDesc('p.created_at') ->limit(7) ->get(); $historyRates = []; foreach ($historyRows as $row) { $total = (float) ($row->total_score ?? 0); if ($total <= 0) { continue; } $historyRates[] = (float) ($row->obtained_score ?? 0) / $total; } if (! empty($historyRates) && $currentScoreRate !== null) { $baseline = array_sum($historyRates) / count($historyRates); $delta = $currentScoreRate - $baseline; $history['has_data'] = true; $history['sample_size'] = count($historyRates); $history['baseline_score_rate'] = round($baseline, 4); $history['delta_score_rate'] = round($delta, 4); if ($baseline <= 0.02 && count($historyRates) >= 5) { $history['low_baseline_guard'] = true; $history['trend'] = '基线偏低'; $history['message'] = '近7次历史基线过低,单次涨跌参考意义有限,建议重点看后续连续3次趋势。'; } else { $history['trend'] = $this->resolveDeltaTrendLabel($delta); $history['message'] = $delta >= 0 ? '相较于近期个人表现,本次成绩处于上行区间。' : '相较于近期个人表现,本次成绩略有回落,建议优先复盘错因分布。'; } } elseif ($currentScoreRate !== null) { $history['is_first_exam'] = true; $history['message'] = $this->pickFirstExamEncouragementMessageByScore($currentScoreRate); } } catch (\Throwable $e) { Log::warning('ExamPdfExportService: 构建历史对比失败', ['error' => $e->getMessage()]); } try { if ($grade !== '' && $currentScoreRate !== null) { $peerRows = DB::connection('mysql') ->table('papers as p') ->join('students as s', 's.student_id', '=', 'p.student_id') ->join('paper_questions as pq', 'pq.paper_id', '=', 'p.paper_id') ->where('s.grade', $grade) ->where('p.student_id', '!=', $studentId) ->when($paperType !== null, fn ($q) => $q->where('p.paper_type', $paperType)) ->groupBy('p.paper_id', 'p.created_at') ->selectRaw('p.paper_id, p.created_at, SUM(COALESCE(pq.score, 0)) as total_score, SUM(COALESCE(pq.score_obtained, 0)) as obtained_score') ->havingRaw('SUM(COALESCE(pq.score, 0)) > 0') ->orderByDesc('p.created_at') ->limit(300) ->get(); $peerRates = []; foreach ($peerRows as $row) { $total = (float) ($row->total_score ?? 0); if ($total <= 0) { continue; } $peerRates[] = (float) ($row->obtained_score ?? 0) / $total; } if (! empty($peerRates)) { sort($peerRates); $peerAvg = array_sum($peerRates) / count($peerRates); $ltCount = 0; $eqCount = 0; foreach ($peerRates as $rate) { if ($rate < $currentScoreRate) { $ltCount++; } elseif (abs($rate - $currentScoreRate) < 1e-9) { $eqCount++; } } // 使用 mid-rank percentile,避免并列值把分位夸大到 100% $percentile = (100.0 * ($ltCount + 0.5 * $eqCount)) / count($peerRates); $delta = $currentScoreRate - $peerAvg; $peerCount = count($peerRates); $band = $this->resolvePeerBand($percentile); $bandVisual = $this->resolvePeerBandVisual($band); $peers['has_data'] = true; $peers['show_line'] = true; $peers['sample_size'] = $peerCount; $peers['raw_sample_size'] = $peerCount; $peers['peer_avg_score_rate'] = round($peerAvg, 4); $peers['delta_score_rate'] = round($delta, 4); $peers['percentile'] = round($percentile, 1); $peers['band'] = $band; $peers['band_icon'] = $bandVisual['icon']; $peers['band_color'] = $bandVisual['color']; if ($peerCount < 50) { $peers['display_mode'] = 'masked'; $peers['sample_size'] = null; $peers['peer_avg_score_rate'] = null; $peers['percentile'] = null; $peers['message'] = '已建立同群体参照,当前处于'.$band.'区间。'; } elseif ($peerCount > 200) { $peers['display_mode'] = 'percentile_only'; $peers['sample_size'] = null; $peers['peer_avg_score_rate'] = null; $peers['percentile'] = null; $peers['message'] = '和上百位同年级同类型学生相比,你当前处于'.$band.'水平。'; } else { $peers['display_mode'] = 'detailed'; $peers['message'] = '同年级同类型参照中,你当前位于 ' .number_format($percentile, 1).'% 分位,群体均值 ' .number_format($peerAvg * 100, 1).'% 。'; } } } } catch (\Throwable $e) { Log::warning('ExamPdfExportService: 构建同群体对比失败', ['error' => $e->getMessage()]); } return [ 'history' => $history, 'peers' => $peers, 'mastery' => [ 'current_average_mastery' => $currentMastery !== null ? round($currentMastery, 4) : null, ], ]; } private function difficultyRangeByLevel(?int $level): ?array { $map = [ 0 => ['min' => 0.00, 'max' => 0.10], 1 => ['min' => 0.10, 'max' => 0.25], 2 => ['min' => 0.25, 'max' => 0.50], 3 => ['min' => 0.50, 'max' => 0.75], 4 => ['min' => 0.75, 'max' => 1.00], ]; if ($level === null || ! isset($map[$level])) { return null; } return $map[$level]; } private function mapDifficultyValueToLevel(float $difficulty): int { if ($difficulty < 0.10) { return 0; } if ($difficulty < 0.25) { return 1; } if ($difficulty < 0.50) { return 2; } if ($difficulty < 0.75) { return 3; } return 4; } private function difficultyLevelLabel(?int $level, ?string $fallback = null): string { $map = [ 0 => '0基础', 1 => '筑基', 2 => '提分', 3 => '培优', 4 => '竞赛', ]; if ($level !== null && isset($map[$level])) { return $map[$level]; } $fallback = trim((string) $fallback); return $fallback !== '' ? $fallback : '未设置'; } private function resolveDeltaTrendLabel(float $delta): string { if ($delta >= 0.05) { return '显著提升'; } if ($delta >= 0.02) { return '小幅提升'; } if ($delta > -0.02) { return '基本持平'; } if ($delta > -0.05) { return '小幅回落'; } return '明显回落'; } private function resolvePeerBand(float $percentile): string { if ($percentile >= 75) { return '领先'; } if ($percentile >= 45) { return '中上'; } if ($percentile >= 25) { return '中下'; } return '待提升'; } /** * 综合当前表现、历史趋势、同群体位置,给出“整体水平”。 * * @return array{ * label:string, * composite_score:float, * current_score:float, * history_score:float, * peer_score:float, * difficulty_adjust:float * } */ private function buildV3OverallCompositeEvaluation( ?float $scoreRate, ?float $avgMastery, array $difficultyInsight, array $comparisonInsight, string $fallbackLabel ): array { $currentScoreRatePct = $scoreRate !== null ? max(0.0, min(100.0, $scoreRate * 100.0)) : 50.0; $currentMasteryPct = $avgMastery !== null ? max(0.0, min(100.0, $avgMastery * 100.0)) : 50.0; $currentScore = (0.7 * $currentScoreRatePct) + (0.3 * $currentMasteryPct); $history = $comparisonInsight['history'] ?? []; $historyScore = 60.0; if (! empty($history['is_first_exam'])) { $historyScore = 60.0; } elseif (! empty($history['low_baseline_guard'])) { $historyScore = 60.0; } elseif (! empty($history['has_data']) && isset($history['delta_score_rate'])) { $delta = (float) $history['delta_score_rate']; // ±1 $historyScore = 60.0 + (200.0 * $delta); // delta 0.1 => +20 $historyScore = max(0.0, min(100.0, $historyScore)); } $peers = $comparisonInsight['peers'] ?? []; $peerScore = 60.0; if (! empty($peers['show_line'])) { if (isset($peers['percentile']) && $peers['percentile'] !== null) { $peerScore = max(0.0, min(100.0, (float) $peers['percentile'])); } else { $band = (string) ($peers['band'] ?? '暂无'); $peerScore = match ($band) { '领先' => 82.0, '中上' => 66.0, '中下' => 46.0, '待提升' => 28.0, default => 60.0, }; } } $difficultyAdjust = 0.0; $difficultyStatus = (string) ($difficultyInsight['status'] ?? ''); if ($difficultyStatus === '偏难') { $difficultyAdjust = 5.0; } elseif ($difficultyStatus === '偏易') { $difficultyAdjust = -5.0; } $composite = (0.50 * $currentScore) + (0.25 * $historyScore) + (0.25 * $peerScore) + $difficultyAdjust; $composite = max(0.0, min(100.0, $composite)); // 等级标准统一: // S: 90-100, A: 75-89, B: 60-74, C: 40-59, D: 0-39 $grade = match (true) { $composite >= 90.0 => 'S', $composite >= 75.0 => 'A', $composite >= 60.0 => 'B', $composite >= 40.0 => 'C', default => 'D', }; $label = match ($grade) { 'S', 'A' => '优秀', 'B' => '良好', 'C' => '一般', default => '需加强', }; if ($scoreRate === null && $avgMastery === null) { $label = $fallbackLabel !== '' ? $fallbackLabel : '待评估'; } return [ 'grade' => $grade, 'label' => $label, 'composite_score' => round($composite, 1), 'current_score' => round($currentScore, 1), 'history_score' => round($historyScore, 1), 'peer_score' => round($peerScore, 1), 'difficulty_adjust' => round($difficultyAdjust, 1), ]; } /** * 首次出报告时,按得分档位随机抽取鼓励文案(每档10条)。 * * 档位标准统一: * A: 90-100, B: 75-89, C: 60-74, D: 40-59, E: 0-39 */ private function pickFirstExamEncouragementMessageByScore(?float $scoreRate): string { $score = 0.0; if ($scoreRate !== null) { $raw = (float) $scoreRate; $score = $raw <= 1.0 ? ($raw * 100.0) : $raw; $score = max(0.0, min(100.0, $score)); } $bucket = match (true) { $score >= 90.0 => 'A', $score >= 75.0 => 'B', $score >= 60.0 => 'C', $score >= 40.0 => 'D', default => 'E', }; $messagesByBucket = config('exam.analysis_report_v3.first_exam_messages_by_bucket', []); if (! is_array($messagesByBucket) || empty($messagesByBucket)) { $messagesByBucket = [ 'A' => ['这次开局很稳,说明你的基础和状态都在线。'], 'B' => ['这个分数是很不错的起点,方向完全正确。'], 'C' => ['这是正常且可提升的起点,先稳住基础最关键。'], 'D' => ['第一次这个分数不代表上限,只代表当前起点。'], 'E' => ['第一次分数偏低很正常,先把学习路径走顺。'], ]; } $messages = $messagesByBucket[$bucket] ?? $messagesByBucket['C']; $idx = random_int(0, count($messages) - 1); return $messages[$idx]; } /** * 为同群体区间提供可视化图标与颜色。 * * @return array{icon:string,color:string} */ private function resolvePeerBandVisual(string $band): array { return match ($band) { '领先' => ['icon' => '▲', 'color' => '#16a34a'], '中上' => ['icon' => '↗', 'color' => '#0ea5e9'], '中下' => ['icon' => '↘', 'color' => '#f59e0b'], '待提升' => ['icon' => '▼', 'color' => '#ef4444'], default => ['icon' => '•', 'color' => '#64748b'], }; } /** * 收集某学段根节点下的全量后代知识点(不含根节点本身) */ 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 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' => '复数', ]; return [ 'root_code' => 'S000_000', 'module_codes' => array_keys($moduleNames), 'module_names' => $moduleNames, ]; } return [ 'root_code' => 'M00', 'module_codes' => ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07'], 'module_names' => [ 'M01' => '数与代数', 'M02' => '方程与不等式', 'M03' => '图形性质', 'M04' => '图形变化与度量', 'M05' => '相似与勾股', 'M06' => '统计与概率', 'M07' => '函数', ], ]; } 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 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( '/