validateKnowledgeId($knowledgeId)) {
continue;
}
$exists = KnowledgeExplanation::query()
->where('knowledge_id', $knowledgeId)
->exists();
if (! $exists) {
return $knowledgeId;
}
}
throw new \RuntimeException('无法生成唯一的 knowledge_id');
}
public function prepareKnowledgeExplanation(array $payload): array
{
$knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
if (! $this->validateKnowledgeId($knowledgeId)) {
throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 paper_ + 15位数字(兼容 knowledge_ 前缀)');
}
$studentId = (string) ($payload['student_id'] ?? '');
$teacherId = (string) ($payload['teacher_id'] ?? '');
$difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
$kpCodes = $this->resolveKpCodes($payload);
$knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
$history = $this->loadStudentQuestionHistory($studentId);
$casePayload = [];
foreach ($knowledgePoints as &$point) {
$kpCode = (string) ($point['kp_code'] ?? '');
if ($kpCode === '') {
$point['cases'] = [];
continue;
}
$cases = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
$point['cases'] = $cases;
$casePayload[$kpCode] = array_map(static function (array $item): array {
return [
'question_id' => $item['question_id'],
'source_type' => $item['source_type'],
'is_wrong_case' => $item['is_wrong_case'],
'child_kp_code' => $item['child_kp_code'] ?? null,
'child_kp_name' => $item['child_kp_name'] ?? null,
'source_label' => $item['source_label'] ?? null,
];
}, $cases);
}
unset($point);
$contentHash = hash('sha256', json_encode([
'student_id' => $studentId,
'kp_codes' => $kpCodes,
'case_payload' => $casePayload,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$recordPayload = [
'teacher_id' => $teacherId,
'student_id' => $studentId,
'assemble_type' => 22,
'status' => 'processing',
'kp_codes' => $kpCodes,
'case_payload' => $casePayload,
'content_hash' => $contentHash,
'pdf_url' => null,
'generated_at' => null,
];
try {
$record = KnowledgeExplanation::updateOrCreate([
'knowledge_id' => $knowledgeId,
], $recordPayload);
} catch (QueryException $e) {
if (! $this->isDuplicatePrimaryKeyError($e)) {
throw $e;
}
// 兼容线上历史表主键异常(id 非正常自增):
// 1) 若 knowledge_id 已存在则直接更新;
// 2) 否则手动分配一个递增 id 再插入,避免任务失败。
$record = $this->persistWithManualIdFallback($knowledgeId, $recordPayload);
}
return [
'knowledge_id' => $knowledgeId,
'record' => $record,
'knowledge_points' => $knowledgePoints,
];
}
/**
* 仅用于本地模板调试预览:不落库,直接返回渲染数据。
*/
public function previewKnowledgeExplanation(array $payload): array
{
$knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
if (! $this->validateKnowledgeId($knowledgeId)) {
throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 paper_ + 15位数字(兼容 knowledge_ 前缀)');
}
$studentId = (string) ($payload['student_id'] ?? '');
$teacherId = (string) ($payload['teacher_id'] ?? '');
$difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
$kpCodes = $this->resolveKpCodes($payload);
$knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
$history = $this->loadStudentQuestionHistory($studentId);
foreach ($knowledgePoints as &$point) {
$kpCode = (string) ($point['kp_code'] ?? '');
if ($kpCode === '') {
$point['cases'] = [];
continue;
}
$point['cases'] = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
}
unset($point);
return [
'knowledge_id' => $knowledgeId,
'student_id' => $studentId,
'teacher_id' => $teacherId,
'knowledge_points' => $knowledgePoints,
];
}
/**
* 使用已保存的 knowledge_id/kp_codes/case_payload 重建 PDF 渲染数据。
* 知识点正文会读取当前库中最新内容,案例题目按 case_payload 中的 question_id 复原。
*/
public function rebuildKnowledgePointsForRecord(KnowledgeExplanation $record): array
{
$kpCodes = is_array($record->kp_codes) ? $record->kp_codes : [];
$knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
$casePayload = is_array($record->case_payload) ? $record->case_payload : [];
if (! empty($casePayload)) {
$questionIds = [];
foreach ($casePayload as $items) {
if (! is_array($items)) {
continue;
}
foreach ($items as $item) {
$questionId = (int) ($item['question_id'] ?? 0);
if ($questionId > 0) {
$questionIds[$questionId] = true;
}
}
}
$questionsById = empty($questionIds)
? collect()
: Question::query()
->whereIn('id', array_keys($questionIds))
->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty'])
->keyBy('id');
foreach ($knowledgePoints as &$point) {
$kpCode = (string) ($point['kp_code'] ?? '');
$point['cases'] = [];
$items = $casePayload[$kpCode] ?? [];
if (! is_array($items)) {
continue;
}
foreach ($items as $item) {
if (! is_array($item)) {
continue;
}
$questionId = (int) ($item['question_id'] ?? 0);
$question = $questionsById->get($questionId);
if (! $question instanceof Question) {
continue;
}
$sourceType = (string) ($item['source_type'] ?? 'fallback');
$case = $this->formatCaseQuestion($question, $sourceType, (bool) ($item['is_wrong_case'] ?? false));
$case['child_kp_code'] = $item['child_kp_code'] ?? null;
$case['child_kp_name'] = $item['child_kp_name'] ?? null;
$case['source_label'] = $item['source_label'] ?? ($case['source_label'] ?? null);
$point['cases'][] = $case;
}
}
unset($point);
return $knowledgePoints;
}
$payload = [
'knowledge_id' => $record->knowledge_id,
'student_id' => $record->student_id,
'teacher_id' => $record->teacher_id,
'kp_codes' => $kpCodes,
];
return (array) ($this->previewKnowledgeExplanation($payload)['knowledge_points'] ?? []);
}
private function validateKnowledgeId(string $knowledgeId): bool
{
if (! preg_match('/^(?:paper_|knowledge_)([1-9]\d{14})$/', $knowledgeId, $matches)) {
return false;
}
return PaperIdGenerator::validate($matches[1]);
}
private function resolveKpCodes(array $payload): array
{
$raw = $payload['kp_codes'] ?? $payload['kp_code_list'] ?? [];
if (! is_array($raw)) {
return [];
}
$codes = [];
foreach ($raw as $code) {
$value = trim((string) $code);
if ($value === '') {
continue;
}
$codes[$value] = true;
}
return array_keys($codes);
}
private function isDuplicatePrimaryKeyError(QueryException $e): bool
{
$message = (string) $e->getMessage();
return str_contains($message, 'Integrity constraint violation: 1062')
&& str_contains($message, 'knowledge_explanations.PRIMARY');
}
private function persistWithManualIdFallback(string $knowledgeId, array $recordPayload): KnowledgeExplanation
{
$existing = KnowledgeExplanation::query()
->where('knowledge_id', $knowledgeId)
->first();
if ($existing) {
$existing->fill($recordPayload);
$existing->save();
return $existing;
}
return DB::transaction(function () use ($knowledgeId, $recordPayload): KnowledgeExplanation {
$table = (new KnowledgeExplanation())->getTable();
$maxId = (int) DB::table($table)->lockForUpdate()->max('id');
$nextId = $maxId + 1;
$now = now();
DB::table($table)->insert(array_merge($recordPayload, [
'id' => $nextId,
'knowledge_id' => $knowledgeId,
'created_at' => $now,
'updated_at' => $now,
]));
return KnowledgeExplanation::query()
->where('knowledge_id', $knowledgeId)
->firstOrFail();
});
}
private function loadStudentQuestionHistory(string $studentId): array
{
$done = PaperQuestion::query()
->select('paper_questions.question_bank_id')
->join('papers', 'papers.paper_id', '=', 'paper_questions.paper_id')
->where('papers.student_id', $studentId)
->whereNotNull('paper_questions.question_bank_id')
->pluck('paper_questions.question_bank_id')
->map(static fn ($id) => (int) $id)
->filter(static fn ($id) => $id > 0)
->unique()
->values()
->all();
$wrong = MistakeRecord::query()
->where('student_id', $studentId)
->whereNotNull('question_id')
->pluck('question_id')
->map(static fn ($id) => (int) $id)
->filter(static fn ($id) => $id > 0)
->unique()
->values()
->all();
return [
'done' => $done,
'wrong' => $wrong,
];
}
private function pickCasesForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, int $limit, ?int $difficultyCategory = null): array
{
$children = KnowledgePoint::query()
->where('parent_kp_code', $kpCode)
->whereNotNull('kp_code')
->where('kp_code', '!=', '')
->orderBy('id')
->limit($limit)
->get(['kp_code', 'name']);
if ($children->isNotEmpty()) {
$selected = collect();
$usedQuestionIds = [];
foreach ($children as $child) {
if ($selected->count() >= $limit) {
break;
}
$case = $this->pickSingleCaseForKnowledgePoint((string) $child->kp_code, $doneIds, $wrongIds, $usedQuestionIds, $difficultyCategory);
if ($case === null) {
continue;
}
$case['child_kp_code'] = (string) $child->kp_code;
$case['child_kp_name'] = (string) ($child->name ?: $child->kp_code);
$case['source_label'] = (string) ($child->name ?: $child->kp_code);
$selected->push($case);
$usedQuestionIds[] = (int) $case['question_id'];
}
return $selected->values()->all();
}
$selected = collect();
$pick = function (Collection $bucket, string $sourceType, bool $isWrong) use ($selected, $limit): void {
foreach ($bucket as $question) {
if ($selected->count() >= $limit) {
break;
}
if ($selected->contains('question_id', (int) $question->id)) {
continue;
}
$selected->push($this->formatCaseQuestion($question, $sourceType, $isWrong));
}
};
$pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
if (! empty($doneIds)) {
$query->whereNotIn('id', $doneIds);
}
}, $difficultyCategory), 'new', false);
if ($selected->count() < $limit) {
$pick($this->queryBucket($kpCode, static function ($query) use ($wrongIds) {
if (empty($wrongIds)) {
$query->whereRaw('1=0');
return;
}
$query->whereIn('id', $wrongIds);
}, $difficultyCategory), 'wrong', true);
}
if ($selected->count() < $limit) {
$pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
if (empty($doneIds)) {
$query->whereRaw('1=0');
return;
}
$query->whereIn('id', $doneIds);
}, $difficultyCategory), 'reviewed', false);
}
if ($selected->count() < $limit) {
$excluded = $selected->pluck('question_id')->all();
$pick($this->queryBucket($kpCode, static function ($query) use ($excluded) {
if (! empty($excluded)) {
$query->whereNotIn('id', $excluded);
}
}, $difficultyCategory), 'fallback', false);
}
return $selected->values()->all();
}
private function pickSingleCaseForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, array $excludedIds = [], ?int $difficultyCategory = null): ?array
{
$pickOne = function (callable $mutator, string $sourceType, bool $isWrong) use ($kpCode, $difficultyCategory): ?array {
$bucket = $this->queryBucket($kpCode, $mutator, $difficultyCategory);
$question = $bucket->first();
if (! $question) {
return null;
}
return $this->formatCaseQuestion($question, $sourceType, $isWrong);
};
$baseExclude = $excludedIds;
$case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
if (! empty($doneIds)) {
$query->whereNotIn('id', $doneIds);
}
if (! empty($baseExclude)) {
$query->whereNotIn('id', $baseExclude);
}
}, 'new', false);
if ($case) {
return $case;
}
$case = $pickOne(static function ($query) use ($wrongIds, $baseExclude) {
if (empty($wrongIds)) {
$query->whereRaw('1=0');
return;
}
$query->whereIn('id', $wrongIds);
if (! empty($baseExclude)) {
$query->whereNotIn('id', $baseExclude);
}
}, 'wrong', true);
if ($case) {
return $case;
}
$case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
if (empty($doneIds)) {
$query->whereRaw('1=0');
return;
}
$query->whereIn('id', $doneIds);
if (! empty($baseExclude)) {
$query->whereNotIn('id', $baseExclude);
}
}, 'reviewed', false);
if ($case) {
return $case;
}
return $pickOne(static function ($query) use ($baseExclude) {
if (! empty($baseExclude)) {
$query->whereNotIn('id', $baseExclude);
}
}, 'fallback', false);
}
private function queryBucket(string $kpCode, callable $mutator, ?int $difficultyCategory = null): Collection
{
$query = Question::query()
->where('kp_code', $kpCode)
->whereNotNull('stem')
->where('stem', '!=', '')
->whereNotNull('answer')
->whereNotNull('solution')
->inRandomOrder()
->limit(80);
$mutator($query);
$candidates = $query->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty']);
return $this->rankCandidates($candidates, $difficultyCategory, 30);
}
private function rankCandidates(Collection $candidates, ?int $difficultyCategory, int $limit): Collection
{
if ($candidates->isEmpty()) {
return collect();
}
// 无难度偏好时:随机抽样
if ($difficultyCategory === null || $difficultyCategory < 0 || $difficultyCategory > 4) {
return $candidates->shuffle()->take($limit)->values();
}
$target = $this->targetDifficultyByCategory($difficultyCategory);
// 先按难度贴合度排序,再加随机扰动,避免每次都返回同题
$ranked = $candidates
->shuffle()
->map(function (Question $q) use ($target): array {
$difficulty = $this->normalizeDifficultyValue($q->difficulty);
$distance = abs($difficulty - $target);
$jitter = mt_rand(0, 1000) / 10000;
return [
'question' => $q,
'rank_score' => $distance + $jitter,
];
})
->sortBy('rank_score')
->pluck('question')
->take($limit)
->values();
return $ranked;
}
private function targetDifficultyByCategory(int $difficultyCategory): float
{
return match ($difficultyCategory) {
0 => 0.25,
1 => 0.40,
2 => 0.55,
3 => 0.70,
4 => 0.85,
default => 0.55,
};
}
private function normalizeDifficultyValue(mixed $difficulty): float
{
if (! is_numeric($difficulty)) {
return 0.55;
}
$value = (float) $difficulty;
if ($value > 1) {
$value = $value / 5;
}
return max(0.0, min(1.0, $value));
}
private function formatCaseQuestion(Question $question, string $sourceType, bool $isWrongCase): array
{
$sourceLabel = match ($sourceType) {
'wrong' => '错题讲解',
'reviewed' => '已做题',
'fallback' => '补充题',
default => '新题',
};
$stemRaw = (string) ($question->stem ?? '');
$options = $this->normalizeQuestionOptions($question->options);
if (empty($options) && is_array($question->meta ?? null)) {
$meta = (array) $question->meta;
$options = $this->normalizeQuestionOptions($meta['options'] ?? $meta['question_options'] ?? null);
}
if (empty($options)) {
[$stemWithoutExtractedOptions, $extractedOptions] = $this->extractChoiceOptionsFromStem((string) ($question->stem ?? ''));
if (! empty($extractedOptions)) {
$stemRaw = $stemWithoutExtractedOptions;
$options = $extractedOptions;
}
}
return [
'question_id' => (int) $question->id,
'source_type' => $sourceType,
'is_wrong_case' => $isWrongCase,
'source_label' => $sourceLabel,
'stem' => $stemRaw,
'options' => $options,
'answer' => (string) ($question->answer ?? ''),
'solution' => (string) ($question->solution ?? ''),
'question_type' => (string) ($question->question_type ?? ''),
'difficulty' => is_numeric($question->difficulty) ? (float) $question->difficulty : null,
];
}
/**
* 标准化选择题选项,输出为 ['A' => '...', 'B' => '...']。
*/
private function normalizeQuestionOptions(mixed $rawOptions): array
{
if (is_string($rawOptions)) {
$decoded = json_decode($rawOptions, true);
if (is_array($decoded)) {
$rawOptions = $decoded;
}
}
if (! is_array($rawOptions) || empty($rawOptions)) {
return [];
}
$normalized = [];
foreach ($rawOptions as $key => $value) {
$label = strtoupper(trim((string) $key));
$content = '';
if (is_array($value)) {
// 兼容多种选项结构:['A' => '...'] / [['label'=>'A','content'=>'...']]
$candidateLabel = (string) ($value['label'] ?? $value['key'] ?? '');
if ($candidateLabel !== '') {
$label = strtoupper(trim($candidateLabel));
}
$content = (string) ($value['content'] ?? $value['value'] ?? $value['text'] ?? '');
} else {
$content = (string) $value;
}
if (trim($content) === '') {
continue;
}
if (! preg_match('/^[A-Z]$/', $label)) {
$idx = count($normalized);
$label = chr(ord('A') + $idx);
}
// 选项文本保持原样,公式与 HTML 转义由卷子共用 partial(exam-choice-options)处理
$normalized[$label] = $content;
}
return $normalized;
}
/**
* 兜底:从题干中提取 A/B/C/D 选项文本(兼容旧库数据)。
*/
private function extractChoiceOptionsFromStem(string $stem): array
{
if (trim($stem) === '') {
return [$stem, []];
}
$pattern = '/(?:^|
|\r?\n)\s*([A-H])\s*[\..、::]\s*(.+?)(?=(?:
|\r?\n)\s*[A-H]\s*[\..、::]\s*|$)/isu';
preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
if (empty($matches)) {
return [$stem, []];
}
$options = [];
foreach ($matches as $m) {
$label = strtoupper(trim((string) ($m[1][0] ?? '')));
$content = trim((string) ($m[2][0] ?? ''));
if ($label === '' || $content === '') {
continue;
}
$options[$label] = $content;
}
if (empty($options)) {
return [$stem, []];
}
$firstOptionOffset = (int) ($matches[0][0][1] ?? 0);
$stemWithoutOptions = trim(substr($stem, 0, $firstOptionOffset));
return [$stemWithoutOptions !== '' ? $stemWithoutOptions : $stem, $options];
}
}