|
@@ -1033,9 +1033,17 @@ class ExamPdfExportService
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 【修复】处理父节点掌握度数据:过滤零值、转换名称、构建层级关系
|
|
|
|
|
- $examKpCodes = array_column($masteryData, 'kp_code'); // 本次考试涉及的知识点
|
|
|
|
|
- $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examKpCodes, $masteryMap, $snapshotMasteryData);
|
|
|
|
|
|
|
+ // 本卷命中知识点:严格按“这套卷子题目关联知识点”计算
|
|
|
|
|
+ $examQuestionKpCodes = array_values(array_unique(array_filter(array_map(
|
|
|
|
|
+ fn ($q) => trim((string) ($q['knowledge_point'] ?? '')),
|
|
|
|
|
+ $questions
|
|
|
|
|
+ ))));
|
|
|
|
|
+
|
|
|
|
|
+ // 父节点列表:只基于“本卷命中知识点”映射
|
|
|
|
|
+ $processedParentMastery = $this->processParentMasteryLevels($parentMasteryLevels, $kpNameMap, $examQuestionKpCodes, $masteryMap, $snapshotMasteryData);
|
|
|
|
|
+ if (empty($processedParentMastery) && !empty($examQuestionKpCodes)) {
|
|
|
|
|
+ $processedParentMastery = $this->buildParentMasteryFromHitCodes($examQuestionKpCodes, $kpNameMap, $masteryMap, $snapshotMasteryData);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
|
'raw_count' => count($parentMasteryLevels),
|
|
'raw_count' => count($parentMasteryLevels),
|
|
@@ -1057,6 +1065,7 @@ class ExamPdfExportService
|
|
|
'teacher' => $teacherInfo,
|
|
'teacher' => $teacherInfo,
|
|
|
'questions' => $questions,
|
|
'questions' => $questions,
|
|
|
'mastery' => $masterySummary,
|
|
'mastery' => $masterySummary,
|
|
|
|
|
+ 'exam_hit_kp_codes' => $examQuestionKpCodes,
|
|
|
'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
|
|
'parent_mastery_levels' => $processedParentMastery, // 【修复】使用处理后的父节点数据
|
|
|
'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
|
|
'insights' => $analysisData['question_analysis'] ?? [], // 使用question_analysis替代question_results
|
|
|
'recommendations' => $recommendations,
|
|
'recommendations' => $recommendations,
|
|
@@ -1292,7 +1301,7 @@ class ExamPdfExportService
|
|
|
'XDG_RUNTIME_DIR' => $runtimeXdg,
|
|
'XDG_RUNTIME_DIR' => $runtimeXdg,
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
- $process->setTimeout(90); // 【修复】增加超时时间到90秒
|
|
|
|
|
|
|
+ $process->setTimeout(180); // 复杂学情报告页允许更长渲染时间,降低超时失败率
|
|
|
$killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
|
|
$killSignal = \defined('SIGKILL') ? \SIGKILL : 9;
|
|
|
|
|
|
|
|
Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [
|
|
Log::warning('ExamPdfExportService: [调试] Chrome命令准备执行', [
|
|
@@ -1341,7 +1350,11 @@ class ExamPdfExportService
|
|
|
'exception' => get_class($e),
|
|
'exception' => get_class($e),
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
- return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt);
|
|
|
|
|
|
|
+ $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, $startedAt);
|
|
|
|
|
+ if ($result !== null) {
|
|
|
|
|
+ return $result;
|
|
|
|
|
+ }
|
|
|
|
|
+ return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
|
|
|
} catch (\Throwable $e) {
|
|
} catch (\Throwable $e) {
|
|
|
if ($process->isRunning()) {
|
|
if ($process->isRunning()) {
|
|
|
$process->stop(3, $killSignal); // 【优化】减少停止等待时间
|
|
$process->stop(3, $killSignal); // 【优化】减少停止等待时间
|
|
@@ -1351,10 +1364,18 @@ class ExamPdfExportService
|
|
|
'error' => $e->getMessage(),
|
|
'error' => $e->getMessage(),
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
- return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
|
|
|
|
|
|
|
+ $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
|
|
|
|
|
+ if ($result !== null) {
|
|
|
|
|
+ return $result;
|
|
|
|
|
+ }
|
|
|
|
|
+ return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
|
|
|
|
|
|
|
+ $result = $this->handleChromeProcessResult($tmpPdf, $userDataDir, $process, null);
|
|
|
|
|
+ if ($result !== null) {
|
|
|
|
|
+ return $result;
|
|
|
|
|
+ }
|
|
|
|
|
+ return $this->renderWithChromeMinimal($chromeBinary, $htmlPath);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -1398,6 +1419,47 @@ class ExamPdfExportService
|
|
|
return null;
|
|
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二进制文件
|
|
* 查找Chrome二进制文件
|
|
|
*/
|
|
*/
|
|
@@ -2277,8 +2339,8 @@ class ExamPdfExportService
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 过滤零值和空值(在统一口径重算后再过滤)
|
|
|
|
|
- if ($masteryLevel === null || $masteryLevel === 0.0 || $masteryLevel <= 0.001) {
|
|
|
|
|
|
|
+ // 仅过滤空值;0 掌握度的命中父节点也要展示
|
|
|
|
|
+ if ($masteryLevel === null) {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2287,6 +2349,12 @@ class ExamPdfExportService
|
|
|
$childrenData['hit_children'] ?? []
|
|
$childrenData['hit_children'] ?? []
|
|
|
);
|
|
);
|
|
|
$hitAvg = ! empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null;
|
|
$hitAvg = ! empty($hitLevels) ? array_sum($hitLevels) / count($hitLevels) : null;
|
|
|
|
|
+ $hitCount = count($childrenData['hit_children'] ?? []);
|
|
|
|
|
+
|
|
|
|
|
+ // 父节点仅展示“本卷命中知识点”对应的父节点
|
|
|
|
|
+ if ($hitCount <= 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
$processed[$kpCode] = [
|
|
$processed[$kpCode] = [
|
|
|
'kp_code' => $kpCode,
|
|
'kp_code' => $kpCode,
|
|
@@ -2299,7 +2367,7 @@ class ExamPdfExportService
|
|
|
'children' => $childrenData['hit_children'] ?? [],
|
|
'children' => $childrenData['hit_children'] ?? [],
|
|
|
// 新增:全部直接子节点(含掌握度、是否命中)
|
|
// 新增:全部直接子节点(含掌握度、是否命中)
|
|
|
'children_all' => $childrenData['all_children'] ?? [],
|
|
'children_all' => $childrenData['all_children'] ?? [],
|
|
|
- 'children_hit_count' => count($childrenData['hit_children'] ?? []),
|
|
|
|
|
|
|
+ 'children_hit_count' => $hitCount,
|
|
|
'children_total_count' => count($childrenData['all_children'] ?? []),
|
|
'children_total_count' => count($childrenData['all_children'] ?? []),
|
|
|
'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null,
|
|
'children_hit_avg_mastery' => $hitAvg !== null ? round($hitAvg, 4) : null,
|
|
|
'level' => $this->calculateKnowledgePointLevel($kpCode),
|
|
'level' => $this->calculateKnowledgePointLevel($kpCode),
|
|
@@ -2355,6 +2423,97 @@ class ExamPdfExportService
|
|
|
];
|
|
];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 当历史父节点概览缺失时,直接由“本卷命中知识点”反推出父节点并构建展示数据。
|
|
|
|
|
+ */
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 计算知识点层级深度
|
|
* 计算知识点层级深度
|
|
|
*/
|
|
*/
|