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]; } }