$questionIds */ public static function mergeQuestionIdsIntoTuningSession(array $questionIds): void { $questionIds = array_values(array_unique(array_filter(array_map('intval', $questionIds)))); if ($questionIds === []) { return; } $existing = session(self::SESSION_TUNING_QUESTION_IDS, []); if (! is_array($existing)) { $existing = []; } session([ self::SESSION_TUNING_QUESTION_IDS => array_values(array_unique(array_merge($existing, $questionIds))), ]); } /** * 左侧:按 questions 表中该知识点正式题数量升序(题少的在前),仅包含 questions_tem 中出现过的 kp_code * * @param ?int $limit 为 null 时不截断(质检页需完整列表 + 搜索,否则题量大的 KP 如 B01 会落在 500 条之后而无法检索) * @return list */ public function listKnowledgePointsByQuestionsAsc(?int $limit = null): array { if (! Schema::hasTable('questions_tem')) { return []; } $temKps = DB::table('questions_tem') ->whereNotNull('kp_code') ->where('kp_code', '!=', '') ->distinct() ->pluck('kp_code') ->all(); if ($temKps === []) { return []; } $kpNames = []; if (Schema::hasTable('knowledge_points')) { $kpNames = DB::table('knowledge_points') ->whereIn('kp_code', $temKps) ->pluck('name', 'kp_code') ->toArray(); } $counts = []; if (Schema::hasTable('questions')) { $counts = DB::table('questions') ->whereIn('kp_code', $temKps) ->selectRaw('kp_code, COUNT(*) as c') ->groupBy('kp_code') ->pluck('c', 'kp_code') ->toArray(); } $temCounts = DB::table('questions_tem') ->whereIn('kp_code', $temKps) ->selectRaw('kp_code, COUNT(*) as c') ->groupBy('kp_code') ->pluck('c', 'kp_code') ->toArray(); $rows = []; foreach ($temKps as $kp) { $name = isset($kpNames[$kp]) ? trim((string) $kpNames[$kp]) : ''; $rows[] = [ 'kp_code' => $kp, 'kp_name' => $name, 'questions_count' => (int) ($counts[$kp] ?? 0), 'tem_count' => (int) ($temCounts[$kp] ?? 0), ]; } usort($rows, function ($a, $b) { if ($a['questions_count'] === $b['questions_count']) { return strcmp($a['kp_code'], $b['kp_code']); } return $a['questions_count'] <=> $b['questions_count']; }); if ($limit !== null && $limit > 0) { return array_slice($rows, 0, $limit); } return $rows; } /** * 与入库、判重一致:questions_tem 行用于比对的题干(stem 优先,否则 content) */ public function normalizedStemFromTemRow(object|array $row): string { $arr = is_array($row) ? $row : (array) $row; return (string) ($arr['stem'] ?? $arr['content'] ?? ''); } /** * 中间:某知识点下 questions_tem 题目(限制条数) * * @param bool $excludeFormalDuplicates 为 true 时排除「正式库 questions 已存在同 kp_code + 同 stem」的待审行,与 {@see existsDuplicateInQuestions} 一致,减少无效质检 * @return list */ public function listTemQuestionsForKp(string $kpCode, int $limit = 300, bool $excludeFormalDuplicates = true): array { if (! Schema::hasTable('questions_tem') || $kpCode === '') { return []; } $formalStemSet = []; if ($excludeFormalDuplicates && Schema::hasTable('questions')) { foreach (DB::table('questions')->where('kp_code', $kpCode)->pluck('stem') as $stem) { if ($stem === null || $stem === '') { continue; } $formalStemSet[(string) $stem] = true; } } $out = []; $q = DB::table('questions_tem')->where('kp_code', $kpCode)->orderBy('id'); foreach ($q->lazyById(200) as $row) { if ($excludeFormalDuplicates && $formalStemSet !== []) { $stem = $this->normalizedStemFromTemRow($row); if ($stem !== '' && isset($formalStemSet[$stem])) { continue; } } $out[] = $row; if (count($out) >= $limit) { break; } } return $out; } /** * 与 ExamPdfExportService::renderPreviewHtml 一致:公式预处理 + 解析换行,供页面 KaTeX 渲染 * * @param array $row questions_tem 一行转数组 * @return array{stem: string, options: ?array, answer: string, solution: string, question_type: string} */ public function buildPdfStylePreviewFields(array $row): array { $stem = (string) ($row['stem'] ?? $row['content'] ?? ''); $answer = (string) ($row['answer'] ?? $row['correct_answer'] ?? ''); $solution = (string) ($row['solution'] ?? ''); $questionType = strtolower((string) ($row['question_type'] ?? $row['tags'] ?? 'answer')); $options = $row['options'] ?? null; if (is_string($options) && trim($options) !== '') { $decoded = json_decode($options, true); $options = is_array($decoded) ? $decoded : null; } $processedStem = MathFormulaProcessor::processFormulas($stem); $processedAnswer = MathFormulaProcessor::processFormulas($answer); $processedSolution = MathFormulaProcessor::processFormulas($this->formatNewlinesForPdf($solution)); $processedOptions = null; if (is_array($options)) { $processedOptions = []; foreach ($options as $key => $value) { if (is_array($value)) { $text = (string) ($value['text'] ?? $value['value'] ?? reset($value) ?? ''); $processedOptions[$key] = MathFormulaProcessor::processFormulas($text); } else { $processedOptions[$key] = MathFormulaProcessor::processFormulas((string) $value); } } } return [ 'stem' => $processedStem, 'options' => $processedOptions, 'answer' => $processedAnswer, 'solution' => $processedSolution, 'question_type' => $questionType, ]; } private function formatNewlinesForPdf(?string $text): string { if ($text === null || $text === '') { return ''; } $text = preg_replace('/\\\\n(?![a-zA-Z])/', '
', $text); return (string) preg_replace('/(
\s*){3,}/', '

', $text); } /** * 是否已在 questions 中存在(同 kp + 题干完全一致则视为重复) */ public function existsDuplicateInQuestions(string $kpCode, string $stem): bool { if ($stem === '' || ! Schema::hasTable('questions')) { return false; } return Question::query() ->where('kp_code', $kpCode) ->where('stem', $stem) ->exists(); } /** * 待审行默认难度:与入库写入规则一致,限制在 [0.00, 0.90] 并保留两位小数 * * @param object|array $row */ public function defaultDifficultyForTemRow(object|array $row): float { $arr = is_array($row) ? $row : (array) $row; $d = 0.5; if (array_key_exists('difficulty', $arr) && $arr['difficulty'] !== null && $arr['difficulty'] !== '') { $d = (float) $arr['difficulty']; } return max(0.0, min(0.9, round($d, 2))); } /** * 将 questions_tem 一行写入 questions(入库) * * @param ?float $difficultyOverride 若传入则作为 questions.difficulty(仍限制 0.00–0.90、两位小数);null 时按表内字段或默认 0.5 * @return array{ok: bool, message: string, question_id: ?int} */ public function importTemRowToQuestions(int $temId, ?float $difficultyOverride = null): array { if (! Schema::hasTable('questions_tem')) { return ['ok' => false, 'message' => 'questions_tem 表不存在', 'question_id' => null]; } $row = DB::table('questions_tem')->where('id', $temId)->first(); if (! $row) { return ['ok' => false, 'message' => '待入库题目不存在', 'question_id' => null]; } $arr = (array) $row; $stem = $this->normalizedStemFromTemRow($arr); $kp = (string) ($arr['kp_code'] ?? ''); if ($stem === '' || $kp === '') { return ['ok' => false, 'message' => '题干或知识点为空', 'question_id' => null]; } if ($this->existsDuplicateInQuestions($kp, $stem)) { return ['ok' => false, 'message' => '正式库已存在相同知识点且题干一致的题目', 'question_id' => null]; } $options = $arr['options'] ?? null; if (is_string($options) && trim($options) !== '') { $decoded = json_decode($options, true); $options = is_array($decoded) ? $decoded : null; } $questionType = $this->normalizeQuestionTypeForDb($arr['question_type'] ?? $arr['tags'] ?? 'answer'); $difficulty = $difficultyOverride !== null ? max(0.0, min(0.9, round($difficultyOverride, 2))) : $this->defaultDifficultyForTemRow($arr); $payload = [ 'question_code' => 'QT'.strtoupper(Str::random(12)), 'question_type' => $questionType, 'kp_code' => $kp, 'stem' => $stem, 'options' => $options, 'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''), 'solution' => (string) ($arr['solution'] ?? ''), 'difficulty' => $difficulty, 'source' => 'questions_tem_review', 'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : null, 'meta' => [ 'imported_from' => 'questions_tem', 'questions_tem_id' => $temId, ], ]; if (isset($arr['textbook_id'])) { $payload['textbook_id'] = (int) $arr['textbook_id']; } try { $question = Question::query()->create($payload); $updates = []; if (Schema::hasColumn('questions', 'audit_status')) { $updates['audit_status'] = 0; } if (Schema::hasColumn('questions', 'grade') && isset($arr['grade'])) { $updates['grade'] = (int) $arr['grade']; } if ($updates !== []) { DB::table('questions')->where('id', $question->id)->update($updates); } return [ 'ok' => true, 'message' => '已入库', 'question_id' => (int) $question->id, ]; } catch (\Throwable $e) { return [ 'ok' => false, 'message' => '入库失败:'.$e->getMessage(), 'question_id' => null, ]; } } /** * 批量将 questions_tem 行写入 questions(每行逻辑与 importTemRowToQuestions 相同) * * @param list $temIds * @return array{imported: int, skipped: int, failed: int, lines: list} */ /** * @return array{imported: int, skipped: int, failed: int, lines: list, imported_question_ids: list} */ public function importTemIdsToQuestions(array $temIds): array { $imported = 0; $skipped = 0; $failed = 0; $lines = []; $importedQuestionIds = []; foreach (array_unique(array_filter(array_map('intval', $temIds))) as $id) { if ($id <= 0) { continue; } $r = $this->importTemRowToQuestions($id); if ($r['ok']) { $imported++; if (! empty($r['question_id'])) { $importedQuestionIds[] = (int) $r['question_id']; } continue; } $msg = $r['message']; if ( str_contains($msg, '正式库已存在') || str_contains($msg, '题干或知识点为空') ) { $skipped++; } else { $failed++; } if (count($lines) < 30) { $lines[] = "#{$id}: {$msg}"; } } return [ 'imported' => $imported, 'skipped' => $skipped, 'failed' => $failed, 'lines' => $lines, 'imported_question_ids' => $importedQuestionIds, ]; } private function normalizeQuestionTypeForDb(mixed $raw): string { $t = strtolower(trim((string) $raw)); if (str_contains($t, 'choice') || str_contains($t, '选择')) { return 'choice'; } if (str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空')) { return 'fill'; } return 'answer'; } }