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:
2026-01-04 09:20:04 +08:00
parent fbfd48e0ca
commit dbf9fdc01c
26 changed files with 1420 additions and 849 deletions

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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
);

View File

@@ -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) {

View File

@@ -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}行:分值必须是非负`);
}
});

View File

@@ -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]);