feat: 修改部分导入文本的逻辑,添加部署脚本和样式文件,更新数据库迁移逻辑
- 新增部署脚本 `build-deploy-bundle.mjs`,用于构建和部署 web 和 server 目录。 - 新增样式文件 `index-acd65452.css`,包含基础样式和响应式设计。 - 新增脚本 `repro-import-text.mjs`,用于测试文本导入 API。 - 新增测试文件 `db-migration-score-zero.test.ts`,验证历史数据库中 questions.score 约束的迁移逻辑。 - 更新数据库初始化逻辑,允许插入 score=0 的问题。
This commit is contained in:
@@ -96,6 +96,18 @@ export class QuizController {
|
||||
|
||||
totalPossibleScore += Number(question.score) || 0;
|
||||
|
||||
if (answer.userAnswer === undefined || answer.userAnswer === null) {
|
||||
answer.userAnswer = '';
|
||||
}
|
||||
|
||||
// 规则:分值为0的题目不判定正误,不要求答案,默认正确
|
||||
if (Number(question.score) === 0) {
|
||||
answer.score = 0;
|
||||
answer.isCorrect = true;
|
||||
processedAnswers.push(answer);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (question.type === 'multiple') {
|
||||
const optionCount = question.options ? question.options.length : 0;
|
||||
const unitScore = optionCount > 0 ? question.score / optionCount : 0;
|
||||
|
||||
@@ -164,9 +164,84 @@ const ensureUserGroupSchemaAndAllUsersMembership = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getTableCreateSql = async (tableName: string): Promise<string> => {
|
||||
const row = await get(
|
||||
`SELECT sql FROM sqlite_master WHERE type='table' AND name=? LIMIT 1`,
|
||||
[tableName],
|
||||
);
|
||||
return (row?.sql as string | undefined) ?? '';
|
||||
};
|
||||
|
||||
const tableSqlHasScoreGreaterThanZeroCheck = (tableSql: string): boolean => {
|
||||
if (!tableSql) return false;
|
||||
// 兼容不同空白/大小写写法:CHECK(score > 0) / CHECK ( score>0 )
|
||||
return /check\s*\(\s*score\s*>\s*0\s*\)/i.test(tableSql);
|
||||
};
|
||||
|
||||
const migrateQuestionsScoreCheckToAllowZero = async () => {
|
||||
const questionsSql = await getTableCreateSql('questions');
|
||||
if (!tableSqlHasScoreGreaterThanZeroCheck(questionsSql)) return;
|
||||
|
||||
console.log('检测到旧表约束:questions.score CHECK(score > 0),开始迁移为 >= 0');
|
||||
|
||||
// 迁移方式:重建 questions 表(SQLite 不支持直接修改 CHECK 约束)
|
||||
// 注意:questions 被 quiz_answers 外键引用,因此迁移期间临时关闭 foreign_keys。
|
||||
await exec('PRAGMA foreign_keys = OFF;');
|
||||
try {
|
||||
await exec('BEGIN TRANSACTION;');
|
||||
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS questions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('single', 'multiple', 'judgment', 'text')),
|
||||
options TEXT,
|
||||
answer TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER NOT NULL CHECK(score >= 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await exec(`
|
||||
INSERT INTO questions_new (id, content, type, options, answer, analysis, score, category, created_at)
|
||||
SELECT
|
||||
id,
|
||||
content,
|
||||
type,
|
||||
options,
|
||||
COALESCE(answer, ''),
|
||||
COALESCE(analysis, ''),
|
||||
score,
|
||||
COALESCE(category, '通用'),
|
||||
created_at
|
||||
FROM questions;
|
||||
`);
|
||||
|
||||
await exec('DROP TABLE questions;');
|
||||
await exec('ALTER TABLE questions_new RENAME TO questions;');
|
||||
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_questions_type ON questions(type);');
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_questions_score ON questions(score);');
|
||||
await exec('CREATE INDEX IF NOT EXISTS idx_questions_category ON questions(category);');
|
||||
|
||||
await exec('COMMIT;');
|
||||
console.log('questions 表迁移完成:score 允许 0 分');
|
||||
} catch (error) {
|
||||
try {
|
||||
await exec('ROLLBACK;');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await exec('PRAGMA foreign_keys = ON;');
|
||||
}
|
||||
};
|
||||
|
||||
const migrateDatabase = async () => {
|
||||
// 跳过迁移,因为数据库连接可能未初始化
|
||||
console.log('跳过数据库迁移');
|
||||
await migrateQuestionsScoreCheckToAllowZero();
|
||||
};
|
||||
|
||||
// 数据库初始化函数
|
||||
@@ -194,6 +269,9 @@ export const initDatabase = async () => {
|
||||
await ensureColumn('quiz_records', "score_percentage REAL", 'score_percentage');
|
||||
await ensureColumn('quiz_records', "status TEXT", 'status');
|
||||
|
||||
// 兼容历史数据库:迁移无法通过 init.sql 修复的约束/结构
|
||||
await migrateDatabase();
|
||||
|
||||
// 用户组(含“全体用户”系统组)
|
||||
await ensureUserGroupSchemaAndAllUsersMembership();
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ CREATE TABLE questions (
|
||||
options TEXT, -- JSON格式存储选项
|
||||
answer TEXT NOT NULL,
|
||||
analysis TEXT NOT NULL DEFAULT '',
|
||||
score INTEGER NOT NULL CHECK(score > 0),
|
||||
score INTEGER NOT NULL CHECK(score >= 0),
|
||||
category TEXT NOT NULL DEFAULT '通用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -306,7 +306,7 @@ export class ExamSubjectModel {
|
||||
judgment: 20,
|
||||
text: 10
|
||||
}),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, {}),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
}));
|
||||
@@ -341,7 +341,7 @@ export class ExamSubjectModel {
|
||||
judgment: 20,
|
||||
text: 10
|
||||
}),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, { 通用: 100 }),
|
||||
categoryRatios: parseJson<Record<string, number>>(row.categoryRatios, {}),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt
|
||||
};
|
||||
@@ -361,7 +361,7 @@ export class ExamSubjectModel {
|
||||
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 categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : {};
|
||||
const typeRatioMode = isRatioMode(data.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
@@ -423,7 +423,7 @@ export class ExamSubjectModel {
|
||||
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 categoryRatios = data.categoryRatios && Object.keys(data.categoryRatios).length > 0 ? data.categoryRatios : {};
|
||||
const typeRatioMode = isRatioMode(data.typeRatios as any);
|
||||
const categoryRatioMode = isRatioMode(categoryRatios);
|
||||
if (typeRatioMode !== categoryRatioMode) {
|
||||
|
||||
@@ -51,11 +51,12 @@ export class QuestionModel {
|
||||
static async create(data: CreateQuestionData): Promise<Question> {
|
||||
const id = uuidv4();
|
||||
const optionsStr = data.options ? JSON.stringify(data.options) : null;
|
||||
const rawAnswer = (data as any).answer;
|
||||
const normalizedAnswer =
|
||||
data.type === 'judgment' && !Array.isArray(data.answer)
|
||||
? this.normalizeJudgmentAnswer(data.answer)
|
||||
: data.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
data.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
const category = data.category && data.category.trim() ? data.category.trim() : '通用';
|
||||
const analysis = String(data.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
@@ -88,11 +89,12 @@ export class QuestionModel {
|
||||
const question = questions[i];
|
||||
const id = uuidv4();
|
||||
const optionsStr = question.options ? JSON.stringify(question.options) : null;
|
||||
const rawAnswer = (question as any).answer;
|
||||
const normalizedAnswer =
|
||||
question.type === 'judgment' && !Array.isArray(question.answer)
|
||||
? this.normalizeJudgmentAnswer(question.answer)
|
||||
: question.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
question.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
|
||||
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
@@ -153,11 +155,12 @@ export class QuestionModel {
|
||||
const question = questions[i];
|
||||
try {
|
||||
const optionsStr = question.options ? JSON.stringify(question.options) : null;
|
||||
const rawAnswer = (question as any).answer;
|
||||
const normalizedAnswer =
|
||||
question.type === 'judgment' && !Array.isArray(question.answer)
|
||||
? this.normalizeJudgmentAnswer(question.answer)
|
||||
: question.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
question.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
const category = question.category && question.category.trim() ? question.category.trim() : '通用';
|
||||
const analysis = String(question.analysis ?? '').trim().slice(0, 255);
|
||||
|
||||
@@ -316,11 +319,12 @@ export class QuestionModel {
|
||||
}
|
||||
|
||||
if (data.answer !== undefined) {
|
||||
const rawAnswer = (data as any).answer;
|
||||
const normalizedAnswer =
|
||||
data.type === 'judgment' && !Array.isArray(data.answer)
|
||||
? this.normalizeJudgmentAnswer(data.answer)
|
||||
: data.answer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : normalizedAnswer;
|
||||
data.type === 'judgment' && !Array.isArray(rawAnswer)
|
||||
? this.normalizeJudgmentAnswer(rawAnswer)
|
||||
: rawAnswer;
|
||||
const answerStr = Array.isArray(normalizedAnswer) ? JSON.stringify(normalizedAnswer) : String(normalizedAnswer ?? '');
|
||||
fields.push('answer = ?');
|
||||
values.push(answerStr);
|
||||
}
|
||||
@@ -411,13 +415,20 @@ export class QuestionModel {
|
||||
}
|
||||
|
||||
// 验证答案
|
||||
if (!data.answer) {
|
||||
errors.push('答案不能为空');
|
||||
const score = Number((data as any).score);
|
||||
const allowEmptyAnswer = Number.isFinite(score) && score === 0;
|
||||
if (!allowEmptyAnswer) {
|
||||
const ans = (data as any).answer;
|
||||
const isEmptyArray = Array.isArray(ans) && ans.length === 0;
|
||||
const isEmptyString = typeof ans === 'string' && ans.trim().length === 0;
|
||||
if (ans === undefined || ans === null || isEmptyArray || isEmptyString) {
|
||||
errors.push('答案不能为空');
|
||||
}
|
||||
}
|
||||
|
||||
// 验证分值
|
||||
if (!data.score || data.score <= 0) {
|
||||
errors.push('分值必须是正数');
|
||||
if (!Number.isFinite(score) || score < 0) {
|
||||
errors.push('分值必须是非负数');
|
||||
}
|
||||
|
||||
if (data.category !== undefined && data.category.trim().length === 0) {
|
||||
@@ -445,12 +456,16 @@ export class QuestionModel {
|
||||
errors.push(`第${index + 1}行:题型必须是 single、multiple、judgment 或 text`);
|
||||
}
|
||||
|
||||
if (!row.answer) {
|
||||
const score = Number((row as any).score);
|
||||
const allowEmptyAnswer = Number.isFinite(score) && score === 0;
|
||||
const ans = (row as any).answer;
|
||||
const isEmptyString = typeof ans === 'string' && ans.trim().length === 0;
|
||||
if (!allowEmptyAnswer && (ans === undefined || ans === null || isEmptyString)) {
|
||||
errors.push(`第${index + 1}行:答案不能为空`);
|
||||
}
|
||||
|
||||
if (!row.score || row.score <= 0) {
|
||||
errors.push(`第${index + 1}行:分值必须是正数`);
|
||||
if (!Number.isFinite(score) || score < 0) {
|
||||
errors.push(`第${index + 1}行:分值必须是非负数`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,11 @@ export class QuizModel {
|
||||
END
|
||||
ELSE r.status
|
||||
END as status,
|
||||
r.created_at as createdAt
|
||||
r.created_at as createdAt,
|
||||
COALESCE(r.subject_id, t.subject_id) as subjectId,
|
||||
COALESCE(s.name, ts.name) as subjectName,
|
||||
r.task_id as taskId,
|
||||
t.name as taskName
|
||||
FROM quiz_records r
|
||||
LEFT JOIN (
|
||||
SELECT a.record_id as recordId, SUM(q.score) as totalPossibleScore
|
||||
@@ -162,6 +166,9 @@ export class QuizModel {
|
||||
JOIN questions q ON a.question_id = q.id
|
||||
GROUP BY a.record_id
|
||||
) totals ON totals.recordId = r.id
|
||||
LEFT JOIN exam_tasks t ON r.task_id = t.id
|
||||
LEFT JOIN exam_subjects s ON r.subject_id = s.id
|
||||
LEFT JOIN exam_subjects ts ON t.subject_id = ts.id
|
||||
WHERE r.id = ?
|
||||
`;
|
||||
const record = await get(sql, [id]);
|
||||
|
||||
Reference in New Issue
Block a user