chore: 后端首次基线提交

运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。
已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
This commit is contained in:
WorkBuddy
2026-08-17 12:25:42 +08:00
commit a303cf2d53
109 changed files with 25904 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('accounts', {
uid: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '用户唯一标识',
},
role: {
type: Sequelize.STRING(32),
allowNull: false,
defaultValue: 'student',
comment: '角色:student/teacher/judge/admin',
},
name_cn: {
type: Sequelize.STRING(64),
allowNull: false,
comment: '中文姓名',
},
name_en: {
type: Sequelize.STRING(128),
allowNull: true,
comment: '英文姓名/拼音',
},
gender: {
type: Sequelize.STRING(8),
allowNull: true,
comment: '性别:男/女',
},
phone: {
type: Sequelize.STRING(32),
allowNull: true,
comment: '手机号码',
},
email: {
type: Sequelize.STRING(128),
allowNull: true,
comment: '电子邮箱',
},
id_number: {
type: Sequelize.STRING(32),
allowNull: false,
unique: true,
comment: '身份证号(唯一)',
},
password: {
type: Sequelize.STRING(128),
allowNull: false,
comment: '密码(加密存储)',
},
salt: {
type: Sequelize.STRING(64),
allowNull: false,
comment: '密码盐值',
},
position: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '职务/职称',
},
major: {
type: Sequelize.STRING(128),
allowNull: true,
comment: '专业',
},
country: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '国家',
},
region: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '地区/省份',
},
organization_cn: {
type: Sequelize.STRING(255),
allowNull: true,
comment: '所属单位(中文)',
},
organization_en: {
type: Sequelize.STRING(255),
allowNull: true,
comment: '所属单位(英文)',
},
activated: {
type: Sequelize.TINYINT,
allowNull: false,
defaultValue: 0,
comment: '激活状态:0未激活 1已激活',
},
score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '总分/积分',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('accounts', ['role'], { name: 'idx_role' }),
queryInterface.addIndex('accounts', ['phone'], { name: 'idx_phone' }),
queryInterface.addIndex('accounts', ['name_cn'], { name: 'idx_name_cn' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('accounts');
},
};

View File

@@ -0,0 +1,105 @@
'use strict';
/**
* 迁移文件:创建选手表(contestants)
* 包含选手基本信息、身份认证、组别和成绩字段
*/
module.exports = {
/**
* 执行迁移:创建 contestants 表
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize').Sequelize} Sequelize
*/
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, BOOLEAN, DATE } = Sequelize;
await queryInterface.createTable('contestants', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '选手唯一标识',
},
// 身份证号,唯一,18位
id_number: {
type: STRING(18),
allowNull: false,
unique: true,
comment: '身份证号',
},
// 选手姓名
name: {
type: STRING(50),
allowNull: false,
comment: '选手姓名',
},
// 手机号码
phone: {
type: STRING(20),
allowNull: false,
comment: '手机号码',
},
// 参赛组别
group: {
type: STRING(20),
allowNull: false,
comment: '参赛组别(zhongzhi/gaozhi/benke/jiaoshi)',
},
// 登录密码
password: {
type: STRING(100),
allowNull: false,
defaultValue: '',
comment: '登录密码',
},
// 账号激活状态
activated: {
type: BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '账号激活状态',
},
// 总分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '选手总分',
},
// 创建时间
created_at: {
type: DATE,
allowNull: false,
comment: '创建时间',
},
// 更新时间
updated_at: {
type: DATE,
allowNull: false,
comment: '更新时间',
},
});
// 为 id_number 创建唯一索引
await queryInterface.addIndex('contestants', ['id_number'], {
unique: true,
name: 'idx_contestants_id_number',
});
// 为 group 创建索引
await queryInterface.addIndex('contestants', ['group'], {
name: 'idx_contestants_group',
});
},
/**
* 回滚迁移:删除 contestants 表
* @param {import('sequelize').QueryInterface} queryInterface
*/
down: async (queryInterface) => {
await queryInterface.dropTable('contestants');
},
};

View File

@@ -0,0 +1,61 @@
'use strict';
/**
* 迁移文件:创建管理员表(admins)
* 存储后台管理员账号信息
*/
module.exports = {
/**
* 执行迁移:创建 admins 表
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize').Sequelize} Sequelize
*/
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, DATE } = Sequelize;
await queryInterface.createTable('admins', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '管理员唯一标识',
},
username: {
type: STRING(50),
allowNull: false,
unique: true,
comment: '管理员用户名',
},
password: {
type: STRING(100),
allowNull: false,
comment: '管理员密码',
},
created_at: {
type: DATE,
allowNull: false,
comment: '创建时间',
},
updated_at: {
type: DATE,
allowNull: false,
comment: '更新时间',
},
});
await queryInterface.addIndex('admins', ['username'], {
unique: true,
name: 'idx_admins_username',
});
},
/**
* 回滚迁移:删除 admins 表
* @param {import('sequelize').QueryInterface} queryInterface
*/
down: async (queryInterface) => {
await queryInterface.dropTable('admins');
},
};

View File

@@ -0,0 +1,56 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('teams', {
team_uid: {
type: Sequelize.INTEGER,
primaryKey: true,
// 注意:运行库中 teams 表真实自增主键为 id,team_uid 为非自增普通列,
// 由应用层(importFromExcel 显式 MAX+1)赋值。故此处不声明 autoIncrement,
// 使迁移与运行库一致,避免新建库时 team_uid 被设为自增而与运行库行为背离。
allowNull: false,
comment: '团队唯一标识(由应用层显式生成,非 DB 自增)',
},
group_name: {
type: Sequelize.STRING(64),
allowNull: false,
comment: '组别名称',
},
team_id: {
type: Sequelize.STRING(64),
allowNull: false,
unique: true,
comment: '团队编号(唯一)',
},
team_number: {
type: Sequelize.STRING(32),
allowNull: true,
comment: '团队序号',
},
address: {
type: Sequelize.STRING(255),
allowNull: true,
comment: '地址',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return queryInterface.addIndex('teams', ['group_name'], { name: 'idx_group_name' });
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('teams');
},
};

View File

@@ -0,0 +1,42 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('competition_info', {
competition_uid: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '赛事唯一标识',
},
competition_name: {
type: Sequelize.STRING(128),
allowNull: false,
comment: '赛事名称',
},
competition_code: {
type: Sequelize.STRING(64),
allowNull: false,
unique: true,
comment: '赛事编码(唯一)',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('competition_info');
},
};

View File

@@ -0,0 +1,94 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('aggregation', {
aggregation_uid: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '报名记录唯一标识',
},
competition_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '赛事ID(关联competition_info)',
references: { model: 'competition_info', key: 'competition_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
team_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '团队ID(关联teams)',
references: { model: 'teams', key: 'team_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
account_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '用户ID(关联accounts)',
references: { model: 'accounts', key: 'uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
identity: {
type: Sequelize.STRING(32),
allowNull: true,
comment: '身份标识',
},
is_registrant: {
type: Sequelize.TINYINT,
allowNull: false,
defaultValue: 0,
comment: '是否为主要报名人:0否 1是',
},
registration_form: {
type: Sequelize.STRING(512),
allowNull: true,
comment: '报名表文件路径',
},
stamped_form: {
type: Sequelize.STRING(512),
allowNull: true,
comment: '盖章报名表文件路径',
},
audit_status: {
type: Sequelize.STRING(32),
allowNull: false,
defaultValue: 'pending',
comment: '审核状态:pending/approved/rejected',
},
reject_reason: {
type: Sequelize.STRING(512),
allowNull: true,
comment: '驳回原因',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('aggregation', ['competition_uid'], { name: 'idx_competition_uid' }),
queryInterface.addIndex('aggregation', ['team_uid'], { name: 'idx_team_uid' }),
queryInterface.addIndex('aggregation', ['account_uid'], { name: 'idx_account_uid' }),
queryInterface.addIndex('aggregation', ['audit_status'], { name: 'idx_audit_status' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('aggregation');
},
};

View File

@@ -0,0 +1,69 @@
'use strict';
/**
* 迁移文件:创建试卷题目表(exam_questions)
* 存储各组别的试卷题目
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, TEXT, DATE } = Sequelize;
await queryInterface.createTable('exam_questions', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '题目唯一标识',
},
group: {
type: STRING(20),
allowNull: false,
comment: '题目所属组别',
},
title: {
type: STRING(200),
allowNull: false,
comment: '题目标题',
},
content: {
type: TEXT,
allowNull: false,
comment: '题目内容描述',
},
score: {
type: INTEGER,
allowNull: false,
comment: '题目分值',
},
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
created_at: {
type: DATE,
allowNull: false,
comment: '创建时间',
},
updated_at: {
type: DATE,
allowNull: false,
comment: '更新时间',
},
});
await queryInterface.addIndex('exam_questions', ['group'], {
name: 'idx_exam_questions_group',
});
await queryInterface.addIndex('exam_questions', ['group', 'sort_order'], {
name: 'idx_exam_questions_group_sort',
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('exam_questions');
},
};

View File

@@ -0,0 +1,67 @@
'use strict';
/**
* 迁移文件:创建答题记录表(exam_submissions)
* 存储选手的试卷答题记录
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER, TEXT, DATE } = Sequelize;
await queryInterface.createTable('exam_submissions', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '答题记录唯一标识',
},
contestant_id: {
type: INTEGER,
allowNull: false,
comment: '选手 ID',
references: {
model: 'contestants',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
answers: {
type: TEXT,
allowNull: false,
comment: '答题内容(JSON)',
},
submit_time: {
type: DATE,
allowNull: false,
comment: '提交时间',
},
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '试卷得分',
},
created_at: {
type: DATE,
allowNull: false,
comment: '创建时间',
},
updated_at: {
type: DATE,
allowNull: false,
comment: '更新时间',
},
});
await queryInterface.addIndex('exam_submissions', ['contestant_id'], {
name: 'idx_exam_submissions_contestant_id',
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('exam_submissions');
},
};

View File

@@ -0,0 +1,68 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('news', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '新闻ID',
},
title: {
type: Sequelize.STRING(255),
allowNull: false,
comment: '新闻标题',
},
content: {
type: Sequelize.TEXT('long'),
allowNull: true,
comment: '新闻内容(富文本)',
},
wx_link: {
type: Sequelize.STRING(512),
allowNull: true,
comment: '微信链接',
},
publish_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '发布时间',
},
pinned: {
type: Sequelize.TINYINT,
allowNull: false,
defaultValue: 0,
comment: '是否置顶:0否 1是',
},
group_name: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '目标组别',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('news', ['publish_time'], { name: 'idx_publish_time' }),
queryInterface.addIndex('news', ['pinned', 'publish_time'], { name: 'idx_pinned_publish_time' }),
queryInterface.addIndex('news', ['group_name'], { name: 'idx_group_name' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('news');
},
};

View File

@@ -0,0 +1,58 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('carousel', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '轮播图ID',
},
image_url: {
type: Sequelize.STRING(512),
allowNull: false,
comment: '图片URL',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
link_url: {
type: Sequelize.STRING(512),
allowNull: true,
comment: '点击跳转链接',
},
enabled: {
type: Sequelize.TINYINT,
allowNull: false,
defaultValue: 1,
comment: '是否启用:0禁用 1启用',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('carousel', ['sort_order'], { name: 'idx_sort_order' }),
queryInterface.addIndex('carousel', ['enabled'], { name: 'idx_enabled' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('carousel');
},
};

View File

@@ -0,0 +1,85 @@
'use strict';
/**
* 迁移文件:创建作品提交表(work_submissions)
* 存储选手上传的作品文件信息
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, DATE } = Sequelize;
await queryInterface.createTable('work_submissions', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '作品提交唯一标识',
},
contestant_id: {
type: INTEGER,
allowNull: false,
comment: '选手 ID',
references: {
model: 'contestants',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
file_type: {
type: STRING(20),
allowNull: false,
comment: '文件类型(plan/ppt)',
},
file_name: {
type: STRING(200),
allowNull: false,
comment: '原始文件名',
},
file_path: {
type: STRING(500),
allowNull: false,
comment: '文件存储路径',
},
file_size: {
type: INTEGER,
allowNull: false,
comment: '文件大小(字节)',
},
submit_time: {
type: DATE,
allowNull: false,
comment: '提交时间',
},
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '作品得分',
},
created_at: {
type: DATE,
allowNull: false,
comment: '创建时间',
},
updated_at: {
type: DATE,
allowNull: false,
comment: '更新时间',
},
});
await queryInterface.addIndex('work_submissions', ['contestant_id'], {
name: 'idx_work_submissions_contestant_id',
});
await queryInterface.addIndex('work_submissions', ['file_type'], {
name: 'idx_work_submissions_file_type',
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('work_submissions');
},
};

View File

@@ -0,0 +1,69 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('zhongzhi_questions', {
question_uid: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '题目唯一标识',
},
question: {
type: Sequelize.TEXT,
allowNull: false,
comment: '题目内容',
},
answer: {
type: Sequelize.STRING(32),
allowNull: false,
comment: '正确答案',
},
question_type: {
type: Sequelize.STRING(16),
allowNull: false,
defaultValue: 'choice',
comment: '题目类型:choice选择题 judge判断题',
},
options: {
type: Sequelize.JSON,
allowNull: true,
comment: '选项(JSON格式)',
},
score: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '题目分值',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('zhongzhi_questions', ['question_type'], { name: 'idx_question_type' }),
queryInterface.addIndex('zhongzhi_questions', ['sort_order'], { name: 'idx_sort_order' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('zhongzhi_questions');
},
};

View File

@@ -0,0 +1,69 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('gaozhi_questions', {
question_uid: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '题目唯一标识',
},
question: {
type: Sequelize.TEXT,
allowNull: false,
comment: '题目内容',
},
answer: {
type: Sequelize.STRING(32),
allowNull: false,
comment: '正确答案',
},
question_type: {
type: Sequelize.STRING(16),
allowNull: false,
defaultValue: 'choice',
comment: '题目类型:choice选择题 judge判断题',
},
options: {
type: Sequelize.JSON,
allowNull: true,
comment: '选项(JSON格式)',
},
score: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '题目分值',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('gaozhi_questions', ['question_type'], { name: 'idx_question_type' }),
queryInterface.addIndex('gaozhi_questions', ['sort_order'], { name: 'idx_sort_order' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('gaozhi_questions');
},
};

View File

@@ -0,0 +1,82 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('zhongzhi_submissions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '提交记录ID',
},
aggregation_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '报名记录ID(关联aggregation)',
references: { model: 'aggregation', key: 'aggregation_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
question_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '题目ID(关联zhongzhi_questions)',
references: { model: 'zhongzhi_questions', key: 'question_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
answer_data: {
type: Sequelize.STRING(255),
allowNull: true,
comment: '提交的答案',
},
is_correct: {
type: Sequelize.TINYINT,
allowNull: true,
comment: '是否正确:0错误 1正确',
},
score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '得分',
},
word_file_path: {
type: Sequelize.STRING(512),
allowNull: true,
comment: 'Word文件路径',
},
word_file_name: {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Word文件原始名称',
},
word_file_md5: {
type: Sequelize.STRING(64),
allowNull: true,
comment: 'Word文件MD5哈希值',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('zhongzhi_submissions', ['aggregation_uid'], { name: 'idx_aggregation_uid' }),
queryInterface.addIndex('zhongzhi_submissions', ['question_uid'], { name: 'idx_question_uid' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('zhongzhi_submissions');
},
};

View File

@@ -0,0 +1,82 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('gaozhi_submissions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '提交记录ID',
},
aggregation_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '报名记录ID(关联aggregation)',
references: { model: 'aggregation', key: 'aggregation_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
question_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '题目ID(关联gaozhi_questions)',
references: { model: 'gaozhi_questions', key: 'question_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
answer_data: {
type: Sequelize.STRING(255),
allowNull: true,
comment: '提交的答案',
},
is_correct: {
type: Sequelize.TINYINT,
allowNull: true,
comment: '是否正确:0错误 1正确',
},
score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '得分',
},
word_file_path: {
type: Sequelize.STRING(512),
allowNull: true,
comment: 'Word文件路径',
},
word_file_name: {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Word文件原始名称',
},
word_file_md5: {
type: Sequelize.STRING(64),
allowNull: true,
comment: 'Word文件MD5哈希值',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('gaozhi_submissions', ['aggregation_uid'], { name: 'idx_aggregation_uid' }),
queryInterface.addIndex('gaozhi_submissions', ['question_uid'], { name: 'idx_question_uid' }),
]);
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('gaozhi_submissions');
},
};

View File

@@ -0,0 +1,77 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('benke_submissions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '提交记录ID',
},
aggregation_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '报名记录ID(关联aggregation)',
references: { model: 'aggregation', key: 'aggregation_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
work_name: {
type: Sequelize.STRING(255),
allowNull: false,
comment: '作品名称',
},
file_path: {
type: Sequelize.STRING(512),
allowNull: false,
comment: '文件存储路径',
},
file_name: {
type: Sequelize.STRING(255),
allowNull: false,
comment: '文件原始名称',
},
file_size: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: '文件大小(字节)',
},
file_md5: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '文件MD5哈希值',
},
upload_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '上传时间',
},
score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '评分',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return queryInterface.addIndex('benke_submissions', ['aggregation_uid'], { name: 'idx_aggregation_uid' });
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('benke_submissions');
},
};

View File

@@ -0,0 +1,77 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('jiaoshi_submissions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '提交记录ID',
},
aggregation_uid: {
type: Sequelize.INTEGER,
allowNull: false,
comment: '报名记录ID(关联aggregation)',
references: { model: 'aggregation', key: 'aggregation_uid' },
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
work_name: {
type: Sequelize.STRING(255),
allowNull: false,
comment: '作品名称',
},
file_path: {
type: Sequelize.STRING(512),
allowNull: false,
comment: '文件存储路径',
},
file_name: {
type: Sequelize.STRING(255),
allowNull: false,
comment: '文件原始名称',
},
file_size: {
type: Sequelize.INTEGER.UNSIGNED,
allowNull: false,
defaultValue: 0,
comment: '文件大小(字节)',
},
file_md5: {
type: Sequelize.STRING(64),
allowNull: true,
comment: '文件MD5哈希值',
},
upload_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '上传时间',
},
score: {
type: Sequelize.INTEGER,
allowNull: true,
comment: '评分',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return queryInterface.addIndex('jiaoshi_submissions', ['aggregation_uid'], { name: 'idx_aggregation_uid' });
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('jiaoshi_submissions');
},
};

View File

@@ -0,0 +1,57 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('competition_times', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '配置ID',
},
group_name: {
type: Sequelize.STRING(64),
allowNull: false,
unique: true,
comment: '组别名称(唯一)',
},
start_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '比赛开始时间',
},
end_time: {
type: Sequelize.DATE,
allowNull: true,
comment: '比赛结束时间',
},
deadline: {
type: Sequelize.DATE,
allowNull: true,
comment: '报名/提交截止时间',
},
word_template: {
type: Sequelize.STRING(512),
allowNull: true,
comment: 'Word模板文件路径',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('competition_times');
},
};

View File

@@ -0,0 +1,42 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('system_configs', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '配置ID',
},
config_key: {
type: Sequelize.STRING(128),
allowNull: false,
unique: true,
comment: '配置键名(唯一)',
},
config_value: {
type: Sequelize.TEXT,
allowNull: true,
comment: '配置值',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('system_configs');
},
};

View File

@@ -0,0 +1,68 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('choice_questions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '题目ID',
},
group: {
type: Sequelize.STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
question: {
type: Sequelize.TEXT,
allowNull: false,
comment: '题目内容',
},
options: {
type: Sequelize.JSON,
allowNull: false,
comment: '选项列表 [{ key: "A", text: "..." }, ...]',
},
answer: {
type: Sequelize.STRING(10),
allowNull: false,
comment: '正确答案(如 A / AB / ABCD)',
},
score: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 5,
comment: '分值',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('choice_questions', ['group'], { name: 'idx_choice_group' }),
queryInterface.addIndex('choice_questions', ['sort_order'], { name: 'idx_choice_sort' }),
]);
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('choice_questions');
},
};

View File

@@ -0,0 +1,63 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('judge_questions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '题目ID',
},
group: {
type: Sequelize.STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
question: {
type: Sequelize.TEXT,
allowNull: false,
comment: '题目内容',
},
answer: {
type: Sequelize.STRING(10),
allowNull: false,
comment: '正确答案:true 或 false',
},
score: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 5,
comment: '分值',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('judge_questions', ['group'], { name: 'idx_judge_group' }),
queryInterface.addIndex('judge_questions', ['sort_order'], { name: 'idx_judge_sort' }),
]);
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('judge_questions');
},
};

View File

@@ -0,0 +1,68 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('word_questions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
comment: '题目ID',
},
group: {
type: Sequelize.STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
title: {
type: Sequelize.STRING(200),
allowNull: false,
comment: '题目标题',
},
description: {
type: Sequelize.TEXT,
allowNull: true,
comment: '题目描述/操作要求',
},
score: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 30,
comment: '分值',
},
sort_order: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
template_path: {
type: Sequelize.STRING(500),
allowNull: true,
comment: 'Word 模板文件路径',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}).then(() => {
return Promise.all([
queryInterface.addIndex('word_questions', ['group'], { name: 'idx_word_group' }),
queryInterface.addIndex('word_questions', ['sort_order'], { name: 'idx_word_sort' }),
]);
});
},
down: async (queryInterface) => {
await queryInterface.dropTable('word_questions');
},
};

View File

@@ -0,0 +1,23 @@
'use strict';
/**
* 迁移文件:为 contestants 表添加 test_flag 字段
* 用于区分测试数据和生产数据,防止重置操作误删生产数据
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER } = Sequelize;
await queryInterface.addColumn('contestants', 'test_flag', {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '测试数据标记:0=生产数据,1=测试数据',
});
},
down: async (queryInterface) => {
await queryInterface.removeColumn('contestants', 'test_flag');
},
};

View File

@@ -0,0 +1,17 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('carousel', 'title', {
type: Sequelize.STRING(200),
allowNull: true,
defaultValue: null,
comment: '轮播图标题',
after: 'image_url',
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('carousel', 'title');
},
};

View File

@@ -0,0 +1,40 @@
'use strict';
/**
* 迁移 020:为 zhongzhi_submissions / gaozhi_submissions 增加 question_type 列
*
* 背景(Bug #11 / B11):选择题(choice_questions)与判断题(judge_questions)的 id
* 在不同表中数值重叠(如 choice#5 与 judge#5 均为 5),原 saveAnswers 仅按
* (aggregation_uid, question_uid) 查找并 upsert,导致同 id 的选择题与判断题被
* 折叠成一条记录,考生作答数少于抽题数、部分答案静默丢失。
*
* 修复:新增 question_type 列(choice/judge),与 question_uid 联合唯一标识一道题;
* service.saveAnswers 的查找/写入键改为 (aggregation_uid, question_uid, question_type);
* autoScore 判分不受影响(其本就按 question_type 选模型 + 用原始 id 作主键)。
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
const addCol = async (table) => {
await queryInterface.addColumn(table, 'question_type', {
type: Sequelize.STRING(16),
allowNull: false,
defaultValue: '',
comment: '题型:choice/judge,与 question_uid 联合区分题目',
});
await queryInterface.addIndex(
table,
['aggregation_uid', 'question_uid', 'question_type'],
{ name: `idx_${table}_agg_qtype` }
);
};
await addCol('zhongzhi_submissions');
await addCol('gaozhi_submissions');
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('zhongzhi_submissions', 'idx_zhongzhi_submissions_agg_qtype');
await queryInterface.removeColumn('zhongzhi_submissions', 'question_type');
await queryInterface.removeIndex('gaozhi_submissions', 'idx_gaozhi_submissions_agg_qtype');
await queryInterface.removeColumn('gaozhi_submissions', 'question_type');
},
};

View File

@@ -0,0 +1,99 @@
'use strict';
/**
* 迁移 021:为答题提交表增加唯一约束,杜绝并发作答重复落库(Bug #12 / B12)
*
* 背景(B12):原 saveAnswers 使用「先 findOne 再 create」的非原子写法,且
* (aggregation_uid, question_uid, question_type) 仅为普通索引。同一用户并发提交
* 时,多个请求都 find 不到记录,于是各自 create,导致同一题被插入多行
* (实测:3 路并发产生 57 行 / 20 题,exam_submissions 出现 3 条同选手记录)。
* 重复行会污染 answeredCount、判分明细与成绩恢复,属数据一致性缺陷。
*
* 修复:
* 1) zhongzhi_submissions / gaozhi_submissions 的复合索引升级为唯一索引
* uk_{table}_submission (aggregation_uid, question_uid, question_type);
* service.saveAnswers 改用 upsert(conflictFields=[...]),由 MySQL 的
* INSERT ... ON DUPLICATE KEY UPDATE 在并发下原子折叠为单行更新。
* 2) exam_submissions 的 contestant_id 索引升级为唯一索引
* uk_exam_submission_contestant,配合 service 中并发安全的
* _ensureExamSubmissionSafe,保证每选手仅一条提交记录。
*
* 注意:up/down 均先去重(保留最小 id)再加减索引,确保幂等且不会因历史脏数据失败。
*/
module.exports = {
up: async (queryInterface) => {
// ---- 去重:同 (aggregation_uid, question_uid, question_type) 仅留最小 id ----
await queryInterface.sequelize.query(`
DELETE t FROM zhongzhi_submissions t
WHERE t.id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM zhongzhi_submissions
GROUP BY aggregation_uid, question_uid, question_type
) AS keep
)
`);
await queryInterface.sequelize.query(`
DELETE t FROM gaozhi_submissions t
WHERE t.id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM gaozhi_submissions
GROUP BY aggregation_uid, question_uid, question_type
) AS keep
)
`);
// exam_submissions:每 contestant_id 仅留最小 id
await queryInterface.sequelize.query(`
DELETE t FROM exam_submissions t
WHERE t.id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM exam_submissions
GROUP BY contestant_id
) AS keep
)
`);
// ---- zhongzhi ----
await queryInterface.removeIndex('zhongzhi_submissions', 'idx_zhongzhi_submissions_agg_qtype').catch(() => {});
await queryInterface.addIndex('zhongzhi_submissions', {
name: 'uk_zhongzhi_submission',
unique: true,
fields: ['aggregation_uid', 'question_uid', 'question_type'],
}).catch(() => {});
// ---- gaozhi ----
await queryInterface.removeIndex('gaozhi_submissions', 'idx_gaozhi_submissions_agg_qtype').catch(() => {});
await queryInterface.addIndex('gaozhi_submissions', {
name: 'uk_gaozhi_submission',
unique: true,
fields: ['aggregation_uid', 'question_uid', 'question_type'],
}).catch(() => {});
// ---- exam_submissions ----
await queryInterface.removeIndex('exam_submissions', 'idx_exam_submissions_contestant_id').catch(() => {});
await queryInterface.addIndex('exam_submissions', {
name: 'uk_exam_submission_contestant',
unique: true,
fields: ['contestant_id'],
}).catch(() => {});
},
down: async (queryInterface) => {
await queryInterface.removeIndex('zhongzhi_submissions', 'uk_zhongzhi_submission').catch(() => {});
await queryInterface.addIndex('zhongzhi_submissions', {
name: 'idx_zhongzhi_submissions_agg_qtype',
fields: ['aggregation_uid', 'question_uid', 'question_type'],
});
await queryInterface.removeIndex('gaozhi_submissions', 'uk_gaozhi_submission').catch(() => {});
await queryInterface.addIndex('gaozhi_submissions', {
name: 'idx_gaozhi_submissions_agg_qtype',
fields: ['aggregation_uid', 'question_uid', 'question_type'],
});
await queryInterface.removeIndex('exam_submissions', 'uk_exam_submission_contestant').catch(() => {});
await queryInterface.addIndex('exam_submissions', {
name: 'idx_exam_submissions_contestant_id',
fields: ['contestant_id'],
});
},
};

View File

@@ -0,0 +1,56 @@
'use strict';
/**
* 为 carousel 表新增富文本正文与可配置按钮字段,并放宽 image_url 允许为空
* (支持"渐变底图回退"的固定页迁移)。
*
* 新增:
* - content TEXT 富文本正文(HTML,由 wangEditor 生成,前端 v-html 渲染)
* - button_text VARCHAR(100) 按钮文字(为空则不显示按钮)
* - button_link VARCHAR(500) 按钮链接(内部路由如 /activate,或外部 http(s):// 链接)
* 变更:
* - image_url 由 NOT NULL 放宽为 允许 NULL(无底图时前端回退 CSS 渐变)
*/
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('carousel', 'content', {
type: Sequelize.TEXT,
allowNull: true,
defaultValue: null,
comment: '富文本正文(HTML)',
after: 'title',
});
await queryInterface.addColumn('carousel', 'button_text', {
type: Sequelize.STRING(100),
allowNull: true,
defaultValue: null,
comment: '按钮文字(为空则不显示按钮)',
after: 'content',
});
await queryInterface.addColumn('carousel', 'button_link', {
type: Sequelize.STRING(500),
allowNull: true,
defaultValue: null,
comment: '按钮链接(内部路由/外部URL)',
after: 'button_text',
});
// 放宽 image_url 允许为空,支持渐变底图回退
await queryInterface.changeColumn('carousel', 'image_url', {
type: Sequelize.STRING(500),
allowNull: true,
defaultValue: null,
comment: '图片 URL 地址(可为空时前端回退渐变)',
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('carousel', 'content');
await queryInterface.removeColumn('carousel', 'button_text');
await queryInterface.removeColumn('carousel', 'button_link');
await queryInterface.changeColumn('carousel', 'image_url', {
type: Sequelize.STRING(500),
allowNull: false,
comment: '图片 URL 地址',
});
},
};