| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- <?php
- namespace App\Services;
- use App\Models\Question;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Schema;
- use Illuminate\Support\Str;
- /**
- * questions_tem 质检入库页:知识点排序、PDF 口径预览、写入 questions
- */
- class QuestionTemReviewService
- {
- /** Session 键:供「入库题目调难度」页列举的本轮已入库 question.id */
- public const SESSION_TUNING_QUESTION_IDS = 'import_difficulty_tune_question_ids';
- /**
- * 将成功入库的正式题 ID 合并进会话,供调难度页使用。
- *
- * @param list<int> $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<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int}>
- */
- 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<object>
- */
- 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<string, mixed> $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])/', '<br>', $text);
- return (string) preg_replace('/(<br>\s*){3,}/', '<br><br>', $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<string, mixed> $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<int> $temIds
- * @return array{imported: int, skipped: int, failed: int, lines: list<string>}
- */
- /**
- * @return array{imported: int, skipped: int, failed: int, lines: list<string>, imported_question_ids: list<int>}
- */
- 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';
- }
- }
|