|
@@ -12,6 +12,7 @@ use App\Services\Analytics\QuestionDifficultyCalibrationAnalyzer;
|
|
|
use App\Support\GradingStyleQuestionStem;
|
|
use App\Support\GradingStyleQuestionStem;
|
|
|
use App\Support\PaperNaming;
|
|
use App\Support\PaperNaming;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
+use Illuminate\Support\Facades\Cache;
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\File;
|
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Log;
|
|
@@ -33,13 +34,15 @@ class ExamPdfExportService
|
|
|
private ?KatexRenderer $katexRenderer = null;
|
|
private ?KatexRenderer $katexRenderer = null;
|
|
|
private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
|
|
private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
|
|
|
private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
|
|
private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
|
|
|
|
|
+ private const KNOWLEDGE_POINTS_CACHE_TTL_SECONDS = 86400 * 3;
|
|
|
|
|
+ private const KNOWLEDGE_POINTS_VERSION_TTL_SECONDS = 600;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* @var array<string, array{w:int,h:int}|null>
|
|
* @var array<string, array{w:int,h:int}|null>
|
|
|
*/
|
|
*/
|
|
|
private array $pdfImageDimensionCache = [];
|
|
private array $pdfImageDimensionCache = [];
|
|
|
private ?bool $hasPdfImageMetricsTable = null;
|
|
private ?bool $hasPdfImageMetricsTable = null;
|
|
|
- private ?array $knowledgePointMetaCache = null;
|
|
|
|
|
|
|
+ private array $knowledgePointMetaCache = [];
|
|
|
private ?string $lastDebugHtmlPath = null;
|
|
private ?string $lastDebugHtmlPath = null;
|
|
|
|
|
|
|
|
public function __construct(
|
|
public function __construct(
|
|
@@ -347,6 +350,10 @@ class ExamPdfExportService
|
|
|
'student_id' => $studentId,
|
|
'student_id' => $studentId,
|
|
|
'record_id' => $recordId,
|
|
'record_id' => $recordId,
|
|
|
]);
|
|
]);
|
|
|
|
|
+ Log::warning('ExamPdfExportService: ANALYSIS_PDF_START', [
|
|
|
|
|
+ 'paper_id' => $paperId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ ]);
|
|
|
|
|
|
|
|
// 构建分析数据
|
|
// 构建分析数据
|
|
|
$analysisData = $this->buildAnalysisData($paperId, $studentId);
|
|
$analysisData = $this->buildAnalysisData($paperId, $studentId);
|
|
@@ -360,12 +367,13 @@ class ExamPdfExportService
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Log::info('ExamPdfExportService: buildAnalysisData返回数据', [
|
|
|
|
|
|
|
+ Log::info('ExamPdfExportService: buildAnalysisData返回摘要', [
|
|
|
'paper_id' => $paperId,
|
|
'paper_id' => $paperId,
|
|
|
'student_id' => $studentId,
|
|
'student_id' => $studentId,
|
|
|
- 'analysisData_keys' => array_keys($analysisData),
|
|
|
|
|
- 'mastery_count' => count($analysisData['mastery']['items'] ?? []),
|
|
|
|
|
|
|
+ 'analysisData_keys_count' => count(array_keys($analysisData)),
|
|
|
|
|
+ 'analysis_question_count' => count($analysisData['analysis_data']['question_analysis'] ?? []),
|
|
|
'questions_count' => count($analysisData['questions'] ?? []),
|
|
'questions_count' => count($analysisData['questions'] ?? []),
|
|
|
|
|
+ 'mastery_count' => count($analysisData['mastery']['items'] ?? []),
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
// 创建DTO
|
|
// 创建DTO
|
|
@@ -373,17 +381,13 @@ class ExamPdfExportService
|
|
|
$payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
|
|
$payloadDto = ReportPayloadDto::fromExamAnalysisDataDto($dto);
|
|
|
$mark('build_payload_dto_ms');
|
|
$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'] ?? []),
|
|
|
|
|
- ]);
|
|
|
|
|
|
|
+ $templateData = $this->reduceTemplateDataForV3($payloadDto->toArray());
|
|
|
|
|
+ unset($analysisData, $dto, $payloadDto);
|
|
|
|
|
+ if (function_exists('gc_collect_cycles')) {
|
|
|
|
|
+ gc_collect_cycles();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Log::info('ExamPdfExportService: 模板数据摘要', $this->summarizeTemplateDataForLog($templateData));
|
|
|
|
|
|
|
|
// V3 学情报告不再渲染逐题错题卡,保留题目元数据用于统计即可。
|
|
// V3 学情报告不再渲染逐题错题卡,保留题目元数据用于统计即可。
|
|
|
$mark('prepare_report_data_ms');
|
|
$mark('prepare_report_data_ms');
|
|
@@ -462,13 +466,13 @@ class ExamPdfExportService
|
|
|
$questionRows = $rawAnalysis['question_analysis'] ?? [];
|
|
$questionRows = $rawAnalysis['question_analysis'] ?? [];
|
|
|
$overallSummary = $rawAnalysis['overall_summary'] ?? [];
|
|
$overallSummary = $rawAnalysis['overall_summary'] ?? [];
|
|
|
|
|
|
|
|
- $kpMeta = $this->getKnowledgePointMetaMap();
|
|
|
|
|
$grade = (string) ($templateData['student']['grade'] ?? '');
|
|
$grade = (string) ($templateData['student']['grade'] ?? '');
|
|
|
$stage = $this->resolveRadarStage($grade, $templateData['full_parent_mastery_levels'] ?? []);
|
|
$stage = $this->resolveRadarStage($grade, $templateData['full_parent_mastery_levels'] ?? []);
|
|
|
$profile = $this->getRadarProfileByStage($stage);
|
|
$profile = $this->getRadarProfileByStage($stage);
|
|
|
$rootCode = (string) ($profile['root_code'] ?? 'M00');
|
|
$rootCode = (string) ($profile['root_code'] ?? 'M00');
|
|
|
$moduleCodes = $profile['module_codes'] ?? [];
|
|
$moduleCodes = $profile['module_codes'] ?? [];
|
|
|
$moduleNames = $profile['module_names'] ?? [];
|
|
$moduleNames = $profile['module_names'] ?? [];
|
|
|
|
|
+ $kpMeta = $this->getKnowledgePointMetaMap($rootCode, $moduleCodes);
|
|
|
|
|
|
|
|
$moduleAgg = [];
|
|
$moduleAgg = [];
|
|
|
foreach ($moduleCodes as $moduleCode) {
|
|
foreach ($moduleCodes as $moduleCode) {
|
|
@@ -668,7 +672,14 @@ class ExamPdfExportService
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- $stageCodes = $this->collectStageKnowledgePoints($rootCode);
|
|
|
|
|
|
|
+ $moduleSet = array_fill_keys(array_map(static fn ($code) => (string) $code, $moduleCodes), true);
|
|
|
|
|
+ $stageCodes = [];
|
|
|
|
|
+ foreach (array_keys($kpMeta) as $kpCode) {
|
|
|
|
|
+ if ($kpCode === $rootCode || isset($moduleSet[$kpCode])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $stageCodes[] = $kpCode;
|
|
|
|
|
+ }
|
|
|
$childrenByParent = [];
|
|
$childrenByParent = [];
|
|
|
foreach ($kpMeta as $code => $meta) {
|
|
foreach ($kpMeta as $code => $meta) {
|
|
|
$p = trim((string) ($meta['parent_kp_code'] ?? ''));
|
|
$p = trim((string) ($meta['parent_kp_code'] ?? ''));
|
|
@@ -1472,41 +1483,52 @@ class ExamPdfExportService
|
|
|
return [];
|
|
return [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ $cacheVersion = $this->getKnowledgePointsCacheVersion();
|
|
|
|
|
+ $cacheKey = sprintf('exam_pdf:kp_descendants:v1:%s:%s', $rootCode, $cacheVersion);
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
- $rows = DB::connection('mysql')
|
|
|
|
|
- ->table('knowledge_points')
|
|
|
|
|
- ->select('kp_code', 'parent_kp_code')
|
|
|
|
|
- ->get();
|
|
|
|
|
|
|
+ return $this->rememberWithFallback($cacheKey, self::KNOWLEDGE_POINTS_CACHE_TTL_SECONDS, function () use ($rootCode) {
|
|
|
|
|
+ $result = [];
|
|
|
|
|
+ $currentParents = [$rootCode];
|
|
|
|
|
+ $visitedParents = [];
|
|
|
|
|
+ $seenCodes = [];
|
|
|
|
|
+ while (! empty($currentParents)) {
|
|
|
|
|
+ $batchParents = [];
|
|
|
|
|
+ foreach ($currentParents as $parentCode) {
|
|
|
|
|
+ $parentCode = trim((string) $parentCode);
|
|
|
|
|
+ if ($parentCode === '' || isset($visitedParents[$parentCode])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $visitedParents[$parentCode] = true;
|
|
|
|
|
+ $batchParents[] = $parentCode;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $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;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if ($batchParents === []) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- $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;
|
|
|
|
|
|
|
+ $rows = DB::connection('mysql')
|
|
|
|
|
+ ->table('knowledge_points')
|
|
|
|
|
+ ->whereIn('parent_kp_code', $batchParents)
|
|
|
|
|
+ ->select('kp_code')
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ $nextParents = [];
|
|
|
|
|
+ foreach ($rows as $row) {
|
|
|
|
|
+ $childCode = trim((string) ($row->kp_code ?? ''));
|
|
|
|
|
+ if ($childCode === '' || $childCode === $rootCode || isset($seenCodes[$childCode])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $seenCodes[$childCode] = true;
|
|
|
|
|
+ $result[] = $childCode;
|
|
|
|
|
+ $nextParents[] = $childCode;
|
|
|
}
|
|
}
|
|
|
- $result[] = $childCode;
|
|
|
|
|
- $queue[] = $childCode;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ $currentParents = $nextParents;
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- return array_values(array_unique($result));
|
|
|
|
|
|
|
+ return $result;
|
|
|
|
|
+ });
|
|
|
} catch (\Throwable $e) {
|
|
} catch (\Throwable $e) {
|
|
|
Log::warning('ExamPdfExportService: collectStageKnowledgePoints failed', [
|
|
Log::warning('ExamPdfExportService: collectStageKnowledgePoints failed', [
|
|
|
'root_code' => $rootCode,
|
|
'root_code' => $rootCode,
|
|
@@ -1626,32 +1648,239 @@ class ExamPdfExportService
|
|
|
];
|
|
];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private function getKnowledgePointMetaMap(): array
|
|
|
|
|
|
|
+ private function getKnowledgePointMetaMap(string $rootCode, array $moduleCodes): array
|
|
|
{
|
|
{
|
|
|
- if ($this->knowledgePointMetaCache !== null) {
|
|
|
|
|
- return $this->knowledgePointMetaCache;
|
|
|
|
|
|
|
+ $moduleCodes = array_values(array_unique(array_filter(array_map(static fn ($code) => trim((string) $code), $moduleCodes))));
|
|
|
|
|
+ $cacheVersion = $this->getKnowledgePointsCacheVersion();
|
|
|
|
|
+ $inMemoryKey = $rootCode.'|'.implode(',', $moduleCodes).'|'.$cacheVersion;
|
|
|
|
|
+ if (isset($this->knowledgePointMetaCache[$inMemoryKey])) {
|
|
|
|
|
+ return $this->knowledgePointMetaCache[$inMemoryKey];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $rows = DB::connection('mysql')
|
|
|
|
|
- ->table('knowledge_points')
|
|
|
|
|
- ->select(['kp_code', 'name', 'parent_kp_code'])
|
|
|
|
|
- ->get();
|
|
|
|
|
|
|
+ $cacheKey = sprintf('exam_pdf:kp_meta_map:v2:%s:%s:%s', $rootCode, md5(implode(',', $moduleCodes)), $cacheVersion);
|
|
|
|
|
|
|
|
- $map = [];
|
|
|
|
|
- foreach ($rows as $row) {
|
|
|
|
|
- $code = trim((string) ($row->kp_code ?? ''));
|
|
|
|
|
- if ($code === '') {
|
|
|
|
|
|
|
+ $map = $this->rememberWithFallback($cacheKey, self::KNOWLEDGE_POINTS_CACHE_TTL_SECONDS, function () use ($rootCode, $moduleCodes) {
|
|
|
|
|
+ $seedCodes = array_values(array_unique(array_filter(array_merge([$rootCode], $moduleCodes))));
|
|
|
|
|
+ if ($seedCodes === []) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $rows = DB::connection('mysql')
|
|
|
|
|
+ ->table('knowledge_points')
|
|
|
|
|
+ ->whereIn('kp_code', $seedCodes)
|
|
|
|
|
+ ->select(['kp_code', 'name', 'parent_kp_code'])
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ $result = [];
|
|
|
|
|
+ foreach ($rows as $row) {
|
|
|
|
|
+ $code = trim((string) ($row->kp_code ?? ''));
|
|
|
|
|
+ if ($code === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $result[$code] = [
|
|
|
|
|
+ 'name' => (string) ($row->name ?? $code),
|
|
|
|
|
+ 'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 从模块出发按层拉取子树,避免从学段根节点全量展开
|
|
|
|
|
+ $currentParents = $moduleCodes;
|
|
|
|
|
+ $visitedParents = [];
|
|
|
|
|
+ $guard = 0;
|
|
|
|
|
+ while (! empty($currentParents) && $guard < 40) {
|
|
|
|
|
+ $batchParents = [];
|
|
|
|
|
+ foreach ($currentParents as $parentCode) {
|
|
|
|
|
+ $parentCode = trim((string) $parentCode);
|
|
|
|
|
+ if ($parentCode === '' || isset($visitedParents[$parentCode])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $visitedParents[$parentCode] = true;
|
|
|
|
|
+ $batchParents[] = $parentCode;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($batchParents === []) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $childRows = DB::connection('mysql')
|
|
|
|
|
+ ->table('knowledge_points')
|
|
|
|
|
+ ->whereIn('parent_kp_code', $batchParents)
|
|
|
|
|
+ ->select(['kp_code', 'name', 'parent_kp_code'])
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ $nextParents = [];
|
|
|
|
|
+ foreach ($childRows as $row) {
|
|
|
|
|
+ $code = trim((string) ($row->kp_code ?? ''));
|
|
|
|
|
+ if ($code === '' || isset($result[$code])) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $result[$code] = [
|
|
|
|
|
+ 'name' => (string) ($row->name ?? $code),
|
|
|
|
|
+ 'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
|
|
|
|
|
+ ];
|
|
|
|
|
+ $nextParents[] = $code;
|
|
|
|
|
+ }
|
|
|
|
|
+ $currentParents = $nextParents;
|
|
|
|
|
+ $guard++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 补齐祖先链,确保路径与模块归属计算不丢失
|
|
|
|
|
+ $pendingParents = [];
|
|
|
|
|
+ foreach ($result as $meta) {
|
|
|
|
|
+ $parent = trim((string) ($meta['parent_kp_code'] ?? ''));
|
|
|
|
|
+ if ($parent !== '' && ! isset($result[$parent])) {
|
|
|
|
|
+ $pendingParents[$parent] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $ancestorGuard = 0;
|
|
|
|
|
+ while (! empty($pendingParents) && $ancestorGuard < 24) {
|
|
|
|
|
+ $parentBatch = array_keys($pendingParents);
|
|
|
|
|
+ $pendingParents = [];
|
|
|
|
|
+ foreach (array_chunk($parentBatch, 500) as $chunk) {
|
|
|
|
|
+ $ancestorRows = DB::connection('mysql')
|
|
|
|
|
+ ->table('knowledge_points')
|
|
|
|
|
+ ->whereIn('kp_code', $chunk)
|
|
|
|
|
+ ->select(['kp_code', 'name', 'parent_kp_code'])
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($ancestorRows as $row) {
|
|
|
|
|
+ $code = trim((string) ($row->kp_code ?? ''));
|
|
|
|
|
+ if ($code === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (! isset($result[$code])) {
|
|
|
|
|
+ $result[$code] = [
|
|
|
|
|
+ 'name' => (string) ($row->name ?? $code),
|
|
|
|
|
+ 'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ $parent = trim((string) ($result[$code]['parent_kp_code'] ?? ''));
|
|
|
|
|
+ if ($parent !== '' && ! isset($result[$parent])) {
|
|
|
|
|
+ $pendingParents[$parent] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ $ancestorGuard++;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $result;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $this->knowledgePointMetaCache[$inMemoryKey] = $map;
|
|
|
|
|
+
|
|
|
|
|
+ return $map;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function getKnowledgePointsCacheVersion(): string
|
|
|
|
|
+ {
|
|
|
|
|
+ return $this->rememberWithFallback('exam_pdf:kp_cache_version:v1', self::KNOWLEDGE_POINTS_VERSION_TTL_SECONDS, function () {
|
|
|
|
|
+ $row = DB::connection('mysql')
|
|
|
|
|
+ ->table('knowledge_points')
|
|
|
|
|
+ ->selectRaw('COALESCE(UNIX_TIMESTAMP(MAX(updated_at)), 0) as max_updated_ts, COUNT(*) as total_count')
|
|
|
|
|
+ ->first();
|
|
|
|
|
+
|
|
|
|
|
+ $maxUpdatedTs = (string) (($row->max_updated_ts ?? 0));
|
|
|
|
|
+ $totalCount = (string) (($row->total_count ?? 0));
|
|
|
|
|
+
|
|
|
|
|
+ return $maxUpdatedTs.'_'.$totalCount;
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function rememberWithFallback(string $key, int $ttlSeconds, callable $resolver): mixed
|
|
|
|
|
+ {
|
|
|
|
|
+ $resolved = false;
|
|
|
|
|
+ $resolvedValue = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Cache::remember($key, now()->addSeconds($ttlSeconds), function () use ($resolver, &$resolved, &$resolvedValue) {
|
|
|
|
|
+ $resolvedValue = $resolver();
|
|
|
|
|
+ $resolved = true;
|
|
|
|
|
+
|
|
|
|
|
+ return $resolvedValue;
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (\Throwable $e) {
|
|
|
|
|
+ Log::warning('ExamPdfExportService: 缓存不可用,回退直查', [
|
|
|
|
|
+ 'cache_key' => $key,
|
|
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ if ($resolved) {
|
|
|
|
|
+ return $resolvedValue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $resolver();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * V3 报告仅保留模板和统计所需字段,避免大对象在内存中长期驻留。
|
|
|
|
|
+ */
|
|
|
|
|
+ private function reduceTemplateDataForV3(array $templateData): array
|
|
|
|
|
+ {
|
|
|
|
|
+ unset($templateData['question_insights'], $templateData['recommendations']);
|
|
|
|
|
+
|
|
|
|
|
+ $templateData['questions'] = $this->compactQuestionsForV3($templateData['questions'] ?? []);
|
|
|
|
|
+
|
|
|
|
|
+ if (isset($templateData['analysis_data']['question_analysis']) && is_array($templateData['analysis_data']['question_analysis'])) {
|
|
|
|
|
+ $templateData['analysis_data']['question_analysis'] = array_map(static function ($item) {
|
|
|
|
|
+ if (! is_array($item)) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question_id' => $item['question_id'] ?? null,
|
|
|
|
|
+ 'question_bank_id' => $item['question_bank_id'] ?? null,
|
|
|
|
|
+ 'is_correct' => $item['is_correct'] ?? null,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }, $templateData['analysis_data']['question_analysis']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $templateData;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 仅输出摘要日志,避免将大数组序列化进日志造成额外内存开销。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array<string, mixed>
|
|
|
|
|
+ */
|
|
|
|
|
+ private function summarizeTemplateDataForLog(array $templateData): array
|
|
|
|
|
+ {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'paper_id' => $templateData['paper']['id'] ?? null,
|
|
|
|
|
+ 'student_id' => $templateData['student']['id'] ?? null,
|
|
|
|
|
+ 'questions_count' => count($templateData['questions'] ?? []),
|
|
|
|
|
+ 'analysis_question_count' => count($templateData['analysis_data']['question_analysis'] ?? []),
|
|
|
|
|
+ 'mastery_count' => count($templateData['mastery']['items'] ?? []),
|
|
|
|
|
+ 'exam_hit_kp_count' => count($templateData['exam_hit_kp_codes'] ?? []),
|
|
|
|
|
+ 'parent_mastery_count' => count($templateData['parent_mastery_levels'] ?? []),
|
|
|
|
|
+ 'full_parent_mastery_count' => count($templateData['full_parent_mastery_levels'] ?? []),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 仅保留 PDF V3 模板使用到的题目字段,减少内存占用与序列化开销。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array<int, array<string, mixed>>
|
|
|
|
|
+ */
|
|
|
|
|
+ private function compactQuestionsForV3(array $questions): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $compact = [];
|
|
|
|
|
+ foreach ($questions as $question) {
|
|
|
|
|
+ if (! is_array($question)) {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- $map[$code] = [
|
|
|
|
|
- 'name' => (string) ($row->name ?? $code),
|
|
|
|
|
- 'parent_kp_code' => trim((string) ($row->parent_kp_code ?? '')),
|
|
|
|
|
|
|
+ $compact[] = [
|
|
|
|
|
+ 'question_id' => $question['question_id'] ?? null,
|
|
|
|
|
+ 'question_bank_id' => $question['question_bank_id'] ?? null,
|
|
|
|
|
+ 'is_correct' => $question['is_correct'] ?? null,
|
|
|
|
|
+ 'student_answer' => $question['student_answer'] ?? null,
|
|
|
|
|
+ 'answer' => $question['answer'] ?? null,
|
|
|
|
|
+ 'correct_answer' => $question['correct_answer'] ?? null,
|
|
|
|
|
+ 'knowledge_point' => $question['knowledge_point'] ?? null,
|
|
|
|
|
+ 'knowledge_point_name' => $question['knowledge_point_name'] ?? null,
|
|
|
];
|
|
];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- $this->knowledgePointMetaCache = $map;
|
|
|
|
|
-
|
|
|
|
|
- return $map;
|
|
|
|
|
|
|
+ return $compact;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private function mapKpToStageModule(string $kpCode, array $kpMeta, string $rootCode): ?string
|
|
private function mapKpToStageModule(string $kpCode, array $kpMeta, string $rootCode): ?string
|
|
@@ -2268,20 +2497,48 @@ class ExamPdfExportService
|
|
|
|
|
|
|
|
// 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
|
|
// 计算与本次考试相关的父节点掌握度(基于所有兄弟节点)
|
|
|
$parentMasteryLevels = [];
|
|
$parentMasteryLevels = [];
|
|
|
|
|
+ $parentCodes = array_values(array_unique(array_filter(array_map(static fn ($code) => trim((string) $code), array_keys($allParentMasteryLevels)))));
|
|
|
|
|
+
|
|
|
|
|
+ if ($parentCodes !== []) {
|
|
|
|
|
+ $childRows = DB::connection('mysql')
|
|
|
|
|
+ ->table('knowledge_points')
|
|
|
|
|
+ ->whereIn('parent_kp_code', $parentCodes)
|
|
|
|
|
+ ->select(['parent_kp_code', 'kp_code'])
|
|
|
|
|
+ ->get();
|
|
|
|
|
+
|
|
|
|
|
+ $childrenByParent = [];
|
|
|
|
|
+ foreach ($childRows as $row) {
|
|
|
|
|
+ $parentCode = trim((string) ($row->parent_kp_code ?? ''));
|
|
|
|
|
+ $childCode = trim((string) ($row->kp_code ?? ''));
|
|
|
|
|
+ if ($parentCode === '' || $childCode === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $childrenByParent[$parentCode][] = $childCode;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 【修复】使用数据库查询正确匹配父子关系,而不是字符串前缀
|
|
|
|
|
- foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
|
|
|
|
|
- // 查询这个父节点的所有子节点
|
|
|
|
|
- $childNodes = DB::connection('mysql')
|
|
|
|
|
|
|
+ $parentNameMap = DB::connection('mysql')
|
|
|
->table('knowledge_points')
|
|
->table('knowledge_points')
|
|
|
- ->where('parent_kp_code', $parentKpCode)
|
|
|
|
|
- ->pluck('kp_code')
|
|
|
|
|
|
|
+ ->whereIn('kp_code', $parentCodes)
|
|
|
|
|
+ ->pluck('name', 'kp_code')
|
|
|
->toArray();
|
|
->toArray();
|
|
|
|
|
|
|
|
- // 检查是否有子节点在本次考试中出现
|
|
|
|
|
- $relevantChildren = array_intersect($examKpCodes, $childNodes);
|
|
|
|
|
|
|
+ foreach ($allParentMasteryLevels as $parentKpCode => $parentMastery) {
|
|
|
|
|
+ $parentKpCode = trim((string) $parentKpCode);
|
|
|
|
|
+ if ($parentKpCode === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $childNodes = $childrenByParent[$parentKpCode] ?? [];
|
|
|
|
|
+ if ($childNodes === []) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否有子节点在本次考试中出现
|
|
|
|
|
+ $relevantChildren = array_intersect($examKpCodes, $childNodes);
|
|
|
|
|
+ if (empty($relevantChildren)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (! empty($relevantChildren)) {
|
|
|
|
|
// 口径统一:父节点掌握度 = 全部直接子节点(含未命中,缺失按0)均值
|
|
// 口径统一:父节点掌握度 = 全部直接子节点(含未命中,缺失按0)均值
|
|
|
$childCurrentLevels = [];
|
|
$childCurrentLevels = [];
|
|
|
$childPreviousLevels = [];
|
|
$childPreviousLevels = [];
|
|
@@ -2303,15 +2560,9 @@ class ExamPdfExportService
|
|
|
: $finalParentMastery;
|
|
: $finalParentMastery;
|
|
|
$finalParentChange = $finalParentMastery - $previousParentMastery;
|
|
$finalParentChange = $finalParentMastery - $previousParentMastery;
|
|
|
|
|
|
|
|
- // 获取父节点中文名称
|
|
|
|
|
- $parentKpInfo = DB::connection('mysql')
|
|
|
|
|
- ->table('knowledge_points')
|
|
|
|
|
- ->where('kp_code', $parentKpCode)
|
|
|
|
|
- ->first();
|
|
|
|
|
-
|
|
|
|
|
$parentMasteryLevels[$parentKpCode] = [
|
|
$parentMasteryLevels[$parentKpCode] = [
|
|
|
'kp_code' => $parentKpCode,
|
|
'kp_code' => $parentKpCode,
|
|
|
- 'kp_name' => $parentKpInfo->name ?? $parentKpCode,
|
|
|
|
|
|
|
+ 'kp_name' => $parentNameMap[$parentKpCode] ?? $parentKpCode,
|
|
|
'mastery_level' => $finalParentMastery,
|
|
'mastery_level' => $finalParentMastery,
|
|
|
'mastery_percentage' => round($finalParentMastery * 100, 1),
|
|
'mastery_percentage' => round($finalParentMastery * 100, 1),
|
|
|
'mastery_change' => $finalParentChange,
|
|
'mastery_change' => $finalParentChange,
|
|
@@ -2334,7 +2585,7 @@ class ExamPdfExportService
|
|
|
|
|
|
|
|
Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [
|
|
Log::info('ExamPdfExportService: 使用analysisData中的掌握度数据', [
|
|
|
'count' => count($masteryData),
|
|
'count' => count($masteryData),
|
|
|
- 'masteryData_sample' => ! empty($masteryData) ? array_slice($masteryData, 0, 2) : [],
|
|
|
|
|
|
|
+ 'has_change_values' => collect($masteryData)->contains(fn ($item) => is_array($item) && array_key_exists('mastery_change', $item)),
|
|
|
]);
|
|
]);
|
|
|
} else {
|
|
} else {
|
|
|
// 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览
|
|
// 如果没有knowledge_point_analysis,使用MasteryCalculator获取多层级掌握度概览
|
|
@@ -2447,7 +2698,6 @@ class ExamPdfExportService
|
|
|
$kpNameMap = $this->buildKnowledgePointNameMap();
|
|
$kpNameMap = $this->buildKnowledgePointNameMap();
|
|
|
Log::info('ExamPdfExportService: 获取知识点名称映射', [
|
|
Log::info('ExamPdfExportService: 获取知识点名称映射', [
|
|
|
'kpNameMap_count' => count($kpNameMap),
|
|
'kpNameMap_count' => count($kpNameMap),
|
|
|
- 'kpNameMap_keys_sample' => ! empty($kpNameMap) ? array_slice(array_keys($kpNameMap), 0, 5) : [],
|
|
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
// 【修复】直接从MySQL数据库获取题目详情(不通过API)
|
|
// 【修复】直接从MySQL数据库获取题目详情(不通过API)
|
|
@@ -2464,9 +2714,9 @@ class ExamPdfExportService
|
|
|
Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
|
|
Log::info('ExamPdfExportService: buildMasterySummary返回结果', [
|
|
|
'masteryData_count' => count($masteryData),
|
|
'masteryData_count' => count($masteryData),
|
|
|
'kpNameMap_count' => count($kpNameMap),
|
|
'kpNameMap_count' => count($kpNameMap),
|
|
|
- 'masterySummary_keys' => array_keys($masterySummary),
|
|
|
|
|
'masterySummary_items_count' => count($masterySummary['items'] ?? []),
|
|
'masterySummary_items_count' => count($masterySummary['items'] ?? []),
|
|
|
- 'masterySummary_items_sample' => ! empty($masterySummary['items']) ? array_slice($masterySummary['items'], 0, 2) : [],
|
|
|
|
|
|
|
+ 'masterySummary_avg' => $masterySummary['average'] ?? null,
|
|
|
|
|
+ 'masterySummary_weak_count' => count($masterySummary['weak_list'] ?? []),
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
// 构建当前学生掌握度映射,供父子影响分析展示使用
|
|
// 构建当前学生掌握度映射,供父子影响分析展示使用
|
|
@@ -2500,7 +2750,6 @@ class ExamPdfExportService
|
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
Log::info('ExamPdfExportService: 处理后的父节点掌握度', [
|
|
|
'raw_count' => count($parentMasteryLevels),
|
|
'raw_count' => count($parentMasteryLevels),
|
|
|
'processed_count' => count($processedParentMastery),
|
|
'processed_count' => count($processedParentMastery),
|
|
|
- 'processed_sample' => ! empty($processedParentMastery) ? array_slice($processedParentMastery, 0, 3) : [],
|
|
|
|
|
]);
|
|
]);
|
|
|
|
|
|
|
|
return [
|
|
return [
|