import { v4 as uuidv4 } from 'uuid'; import { get, query, run } from '../database'; export type QuestionType = 'single' | 'multiple' | 'judgment' | 'text'; export interface ExamSubject { id: string; name: string; totalScore: number; timeLimitMinutes: number; typeRatios: Record; categoryRatios: Record; createdAt: string; updatedAt: string; } type RawSubjectRow = { id: string; name: string; typeRatios: string; categoryRatios: string; totalScore: number; timeLimitMinutes: number; createdAt: string; updatedAt: string; }; const parseJson = (value: string, fallback: T): T => { try { return JSON.parse(value) as T; } catch { return fallback; } }; const validateNonNegativeNumbers = (valuesMap: Record, label: string) => { const values = Object.values(valuesMap); if (values.length === 0) throw new Error(`${label}不能为空`); if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) { throw new Error(`${label}必须是非负数字`); } if (!values.some((v) => v > 0)) { throw new Error(`${label}至少需要一个大于0的配置`); } }; const validateIntegerCounts = (valuesMap: Record, label: string) => { validateNonNegativeNumbers(valuesMap, label); if (Object.values(valuesMap).some((v) => !Number.isInteger(v))) { throw new Error(`${label}必须为整数`); } }; const sumValues = (valuesMap: Record) => Object.values(valuesMap).reduce((a, b) => a + b, 0); const isRatioMode = (valuesMap: Record) => { const values = Object.values(valuesMap); if (values.length === 0) return false; if (values.some((v) => typeof v !== 'number' || Number.isNaN(v) || v < 0)) return false; if (values.some((v) => v > 100)) return false; const sum = values.reduce((a, b) => a + b, 0); return Math.abs(sum - 100) <= 0.01; }; export class ExamSubjectModel { static async generateQuizQuestions(subject: ExamSubject): Promise<{ questions: Awaited>; totalScore: number; timeLimitMinutes: number; }> { const { QuestionModel } = await import('./question'); let questions: Awaited> = []; const typeRatioMode = isRatioMode(subject.typeRatios as any); const categoryRatioMode = isRatioMode(subject.categoryRatios); if (typeRatioMode !== categoryRatioMode) { throw new Error('题型配置与题目类别配置必须同为比例或同为数量'); } const categoryEntries = Object.entries(subject.categoryRatios).filter(([, v]) => v > 0); const categories = categoryEntries.map(([c]) => c); if (categories.length === 0) categories.push('通用'); if (typeRatioMode) { const weightedCategories: string[] = []; for (const [category, ratio] of categoryEntries) { const weight = Math.min(100, Math.max(0, Math.round(ratio))); for (let i = 0; i < weight; i++) weightedCategories.push(category); } if (weightedCategories.length === 0) { weightedCategories.push('通用'); } for (const [type, typeRatio] of Object.entries(subject.typeRatios)) { if (typeRatio <= 0) continue; const targetTypeScore = Math.round((typeRatio / 100) * subject.totalScore); let currentTypeScore = 0; let typeQuestions: Awaited> = []; while (currentTypeScore < targetTypeScore) { const randomCategory = weightedCategories[Math.floor(Math.random() * weightedCategories.length)]; const availableQuestions = await QuestionModel.getRandomQuestions(type as any, 10, [randomCategory]); if (availableQuestions.length === 0) break; const availableUnselected = availableQuestions.filter((q) => !questions.some((selected) => selected.id === q.id)); if (availableUnselected.length === 0) break; const remainingForType = targetTypeScore - currentTypeScore; const selectedQuestion = availableUnselected.reduce((prev, curr) => { const prevDiff = Math.abs(remainingForType - prev.score); const currDiff = Math.abs(remainingForType - curr.score); return currDiff < prevDiff ? curr : prev; }); typeQuestions.push(selectedQuestion); currentTypeScore += selectedQuestion.score; if (typeQuestions.length > 100) break; } questions.push(...typeQuestions); } let totalScore = questions.reduce((sum, q) => sum + q.score, 0); while (totalScore < subject.totalScore) { const allTypes = Object.keys(subject.typeRatios).filter((t) => (subject.typeRatios as any)[t] > 0); if (allTypes.length === 0) break; const randomType = allTypes[Math.floor(Math.random() * allTypes.length)]; const availableQuestions = await QuestionModel.getRandomQuestions(randomType as any, 10, categories); if (availableQuestions.length === 0) break; const availableUnselected = availableQuestions.filter((q) => !questions.some((selected) => selected.id === q.id)); if (availableUnselected.length === 0) break; const remainingScore = subject.totalScore - totalScore; const selectedQuestion = availableUnselected.reduce((prev, curr) => { const prevDiff = Math.abs(remainingScore - prev.score); const currDiff = Math.abs(remainingScore - curr.score); return currDiff < prevDiff ? curr : prev; }); questions.push(selectedQuestion); totalScore += selectedQuestion.score; if (questions.length > 200) break; } while (totalScore > subject.totalScore) { const excessScore = totalScore - subject.totalScore; let closestIndex = -1; let closestDiff = Infinity; for (let i = 0; i < questions.length; i++) { const diff = Math.abs(questions[i].score - excessScore); if (diff < closestDiff) { closestDiff = diff; closestIndex = i; } } if (closestIndex === -1) break; totalScore -= questions[closestIndex].score; questions.splice(closestIndex, 1); } return { questions, totalScore, timeLimitMinutes: subject.timeLimitMinutes, }; } const categoryRemaining = new Map(); for (const [category, count] of categoryEntries) { const c = Math.max(0, Math.round(count)); if (c > 0) categoryRemaining.set(category, c); } if (categoryRemaining.size === 0) { categoryRemaining.set('通用', sumValues(subject.typeRatios as any)); if (!categories.includes('通用')) categories.push('通用'); } const pickCategory = (exclude?: Set) => { const entries = Array.from(categoryRemaining.entries()).filter(([, c]) => c > 0); const usable = exclude ? entries.filter(([k]) => !exclude.has(k)) : entries; if (usable.length === 0) return null; const total = usable.reduce((s, [, c]) => s + c, 0); let r = Math.floor(Math.random() * total); for (const [k, c] of usable) { if (r < c) return k; r -= c; } return usable[0][0]; }; let currentTotal = 0; let remainingSlots = sumValues(Object.fromEntries(categoryRemaining) as any); for (const [type, rawCount] of Object.entries(subject.typeRatios)) { const targetCount = Math.max(0, Math.round(rawCount)); if (targetCount <= 0) continue; for (let i = 0; i < targetCount; i++) { const tried = new Set(); let selected = null as null | Awaited>[number]; let selectedCategory: string | null = null; for (let attempt = 0; attempt < categoryRemaining.size; attempt++) { const category = pickCategory(tried); if (!category) break; tried.add(category); const desiredAvg = remainingSlots > 0 ? (subject.totalScore - currentTotal) / remainingSlots : 0; const fetched = await QuestionModel.getRandomQuestions(type as any, 30, [category]); const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id)); if (candidates.length === 0) continue; selected = candidates.reduce((prev, curr) => { const prevDiff = Math.abs(desiredAvg - prev.score); const currDiff = Math.abs(desiredAvg - curr.score); return currDiff < prevDiff ? curr : prev; }); selectedCategory = category; break; } if (!selected || !selectedCategory) { throw new Error('题库中缺少满足当前配置的题目'); } questions.push(selected); currentTotal += selected.score; categoryRemaining.set(selectedCategory, (categoryRemaining.get(selectedCategory) || 0) - 1); remainingSlots -= 1; } } let totalScore = questions.reduce((sum, q) => sum + q.score, 0); for (let i = 0; i < 30; i++) { const diff = subject.totalScore - totalScore; if (diff === 0) break; if (questions.length === 0) break; const idx = Math.floor(Math.random() * questions.length); const base = questions[idx]; const fetched = await QuestionModel.getRandomQuestions(base.type as any, 30, [base.category]); const candidates = fetched.filter((q) => !questions.some((selectedQ) => selectedQ.id === q.id)); if (candidates.length === 0) continue; const currentBest = Math.abs(diff); let best = null as null | (typeof candidates)[number]; let bestAbs = currentBest; for (const cand of candidates) { const nextTotal = totalScore - base.score + cand.score; const abs = Math.abs(subject.totalScore - nextTotal); if (abs < bestAbs) { bestAbs = abs; best = cand; } } if (!best) continue; questions[idx] = best; totalScore = totalScore - base.score + best.score; } return { questions, totalScore, timeLimitMinutes: subject.timeLimitMinutes, }; } static async findAll(): Promise { const sql = ` SELECT id, name, type_ratios as typeRatios, category_ratios as categoryRatios, total_score as totalScore, duration_minutes as timeLimitMinutes, created_at as createdAt, updated_at as updatedAt FROM exam_subjects ORDER BY created_at DESC `; const rows: RawSubjectRow[] = await query(sql); return rows.map((row) => ({ id: row.id, name: row.name, totalScore: row.totalScore, timeLimitMinutes: row.timeLimitMinutes, typeRatios: parseJson>(row.typeRatios, { single: 40, multiple: 30, judgment: 20, text: 10 }), categoryRatios: parseJson>(row.categoryRatios, { 通用: 100 }), createdAt: row.createdAt, updatedAt: row.updatedAt })); } static async findById(id: string): Promise { const sql = ` SELECT id, name, type_ratios as typeRatios, category_ratios as categoryRatios, total_score as totalScore, duration_minutes as timeLimitMinutes, created_at as createdAt, updated_at as updatedAt FROM exam_subjects WHERE id = ? `; const row: RawSubjectRow | undefined = await get(sql, [id]); if (!row) return null; return { id: row.id, name: row.name, totalScore: row.totalScore, timeLimitMinutes: row.timeLimitMinutes, typeRatios: parseJson>(row.typeRatios, { single: 40, multiple: 30, judgment: 20, text: 10 }), categoryRatios: parseJson>(row.categoryRatios, { 通用: 100 }), createdAt: row.createdAt, updatedAt: row.updatedAt }; } static async create(data: { name: string; totalScore: number; timeLimitMinutes?: number; typeRatios: Record; categoryRatios?: Record; }): Promise { const name = data.name.trim(); if (!name) throw new Error('科目名称不能为空'); if (!data.totalScore || data.totalScore <= 0) throw new Error('总分必须为正整数'); const timeLimitMinutes = data.timeLimitMinutes ?? 60; if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)'); const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 }; const typeRatioMode = isRatioMode(data.typeRatios as any); const categoryRatioMode = isRatioMode(categoryRatios); if (typeRatioMode !== categoryRatioMode) { throw new Error('题型配置与题目类别配置必须同为比例或同为数量'); } if (typeRatioMode) { validateNonNegativeNumbers(data.typeRatios as any, '题型配置'); validateNonNegativeNumbers(categoryRatios, '题目类别配置'); } else { validateIntegerCounts(data.typeRatios as any, '题型数量配置'); validateIntegerCounts(categoryRatios, '题目类别数量配置'); const typeTotal = sumValues(data.typeRatios as any); const categoryTotal = sumValues(categoryRatios); if (typeTotal !== categoryTotal) { throw new Error('题型数量总和必须等于题目类别数量总和'); } } const id = uuidv4(); const sql = ` INSERT INTO exam_subjects ( id, name, type_ratios, category_ratios, total_score, duration_minutes, updated_at ) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) `; try { await run(sql, [ id, name, JSON.stringify(data.typeRatios), JSON.stringify(categoryRatios), data.totalScore, timeLimitMinutes ]); } catch (error: any) { if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { throw new Error('科目名称已存在'); } throw error; } return (await this.findById(id)) as ExamSubject; } static async update(id: string, data: { name: string; totalScore: number; timeLimitMinutes?: number; typeRatios: Record; categoryRatios?: Record; }): Promise { const existing = await this.findById(id); if (!existing) throw new Error('科目不存在'); const name = data.name.trim(); if (!name) throw new Error('科目名称不能为空'); if (!data.totalScore || data.totalScore <= 0) throw new Error('总分必须为正整数'); const timeLimitMinutes = data.timeLimitMinutes ?? 60; if (!Number.isInteger(timeLimitMinutes) || timeLimitMinutes <= 0) throw new Error('答题时间必须为正整数(分钟)'); const categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : { 通用: 100 }; const typeRatioMode = isRatioMode(data.typeRatios as any); const categoryRatioMode = isRatioMode(categoryRatios); if (typeRatioMode !== categoryRatioMode) { throw new Error('题型配置与题目类别配置必须同为比例或同为数量'); } if (typeRatioMode) { validateNonNegativeNumbers(data.typeRatios as any, '题型配置'); validateNonNegativeNumbers(categoryRatios, '题目类别配置'); } else { validateIntegerCounts(data.typeRatios as any, '题型数量配置'); validateIntegerCounts(categoryRatios, '题目类别数量配置'); const typeTotal = sumValues(data.typeRatios as any); const categoryTotal = sumValues(categoryRatios); if (typeTotal !== categoryTotal) { throw new Error('题型数量总和必须等于题目类别数量总和'); } } const sql = ` UPDATE exam_subjects SET name = ?, type_ratios = ?, category_ratios = ?, total_score = ?, duration_minutes = ?, updated_at = datetime('now') WHERE id = ? `; try { await run(sql, [ name, JSON.stringify(data.typeRatios), JSON.stringify(categoryRatios), data.totalScore, timeLimitMinutes, id ]); } catch (error: any) { if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') { throw new Error('科目名称已存在'); } throw error; } return (await this.findById(id)) as ExamSubject; } static async delete(id: string): Promise { const existing = await this.findById(id); if (!existing) throw new Error('科目不存在'); const taskCount = await get(`SELECT COUNT(*) as total FROM exam_tasks WHERE subject_id = ?`, [id]); if (taskCount && taskCount.total > 0) { throw new Error('该科目已被考试任务使用,无法删除'); } await run(`DELETE FROM exam_subjects WHERE id = ?`, [id]); } }