Files
Web_BLV_OA_Exam_Prod/api/models/question.ts

431 lines
13 KiB
TypeScript
Raw Normal View History

import { v4 as uuidv4 } from 'uuid';
import { query, run, get } from '../database';
export interface Question {
id: string;
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category: string;
options?: string[];
answer: string | string[];
analysis: string;
score: number;
createdAt: string;
}
export interface CreateQuestionData {
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
category?: string;
options?: string[];
answer: string | string[];
analysis?: string;
score: number;
}
export interface ExcelQuestionData {
content: string;
type: string;
category?: string;
answer: string;
analysis?: string;
score: number;
options?: string[];
}
export class QuestionModel {
// 创建题目
static async create(data: CreateQuestionData): Promise<Question> {
const id = uuidv4();
const optionsStr = data.options ? JSON.stringify(data.options) : null;
const answerStr = Array.isArray(data.answer) ? JSON.stringify(data.answer) : data.answer;
const category = data.category && data.category.trim() ? data.category.trim() : '通用';
const analysis = String(data.analysis ?? '').trim().slice(0, 255);
const sql = `
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
await run(sql, [id, data.content, data.type, optionsStr, answerStr, analysis, data.score, category]);
return this.findById(id) as Promise<Question>;
}
// 批量创建题目 - 优化为使用事务批量插入
static async createMany(questions: CreateQuestionData[]): Promise<{ success: number; errors: string[] }> {
const errors: string[] = [];
let success = 0;
// 使用事务提高性能
try {
// 开始事务
await run('BEGIN TRANSACTION');
const sql = `
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
for (let i = 0; i < questions.length; i++) {
try {
const question = questions[i];
const id = uuidv4();
const optionsStr = question.options ? JSON.stringify(question.options) : null;
const answerStr = Array.isArray(question.answer) ? JSON.stringify(question.answer) : question.answer;
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
// 直接执行插入不调用单个create方法
await run(sql, [id, question.content, question.type, optionsStr, answerStr, analysis, question.score, category]);
success++;
} catch (error: any) {
errors.push(`${i + 1}题: ${error.message}`);
}
}
// 提交事务
await run('COMMIT');
} catch (error: any) {
// 回滚事务
await run('ROLLBACK');
errors.push(`事务错误: ${error.message}`);
}
return { success, errors };
}
static async importFromText(
mode: 'overwrite' | 'incremental',
questions: CreateQuestionData[],
): Promise<{
inserted: number;
updated: number;
errors: string[];
cleared?: { questions: number; quizRecords: number; quizAnswers: number };
}> {
const errors: string[] = [];
let inserted = 0;
let updated = 0;
let cleared: { questions: number; quizRecords: number; quizAnswers: number } | undefined;
const insertSql = `
INSERT INTO questions (id, content, type, options, answer, analysis, score, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
await run('BEGIN TRANSACTION');
try {
if (mode === 'overwrite') {
const [qCount, rCount, aCount] = await Promise.all([
get(`SELECT COUNT(*) as total FROM questions`),
get(`SELECT COUNT(*) as total FROM quiz_records`),
get(`SELECT COUNT(*) as total FROM quiz_answers`),
]);
cleared = { questions: qCount.total, quizRecords: rCount.total, quizAnswers: aCount.total };
await run(`DELETE FROM quiz_answers`);
await run(`DELETE FROM quiz_records`);
await run(`DELETE FROM questions`);
}
for (let i = 0; i < questions.length; i++) {
const question = questions[i];
try {
const optionsStr = question.options ? JSON.stringify(question.options) : null;
const answerStr = Array.isArray(question.answer) ? JSON.stringify(question.answer) : question.answer;
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
if (mode === 'incremental') {
const existing = await get(`SELECT id FROM questions WHERE content = ?`, [question.content]);
if (existing?.id) {
await run(
`UPDATE questions SET content = ?, type = ?, options = ?, answer = ?, analysis = ?, score = ?, category = ? WHERE id = ?`,
[question.content, question.type, optionsStr, answerStr, analysis, question.score, category, existing.id],
);
updated++;
continue;
}
}
const id = uuidv4();
await run(insertSql, [id, question.content, question.type, optionsStr, answerStr, analysis, question.score, category]);
inserted++;
} catch (error: any) {
errors.push(`${i + 1}题: ${error.message}`);
}
}
await run('COMMIT');
} catch (error) {
await run('ROLLBACK');
throw error;
}
return { inserted, updated, errors, cleared };
}
// 根据ID查找题目
static async findById(id: string): Promise<Question | null> {
const sql = `SELECT * FROM questions WHERE id = ?`;
const question = await get(sql, [id]);
if (!question) return null;
return this.formatQuestion(question);
}
// 根据题目内容查找题目
static async findByContent(content: string): Promise<Question | null> {
const sql = `SELECT * FROM questions WHERE content = ?`;
const question = await get(sql, [content]);
if (!question) return null;
return this.formatQuestion(question);
}
// 获取题目列表(支持筛选和分页)
static async findAll(filters: {
type?: string;
category?: string;
keyword?: string;
startDate?: string;
endDate?: string;
limit?: number;
offset?: number;
} = {}): Promise<{ questions: Question[]; total: number }> {
const { type, category, keyword, startDate, endDate, limit = 10, offset = 0 } = filters;
const whereParts: string[] = [];
const params: any[] = [];
if (type) {
whereParts.push('type = ?');
params.push(type);
}
if (category) {
whereParts.push('category = ?');
params.push(category);
}
if (keyword) {
whereParts.push('content LIKE ?');
params.push(`%${keyword}%`);
}
if (startDate) {
whereParts.push('created_at >= ?');
params.push(`${startDate} 00:00:00`);
}
if (endDate) {
whereParts.push('created_at <= ?');
params.push(`${endDate} 23:59:59`);
}
const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
const questionsSql = `
SELECT * FROM questions
${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`;
const countSql = `
SELECT COUNT(*) as total FROM questions ${whereClause}
`;
const [questions, countResult] = await Promise.all([
query(questionsSql, [...params, limit, offset]),
get(countSql, params)
]);
return {
questions: questions.map((q) => this.formatQuestion(q)),
total: countResult.total
};
}
// 随机获取题目(按类型和数量)
static async getRandomQuestions(type: string, count: number, categories?: string[]): Promise<Question[]> {
const whereParts: string[] = ['type = ?'];
const params: any[] = [type];
if (categories && categories.length > 0) {
whereParts.push(`category IN (${categories.map(() => '?').join(',')})`);
params.push(...categories);
}
const sql = `
SELECT * FROM questions
WHERE ${whereParts.join(' AND ')}
ORDER BY RANDOM()
LIMIT ?
`;
const questions = await query(sql, [...params, count]);
return questions.map((q) => this.formatQuestion(q));
}
// 更新题目
static async update(id: string, data: Partial<CreateQuestionData>): Promise<Question> {
const fields: string[] = [];
const values: any[] = [];
if (data.content) {
fields.push('content = ?');
values.push(data.content);
}
if (data.type) {
fields.push('type = ?');
values.push(data.type);
}
if (data.options !== undefined) {
fields.push('options = ?');
values.push(data.options ? JSON.stringify(data.options) : null);
}
if (data.answer !== undefined) {
const answerStr = Array.isArray(data.answer) ? JSON.stringify(data.answer) : data.answer;
fields.push('answer = ?');
values.push(answerStr);
}
if (data.analysis !== undefined) {
fields.push('analysis = ?');
values.push(String(data.analysis ?? '').trim().slice(0, 255));
}
if (data.score !== undefined) {
fields.push('score = ?');
values.push(data.score);
}
if (data.category !== undefined) {
fields.push('category = ?');
values.push(data.category && data.category.trim() ? data.category.trim() : '通用');
}
if (fields.length === 0) {
throw new Error('没有要更新的字段');
}
values.push(id);
const sql = `UPDATE questions SET ${fields.join(', ')} WHERE id = ?`;
await run(sql, values);
return this.findById(id) as Promise<Question>;
}
// 删除题目
static async delete(id: string): Promise<boolean> {
const sql = `DELETE FROM questions WHERE id = ?`;
const result = await run(sql, [id]);
return result.id !== undefined;
}
// 格式化题目数据
private static formatQuestion(row: any): Question {
return {
id: row.id,
content: row.content,
type: row.type,
category: row.category || '通用',
options: row.options ? JSON.parse(row.options) : undefined,
answer: this.parseAnswer(row.answer, row.type),
analysis: String(row.analysis ?? ''),
score: row.score,
createdAt: row.created_at
};
}
// 解析答案
private static parseAnswer(answerStr: string, type: string): string | string[] {
if (type === 'multiple') {
try {
return JSON.parse(answerStr);
} catch {
return answerStr;
}
}
return answerStr;
}
// 验证题目数据
static validateQuestionData(data: CreateQuestionData): string[] {
const errors: string[] = [];
// 验证题目内容
if (!data.content || data.content.trim().length === 0) {
errors.push('题目内容不能为空');
}
// 验证题型
const validTypes = ['single', 'multiple', 'judgment', 'text'];
if (!validTypes.includes(data.type)) {
errors.push('题型必须是 single、multiple、judgment 或 text');
}
// 验证选项
if (data.type === 'single' || data.type === 'multiple') {
if (!data.options || data.options.length < 2) {
errors.push('单选题和多选题必须至少包含2个选项');
}
}
// 验证答案
if (!data.answer) {
errors.push('答案不能为空');
}
// 验证分值
if (!data.score || data.score <= 0) {
errors.push('分值必须是正数');
}
if (data.category !== undefined && data.category.trim().length === 0) {
errors.push('题目类别不能为空');
}
if (data.analysis !== undefined && String(data.analysis).length > 255) {
errors.push('解析长度不能超过255个字符');
}
return errors;
}
// 验证Excel数据格式
static validateExcelData(data: ExcelQuestionData[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
data.forEach((row, index) => {
if (!row.content) {
errors.push(`${index + 1}行:题目内容不能为空`);
}
const validTypes = ['single', 'multiple', 'judgment', 'text'];
if (!validTypes.includes(row.type)) {
errors.push(`${index + 1}行:题型必须是 single、multiple、judgment 或 text`);
}
if (!row.answer) {
errors.push(`${index + 1}行:答案不能为空`);
}
if (!row.score || row.score <= 0) {
errors.push(`${index + 1}行:分值必须是正数`);
}
});
return {
valid: errors.length === 0,
errors
};
}
}