Files

477 lines
17 KiB
TypeScript
Raw Permalink Normal View History

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<QuestionType, number>;
categoryRatios: Record<string, number>;
createdAt: string;
updatedAt: string;
}
type RawSubjectRow = {
id: string;
name: string;
typeRatios: string;
categoryRatios: string;
totalScore: number;
timeLimitMinutes: number;
createdAt: string;
updatedAt: string;
};
const parseJson = <T>(value: string, fallback: T): T => {
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
};
2025-12-26 18:39:17 +08:00
const validateNonNegativeNumbers = (valuesMap: Record<string, number>, 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}必须是非负数字`);
}
2025-12-26 18:39:17 +08:00
if (!values.some((v) => v > 0)) {
throw new Error(`${label}至少需要一个大于0的配置`);
}
};
2025-12-26 18:39:17 +08:00
const validateIntegerCounts = (valuesMap: Record<string, number>, label: string) => {
validateNonNegativeNumbers(valuesMap, label);
if (Object.values(valuesMap).some((v) => !Number.isInteger(v))) {
throw new Error(`${label}必须为整数`);
}
};
const sumValues = (valuesMap: Record<string, number>) => Object.values(valuesMap).reduce((a, b) => a + b, 0);
const isRatioMode = (valuesMap: Record<string, number>) => {
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 {
2025-12-26 18:39:17 +08:00
static async generateQuizQuestions(subject: ExamSubject): Promise<{
questions: Awaited<ReturnType<typeof import('./question').QuestionModel.getRandomQuestions>>;
totalScore: number;
timeLimitMinutes: number;
}> {
const { QuestionModel } = await import('./question');
let questions: Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
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<ReturnType<typeof QuestionModel.getRandomQuestions>> = [];
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<string, number>();
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<string>) => {
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<string>();
let selected = null as null | Awaited<ReturnType<typeof QuestionModel.getRandomQuestions>>[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<ExamSubject[]> {
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<Record<QuestionType, number>>(row.typeRatios, {
single: 40,
multiple: 30,
judgment: 20,
text: 10
}),
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
createdAt: row.createdAt,
updatedAt: row.updatedAt
}));
}
static async findById(id: string): Promise<ExamSubject | null> {
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<Record<QuestionType, number>>(row.typeRatios, {
single: 40,
multiple: 30,
judgment: 20,
text: 10
}),
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
createdAt: row.createdAt,
updatedAt: row.updatedAt
};
}
static async create(data: {
name: string;
totalScore: number;
timeLimitMinutes?: number;
typeRatios: Record<QuestionType, number>;
categoryRatios?: Record<string, number>;
}): Promise<ExamSubject> {
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 };
2025-12-26 18:39:17 +08:00
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<QuestionType, number>;
categoryRatios?: Record<string, number>;
}): Promise<ExamSubject> {
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 };
2025-12-26 18:39:17 +08:00
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<void> {
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]);
}
}