Files
Web_BLV_OA_Exam_Prod/api/models/question.ts

431 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}
}