*/ 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'] ?? []), ]); // 【重要】处理题目数据中的图片标签和公式 // 将 转换为 ,并处理公式 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); } } $mark('process_formula_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'] ?? []; $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'); $paths = $this->buildV3LearningPaths($dimensions); $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 = []; } // 第二块雷达图补充:父轴下子知识点扩散(突出本卷掌握度变化) $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, 'overall_label_detail' => $overallComposite, 'stage' => $stage, 'difficulty' => $difficultyInsight, 'comparison' => $comparisonInsight, ], '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 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 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( '/
]*>([\s\S]*?)<\/div>\s*
]*><\/div>/i', function ($matches) use ($parser) { $markdown = html_entity_decode(trim($matches[1]), ENT_QUOTES, 'UTF-8'); $rendered = $parser->transform($markdown); return '
'.$rendered.'
'; }, $html ); } /** * 【新增】渲染试卷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); } try { // 通过HTTP客户端获取渲染后的HTML(与知识点讲解相同的逻辑) $routeName = $useGradingView ? 'filament.admin.auth.intelligent-exam.grading' : 'filament.admin.auth.intelligent-exam.pdf'; $url = route($routeName, ['paper_id' => $paperId, 'answer' => $includeAnswer ? 'true' : 'false']); $response = Http::get($url); if ($response->successful()) { $html = $response->body(); if (! empty(trim($html))) { return $this->ensureUtf8Html($html); } } Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML失败,使用备用方案', [ 'paper_id' => $paperId, 'url' => $url, ]); } catch (\Exception $e) { Log::warning('ExamPdfExportService: 通过HTTP获取试卷HTML异常', [ 'paper_id' => $paperId, 'error' => $e->getMessage(), ]); } // 备用方案:直接渲染视图 return $this->renderExamHtmlFromView($paperId, $includeAnswer, $useGradingView); } /** * 备用方案:直接渲染视图生成试卷HTML */ 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(); if (empty(trim($html))) { Log::error('ExamPdfExportService: 视图渲染结果为空', [ 'paper_id' => $paperId, 'view_name' => $viewName, 'question_count' => $paper->questions->count(), ]); return null; } return $this->ensureUtf8Html($html); } catch (\Exception $e) { Log::error('ExamPdfExportService: 备用方案渲染失败', [ 'paper_id' => $paperId, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return null; } } /** * 构建分析数据(重构版) * 优先使用本地MySQL数据,减少API依赖 */ private function buildAnalysisData(string $paperId, string $studentId): ?array { // 【关键调试】确认方法被调用 Log::warning('ExamPdfExportService: buildAnalysisData方法被调用了!', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'timestamp' => now()->toISOString(), ]); $paper = Paper::with(['questions' => function ($query) { $query->orderBy('question_number')->orderBy('id'); }])->find($paperId); if (! $paper) { Log::warning('ExamPdfExportService: 未找到试卷,将尝试仅基于分析数据生成PDF', [ 'paper_id' => $paperId, 'student_id' => $studentId, ]); // 【修复】即使试卷不存在,也尝试基于分析数据生成PDF $paper = new \stdClass; $paper->paper_id = $paperId; $paper->paper_name = "学情分析报告_{$studentId}_{$paperId}"; $paper->question_count = 0; $paper->total_score = 0; $paper->created_at = now(); $paper->questions = collect(); } $student = Student::find($studentId); $studentInfo = [ 'id' => $student?->student_id ?? $studentId, 'name' => $student?->name ?? $studentId, 'grade' => $student?->grade ?? '未知年级', 'class' => $student?->class_name ?? '未知班级', ]; $teacherInfo = $this->getTeacherInfo((string) ($paper->teacher_id ?? '')); $assembleType = ($paper->paper_type === null || $paper->paper_type === '') ? null : (int) $paper->paper_type; try { $assembleTypeLabel = $assembleType !== null ? PaperNaming::assembleTypeLabel($assembleType) : '未知类型'; } catch (\Throwable $e) { $assembleTypeLabel = '未知类型'; } // 【修改】直接从本地数据库获取分析数据(不再调用API) $analysisData = []; // 首先尝试从paper->analysis_id获取 if (! empty($paper->analysis_id)) { Log::info('ExamPdfExportService: 从本地数据库获取试卷分析数据', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'analysis_id' => $paper->analysis_id, ]); $analysisRecord = \DB::table('exam_analysis_results') ->where('id', $paper->analysis_id) ->where('student_id', $studentId) ->first(); if ($analysisRecord && ! empty($analysisRecord->analysis_data)) { $analysisData = json_decode($analysisRecord->analysis_data, true); Log::info('ExamPdfExportService: 成功获取本地分析数据(通过analysis_id)', [ 'data_size' => strlen($analysisRecord->analysis_data), ]); } else { Log::warning('ExamPdfExportService: 未找到本地分析数据,将尝试其他方式', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'analysis_id' => $paper->analysis_id, ]); } } // 如果没有analysis_id或未找到数据,直接从exam_analysis_results表查询 if (empty($analysisData)) { Log::info('ExamPdfExportService: 直接从exam_analysis_results表查询分析数据', [ 'paper_id' => $paperId, 'student_id' => $studentId, ]); $analysisRecord = \DB::table('exam_analysis_results') ->where('paper_id', $paperId) ->where('student_id', $studentId) ->orderByDesc('created_at') ->first(); if ($analysisRecord && ! empty($analysisRecord->analysis_data)) { $analysisData = json_decode($analysisRecord->analysis_data, true); Log::info('ExamPdfExportService: 成功获取本地分析数据(直接查询)', [ 'data_size' => strlen($analysisRecord->analysis_data), 'question_count' => count($analysisData['question_analysis'] ?? []), ]); } else { Log::warning('ExamPdfExportService: 未找到任何分析数据,将使用空数据', [ 'paper_id' => $paperId, 'student_id' => $studentId, ]); } } // 【修复】优先使用analysisData中的knowledge_point_analysis数据 $masteryData = []; $parentMasteryLevels = []; // 新增:父节点掌握度数据 $allParentMasteryLevelsRaw = []; // 全量父节点掌握度(非本卷过滤) Log::info('ExamPdfExportService: 开始处理掌握度数据', [ 'student_id' => $studentId, 'analysisData_keys' => array_keys($analysisData), 'has_knowledge_point_analysis' => ! empty($analysisData['knowledge_point_analysis']), ]); $fullMasteryMap = []; $snapshotMasteryData = []; if (! empty($analysisData['knowledge_point_analysis'])) { // 将knowledge_point_analysis转换为buildMasterySummary期望的格式 foreach ($analysisData['knowledge_point_analysis'] as $kp) { $masteryData[] = [ 'kp_code' => $kp['kp_id'] ?? null, 'kp_name' => $kp['kp_id'] ?? '未知知识点', 'mastery_level' => $kp['mastery_level'] ?? 0, 'mastery_change' => $kp['change'] ?? null, ]; } // 【修复】基于所有兄弟节点历史数据计算父节点掌握度,并获取掌握度变化 try { // 获取本次考试涉及的知识点代码列表 $examKpCodes = array_column($masteryData, 'kp_code'); Log::info('ExamPdfExportService: 本次考试涉及的知识点', [ 'count' => count($examKpCodes), 'kp_codes' => $examKpCodes, ]); // 获取最新快照的数据(mastery_data 内已包含 current_mastery 和 previous_mastery) $lastSnapshot = DB::connection('mysql') ->table('knowledge_point_mastery_snapshots') ->where('student_id', $studentId) ->where('paper_id', $paper->paper_id) ->latest('snapshot_time') ->first(); $previousMasteryData = []; $snapshotMasteryData = []; if ($lastSnapshot) { $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true); foreach ($previousMasteryJson as $kpCode => $data) { $snapshotMasteryData[$kpCode] = [ 'current_mastery' => isset($data['current_mastery']) ? floatval($data['current_mastery']) : null, 'previous_mastery' => isset($data['previous_mastery']) ? floatval($data['previous_mastery']) : null, 'change' => isset($data['change']) ? floatval($data['change']) : null, ]; $previousMasteryData[$kpCode] = [ 'current_mastery' => $data['current_mastery'] ?? 0, 'previous_mastery' => $data['previous_mastery'] ?? null, ]; } Log::info('ExamPdfExportService: 获取到上一次快照数据', [ 'snapshot_time' => $lastSnapshot->snapshot_time, 'kp_count' => count($previousMasteryData), ]); } // 为当前知识点添加变化数据 foreach ($masteryData as &$item) { $kpCode = $item['kp_code']; if (isset($previousMasteryData[$kpCode]) && $previousMasteryData[$kpCode]['previous_mastery'] !== null) { $previous = floatval($previousMasteryData[$kpCode]['previous_mastery']); $current = floatval($item['mastery_level']); $item['mastery_change'] = $current - $previous; } } unset($item); // 解除引用 // 获取所有父节点掌握度 $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId); $allParentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; $allParentMasteryLevelsRaw = $allParentMasteryLevels; $overviewDetails = $masteryOverview['details'] ?? []; foreach ($overviewDetails as $detail) { if (is_object($detail)) { $code = $detail->kp_code ?? null; if ($code) { $fullMasteryMap[$code] = floatval($detail->mastery_level ?? 0); } } elseif (is_array($detail)) { $code = $detail['kp_code'] ?? null; if ($code) { $fullMasteryMap[$code] = floatval($detail['mastery_level'] ?? 0); } } } // 计算与本次考试相关的父节点掌握度(基于所有兄弟节点) $parentMasteryLevels = []; // 【修复】使用数据库查询正确匹配父子关系,而不是字符串前缀 foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) { // 查询这个父节点的所有子节点 $childNodes = DB::connection('mysql') ->table('knowledge_points') ->where('parent_kp_code', $parentKpCode) ->pluck('kp_code') ->toArray(); // 检查是否有子节点在本次考试中出现 $relevantChildren = array_intersect($examKpCodes, $childNodes); if (! empty($relevantChildren)) { // 口径统一:父节点掌握度 = 全部直接子节点(含未命中,缺失按0)均值 $childCurrentLevels = []; $childPreviousLevels = []; foreach ($childNodes as $childKpCode) { $currentChild = floatval($fullMasteryMap[$childKpCode] ?? 0); $childCurrentLevels[] = $currentChild; $prevFromSnapshot = $snapshotMasteryData[$childKpCode]['previous_mastery'] ?? null; $currFromSnapshot = $snapshotMasteryData[$childKpCode]['current_mastery'] ?? null; $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild; $childPreviousLevels[] = floatval($previousChild); } $finalParentMastery = ! empty($childCurrentLevels) ? array_sum($childCurrentLevels) / count($childCurrentLevels) : floatval($parentMastery); $previousParentMastery = ! empty($childPreviousLevels) ? array_sum($childPreviousLevels) / count($childPreviousLevels) : $finalParentMastery; $finalParentChange = $finalParentMastery - $previousParentMastery; // 获取父节点中文名称 $parentKpInfo = DB::connection('mysql') ->table('knowledge_points') ->where('kp_code', $parentKpCode) ->first(); $parentMasteryLevels[$parentKpCode] = [ 'kp_code' => $parentKpCode, 'kp_name' => $parentKpInfo->name ?? $parentKpCode, 'mastery_level' => $finalParentMastery, 'mastery_percentage' => round($finalParentMastery * 100, 1), 'mastery_change' => $finalParentChange, 'change_source' => 'children_all_average', 'children' => $relevantChildren, ]; } } Log::info('ExamPdfExportService: 过滤后的父节点掌握度', [ 'all_parent_count' => count($allParentMasteryLevels), 'filtered_parent_count' => count($parentMasteryLevels), 'filtered_codes' => array_keys($parentMasteryLevels), ]); } catch (\Exception $e) { Log::warning('ExamPdfExportService: 获取父节点掌握度失败', [ 'error' => $e->getMessage(), ]); } Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [ 'count' => count($masteryData), 'masteryData_sample' => ! empty($masteryData) ? array_slice($masteryData, 0, 2) : [], ]); } else { // 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览 try { Log::info('ExamPdfExportService: 获取学生多层级掌握度概览', [ 'student_id' => $studentId, ]); $masteryOverview = $this->masteryCalculator->getStudentMasteryOverviewWithHierarchy($studentId); $masteryData = $masteryOverview['details'] ?? []; $parentMasteryLevels = $masteryOverview['parent_mastery_levels'] ?? []; // 获取父节点掌握度 $allParentMasteryLevelsRaw = $parentMasteryLevels; // 【修复】将对象数组转换为关联数组(避免 stdClass 对象访问错误) if (! empty($masteryData) && is_array($masteryData)) { $masteryData = array_map(function ($item) { if (is_object($item)) { $kpCode = $item->kp_code ?? null; return [ 'kp_code' => $kpCode, 'kp_name' => $item->kp_name ?? null, 'mastery_level' => floatval($item->mastery_level ?? 0), 'mastery_change' => $item->mastery_change !== null ? floatval($item->mastery_change) : null, ]; } return $item; }, $masteryData); } foreach ($masteryData as $m) { $code = $m['kp_code'] ?? null; if ($code) { $fullMasteryMap[$code] = floatval($m['mastery_level'] ?? 0); } } // 【修复】获取快照数据以计算掌握度变化 $lastSnapshot = DB::connection('mysql') ->table('knowledge_point_mastery_snapshots') ->where('student_id', $studentId) ->latest('snapshot_time') ->first(); if ($lastSnapshot) { $previousMasteryJson = json_decode($lastSnapshot->mastery_data, true); foreach ($masteryData as &$item) { $kpCode = $item['kp_code']; if (isset($previousMasteryJson[$kpCode])) { $previous = floatval($previousMasteryJson[$kpCode]['previous_mastery'] ?? 0); $current = floatval($item['mastery_level']); $item['mastery_change'] = $current - $previous; } } unset($item); } Log::info('ExamPdfExportService: 成功获取多层级掌握度数据', [ 'count' => count($masteryData), 'parent_count' => count($parentMasteryLevels), ]); } catch (\Exception $e) { Log::error('ExamPdfExportService: 获取掌握度数据失败', [ 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } } // 【修改】使用本地方法获取学习路径推荐(替代API调用) $recommendations = []; try { Log::info('ExamPdfExportService: 获取学习路径推荐', [ 'student_id' => $studentId, ]); $learningPaths = $this->learningAnalyticsService->recommendLearningPaths($studentId, 3); $recommendations = $learningPaths['recommendations'] ?? []; Log::info('ExamPdfExportService: 成功获取学习路径推荐', [ 'count' => count($recommendations), ]); } catch (\Exception $e) { Log::error('ExamPdfExportService: 获取学习路径推荐失败', [ 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } // 获取知识点名称映射 $kpNameMap = $this->buildKnowledgePointNameMap(); Log::info('ExamPdfExportService: 获取知识点名称映射', [ 'kpNameMap_count' => count($kpNameMap), 'kpNameMap_keys_sample' => ! empty($kpNameMap) ? array_slice(array_keys($kpNameMap), 0, 5) : [], ]); // 【修复】直接从MySQL数据库获取题目详情(不通过API) // 只有当 $paper 是 Paper 模型时才查询题目详情 $questionDetails = ($paper instanceof \App\Models\Paper) ? $this->getQuestionDetailsFromMySQL($paper) : []; // 处理题目数据 $questions = $this->processQuestionsForReport($paper, $questionDetails, $kpNameMap); // 【关键调试】查看buildMasterySummary的返回结果 $masterySummary = $this->buildMasterySummary($masteryData, $kpNameMap); Log::info('ExamPdfExportService: buildMasterySummary返回结果', [ 'masteryData_count' => count($masteryData), 'kpNameMap_count' => count($kpNameMap), 'masterySummary_keys' => array_keys($masterySummary), 'masterySummary_items_count' => count($masterySummary['items'] ?? []), 'masterySummary_items_sample' => ! empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : [], ]); // 构建当前学生掌握度映射,供父子影响分析展示使用 $masteryMap = !empty($fullMasteryMap) ? $fullMasteryMap : []; if (empty($masteryMap)) { foreach ($masteryData as $m) { $code = $m['kp_code'] ?? null; if ($code) { $masteryMap[$code] = floatval($m['mastery_level'] ?? 0); } } } // 本卷命中知识点:严格按“这套卷子题目关联知识点”计算 $examQuestionKpCodes = array_values(array_unique(array_filter(array_map( fn ($q) => trim((string) ($q['knowledge_point'] ?? '')), $questions )))); // 父节点列表:直接按“本卷命中子知识点”反查父节点,避免历史全集/补齐口径带偏 $processedParentMastery = $this->buildParentMasteryFromHitCodes( $examQuestionKpCodes, $kpNameMap, $masteryMap, $snapshotMasteryData ); $processedFullParentMastery = $this->buildParentMasteryFromAllParents( $allParentMasteryLevelsRaw, $kpNameMap, $masteryMap, $snapshotMasteryData ); Log::info('ExamPdfExportService: 处理后的父节点掌握度', [ 'raw_count' => count($parentMasteryLevels), 'processed_count' => count($processedParentMastery), 'processed_sample' => ! empty($processedParentMastery) ? array_slice($processedParentMastery, 0, 3) : [], ]); return [ 'paper' => [ 'id' => $paper->paper_id, 'name' => $paper->paper_name, 'paper_type' => $paper->paper_type, 'difficulty_category' => $paper->difficulty_category, 'assemble_type_label' => $assembleTypeLabel, 'total_questions' => $paper->question_count, 'total_score' => $paper->total_score, 'created_at' => $paper->created_at, ], 'student' => $studentInfo, 'teacher' => $teacherInfo, 'questions' => $questions, 'mastery' => $masterySummary, 'exam_hit_kp_codes' => $examQuestionKpCodes, 'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据 'full_parent_mastery_levels' => $processedFullParentMastery, 'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results 'recommendations' => $recommendations, 'analysis_data' => $analysisData, ]; } /** * 【修复】直接从PaperQuestion表获取题目详情(不通过API) */ private function getQuestionDetailsFromMySQL(Paper $paper): array { $details = []; Log::info('ExamPdfExportService: 从PaperQuestion表查询题目详情', [ 'paper_id' => $paper->paper_id, 'question_count' => $paper->questions->count(), ]); foreach ($paper->questions as $pq) { try { // 【关键修复】直接从PaperQuestion对象获取solution和correct_answer $detail = [ 'id' => $pq->question_id, 'content' => $pq->question_text, 'question_type' => $pq->question_type, 'answer' => $pq->correct_answer ?? null, // 【修复】从PaperQuestion获取正确答案 'solution' => $pq->solution ?? null, // 【修复】从PaperQuestion获取解题思路 ]; $details[(string) ($pq->question_id ?? $pq->id)] = $detail; Log::debug('ExamPdfExportService: 成功获取题目详情', [ 'paper_question_id' => $pq->id, 'question_id' => $pq->question_id, 'has_answer' => ! empty($pq->correct_answer), 'has_solution' => ! empty($pq->solution), 'answer_preview' => $pq->correct_answer ? substr($pq->correct_answer, 0, 50) : null, ]); } catch (\Throwable $e) { Log::error('ExamPdfExportService: 获取题目详情失败', [ 'paper_question_id' => $pq->id, 'error' => $e->getMessage(), ]); } } return $details; } /** * 将题库 options 转为 [A=>文本, B=>文本, ...],供学情报告展示 * * @param mixed $raw questions.options(JSON/数组) * @return array */ private function normalizeChoiceOptionsMap($raw): array { if ($raw === null || $raw === '') { return []; } if (is_string($raw)) { $decoded = json_decode($raw, true); $raw = is_array($decoded) ? $decoded : []; } if (! is_array($raw)) { return []; } $out = []; foreach ($raw as $k => $v) { if (is_string($k) && preg_match('/([A-H])/i', $k, $m)) { $letter = strtoupper($m[1]); $text = is_array($v) ? (string) ($v['content'] ?? $v['text'] ?? $v['value'] ?? '') : (string) $v; $text = trim($text); if ($text !== '') { $out[$letter] = $text; } } } if (! empty($out)) { return $out; } $letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; $i = 0; foreach ($raw as $v) { if ($i >= count($letters)) { break; } $text = is_array($v) ? (string) ($v['content'] ?? $v['text'] ?? $v['value'] ?? '') : (string) $v; $text = trim($text); if ($text !== '') { $out[$letters[$i]] = $text; } $i++; } return $out; } /** * 从题干 HTML 中解析选项(与 ExamPdfController::extractOptions 口径一致,输出为字母=>文本) * * @return array */ private function extractChoiceOptionsFromStem(string $content): array { $out = []; $contentWithoutSvg = preg_replace('/]*>.*?<\/svg>/is', '[SVG_PLACEHOLDER]', $content); $pattern = '/(?:^|\s)([A-H])[\.、:.:]\s*(.+?)(?=(?:^|\s)[A-H][\.、:.:]|$)/su'; if (preg_match_all($pattern, $contentWithoutSvg, $matches, PREG_SET_ORDER)) { foreach ($matches as $match) { $letter = strtoupper($match[1]); $optionText = trim($match[2]); $optionText = preg_replace('/\s+$/', '', $optionText); $optionText = preg_replace('/^\$\$\s*/', '', $optionText); $optionText = preg_replace('/\s*\$\$$/', '', $optionText); if ($optionText !== '') { $out[$letter] = $optionText; } } } if (empty($out)) { $lines = preg_split('/[\r\n]+/', $contentWithoutSvg); foreach ($lines as $line) { $line = trim($line); if (preg_match('/^([A-H])[\.、:.:]\s*(.+)$/u', $line, $match)) { $letter = strtoupper($match[1]); $optionText = trim($match[2]); if ($optionText !== '') { $out[$letter] = $optionText; } } } } return $out; } /** * 处理题目数据(用于报告) */ private function processQuestionsForReport($paper, array $questionDetails, array $kpNameMap): array { $grouped = [ 'choice' => [], 'fill' => [], 'answer' => [], ]; // 【修复】处理空的试卷(questions可能不存在) $questions = $paper->questions ?? collect(); if ($questions->isEmpty()) { Log::info('ExamPdfExportService: 试卷没有题目,返回空数组'); return $grouped; } $sortedQuestions = $questions ->sortBy(function ($q, int $idx) { $number = $q->question_number ?? $idx + 1; return is_numeric($number) ? (float) $number : ($q->id ?? $idx); }); $bankIds = $sortedQuestions->pluck('question_bank_id')->filter()->unique()->values()->all(); $optionsByBankId = []; if (! empty($bankIds)) { $optionsByBankId = Question::whereIn('id', $bankIds) ->pluck('options', 'id') ->toArray(); } foreach ($sortedQuestions as $idx => $question) { $kpCode = $question->knowledge_point ?? ''; $kpName = $kpNameMap[$kpCode] ?? $kpCode ?: '未标注'; // 【修复】直接从PaperQuestion对象获取solution和correct_answer $answer = $question->correct_answer ?? null; // 直接从PaperQuestion获取 $solution = $question->solution ?? null; // 直接从PaperQuestion获取 $detail = $questionDetails[(string) ($question->question_id ?? $question->id)] ?? []; $typeRaw = $question->question_type ?? ($detail['question_type'] ?? $detail['type'] ?? ''); $normalizedType = $this->normalizeQuestionType($typeRaw); $number = $question->question_number ?? ($idx + 1); // 处理题干文本 $questionText = is_array($question->question_text) ? json_encode($question->question_text, JSON_UNESCAPED_UNICODE) : ($question->question_text ?? ''); $optionsFromBank = $question->question_bank_id ? ($optionsByBankId[$question->question_bank_id] ?? null) : null; $choiceOptionsMap = $this->normalizeChoiceOptionsMap($optionsFromBank); if (empty($choiceOptionsMap) && $normalizedType === 'choice') { $choiceOptionsMap = $this->extractChoiceOptionsFromStem((string) $questionText); } $questionTextForPayload = $this->formatNewlines($questionText); $questionTextPreprocessed = false; if ($normalizedType === 'choice') { $questionTextForPayload = GradingStyleQuestionStem::buildChoiceStemForReport( (string) $questionTextForPayload ); $questionTextPreprocessed = true; } $payload = [ 'question_id' => $question->question_id ?? null, 'question_bank_id' => $question->question_bank_id ?? $question->question_id ?? null, 'question_number' => $number, 'question_text' => $questionTextForPayload, 'question_text_preprocessed' => $questionTextPreprocessed, 'question_type' => $normalizedType, 'knowledge_point' => $kpCode, 'knowledge_point_name' => $kpName, 'difficulty' => isset($question->difficulty) ? (float) $question->difficulty : null, 'score' => $question->score, 'answer' => $this->formatNewlines($answer), // 格式化换行 'solution' => $this->formatNewlines($solution), // 格式化换行 'student_answer' => $this->formatNewlines($question->student_answer ?? null), // 格式化换行 'correct_answer' => $this->formatNewlines($answer), // 格式化换行 'is_correct' => $question->is_correct ?? null, 'score_obtained' => $question->score_obtained ?? null, 'options' => $choiceOptionsMap, ]; $grouped[$normalizedType][] = $payload; // 【调试】记录题目数据 Log::debug('ExamPdfExportService: 处理题目数据', [ 'paper_question_id' => $question->id, 'question_id' => $question->question_id, 'has_answer' => ! empty($answer), 'has_solution' => ! empty($solution), 'answer_preview' => $answer ? substr($answer, 0, 50) : null, ]); } $ordered = array_merge($grouped['choice'], $grouped['fill'], $grouped['answer']); // 按卷面顺序重新编号 foreach ($ordered as $i => &$q) { $q['display_number'] = $i + 1; } unset($q); return $ordered; } /** * 构建PDF */ private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string { $tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html'; $utf8Html = $this->ensureUtf8Html($html); if ($applyWideImageSizing) { $utf8Html = $scopeToExamPart ? $this->applyAdaptiveWideImageSizingToExamPart($utf8Html) : $this->applyAdaptiveWideImageSizing($utf8Html); } $written = file_put_contents($tmpHtml, $utf8Html); Log::debug('ExamPdfExportService: HTML文件已创建', [ 'path' => $tmpHtml, 'html_length' => strlen($utf8Html), 'written_bytes' => $written, ]); // 【调试】如果启用了HTML保存调试,复制HTML到storage用于分析 if (config('pdf.debug_save_html', false)) { $debugPath = storage_path('app/debug_pdf_'.date('YmdHis').'.html'); @copy($tmpHtml, $debugPath); Log::warning('ExamPdfExportService: [DEBUG] HTML副本已保存', ['path' => $debugPath]); } // 仅使用Chrome渲染 $chromePdf = $this->renderWithChrome($tmpHtml); @unlink($tmpHtml); return $chromePdf; } /** * 对扁长/超扁长图片做全局自适应放大,普通图片不处理。 */ private function applyAdaptiveWideImageSizing(string $html): string { return (string) preg_replace_callback( '/]*\bsrc=(["\'])([^"\']+)\1[^>]*>/i', function (array $m): string { $fullTag = $m[0] ?? ''; $src = $m[2] ?? ''; if ($fullTag === '' || $src === '' || str_starts_with($src, 'data:')) { return $fullTag; } if (! $this->shouldApplyAdaptiveSizingToSrc($src)) { return $fullTag; } $dim = $this->getPdfImageDimensions($src); if (! $dim || ($dim['w'] ?? 0) <= 0 || ($dim['h'] ?? 0) <= 0) { return $fullTag; } $ratio = $dim['w'] / max(1, $dim['h']); if ($ratio < 2.8) { return $fullTag; } $targetWidth = $ratio >= 3.5 ? self::PDF_IMAGE_WIDTH_VERY_WIDE_PX : self::PDF_IMAGE_WIDTH_WIDE_PX; $targetWidth = min($targetWidth, $dim['w']); $targetStyle = sprintf( 'width:%dpx!important;max-width:%dpx!important;max-height:60mm!important;height:auto!important;object-fit:contain!important;', $targetWidth, $targetWidth ); if (preg_match('/\sstyle=(["\'])(.*?)\1/i', $fullTag, $sm)) { $originStyle = $sm[2] ?? ''; $originStyle = preg_replace('/\bmax-width\s*:[^;]+;?/i', '', $originStyle); $originStyle = preg_replace('/\bmax-height\s*:[^;]+;?/i', '', $originStyle); $originStyle = preg_replace('/\bwidth\s*:[^;]+;?/i', '', $originStyle); $originStyle = preg_replace('/\bheight\s*:[^;]+;?/i', '', $originStyle); $originStyle = preg_replace('/\bobject-fit\s*:[^;]+;?/i', '', $originStyle); $newStyle = $targetStyle.trim((string) $originStyle); return preg_replace( '/\sstyle=(["\'])(.*?)\1/i', ' style="'.$newStyle.'"', $fullTag, 1 ) ?? $fullTag; } return preg_replace('/applyAdaptiveWideImageSizing($examContent); return substr($html, 0, $contentStart).$processedExamContent.substr($html, $endPos); } /** * @return array{w:int,h:int}|null */ private function getPdfImageDimensions(string $src): ?array { if (array_key_exists($src, $this->pdfImageDimensionCache)) { return $this->pdfImageDimensionCache[$src]; } try { $persisted = $this->getPersistedPdfImageMetrics($src); if ($persisted !== null) { $this->pdfImageDimensionCache[$src] = $persisted; return $persisted; } if (! str_starts_with($src, 'http://') && ! str_starts_with($src, 'https://')) { $this->pdfImageDimensionCache[$src] = null; return null; } $size = @getimagesize($src); if (is_array($size) && count($size) >= 2) { $data = ['w' => (int) $size[0], 'h' => (int) $size[1]]; $this->persistPdfImageMetrics($src, $data); $this->pdfImageDimensionCache[$src] = $data; return $data; } $this->pdfImageDimensionCache[$src] = null; return null; } catch (\Throwable $e) { Log::debug('ExamPdfExportService: 图片尺寸探测失败', ['src' => $src, 'error' => $e->getMessage()]); $this->pdfImageDimensionCache[$src] = null; return null; } } /** * @return array{w:int,h:int}|null */ private function getPersistedPdfImageMetrics(string $src): ?array { if (! $this->isPdfImageMetricsTableReady()) { return null; } $row = DB::table('pdf_image_metrics') ->where('src', $src) ->first(['width', 'height']); if (! $row) { return null; } $w = (int) ($row->width ?? 0); $h = (int) ($row->height ?? 0); if ($w <= 0 || $h <= 0) { return null; } return ['w' => $w, 'h' => $h]; } /** * @param array{w:int,h:int} $data */ private function persistPdfImageMetrics(string $src, array $data): void { if (! $this->isPdfImageMetricsTableReady()) { return; } $w = (int) ($data['w'] ?? 0); $h = (int) ($data['h'] ?? 0); if ($w <= 0 || $h <= 0) { return; } DB::table('pdf_image_metrics')->upsert([ [ 'src' => $src, 'width' => $w, 'height' => $h, 'ratio' => round($w / max(1, $h), 4), 'updated_at' => now(), 'created_at' => now(), ], ], ['src'], ['width', 'height', 'ratio', 'updated_at']); } private function isPdfImageMetricsTableReady(): bool { if ($this->hasPdfImageMetricsTable !== null) { return $this->hasPdfImageMetricsTable; } $this->hasPdfImageMetricsTable = Schema::hasTable('pdf_image_metrics'); return $this->hasPdfImageMetricsTable; } private function shouldApplyAdaptiveSizingToSrc(string $src): bool { $parts = parse_url($src); if (! is_array($parts)) { return false; } $host = strtolower((string) ($parts['host'] ?? '')); $path = (string) ($parts['path'] ?? ''); if ($host !== 'file.chunsunqiuzhu.com') { return false; } if (! str_contains($path, '/data/')) { return false; } return (bool) preg_match('/\.(png|jpe?g|webp)$/i', $path); } /** * 使用Chrome渲染PDF */ private function renderWithChrome(string $htmlPath): ?string { $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_').'.pdf'; $userDataDir = sys_get_temp_dir().'/chrome-profile-'.uniqid(); $chromeBinary = $this->findChromeBinary(); if (! $chromeBinary) { Log::error('ExamPdfExportService: 未找到可用的Chrome/Chromium'); return null; } // 设置运行时目录 $runtimeHome = sys_get_temp_dir().'/chrome-home'; $runtimeXdg = sys_get_temp_dir().'/chrome-xdg'; if (! File::exists($runtimeHome)) { @File::makeDirectory($runtimeHome, 0755, true); } if (! File::exists($runtimeXdg)) { @File::makeDirectory($runtimeXdg, 0755, true); } $process = new Process([ $chromeBinary, '--headless=new', // 【优化】使用新渲染引擎 '--disable-gpu', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-web-security', '--disable-features=VizDisplayCompositor', '--disable-extensions', // '--disable-background-networking', // 注释掉,可能阻止必要的网络请求 '--disable-component-update', '--disable-client-side-phishing-detection', '--disable-default-apps', '--disable-domain-reliability', '--disable-sync', '--no-first-run', '--no-default-browser-check', '--disable-crash-reporter', '--disable-print-preview', '--disable-features=TranslateUI', '--disable-features=OptimizationHints', '--disable-ipc-flooding-protection', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=AudioServiceOutOfProcess', '--disable-gpu-sandbox', '--disable-software-rasterizer', '--disable-background-mode', '--disable-extensions-http-throttling', '--disable-ipc-flooding-protection', '--disable-features=Dbus', // 禁用 dbus // 【关键修复】添加虚拟时间预算,让Chrome有足够时间加载CDN资源和执行JS '--virtual-time-budget=30000', // 30秒虚拟时间用于加载外部资源 '--run-all-compositor-stages-before-draw', // 确保所有渲染完成后再生成PDF '--user-data-dir='.$userDataDir, '--print-to-pdf='.$tmpPdf, '--print-to-pdf-no-header', '--allow-file-access-from-files', '--font-render-hinting=none', // 【优化】禁用字体渲染提示 '--disable-font-antialiasing', 'file://'.$htmlPath, ], null, [ 'HOME' => $runtimeHome, 'XDG_RUNTIME_DIR' => $runtimeXdg, ]); $process->setTimeout(180); // 复杂学情报告页允许更长渲染时间,降低超时失败率 $killSignal = \defined('SIGKILL') ? \SIGKILL : 9; Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [ 'chrome_binary' => $chromeBinary, 'html_path' => $htmlPath, 'html_exists' => file_exists($htmlPath), 'html_size' => file_exists($htmlPath) ? filesize($htmlPath) : 0, 'pdf_path' => $tmpPdf, 'user_data_dir' => $userDataDir, ]); try { $startedAt = microtime(true); $process->start(); $pdfGenerated = false; // 轮询检测PDF是否生成 $pollStart = microtime(true); $maxPollSeconds = 80; // 【修复】增加轮询超时到80秒 while ($process->isRunning() && (microtime(true) - $pollStart) < $maxPollSeconds) { if (file_exists($tmpPdf) && filesize($tmpPdf) > 0) { $pdfGenerated = true; Log::info('ExamPdfExportService: PDF生成成功,提前终止Chrome进程', [ 'elapsed' => round(microtime(true) - $startedAt, 2), 'pdf_size' => filesize($tmpPdf), ]); $process->stop(3, $killSignal); // 【优化】减少停止等待时间 break; } usleep(100_000); // 【优化】从200ms减少到100ms } if ($process->isRunning()) { $process->stop(3, $killSignal); // 【优化】减少停止等待时间 } // 【优化】删除不必要的wait()调用,避免重复等待 // $process->wait(); } catch (ProcessTimedOutException|ProcessSignaledException $e) { if ($process->isRunning()) { $process->stop(3, $killSignal); // 【优化】减少停止等待时间 } Log::warning('ExamPdfExportService: Chrome进程超时或被信号中断', [ 'elapsed' => round((microtime(true) - $startedAt), 2), 'exception' => get_class($e), ]); $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt); if ($result !== null) { return $result; } return $this->renderWithChromeMinimal($chromeBinary, $htmlPath); } catch (\Throwable $e) { if ($process->isRunning()) { $process->stop(3, $killSignal); // 【优化】减少停止等待时间 } Log::error('ExamPdfExportService: Chrome进程异常', [ 'elapsed' => round((microtime(true) - $startedAt), 2), 'error' => $e->getMessage(), ]); $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null); if ($result !== null) { return $result; } return $this->renderWithChromeMinimal($chromeBinary, $htmlPath); } $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null); if ($result !== null) { return $result; } return $this->renderWithChromeMinimal($chromeBinary, $htmlPath); } /** * 处理Chrome进程结果 */ private function handleChromeProcessResult(string $tmpPdf, string $userDataDir, Process $process, ?float $startedAt): ?string { $pdfExists = file_exists($tmpPdf); $pdfSize = $pdfExists ? filesize($tmpPdf) : null; $elapsed = $startedAt ? round((microtime(true) - $startedAt), 2) : null; // 【优化】即使进程未成功,只要PDF存在且大小合理就返回 if ($pdfExists && $pdfSize > 1000) { // 至少1KB Log::info('ExamPdfExportService: PDF生成成功', [ 'elapsed' => $elapsed, 'pdf_size' => $pdfSize, 'exit_code' => $process->getExitCode(), 'is_successful' => $process->isSuccessful(), ]); $pdfBinary = file_get_contents($tmpPdf); @unlink($tmpPdf); File::deleteDirectory($userDataDir); return $pdfBinary; } // 如果PDF不存在或太小,记录错误 Log::error('ExamPdfExportService: Chrome渲染失败', [ 'elapsed' => $elapsed, 'pdf_exists' => $pdfExists, 'pdf_size' => $pdfSize, 'exit_code' => $process->getExitCode(), 'error' => $process->getErrorOutput(), 'output' => $process->getOutput(), ]); @unlink($tmpPdf); File::deleteDirectory($userDataDir); return null; } /** * Chrome主渲染失败时的最小参数兜底。 */ private function renderWithChromeMinimal(string $chromeBinary, string $htmlPath): ?string { $tmpPdf = tempnam(sys_get_temp_dir(), 'exam_pdf_fallback_').'.pdf'; $process = new Process([ $chromeBinary, '--headless=new', '--disable-gpu', '--no-sandbox', '--print-to-pdf='.$tmpPdf, 'file://'.$htmlPath, ]); $process->setTimeout(180); try { $process->run(); if (file_exists($tmpPdf) && filesize($tmpPdf) > 1000) { Log::warning('ExamPdfExportService: Chrome最小参数兜底成功', [ 'pdf_size' => filesize($tmpPdf), 'exit_code' => $process->getExitCode(), ]); $pdfBinary = file_get_contents($tmpPdf); @unlink($tmpPdf); return $pdfBinary; } Log::error('ExamPdfExportService: Chrome最小参数兜底失败', [ 'exit_code' => $process->getExitCode(), 'error' => $process->getErrorOutput(), 'output' => $process->getOutput(), ]); } catch (\Throwable $e) { Log::error('ExamPdfExportService: Chrome最小参数兜底异常', [ 'error' => $e->getMessage(), ]); } @unlink($tmpPdf); return null; } /** * 查找Chrome二进制文件 */ private function findChromeBinary(): ?string { $candidates = [ env('PDF_CHROME_BINARY'), env('CHROME_BIN'), // Docker Alpine 环境变量 '/usr/bin/chromium-browser', // Alpine Linux '/usr/bin/chromium', '/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // macOS ]; foreach ($candidates as $path) { if ($path && is_file($path) && is_executable($path)) { return $path; } } return null; } /** * 确保HTML为UTF-8编码,并内联外部资源 */ private function ensureUtf8Html(string $html): string { $meta = ''; if (stripos($html, '') !== false) { $html = preg_replace('//i', "{$meta}", $html, 1); } else { $html = $meta.$html; } // 【关键修复】内联KaTeX CSS,避免Chrome在容器中加载CDN资源超时 $html = $this->inlineExternalResources($html); return $html; } /** * 将CDN资源替换为内联资源 * 【关键修复】避免Chrome在容器中加载CDN资源超时,同时支持本地路径 */ private function inlineExternalResources(string $html): string { // 检查是否包含 KaTeX 资源(CDN 或本地) $hasKatexCdn = strpos($html, 'cdn.jsdelivr.net/npm/katex') !== false; $hasKatexLocal = strpos($html, '/js/katex.min.js') !== false || strpos($html, '/css/katex/katex.min.css') !== false; // 【调试】记录HTML内容信息 Log::warning('ExamPdfExportService: inlineExternalResources', [ 'html_length' => strlen($html), 'has_katex_cdn' => $hasKatexCdn, 'has_katex_local' => $hasKatexLocal, ]); // 如果既没有 CDN 也没有本地链接,跳过 if (! $hasKatexCdn && ! $hasKatexLocal) { Log::warning('ExamPdfExportService: HTML 中没有 KaTeX 资源链接,跳过内联'); return $html; } try { // 读取并内联 KaTeX CSS(无论 CDN 还是本地) $katexCssPath = public_path('css/katex/katex.min.css'); if (file_exists($katexCssPath)) { $katexCss = file_get_contents($katexCssPath); // 修复字体路径:将相对路径改为 data URI $fontsDir = public_path('css/katex/fonts'); $katexCss = preg_replace_callback( '/url\(["\']?fonts\/([^"\')\s]+)["\']?\)/i', function ($matches) use ($fontsDir) { $fontFile = $fontsDir.'/'.$matches[1]; if (file_exists($fontFile)) { $fontData = base64_encode(file_get_contents($fontFile)); $mimeType = str_ends_with($matches[1], '.woff2') ? 'font/woff2' : 'font/woff'; return 'url(data:'.$mimeType.';base64,'.$fontData.')'; } return $matches[0]; }, $katexCss ); // 替换 CDN CSS 链接 if ($hasKatexCdn) { $html = preg_replace( '/]*href=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.css["\'][^>]*>/i', '', $html ); } // 替换本地 CSS 链接 if ($hasKatexLocal) { $html = preg_replace( '/]*href=["\']\/css\/katex\/katex\.min\.css["\'][^>]*>/i', '', $html ); } Log::info('ExamPdfExportService: KaTeX CSS 已内联(含字体 data URI)'); } // 读取本地 KaTeX JS(用于移除) $katexJsPath = public_path('js/katex.min.js'); $autoRenderJsPath = public_path('js/auto-render.min.js'); if (file_exists($katexJsPath)) { $katexJs = file_get_contents($katexJsPath); $html = preg_replace( '/]*src=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*katex\.min\.js["\'][^>]*><\/script>/i', '', $html ); } if (file_exists($autoRenderJsPath)) { $autoRenderJs = file_get_contents($autoRenderJsPath); $html = preg_replace( '/]*src=["\']https:\/\/cdn\.jsdelivr\.net\/npm\/katex[^"\']*auto-render[^"\']*["\'][^>]*><\/script>/i', '', $html ); } // 【关键修复】使用服务端预渲染,而不是依赖客户端 JavaScript // Chrome headless 的 --print-to-pdf 不会等待 JS 执行完成 // 所以我们使用 Node.js KaTeX 在服务端预渲染所有公式 // 1. 移除所有 KaTeX JavaScript(不再需要,因为使用服务端渲染) // 移除内联的 katex.min.js $html = preg_replace( '/]*type=["\']text\/javascript["\'][^>]*>[\s\S]*?katex[\s\S]*?<\/script>/i', '', $html ); // 移除 DOMContentLoaded 监听器 $html = preg_replace( '/]*>[\s\S]*?document\.addEventListener[\s\S]*?DOMContentLoaded[\s\S]*?<\/script>/i', '', $html ); // 2. 使用 KatexRenderer 进行服务端预渲染 if ($this->katexRenderer) { $beforeLength = strlen($html); $html = $this->katexRenderer->renderHtml($html); $afterLength = strlen($html); Log::info('ExamPdfExportService: LaTeX 公式服务端预渲染完成', [ 'before_length' => $beforeLength, 'after_length' => $afterLength, 'size_change' => $afterLength - $beforeLength, ]); } else { Log::warning('ExamPdfExportService: KatexRenderer 未初始化,跳过预渲染'); } // 【换行处理】将字面的 \n 转换为
标签(在 KaTeX 渲染后处理,避免破坏公式) // 先替换所有 \n 为
,然后合并连续的
(最多保留2个) $html = str_replace('\\n', '
', $html); $html = preg_replace('/(
\s*){3,}/', '

', $html); // 3个以上合并为2个 } catch (\Exception $e) { Log::warning('ExamPdfExportService: 内联资源处理失败,保留原始HTML', [ 'error' => $e->getMessage(), ]); } return $html; } /** * 【新增】合并两个HTML页面,插入分页符 * 保留原始页面样式和结构,只在中间插入分页符 */ private function mergeHtmlWithPageBreak(string $examHtml, string $gradingHtml, ?string $kpExplainHtml = null): ?string { try { // 确保HTML编码正确 $examHtml = $this->ensureUtf8Html($examHtml); $gradingHtml = $this->ensureUtf8Html($gradingHtml); if ($kpExplainHtml) { $kpExplainHtml = $this->ensureUtf8Html($kpExplainHtml); } // 提取body内容 $examBody = $this->extractBodyContent($examHtml); $gradingBody = $this->extractBodyContent($gradingHtml); // 知识点讲解使用专门的提取方法,避免嵌套完整HTML结构 $kpExplainBody = $kpExplainHtml ? $this->extractKpExplainContent($kpExplainHtml) : null; if (empty($examBody) || empty($gradingBody)) { Log::error('ExamPdfExportService: HTML内容提取失败', [ 'exam_body_length' => strlen($examBody), 'grading_body_length' => strlen($gradingBody), ]); return null; } // 提取head内容(保留原始样式和meta信息) $examHead = $this->extractHeadContent($examHtml); $gradingHead = $this->extractHeadContent($gradingHtml); $kpExplainHead = $kpExplainHtml ? $this->extractHeadContent($kpExplainHtml) : null; // 构建统一HTML文档(保留原始结构) $unifiedHtml = $this->buildUnifiedHtmlWithOriginalStructure( $examHead, $examBody, $gradingBody, $gradingHead, $kpExplainBody, $kpExplainHead ); Log::info('HTML合并成功(保留原始样式)', [ 'exam_length' => strlen($examBody), 'grading_length' => strlen($gradingBody), 'kp_explain_length' => $kpExplainBody ? strlen($kpExplainBody) : 0, 'unified_length' => strlen($unifiedHtml), 'head_length' => strlen($examHead), 'has_kp_explain' => ! empty($kpExplainBody), ]); return $unifiedHtml; } catch (\Throwable $e) { Log::error('mergeHtmlWithPageBreak 失败', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return null; } } /** * 【新增】提取HTML的body内容 */ private function extractBodyContent(string $html): string { // 匹配body标签内容 if (preg_match('/]*>(.*)<\/body>/is', $html, $matches)) { return $matches[1]; } // 如果没有body标签,返回整个HTML return $html; } /** * 【新增】提取HTML的head内容 * 保留原始页面的样式和meta信息 */ private function extractHeadContent(string $html): string { // 匹配head标签内容 if (preg_match('/]*>(.*)<\/head>/is', $html, $matches)) { return $matches[1]; } // 如果没有head标签,返回默认meta return ''; } /** * 【新增】提取知识点讲解的核心内容 * 只提取 kp-explain 容器内部的内容,避免嵌套完整的HTML结构 */ private function extractKpExplainContent(string $html): string { // 如果 HTML 中包含嵌套的 标签,提取嵌套内容 if (preg_match('/]*>(.*)<\/html>/is', $html, $htmlMatches)) { $html = $htmlMatches[1]; } // 如果 HTML 中包含 标签,跳过 head if (preg_match('/]*>.*?<\/head>/is', $html, $headMatch)) { $html = substr($html, strpos($html, '') + 7); } // 如果 HTML 中包含 标签,提取 body 内容 if (preg_match('/]*>(.*)<\/body>/is', $html, $bodyMatches)) { $html = $bodyMatches[1]; } // 移除可能存在的嵌套 , , 标签 $html = preg_replace('/<\/?(html|head|body)[^>]*>/is', '', $html); // 移除 script 标签和注释 $html = preg_replace('/]*>.*?<\/script>/is', '', $html); $html = preg_replace('//is', '', $html); return trim($html); } /** * 【优化重构】构建统一的HTML文档(容器结构 + 消除空白页) * 使用容器结构替代空的 page-break div,避免中间出现空白页 */ private function buildUnifiedHtmlWithOriginalStructure( string $examHead, string $examBody, string $gradingBody, string $gradingHead, ?string $kpExplainBody = null, ?string $kpExplainHead = null ): string { // 清洗内容:移除可能存在的分页符,避免双重分页 $examBody = $this->stripPageBreakElements($examBody); $gradingBody = $this->stripPageBreakElements($gradingBody); if ($kpExplainBody) { $kpExplainBody = $this->stripPageBreakElements($kpExplainBody); } // 合并 head 内容:试卷 head + 判卷 head + 知识点讲解 head(去重)+ 分页控制样式 $mergedHead = $examHead; // 按顺序合并其他 head 里的 '; // 构建HTML内容 $bodyContent = ''; $mergedTitle = '试卷与判卷合并'; if (preg_match('/(.*?)<\/title>/is', $examHead, $titleMatches)) { $candidateTitle = trim(strip_tags($titleMatches[1])); if ($candidateTitle !== '') { $mergedTitle = $candidateTitle; } } // 如果有知识点讲解,添加到最前面 if ($kpExplainBody) { $bodyContent .= ' <!-- 知识点讲解部分 --> <div class="kp-explain-part"> '.$kpExplainBody.' </div> '; } // 添加试卷部分 $bodyContent .= ' <!-- EXAM_PART_START --> <!-- 试卷部分 - 连续显示 --> <div class="exam-part"> '.$examBody.' </div> <!-- EXAM_PART_END --> '; // 添加判卷部分 $bodyContent .= ' <!-- 判卷部分 - 强制新页面开始 --> <div class="grading-part"> '.$gradingBody.' </div> '; return '<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>'.$mergedTitle.' '.$headContent.' '.$bodyContent.' '; } /** * 【新增】清洗HTML内容中的分页符元素 * 移除可能存在的 page-break 元素,避免双重分页导致空白页 */ private function stripPageBreakElements(string $content): string { // 只移除空的 page-break div 元素(通常是
) // 使用更精确的正则,只匹配空内容或纯空白内容的 div $patterns = [ '/]*class="[^"]*page-break[^"]*"[^>]*>\s*<\/div>/si', '/]*style="[^"]*page-break[^"]*"[^>]*>\s*<\/div>/si', ]; foreach ($patterns as $pattern) { $content = preg_replace($pattern, '', $content); } return $content; } /** * 【新增】获取PDF通用样式 */ private function getCommonPdfStyles(): string { return ' * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: "Microsoft YaHei", "SimHei", Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #333; background: #fff; } .paper-container { max-width: 210mm; margin: 0 auto; padding: 20mm; background: white; } .paper-header { text-align: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #333; } .paper-title { font-size: 24px; font-weight: bold; margin-bottom: 10px; } .paper-info { font-size: 14px; color: #666; } .question { margin-bottom: 30px; padding: 20px; border: 1px solid #ddd; border-radius: 5px; } .question-number { font-weight: bold; margin-bottom: 10px; } .question-stem { margin-bottom: 15px; } .options { list-style: none; margin-left: 20px; } .options li { margin-bottom: 8px; } .answer-section { margin-top: 20px; padding-top: 15px; border-top: 1px solid #eee; } .answer-label { font-weight: bold; color: #d9534f; } .solution { margin-top: 10px; padding: 10px; background: #f5f5f5; border-left: 4px solid #5bc0de; } '; } /** * 构建知识点名称映射 */ private function buildKnowledgePointNameMap(): array { try { $options = $this->questionServiceApi->getKnowledgePointOptions(); return $options ?: []; } catch (\Throwable $e) { Log::warning('ExamPdfExportService: 获取知识点名称失败', [ 'error' => $e->getMessage(), ]); return []; } } /** * 构建掌握度摘要 */ private function buildMasterySummary(array $masteryData, array $kpNameMap): array { Log::info('ExamPdfExportService: buildMasterySummary开始处理', [ 'masteryData_count' => count($masteryData), 'kpNameMap_count' => count($kpNameMap), ]); $items = []; $total = 0; $count = 0; foreach ($masteryData as $row) { $code = $row['kp_code'] ?? null; // 【修复】使用kpNameMap转换名称为友好显示名 $name = $kpNameMap[$code] ?? $row['kp_name'] ?? $code ?: '未知知识点'; $level = (float) ($row['mastery_level'] ?? 0); $delta = $row['mastery_change'] ?? null; $items[] = [ 'kp_code' => $code, 'kp_name' => $name, 'mastery_level' => $level, 'mastery_change' => $delta, ]; $total += $level; $count++; } $average = $count > 0 ? round($total / $count, 2) : null; // 按掌握度从低到高排序 if (! empty($items)) { usort($items, fn ($a, $b) => ($a['mastery_level'] <=> $b['mastery_level'])); } $result = [ 'items' => $items, 'average' => $average, 'weak_list' => array_slice($items, 0, 5), ]; Log::info('ExamPdfExportService: buildMasterySummary完成', [ 'total_count' => $count, 'items_count' => count($items), ]); return $result; } /** * 标准化题型 */ private function normalizeQuestionType(string $type): string { $t = strtolower(trim($type)); return match (true) { str_contains($t, 'choice') || str_contains($t, '选择') => 'choice', str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空') => 'fill', default => 'answer', }; } private function buildPaperNamePrefix(Paper $paper): string { $assembleType = ($paper->paper_type === null || $paper->paper_type === '') ? null : (int) $paper->paper_type; $studentName = Student::query() ->where('student_id', $paper->student_id) ->value('name') ?? ($paper->student_id ?: '________'); try { $assembleTypeLabel = $assembleType !== null ? PaperNaming::assembleTypeLabel($assembleType) : '未知类型'; } catch (\Throwable $e) { $assembleTypeLabel = '未知类型'; } return "{$studentName}_".PaperNaming::extractExamCode((string) $paper->paper_id)."_{$assembleTypeLabel}"; } private function buildPaperDisplayTitle(Paper $paper): string { return $this->buildPaperNamePrefix($paper).'_'.now()->format('Ymd'); } private function buildPdfFileName(Paper $paper, ?string $stamp = null): string { $basePrefix = $this->buildPaperNamePrefix($paper); $stamp = $stamp ?: now()->format('YmdHis').strtoupper(Str::random(4)); $base = "{$basePrefix}_{$stamp}"; $safe = PaperNaming::toSafeFilename($base); return "{$safe}.pdf"; } private function extractUploadStamp(string $url): ?string { $path = parse_url($url, PHP_URL_PATH); if (! is_string($path) || $path === '') { return null; } $stem = pathinfo($path, PATHINFO_FILENAME); if (! is_string($stem) || $stem === '') { return null; } if (preg_match('/(\d{14}[A-Za-z0-9]{4})/', $stem, $matches)) { return $matches[1]; } return null; } /** * 保存PDF URL到数据库 */ private function savePdfUrlToDatabase(string $paperId, string $field, string $url): void { try { $paper = Paper::where('paper_id', $paperId)->first(); if ($paper) { $paperDisplayTitle = $this->buildPaperDisplayTitle($paper); $stamp = $this->extractUploadStamp($url); if ($stamp) { $paperDisplayTitle = $this->buildPaperNamePrefix($paper).'_'.$stamp; } $updatePayload = [ $field => $url, 'paper_name' => $paperDisplayTitle, ]; $paper->update($updatePayload); Log::info('ExamPdfExportService: PDF URL已写入数据库', [ 'paper_id' => $paperId, 'field' => $field, 'url' => $url, 'paper_name' => $paperDisplayTitle, ]); } } catch (\Throwable $e) { Log::error('ExamPdfExportService: 写入PDF URL失败', [ 'paper_id' => $paperId, 'field' => $field, 'error' => $e->getMessage(), ]); } } /** * 保存学情分析PDF URL */ private function saveAnalysisPdfUrl(string $paperId, string $studentId, ?string $recordId, string $url): void { try { if ($recordId) { // OCR记录 $ocrRecord = \App\Models\OCRRecord::find($recordId); if ($ocrRecord) { $ocrRecord->update(['analysis_pdf_url' => $url]); Log::info('ExamPdfExportService: OCR记录学情分析PDF URL已写入数据库', [ 'record_id' => $recordId, 'paper_id' => $paperId, 'student_id' => $studentId, 'url' => $url, ]); } } else { // 【修复】同时更新 exam_analysis_results 表和分析报告表 $updated = \DB::connection('mysql')->table('exam_analysis_results') ->where('student_id', $studentId) ->where('paper_id', $paperId) ->update([ 'analysis_pdf_url' => $url, 'updated_at' => now(), ]); if ($updated) { Log::info('ExamPdfExportService: 学情分析PDF URL已写入exam_analysis_results表', [ 'student_id' => $studentId, 'paper_id' => $paperId, 'url' => $url, 'updated_rows' => $updated, ]); } else { Log::warning('ExamPdfExportService: 未找到要更新的学情分析记录', [ 'student_id' => $studentId, 'paper_id' => $paperId, ]); } // 学生记录 - 使用新的 student_reports 表(备用) \App\Models\StudentReport::updateOrCreate( [ 'student_id' => $studentId, 'report_type' => 'exam_analysis', 'paper_id' => $paperId, ], [ 'pdf_url' => $url, 'generation_status' => 'completed', 'generated_at' => now(), 'updated_at' => now(), ] ); Log::info('ExamPdfExportService: 学生学情报告PDF URL已保存到student_reports表(备用)', [ 'student_id' => $studentId, 'paper_id' => $paperId, 'url' => $url, ]); } } catch (\Throwable $e) { Log::error('ExamPdfExportService: 写入学情分析PDF URL失败', [ 'paper_id' => $paperId, 'student_id' => $studentId, 'record_id' => $recordId, 'error' => $e->getMessage(), ]); } } /** * 【修复】处理父节点掌握度数据 * 1. 过滤掉掌握度为0或null的父节点 * 2. 将kp_code转换为友好的kp_name * 3. 构建父子层级关系(只显示本次考试相关的子节点) */ private function processParentMasteryLevels( array $parentMasteryLevels, array $kpNameMap, array $examKpCodes = [], array $masteryMap = [], array $snapshotMasteryData = [] ): array { $processed = []; foreach ($parentMasteryLevels as $kpCode => $masteryData) { // 兼容不同数据结构:可能是数组或数字 $masteryLevel = is_array($masteryData) ? ($masteryData['mastery_level'] ?? 0) : $masteryData; $masteryChange = is_array($masteryData) ? ($masteryData['mastery_change'] ?? null) : null; $changeSource = is_array($masteryData) ? ($masteryData['change_source'] ?? null) : null; // 获取友好名称 $kpName = $kpNameMap[$kpCode] ?? $kpCode; // 构建父节点数据,包含子节点信息(只显示本次考试相关的) $childrenData = $this->getChildKnowledgePoints($kpCode, $kpNameMap, $examKpCodes, $masteryMap); $allChildren = $childrenData['all_children'] ?? []; // 口径统一:优先使用全部直接子节点均值作为父节点掌握度 if (! empty($allChildren)) { $allLevels = array_map( fn ($c) => floatval($c['mastery_level'] ?? 0), $allChildren ); $masteryLevel = ! empty($allLevels) ? array_sum($allLevels) / count($allLevels) : floatval($masteryLevel); if (! empty($snapshotMasteryData)) { $prevLevels = []; foreach ($allChildren as $child) { $childCode = (string) ($child['kp_code'] ?? ''); if ($childCode === '') { continue; } $currentChild = floatval($masteryMap[$childCode] ?? 0); $prevFromSnapshot = $snapshotMasteryData[$childCode]['previous_mastery'] ?? null; $currFromSnapshot = $snapshotMasteryData[$childCode]['current_mastery'] ?? null; $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild; $prevLevels[] = floatval($previousChild); } if (! empty($prevLevels)) { $masteryChange = $masteryLevel - (array_sum($prevLevels) / count($prevLevels)); $changeSource = 'children_all_average'; } } } // 仅过滤空值;0 掌握度的命中父节点也要展示 if ($masteryLevel === null) { continue; } $hitLevels = array_map( fn ($c) => floatval($c['mastery_level'] ?? 0), $childrenData['hit_children'] ?? [] ); $hitAvg = ! empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null; $hitCount = count($childrenData['hit_children'] ?? []); // 父节点仅展示“本卷命中知识点”对应的父节点 if ($hitCount <= 0) { continue; } $processed[$kpCode] = [ 'kp_code' => $kpCode, 'kp_name' => $kpName, 'mastery_level' => round(floatval($masteryLevel), 4), 'mastery_percentage' => round(floatval($masteryLevel) * 100, 2), 'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null, 'change_source' => $changeSource, // 兼容旧模板字段:仅命中子节点 'children' => $childrenData['hit_children'] ?? [], // 新增:全部直接子节点(含掌握度、是否命中) 'children_all' => $childrenData['all_children'] ?? [], 'children_hit_count' => $hitCount, 'children_total_count' => count($childrenData['all_children'] ?? []), 'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null, 'level' => $this->calculateKnowledgePointLevel($kpCode), ]; } // 按掌握度降序排序 uasort($processed, function ($a, $b) { return $b['mastery_level'] <=> $a['mastery_level']; }); return $processed; } /** * 【修复】获取子知识点列表(只返回本次考试涉及的) */ private function getChildKnowledgePoints(string $parentKpCode, array $kpNameMap, array $examKpCodes = [], array $masteryMap = []): array { $allChildren = []; $hitChildren = []; try { $childCodes = DB::connection('mysql') ->table('knowledge_points') ->where('parent_kp_code', $parentKpCode) ->pluck('kp_code') ->toArray(); foreach ($childCodes as $childCode) { $isHit = in_array($childCode, $examKpCodes); $childData = [ 'kp_code' => $childCode, 'kp_name' => $kpNameMap[$childCode] ?? $childCode, 'mastery_level' => floatval($masteryMap[$childCode] ?? 0), 'is_hit' => $isHit, ]; $allChildren[] = $childData; if ($isHit) { $hitChildren[] = $childData; } } } catch (\Exception $e) { Log::warning('获取子知识点失败', [ 'parent_kp_code' => $parentKpCode, 'error' => $e->getMessage(), ]); } return [ 'all_children' => $allChildren, 'hit_children' => $hitChildren, ]; } /** * 当历史父节点概览缺失时,直接由“本卷命中知识点”反推出父节点并构建展示数据。 */ private function buildParentMasteryFromHitCodes( array $examKpCodes, array $kpNameMap, array $masteryMap = [], array $snapshotMasteryData = [] ): array { $codes = array_values(array_unique(array_filter(array_map(fn ($c) => trim((string) $c), $examKpCodes)))); if (empty($codes)) { return []; } $rows = DB::connection('mysql') ->table('knowledge_points') ->whereIn('kp_code', $codes) ->whereNotNull('parent_kp_code') ->where('parent_kp_code', '!=', '') ->select('kp_code', 'parent_kp_code') ->get(); $parentMap = []; foreach ($rows as $r) { $parentCode = trim((string) ($r->parent_kp_code ?? '')); $childCode = trim((string) ($r->kp_code ?? '')); if ($parentCode === '' || $childCode === '') { continue; } $parentMap[$parentCode][] = $childCode; } if (empty($parentMap)) { return []; } $parents = DB::connection('mysql') ->table('knowledge_points') ->whereIn('kp_code', array_keys($parentMap)) ->pluck('name', 'kp_code') ->toArray(); $processed = []; foreach ($parentMap as $parentCode => $hitChildrenCodes) { $childrenData = $this->getChildKnowledgePoints($parentCode, $kpNameMap, $codes, $masteryMap); $allChildren = $childrenData['all_children'] ?? []; $hitChildren = $childrenData['hit_children'] ?? []; $hitCount = count($hitChildren); if ($hitCount <= 0) { continue; } $allLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $allChildren); $masteryLevel = !empty($allLevels) ? array_sum($allLevels) / count($allLevels) : 0.0; $prevLevels = []; foreach ($allChildren as $child) { $childCode = (string) ($child['kp_code'] ?? ''); if ($childCode === '') { continue; } $currentChild = floatval($masteryMap[$childCode] ?? 0); $prevFromSnapshot = $snapshotMasteryData[$childCode]['previous_mastery'] ?? null; $currFromSnapshot = $snapshotMasteryData[$childCode]['current_mastery'] ?? null; $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild; $prevLevels[] = floatval($previousChild); } $masteryChange = !empty($prevLevels) ? ($masteryLevel - (array_sum($prevLevels) / count($prevLevels))) : null; $hitLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $hitChildren); $hitAvg = !empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null; $processed[$parentCode] = [ 'kp_code' => $parentCode, 'kp_name' => $parents[$parentCode] ?? ($kpNameMap[$parentCode] ?? $parentCode), 'mastery_level' => round(floatval($masteryLevel), 4), 'mastery_percentage' => round(floatval($masteryLevel) * 100, 2), 'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null, 'change_source' => 'hit_kp_parent_fallback', 'children' => $hitChildren, 'children_all' => $allChildren, 'children_hit_count' => $hitCount, 'children_total_count' => count($allChildren), 'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null, 'level' => $this->calculateKnowledgePointLevel($parentCode), ]; } uasort($processed, fn ($a, $b) => $b['mastery_level'] <=> $a['mastery_level']); return $processed; } /** * 基于学生全量掌握度构建父节点列表(不按本卷命中过滤) */ private function buildParentMasteryFromAllParents( array $allParentMasteryLevels, array $kpNameMap, array $masteryMap = [], array $snapshotMasteryData = [] ): array { if (empty($allParentMasteryLevels)) { return []; } $parentCodes = []; foreach ($allParentMasteryLevels as $code => $value) { $parentCode = trim((string) $code); if ($parentCode === '') { continue; } $parentCodes[] = $parentCode; } $parentCodes = array_values(array_unique($parentCodes)); if (empty($parentCodes)) { return []; } $parents = DB::connection('mysql') ->table('knowledge_points') ->whereIn('kp_code', $parentCodes) ->pluck('name', 'kp_code') ->toArray(); $processed = []; foreach ($parentCodes as $parentCode) { $raw = $allParentMasteryLevels[$parentCode] ?? null; $rawMastery = is_array($raw) ? floatval($raw['mastery_level'] ?? 0) : (is_object($raw) ? floatval($raw->mastery_level ?? 0) : floatval($raw ?? 0)); $childrenData = $this->getChildKnowledgePoints($parentCode, $kpNameMap, [], $masteryMap); $allChildren = $childrenData['all_children'] ?? []; $allLevels = array_map(fn ($c) => floatval($c['mastery_level'] ?? 0), $allChildren); $masteryLevel = ! empty($allLevels) ? (array_sum($allLevels) / count($allLevels)) : $rawMastery; $prevLevels = []; foreach ($allChildren as $child) { $childCode = (string) ($child['kp_code'] ?? ''); if ($childCode === '') { continue; } $currentChild = floatval($masteryMap[$childCode] ?? 0); $prevFromSnapshot = $snapshotMasteryData[$childCode]['previous_mastery'] ?? null; $currFromSnapshot = $snapshotMasteryData[$childCode]['current_mastery'] ?? null; $previousChild = $prevFromSnapshot ?? $currFromSnapshot ?? $currentChild; $prevLevels[] = floatval($previousChild); } $masteryChange = ! empty($prevLevels) ? ($masteryLevel - (array_sum($prevLevels) / count($prevLevels))) : null; $processed[$parentCode] = [ 'kp_code' => $parentCode, 'kp_name' => $parents[$parentCode] ?? ($kpNameMap[$parentCode] ?? $parentCode), 'mastery_level' => round(floatval($masteryLevel), 4), 'mastery_percentage' => round(floatval($masteryLevel) * 100, 2), 'mastery_change' => $masteryChange !== null ? round(floatval($masteryChange), 4) : null, 'change_source' => 'full_mastery_overview', 'children' => [], 'children_all' => $allChildren, 'children_hit_count' => 0, 'children_total_count' => count($allChildren), 'children_hit_avg_mastery' => null, 'level' => $this->calculateKnowledgePointLevel($parentCode), ]; } uasort($processed, fn ($a, $b) => $b['mastery_level'] <=> $a['mastery_level']); return $processed; } /** * 计算知识点层级深度 */ private function calculateKnowledgePointLevel(string $kpCode): int { // 根据kp_code前缀判断层级深度 // 例如: M (1级) -> M01 (2级) -> M01A (3级) if (preg_match('/^[A-Z]+$/', $kpCode)) { return 1; // 一级分类,如 M, S, E, G } elseif (preg_match('/^[A-Z]+\d+$/', $kpCode)) { return 2; // 二级分类,如 M01, S02 } elseif (preg_match('/^[A-Z]+\d+[A-Z]+$/', $kpCode)) { return 3; // 三级分类,如 M01A, S02B } elseif (preg_match('/^[A-Z]+\d+[A-Z]+\d+$/', $kpCode)) { return 4; // 四级分类,如 M01A1 } return 1; // 默认一级 } /** * 构建题目数据(用于PDF生成) */ private function buildQuestionsData(Paper $paper): array { $paperQuestions = $paper->questions()->orderBy('question_number')->get(); $questionsData = []; foreach ($paperQuestions as $pq) { $questionsData[] = [ 'id' => $pq->question_bank_id, 'question_number' => $pq->question_number, // 传递原始题号,确保渲染时序号正确 'kp_code' => $pq->knowledge_point, 'question_type' => $pq->question_type ?? 'answer', 'stem' => $pq->question_text ?? '题目内容缺失', 'solution' => $pq->solution ?? '', 'answer' => $pq->correct_answer ?? '', 'difficulty' => $pq->difficulty ?? 0.5, 'score' => $pq->score ?? 5, 'tags' => '', 'content' => $pq->question_text ?? '', ]; } // 获取完整题目详情 if (! empty($questionsData)) { $questionIds = array_column($questionsData, 'id'); $questionsResponse = $this->questionServiceApi->getQuestionsByIds($questionIds); $responseData = $questionsResponse['data'] ?? []; if (! empty($responseData)) { $responseDataMap = []; foreach ($responseData as $respQ) { $responseDataMap[$respQ['id']] = $respQ; } $questionsData = array_map(function ($q) use ($responseDataMap) { if (isset($responseDataMap[$q['id']])) { $apiData = $responseDataMap[$q['id']]; $q['stem'] = $apiData['stem'] ?? $q['stem'] ?? $q['content'] ?? ''; $q['content'] = $q['stem']; $q['answer'] = $apiData['answer'] ?? $q['answer'] ?? ''; $q['solution'] = $apiData['solution'] ?? $q['solution'] ?? ''; $q['tags'] = $apiData['tags'] ?? $q['tags'] ?? ''; $q['options'] = $apiData['options'] ?? []; } return $q; }, $questionsData); } } // 按题型分类 $classified = ['choice' => [], 'fill' => [], 'answer' => []]; foreach ($questionsData as $q) { $type = $this->determineQuestionType($q); $classified[$type][] = (object) $q; } // 【调试】记录题目分类情况 Log::debug('buildQuestionsData: 题目分类结果', [ 'total_questions' => count($questionsData), 'choice_count' => count($classified['choice']), 'fill_count' => count($classified['fill']), 'answer_count' => count($classified['answer']), 'choice_numbers' => array_map(fn ($q) => $q->question_number ?? 'N/A', $classified['choice']), 'fill_numbers' => array_map(fn ($q) => $q->question_number ?? 'N/A', $classified['fill']), 'answer_numbers' => array_map(fn ($q) => $q->question_number ?? 'N/A', $classified['answer']), ]); return $classified; } /** * 获取学生信息 */ private function getStudentInfo(?string $studentId): array { if (! $studentId) { return [ 'name' => '未知学生', 'grade' => '未知年级', 'class' => '未知班级', ]; } try { $student = DB::table('students') ->where('student_id', $studentId) ->first(); if ($student) { return [ 'name' => $student->name ?? $studentId, 'grade' => $student->grade ?? '未知', 'class' => $student->class ?? '未知', ]; } } catch (\Exception $e) { Log::warning('获取学生信息失败', [ 'student_id' => $studentId, 'error' => $e->getMessage(), ]); } return [ 'name' => $studentId, 'grade' => '未知', 'class' => '未知', ]; } /** * 获取教师信息 */ private function getTeacherInfo(?string $teacherId): array { if (! $teacherId) { return [ 'name' => '未知老师', 'subject' => '数学', ]; } try { $query = DB::table('teachers')->where('teacher_id', $teacherId); // 仅在列存在时追加,避免 Unknown column 'id' if (Schema::hasColumn('teachers', 'id')) { $query->orWhere('id', $teacherId); } $teacher = $query->first(); if ($teacher) { return [ 'name' => $teacher->name ?? '________', 'subject' => $teacher->subject ?? '数学', ]; } } catch (\Exception $e) { Log::warning('获取教师信息失败', [ 'teacher_id' => $teacherId, 'error' => $e->getMessage(), ]); } return [ 'name' => '________', 'subject' => '数学', ]; } /** * 判断题目类型 * 【修复】优先使用 question_type 字段,避免选择题因含有()被误判为填空题 */ private function determineQuestionType(array $question): string { // 1. 【优先】根据已有类型字段判断(最可靠的来源) if (! empty($question['question_type'])) { $type = strtolower(trim($question['question_type'])); if (in_array($type, ['choice', '选择题'])) { return 'choice'; } if (in_array($type, ['fill', '填空题'])) { return 'fill'; } if (in_array($type, ['answer', '解答题'])) { return 'answer'; } } // 2. 如果没有明确类型,则根据题干内容推断 $stem = $question['stem'] ?? $question['content'] ?? ''; if (is_string($stem)) { // 检查是否有选项模式 A. B. C. D. $hasOptionA = preg_match('/\bA\s*[\.\、\:]/', $stem) || preg_match('/\(A\)/', $stem) || preg_match('/^A[\.\s]/', $stem); $hasOptionB = preg_match('/\bB\s*[\.\、\:]/', $stem) || preg_match('/\(B\)/', $stem) || preg_match('/^B[\.\s]/', $stem); $hasOptionC = preg_match('/\bC\s*[\.\、\:]/', $stem) || preg_match('/\(C\)/', $stem) || preg_match('/^C[\.\s]/', $stem); $hasOptionD = preg_match('/\bD\s*[\.\、\:]/', $stem) || preg_match('/\(D\)/', $stem) || preg_match('/^D[\.\s]/', $stem); $optionCount = ($hasOptionA ? 1 : 0) + ($hasOptionB ? 1 : 0) + ($hasOptionC ? 1 : 0) + ($hasOptionD ? 1 : 0); if ($optionCount >= 2) { return 'choice'; } // 检查是否有填空标记(但要排除选择题的括号) if (preg_match('/(\s*)|\(\s*\)/', $stem)) { return 'fill'; } } // 3. 默认返回解答题 return 'answer'; } /** * 格式化换行符:将字面的 \n 转换为 HTML
标签 * 用于 PDF 渲染时正确显示换行 */ private function formatNewlines(?string $text): ?string { if ($text === null) { return null; } // 【修复】替换 \n 为
,但保护 LaTeX 命令如 \neq, \ne, \newline, \nu 等 // 使用负向前瞻 (?![a-zA-Z]) 避免误伤 LaTeX 命令 $text = preg_replace('/\\\\n(?![a-zA-Z])/', '
', $text); // 合并连续的
(最多保留2个) return preg_replace('/(
\s*){3,}/', '

', $text); } /** * 根据指定的题目生成PDF(新增方法) * * @param object $paper 虚拟试卷对象 * @param array $groupedQuestions 按题型分组的题目数据 * @param array $student 学生信息 ['name' => '', 'grade' => ''] * @param array $teacher 教师信息 ['name' => ''] * @param bool $includeGrading 是否包含判卷版本 * @return array 返回 ['pdf_url' => '...', 'grading_pdf_url' => '...'] */ public function generateByQuestions( object $paper, array $groupedQuestions, array $student = [], array $teacher = [], bool $includeGrading = false ): array { Log::info('generateByQuestions 开始', [ 'paper_id' => $paper->paper_id, 'question_counts' => [ 'choice' => count($groupedQuestions['choice'] ?? []), 'fill' => count($groupedQuestions['fill'] ?? []), 'answer' => count($groupedQuestions['answer'] ?? []), ], 'include_grading' => $includeGrading, ]); try { $result = []; // 1. 生成试卷PDF(不含答案) $examHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, false); if ($examHtml) { // 【修复】使用服务端 KaTeX 预渲染 LaTeX 公式 if ($this->katexRenderer) { $examHtml = $this->katexRenderer->renderHtml($examHtml); } $examPdf = $this->buildPdf($examHtml); if ($examPdf) { $examPath = "custom_exams/{$paper->paper_id}_exam.pdf"; $examUrl = $this->pdfStorageService->put($examPath, $examPdf); $result['pdf_url'] = $examUrl; Log::info('试卷PDF生成成功', ['url' => $examUrl]); } } // 2. 如果需要,生成判卷PDF(含答案) if ($includeGrading) { $gradingHtml = $this->renderCustomExamHtml($paper, $groupedQuestions, $student, $teacher, true); if ($gradingHtml) { // 【修复】使用服务端 KaTeX 预渲染 LaTeX 公式 if ($this->katexRenderer) { $gradingHtml = $this->katexRenderer->renderHtml($gradingHtml); } $gradingPdf = $this->buildPdf($gradingHtml); if ($gradingPdf) { $gradingPath = "custom_exams/{$paper->paper_id}_grading.pdf"; $gradingUrl = $this->pdfStorageService->put($gradingPath, $gradingPdf); $result['grading_pdf_url'] = $gradingUrl; Log::info('判卷PDF生成成功', ['url' => $gradingUrl]); } } } return $result; } catch (\Throwable $e) { Log::error('generateByQuestions 失败', [ 'paper_id' => $paper->paper_id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } } /** * 题目质检专用 PDF:固定使用判题卡体系模板(答案详解 + 判题卡)。 * 不进入正常组卷流程,仅用于检查题干、答案、解题思路渲染效果。 * * @return array{pdf_url?: string} */ public function generateQuestionCheckPdf( object $paper, array $groupedQuestions, array $student = [], array $teacher = [] ): array { Log::info('generateQuestionCheckPdf 开始', [ 'paper_id' => $paper->paper_id ?? null, 'question_counts' => [ 'choice' => count($groupedQuestions['choice'] ?? []), 'fill' => count($groupedQuestions['fill'] ?? []), 'answer' => count($groupedQuestions['answer'] ?? []), ], ]); $studentName = $student['name'] ?? ($paper->student_id ?? '________'); $examCode = \App\Support\PaperNaming::extractExamCode((string) ($paper->paper_id ?? 'custom')); $pdfMeta = [ 'student_name' => $studentName, 'exam_code' => $examCode, 'assemble_type_label' => '题目质检', 'header_title' => $examCode, 'exam_pdf_title' => '题目质检_'.$examCode, 'grading_pdf_title' => '题目质检_'.$examCode, 'knowledge_pdf_title' => '题目质检_'.$examCode, ]; try { // 固定走题目质检专用模板:题干+答案+解题思路 + 判题卡附页 $html = view('pdf.question-check', [ 'paper' => $paper, 'questions' => $groupedQuestions, 'student' => $student, 'teacher' => $teacher, 'pdfMeta' => $pdfMeta, ])->render(); if (empty(trim($html))) { Log::error('generateQuestionCheckPdf: HTML 渲染为空', [ 'paper_id' => $paper->paper_id ?? null, ]); return []; } if ($this->katexRenderer) { $html = $this->katexRenderer->renderHtml($html); } $pdfBinary = $this->buildPdf($this->ensureUtf8Html($html)); if (empty($pdfBinary)) { Log::error('generateQuestionCheckPdf: buildPdf 失败', [ 'paper_id' => $paper->paper_id ?? null, ]); return []; } $path = 'custom_exams/'.($paper->paper_id ?? ('custom_'.time())).'.pdf'; $url = $this->pdfStorageService->put($path, $pdfBinary); if (! $url) { Log::error('generateQuestionCheckPdf: 上传失败', [ 'paper_id' => $paper->paper_id ?? null, 'path' => $path, ]); return []; } return [ 'pdf_url' => $url, 'grading_pdf_url' => $url, ]; } catch (\Throwable $e) { Log::error('generateQuestionCheckPdf 失败', [ 'paper_id' => $paper->paper_id ?? null, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); throw $e; } } /** * 渲染自定义题目的HTML */ private function renderCustomExamHtml( object $paper, array $groupedQuestions, array $student, array $teacher, bool $grading ): ?string { try { $viewName = $this->resolveExamViewName($grading); $html = view($viewName, [ 'paper' => $paper, 'questions' => $groupedQuestions, 'student' => $student, 'teacher' => $teacher, 'grading' => $grading, 'includeAnswer' => false, // exam-paper 视图需要这个变量 ])->render(); if (empty(trim($html))) { Log::error('renderCustomExamHtml: 视图渲染结果为空', [ 'paper_id' => $paper->paper_id, 'view_name' => $viewName, ]); return null; } return $this->ensureUtf8Html($html); } catch (\Exception $e) { Log::error('renderCustomExamHtml 失败', [ 'paper_id' => $paper->paper_id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return null; } } private function resolveExamViewName(bool $useGradingView): string { if (! $useGradingView) { return 'pdf.exam-paper'; } return config('exam.pdf_grading_append_scan_sheet', false) ? 'pdf.exam-answer-detail' : 'pdf.exam-grading'; } private function normalizeAnswerFieldForPdf(object $question): object { $normalizedQuestion = clone $question; // 以 paper_questions.question_text 为标准题干字段,兼容旧链路 content。 $questionText = trim((string) ($normalizedQuestion->question_text ?? '')); if ($questionText === '') { $questionText = trim((string) ($normalizedQuestion->content ?? '')); } if ($questionText !== '') { $normalizedQuestion->question_text = $questionText; $normalizedQuestion->content = $questionText; } $answerText = trim((string) ($normalizedQuestion->answer ?? '')); if ($answerText !== '') { return $normalizedQuestion; } foreach (['correct_answer', 'standard_answer', 'reference_answer'] as $fallbackField) { $candidate = trim((string) ($normalizedQuestion->{$fallbackField} ?? '')); if ($candidate !== '') { $normalizedQuestion->answer = $candidate; break; } } return $normalizedQuestion; } /** * 生成预览 PDF(用于题目预览验证工具) * * @param array $questionData 题目数据 ['stem', 'options', 'answer', 'solution', 'question_type'] * @return array|null 返回 ['url' => '...'] 或 null */ public function generatePreviewPdf(array $questionData): ?array { try { Log::info('generatePreviewPdf 开始', ['question_data' => $questionData]); // 渲染 HTML $html = $this->renderPreviewHtml($questionData); if (empty($html)) { Log::error('generatePreviewPdf: HTML 渲染为空'); return null; } // 生成 PDF $pdfContent = $this->buildPdf($html); if (empty($pdfContent)) { Log::error('generatePreviewPdf: PDF 生成为空'); return null; } // 保存到临时目录 $filename = 'preview_'.uniqid().'.pdf'; $path = "previews/{$filename}"; $url = $this->pdfStorageService->put($path, $pdfContent); Log::info('generatePreviewPdf 成功', ['url' => $url]); return ['url' => $url]; } catch (\Exception $e) { Log::error('generatePreviewPdf 失败', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return null; } } /** * 渲染预览 HTML */ private function renderPreviewHtml(array $questionData): string { $stem = $questionData['stem'] ?? ''; $options = $questionData['options'] ?? null; $answer = $questionData['answer'] ?? ''; $solution = $questionData['solution'] ?? ''; $questionType = $questionData['question_type'] ?? 'fill'; // 使用 MathFormulaProcessor 处理公式 $processedStem = MathFormulaProcessor::processFormulas($stem); $processedAnswer = MathFormulaProcessor::processFormulas($answer); $processedSolution = MathFormulaProcessor::processFormulas($this->formatNewlines($solution)); $processedOptions = null; if ($options && is_array($options)) { $processedOptions = []; foreach ($options as $key => $value) { $processedOptions[$key] = MathFormulaProcessor::processFormulas($value); } } // 渲染 HTML return view('pdf.question-preview', [ 'stem' => $processedStem, 'options' => $processedOptions, 'answer' => $processedAnswer, 'solution' => $processedSolution, 'questionType' => $questionType, ])->render(); } /** * 批量获取知识点的讲解内容 * * @param array $kpCodes 知识点代码数组 * @return array [kp_code => explanation] */ public function buildExplanations(array $kpCodes): array { $result = []; if (empty($kpCodes)) { return $result; } try { // 批量获取知识点讲解 $kps = \App\Models\KnowledgePoint::whereIn('kp_code', $kpCodes)->get()->keyBy('kp_code'); // 有多少算多少 foreach ($kpCodes as $kpCode) { $kp = $kps->get($kpCode); if ($kp && ! empty($kp->explanation)) { $result[] = [ 'kp_code' => $kpCode, 'kp_name' => $kp->name ?? $kpCode, 'explanation' => $this->normalizeKpExplanation($kp->explanation), ]; continue; } $result[] = [ 'kp_code' => $kpCode, 'kp_name' => $kp->name ?? $kpCode, 'explanation' => $this->normalizeKpExplanation( $this->getDefaultExplanation($kpCode, $kp->name ?? $kpCode) ), ]; } } catch (\Throwable $e) { Log::warning('批量获取知识点讲解失败', [ 'kp_codes' => $kpCodes, 'error' => $e->getMessage(), ]); // 失败时返回默认内容 foreach ($kpCodes as $kpCode) { $result[] = [ 'kp_code' => $kpCode, 'kp_name' => $kpCode, 'explanation' => $this->normalizeKpExplanation( $this->getDefaultExplanation($kpCode, $kpCode) ), ]; } } return $result; } /** * 获取默认的知识点讲解内容 */ private function getDefaultExplanation(string $kpCode, string $kpName): string { // 默认讲解模板 // 注意:PHP HEREDOC 中 \e 会被解释为 ESC 字符,所以需要用 \\end 而非 \end return <<<'MARKDOWN' ## 知识点 (1) 核心定义:二元一次方程是含有两个未知数且每个未知数的次数都是 1 的方程,一般形式为 $ax + by = c$(其中 $a,b,c$ 为常数,且 $a,b$ 不同时为 0)。二元一次方程组是由两个(或两个以上)二元一次方程组成,常见为 $$ \begin{cases} a_1x + b_1y = c_1\\ a_2x + b_2y = c_2 \end{cases} $$ 方程组应用建模就是把题目中的数量关系翻译成方程组,通过求解得到实际问题的答案。 (2) 性质定理:方程组的解必须同时满足方程组中的每一个方程,因此解是两个方程公共的 $(x,y)$。常用解法有代入消元法与加减消元法:代入消元法适合某个方程易表示成 $x = \dots$ 或 $y = \dots$;加减消元法适合两个方程中某个未知数的系数相同或相反,便于相加或相减消去一个未知数。建模时常用关系包括:总量关系(如“总数 = 部分之和”)、单价数量总价关系(“$总价 = 单价 \times 数量$”)、路程速度时间关系($路程 = 速度 \times 时间$)等。 (3) 注意事项:设未知数要带单位与含义,例如“设甲买了 $x$ 支笔”;列方程要对应同一类量,别把“支数”和“元数”混在一条等式里;检查条件是否满足题意(如数量应为整数且 $> 0$);消元后别忘了回代求另一个未知数;最后答案要写清对象与单位,并可把解代回原式检验。 ## 知识点应用 - 典型例题:文具店买笔和本子。小明买了 2 支笔和 3 本本子共花 19 元;小红买了 3 支笔和 2 本本子共花 18 元。求每支笔和每本本子的单价。 - 关键步骤: 1. 设未知数并写出含义:设每支笔单价为 $x$ 元,每本本子单价为 $y$ 元。 2. 根据题意列方程组:由“$总价 = 单价 \times 数量$”得 $$ \begin{cases} 2x + 3y = 19\\ 3x + 2y = 18 \end{cases} $$ 3. 选择消元并求解:用加减消元。将第一式乘 3、第二式乘 2: $$ \begin{cases} 6x + 9y = 57\\ 6x + 4y = 36 \end{cases} $$ 相减得 $5y = 21$,所以 $y = \dfrac{21}{5} = 4.2$。代入 $3x + 2y = 18$ 得 $3x + 8.4 = 18$,所以 $3x = 9.6$,$x = 3.2$。 - 结论:每支笔 3.2 元,每本本子 4.2 元(两者均 $> 0$,符合题意)。 MARKDOWN; } private function normalizeKpExplanation(string $content): string { $content = trim($content); if ($content === '') { return ''; } if ($this->looksLikeHtml($content)) { return $content; } if (! class_exists(\Michelf\MarkdownExtra::class)) { $safe = htmlspecialchars($content, ENT_QUOTES, 'UTF-8'); return '
'.nl2br($safe).'
'; } $parser = new \Michelf\MarkdownExtra; $markdown = html_entity_decode($content, ENT_QUOTES, 'UTF-8'); $rendered = $parser->transform($markdown); return '
'.$rendered.'
'; } private function looksLikeHtml(string $content): bool { if (stripos($content, 'kp-markdown-container') !== false || stripos($content, 'kp-markdown-content') !== false) { return true; } return (bool) preg_match('/<\s*(p|div|h[1-6]|ul|ol|li|table|span|blockquote|pre|code|br)\b/i', $content); } private function shouldUseDefaultExplanations(): bool { if (!Schema::hasTable('knowledge_points')) { return true; } return !Schema::hasColumn('knowledge_points', 'explanation'); } }