*/
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,
'question_count' => 0,
];
}
$fullParentMap = $templateData['full_parent_mastery_levels'] ?? [];
if (! is_array($fullParentMap)) {
$fullParentMap = [];
}
foreach ($kpRows as $row) {
$kpId = trim((string) ($row['kp_id'] ?? ''));
if ($kpId === '') {
continue;
}
$moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
continue;
}
$mastery = (float) ($row['mastery_level'] ?? 0);
$moduleAgg[$moduleCode]['mastery_sum'] += $mastery;
$moduleAgg[$moduleCode]['mastery_count']++;
}
foreach ($questionRows as $questionRow) {
$maxScore = (float) ($questionRow['max_score'] ?? 0);
$obtainedScore = (float) ($questionRow['score_obtained'] ?? 0);
if ($maxScore <= 0) {
continue;
}
$kpId = '';
$questionKps = $questionRow['knowledge_points'] ?? [];
if (is_array($questionKps) && ! empty($questionKps)) {
$first = $questionKps[0] ?? [];
if (is_array($first)) {
$kpId = trim((string) ($first['kp_id'] ?? ''));
}
}
if ($kpId === '') {
continue;
}
$moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
if (! $moduleCode || ! isset($moduleAgg[$moduleCode])) {
continue;
}
$moduleAgg[$moduleCode]['question_max'] += $maxScore;
$moduleAgg[$moduleCode]['question_obtained'] += max(0.0, min($obtainedScore, $maxScore));
$moduleAgg[$moduleCode]['question_count']++;
}
$moduleRows = [];
foreach ($moduleCodes as $moduleCode) {
$agg = $moduleAgg[$moduleCode] ?? null;
$masteryLevel = null;
$kpCount = 0;
if (isset($fullParentMap[$moduleCode])) {
$parentRow = $fullParentMap[$moduleCode];
$masteryLevel = isset($parentRow['mastery_level']) ? (float) $parentRow['mastery_level'] : null;
$kpCount = (int) ($parentRow['children_total_count'] ?? 0);
}
if ($masteryLevel === null && $agg && $agg['mastery_count'] > 0) {
$masteryLevel = $agg['mastery_sum'] / $agg['mastery_count'];
}
$masteryScore5 = $masteryLevel !== null ? round($masteryLevel * 5, 2) : null;
$examMax = (float) ($agg['question_max'] ?? 0.0);
$examObtained = (float) ($agg['question_obtained'] ?? 0.0);
$examRate = $examMax > 0 ? round($examObtained / $examMax, 4) : null;
if ($kpCount <= 0) {
$kpCount = (int) ($agg['mastery_count'] ?? 0);
}
$moduleRows[] = [
'module_code' => $moduleCode,
'module_name' => $moduleNames[$moduleCode] ?? $moduleCode,
'mastery_level' => $masteryLevel !== null ? round($masteryLevel, 4) : null,
'mastery_score_5' => $masteryScore5,
'status' => $this->resolveMasteryStatus($masteryLevel),
'kp_count' => $kpCount,
'question_count' => (int) ($agg['question_count'] ?? 0),
'exam_max_score' => round($examMax, 2),
'exam_obtained_score' => round($examObtained, 2),
'exam_score_rate' => $examRate,
];
}
$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 = [];
}
// 第二块聚类视图数据:严格按学段知识树叶子节点分组(与 math.client-pc 层级一致)
$radarChildrenByModule = [];
$examHitCodes = $templateData['exam_hit_kp_codes'] ?? [];
if (! is_array($examHitCodes)) {
$examHitCodes = [];
}
$examHitSet = array_fill_keys(array_map(static fn ($v) => (string) $v, $examHitCodes), true);
$masteryMapForCluster = $templateData['mastery_map'] ?? [];
if (! is_array($masteryMapForCluster)) {
$masteryMapForCluster = [];
}
$kpChangeMap = [];
foreach ($kpRows as $row) {
$code = trim((string) ($row['kp_code'] ?? $row['knowledge_point_code'] ?? $row['code'] ?? ''));
if ($code === '') {
continue;
}
$changeRaw = $row['mastery_change'] ?? $row['change'] ?? null;
if ($changeRaw === null || $changeRaw === '') {
continue;
}
$kpChangeMap[$code] = (float) $changeRaw;
}
if (empty($kpChangeMap)) {
$studentId = (string) ($templateData['student']['id'] ?? '');
$paperId = (string) ($templateData['paper']['id'] ?? '');
if ($studentId !== '' && $paperId !== '') {
try {
$snapshot = DB::connection('mysql')
->table('knowledge_point_mastery_snapshots')
->where('student_id', $studentId)
->where('paper_id', $paperId)
->latest('snapshot_time')
->first();
if ($snapshot && ! empty($snapshot->mastery_data)) {
$snapshotData = json_decode((string) $snapshot->mastery_data, true);
if (is_array($snapshotData)) {
foreach ($snapshotData as $kpCode => $node) {
if (! is_array($node)) {
continue;
}
$change = $node['change'] ?? null;
if ($change === null && isset($node['current_mastery'], $node['previous_mastery'])) {
$change = floatval($node['current_mastery']) - floatval($node['previous_mastery']);
}
if ($change === null || $change === '') {
continue;
}
$code = trim((string) $kpCode);
if ($code === '') {
continue;
}
$kpChangeMap[$code] = (float) $change;
}
}
}
} catch (\Throwable $e) {
Log::warning('ExamPdfExportService: 聚类变化值快照回填失败', [
'paper_id' => $paperId,
'student_id' => $studentId,
'error' => $e->getMessage(),
]);
}
}
}
$stageCodes = $this->collectStageKnowledgePoints($rootCode);
$childrenByParent = [];
foreach ($kpMeta as $code => $meta) {
$p = trim((string) ($meta['parent_kp_code'] ?? ''));
if ($p !== '') {
$childrenByParent[$p][] = (string) $code;
}
}
$resolveHierarchyForCluster = function (string $kpId) use ($kpMeta, $rootCode): array {
$rootName = trim((string) ($kpMeta[$rootCode]['name'] ?? $rootCode));
$lineage = [];
$cursor = $kpId;
$guard = 0;
while ($cursor !== '' && isset($kpMeta[$cursor]) && $guard < 24) {
$nodeName = trim((string) ($kpMeta[$cursor]['name'] ?? $cursor));
if ($nodeName !== '') {
$lineage[] = $nodeName;
}
$parent = trim((string) ($kpMeta[$cursor]['parent_kp_code'] ?? ''));
if ($parent === '' || $parent === $cursor || $parent === $rootCode) {
break;
}
$cursor = $parent;
$guard++;
}
$nonRootPath = array_reverse($lineage); // 等价前端去 root 后的路径
$safePath = !empty($nonRootPath) ? $nonRootPath : [trim((string) ($kpMeta[$kpId]['name'] ?? $kpId))];
$pathCount = count($safePath);
$parentName = $safePath[$pathCount - 2] ?? $safePath[$pathCount - 1] ?? $rootName;
$grandParentName = $safePath[$pathCount - 3] ?? $safePath[0] ?? $parentName;
$greatGrandParentName = $safePath[$pathCount - 4] ?? $safePath[0] ?? $grandParentName;
$path = implode(' > ', array_filter(array_merge([$rootName], $safePath)));
return [
'parent_name' => $parentName,
'grand_parent_name' => $grandParentName,
'great_grand_parent_name' => $greatGrandParentName,
'path' => $path,
];
};
foreach ($stageCodes as $kpId) {
$kpId = trim((string) $kpId);
if ($kpId === '' || ! isset($kpMeta[$kpId])) {
continue;
}
// 仅纳入叶子知识点,保证与前端 flattenKnowledgePoints 一致
if (! empty($childrenByParent[$kpId] ?? [])) {
continue;
}
$moduleCode = $this->mapKpToStageModule($kpId, $kpMeta, $rootCode);
if (! $moduleCode || ! in_array($moduleCode, $moduleCodes, true)) {
continue;
}
$parentCode = (string) ($kpMeta[$kpId]['parent_kp_code'] ?? '');
$depth = $this->resolveDepthToModule($kpId, $moduleCode, $kpMeta);
$hierarchy = $resolveHierarchyForCluster($kpId);
$masteryValue = array_key_exists($kpId, $masteryMapForCluster)
? floatval($masteryMapForCluster[$kpId])
: null;
$changeValue = array_key_exists($kpId, $kpChangeMap)
? (float) $kpChangeMap[$kpId]
: null;
$isHit = isset($examHitSet[$kpId]);
$radarChildrenByModule[$moduleCode][] = [
'code' => $kpId,
'name' => $kpMeta[$kpId]['name'] ?? $kpId,
'parent_code' => $parentCode,
'parent_name' => $hierarchy['parent_name'],
'grand_parent_name' => $hierarchy['grand_parent_name'],
'great_grand_parent_name' => $hierarchy['great_grand_parent_name'],
'path' => $hierarchy['path'],
'depth' => $depth,
'change' => $changeValue !== null ? round($changeValue, 4) : null,
'mastery_level' => $masteryValue !== null ? round($masteryValue, 4) : null,
'changed' => $changeValue !== null,
'is_hit' => $isHit,
];
}
foreach ($radarChildrenByModule as $moduleCode => $items) {
usort($items, static function ($a, $b) {
$da = (int) ($a['depth'] ?? 1);
$db = (int) ($b['depth'] ?? 1);
if ($da !== $db) {
return $da <=> $db;
}
$ca = abs((float) ($a['change'] ?? 0));
$cb = abs((float) ($b['change'] ?? 0));
if ($ca === $cb) {
return strcmp((string) ($a['code'] ?? ''), (string) ($b['code'] ?? ''));
}
return $cb <=> $ca;
});
$radarChildrenByModule[$moduleCode] = $items;
}
foreach ($moduleCodes as $moduleCode) {
$hitParentRow = $hitParentMap[$moduleCode] ?? null;
$hitAvg = null;
if (is_array($hitParentRow) && isset($hitParentRow['children_hit_avg_mastery'])) {
$hitAvg = (float) $hitParentRow['children_hit_avg_mastery'];
}
$fullParentRow = $fullParentMap[$moduleCode] ?? null;
$fullMastery = null;
if (is_array($fullParentRow) && isset($fullParentRow['mastery_level'])) {
$fullMastery = (float) $fullParentRow['mastery_level'];
}
$mastery = $hitAvg ?? $fullMastery;
$hasMastery = $mastery !== null;
$score5 = $hasMastery ? round($mastery * 5, 2) : 0.0;
$status = $hasMastery ? $this->resolveMasteryStatus($mastery) : '未涉及';
$radar[] = [
'code' => $moduleCode,
'name' => $moduleNames[$moduleCode] ?? $moduleCode,
'value' => $score5,
'status' => $status,
'has_mastery' => $hasMastery,
'children' => $radarChildrenByModule[$moduleCode] ?? [],
];
}
return [
'summary' => [
'score_obtained' => round($allObtained, 1),
'score_total' => round($allMax, 1),
'score_rate' => $scoreRate,
'average_mastery' => $avgMastery !== null ? round($avgMastery, 4) : null,
'overall_label' => $overallLabel,
'overall_label_detail' => $overallComposite,
'stage' => $stage,
'difficulty' => $difficultyInsight,
'comparison' => $comparisonInsight,
],
'radar' => $radar,
'modules' => $moduleRows,
'dimensions' => $dimensions,
'paths' => $paths,
];
}
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 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(
'/