]*><\/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 = []; // 新增:父节点掌握度数据
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'] ?? [];
$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'] ?? []; // 获取父节点掌握度
// 【修复】将对象数组转换为关联数组(避免 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
);
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,
'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, // 【修复】使用处理后的父节点数据
'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('/