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

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Runtime (egg)
run/
.wb_*/
.wb_check_state.js
.egg-config.json
# Env / secrets (defense-in-depth; real .env.production lives outside this repo)
.env*
!.env.example
# Coverage / cache
coverage/
.cache/
# Agent / tooling state
.workbuddy/
# Editor
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
# Uploaded / runtime data under app/public (keep templates as source)
app/public/uploads/
app/public/answers/
app/public/works/
app/public/materials/
app/public/news/
app/public/carousel/
app/public/tmp/
app/public/exports/
test_dir/

10
.sequelizerc Normal file
View File

@@ -0,0 +1,10 @@
'use strict';
const path = require('path');
module.exports = {
config: path.resolve(__dirname, 'database', 'config.json'),
'migrations-path': path.resolve(__dirname, 'database', 'migrations'),
'seeders-path': path.resolve(__dirname, 'database', 'seeders'),
'models-path': path.resolve(__dirname, 'app', 'model'),
};

23
add_word_cols.js Normal file
View File

@@ -0,0 +1,23 @@
const mysql = require('mysql2/promise');
(async () => {
const c = await mysql.createConnection({
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
});
try {
await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_path VARCHAR(500) NULL DEFAULT NULL COMMENT 'Word答案文件路径' AFTER answers");
console.log('Added word_file_path');
} catch (e) {
if (e.code === 'ER_DUP_FIELDNAME') console.log('word_file_path exists');
else throw e;
}
try {
await c.query("ALTER TABLE exam_submissions ADD COLUMN word_file_name VARCHAR(255) NULL DEFAULT NULL COMMENT 'Word答案文件名' AFTER word_file_path");
console.log('Added word_file_name');
} catch (e) {
if (e.code === 'ER_DUP_FIELDNAME') console.log('word_file_name exists');
else throw e;
}
const [cols] = await c.query('SHOW COLUMNS FROM exam_submissions');
cols.forEach(x => console.log(x.Field, x.Type, x.Null));
await c.end();
})();

23
add_wx_link.js Normal file
View File

@@ -0,0 +1,23 @@
const mysql = require('mysql2/promise');
(async () => {
const c = await mysql.createConnection({
host: 'localhost', user: 'root', password: '74191221', database: 'competition_db'
});
try {
await c.query("ALTER TABLE news ADD COLUMN wx_link VARCHAR(500) NULL DEFAULT NULL COMMENT '微信公众号文章链接' AFTER content");
console.log('Added wx_link column');
} catch (e) {
if (e.code === 'ER_DUP_FIELDNAME') console.log('wx_link already exists');
else throw e;
}
try {
await c.query("ALTER TABLE news ADD COLUMN news_type VARCHAR(20) NOT NULL DEFAULT 'content' COMMENT '新闻类型' AFTER wx_link");
console.log('Added news_type column');
} catch (e) {
if (e.code === 'ER_DUP_FIELDNAME') console.log('news_type already exists');
else throw e;
}
const [cols] = await c.query('SHOW COLUMNS FROM news');
cols.forEach(x => console.log(x.Field, x.Type, x.Null));
await c.end();
})();

88
app.js Normal file
View File

@@ -0,0 +1,88 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* Egg.js 应用启动钩子 - AppBootHook
* 表结构通过 migration/seeder 管理,不在启动时自动同步
*/
class AppBootHook {
constructor(app) {
this.app = app;
}
async willReady() {
// 数据库表结构由 migration 管理,不在启动时自动 sync
this.app.logger.info('[AppBoot] 服务启动完成');
}
/**
* 进程就绪后,确保 app/public 下所有业务目录存在。
*
* 关键背景(生产踩坑):
* app/public/{works,answers,materials,tmp,...} 这类上传目录通常被 .gitignore 忽略,
* 每次「整目录重新部署」(rsync --delete / 重新 clone) 都会被清空。
* 若运行中的代码某条写入路径恰好没有即时 mkdir,就会 ENOENT → 上传失败 / 下载 404,
* 且旧版 auth 中间件会把这类磁盘异常伪装成「401 登录过期」误导排查。
* 此处统一在启动时把全部已知写入目录递归建好,作为永久兜底,
* 与各处写入点的 fs.mkdirSync(recursive:true) 形成双保险。
*/
async didReady() {
const publicDir = path.join(this.app.baseDir, 'app/public');
const groups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
// 需要按「目录/组别」创建的(组别子目录)
const grouped = ['works', 'answers', 'materials', 'templates'];
// 需要直接创建(无组别)的
const flat = ['tmp', 'tmp/excel', 'tmp/zip', 'carousel', 'news'];
const targets = [];
for (const d of grouped) {
for (const g of groups) targets.push(path.join(publicDir, d, g));
}
for (const d of flat) targets.push(path.join(publicDir, d));
let created = 0;
let existed = 0;
for (const t of targets) {
try {
if (!fs.existsSync(t)) {
fs.mkdirSync(t, { recursive: true });
created++;
} else {
existed++;
}
} catch (e) {
this.app.logger.error('[AppBoot] 创建上传目录失败', { target: t, message: e.message });
}
}
this.app.logger.info(
`[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})`
);
// 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker,
// 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151),
// 高峰期会偶发 "connection acquire timeout" / "too many connections"。
// 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。
try {
const os = require('os');
const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20;
const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT });
const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151;
const workers = os.cpus().length;
const estimated = workers * poolMax;
if (estimated > dbMax) {
this.app.logger.warn(
`[AppBoot][pool] 预估总连接数 ${estimated} (${workers} workers × ${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX`
);
} else {
this.app.logger.info(`[AppBoot][pool] 连接池容量 OK:${estimated} (${workers} × ${poolMax}) ≤ MySQL max_connections=${dbMax}`);
}
} catch (e) {
this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message));
}
}
}
module.exports = AppBootHook;

1664
app/controller/admin.js Normal file

File diff suppressed because it is too large Load Diff

610
app/controller/auth.js Normal file
View File

@@ -0,0 +1,610 @@
'use strict';
const Controller = require('egg').Controller;
const svgCaptcha = require('svg-captcha');
const VALIDATION_RULES = require('../validate');
/**
* 认证控制器 - AuthController
*
* 负责选手和管理员的登录认证、账号激活、验证码、密码重置等功能。
* 使用 Contestant 模型进行选手认证,Admin 模型进行管理员认证。
* 密码采用 bcrypt 加盐哈希存储;登录时兼容存量 SHA256 旧哈希并在验证成功后透明升级为 bcrypt。
* 统一响应格式:ctx.helper.success(data, msg) / ctx.helper.error(code, msg)
*
* 主要接口:
* - POST /api/auth/captcha : 获取图形验证码(SVG)
* - POST /api/auth/sms : 发送短信验证码(模拟)
* - POST /api/auth/activate : 激活账号(设置密码)
* - POST /api/auth/login : 选手登录
* - POST /api/auth/admin-login : 管理员登录
* - GET /api/auth/current-user : 获取当前用户信息(需认证)
* - POST /api/auth/forgot-password : 忘记密码 - 发送验证码
* - POST /api/auth/reset-password : 重置密码
* - POST /api/auth/logout : 退出登录(需认证)
*/
class AuthController extends Controller {
/**
* 交付短信验证码(统一入口,供 sms / forgotPassword 复用)
*
* 行为由运行模式(config.isProduction,来自环境变量 APP_ENV)驱动,
* 使测试版本与生产版本代码路径完全一致、仅差异于配置:
* - 生产环境 + 网关已开启(config.sms.realSend):通过阿里云真实下发,
* 验证码绝不写入 HTTP 响应体、不打印到控制台(防凭身份证号接管账号)。
* - 测试环境(APP_ENV=test,或缺省):【不真实发送】验证码明文随响应返回
* (字段 smsCode),由前端弹窗展示,便于测试人员直接获取,无需配置短信网关。
* - 生产环境 + 网关未开启:安全起见不下发、不回传,仅记录告警(需运维配置 SMS_ENABLED=1)。
*
* @param {object} contestant - 选手对象(含 phone)
* @param {string} smsCode - 6 位验证码
* @param {string} sceneLabel - 场景标签(如 '账号激活' / '忘记密码')
* @return {boolean} 是否允许在响应体中回传验证码(仅测试环境为 true)
*/
async _deliverSmsCode(contestant, smsCode, sceneLabel) {
const { ctx, app } = this;
const sms = app.config.sms || {};
const isProduction = app.config.isProduction;
// 服务端审计日志(仅落日志文件,不含验证码明文回传前端)
ctx.logger.info(
`[短信验证码] 场景=${sceneLabel} 手机号=${contestant.phone} 环境=${app.config.runMode} realSend=${!!sms.realSend}`
);
// 生产环境 + 网关已开启 → 真实下发,绝不回传验证码明文
if (isProduction && sms.realSend) {
try {
await ctx.service.sms.sendVerifyCode(contestant.phone, smsCode);
ctx.logger.info(`[短信验证码] 场景=${sceneLabel} 已通过网关下发至 ${contestant.phone}`);
} catch (e) {
// 下发失败:记录告警;验证码已存入 session,用户可重试获取,绝不在响应回传
ctx.logger.error(`[短信验证码] 场景=${sceneLabel} 网关下发失败(${contestant.phone}):${e && e.message}`);
}
return false;
}
if (isProduction) {
// 生产环境未开启网关:为避免账号接管风险,绝不回传或打印验证码,仅记录告警
ctx.logger.warn('[短信验证码] 生产环境未启用短信网关(realSend=false),验证码未下发。请设置 SMS_ENABLED=1 并配置阿里云密钥。');
return false;
}
// 测试环境:不真实发送,回传验证码供前端弹窗显示(控制台同步打印便于联调)
console.log('');
console.log('╔══════════════════════════════════════════════╗');
console.log(`║ 📱 [测试环境] 模拟短信验证码 - ${sceneLabel}`);
console.log('╠══════════════════════════════════════════════╣');
console.log(`║ 手机号 : ${contestant.phone}`);
console.log(`║ 验证码 : ${smsCode}`);
console.log('║ 有效期 : 5 分钟');
console.log('╚══════════════════════════════════════════════╝');
console.log('');
return true;
}
/**
* 获取点击式验证码(按顺序点击文字)
*
* 生成 4 个随机中文汉字,指定点击顺序,返回打乱后的文字和提示。
* 用户需按照提示顺序依次点击文字,点击序列存入 session 用于校验。
*/
async captcha() {
const { ctx } = this;
// 字符池(常用中文字符,避免生僻字)
const charPool = ['天', '地', '人', '和', '山', '水', '风', '火', '日', '月', '星', '辰', '花', '鸟', '云', '雨'];
// 随机选取 4 个不重复字符
const pool = [...charPool];
const selected = [];
for (let i = 0; i < 4; i++) {
const idx = Math.floor(Math.random() * pool.length);
selected.push(pool.splice(idx, 1)[0]);
}
// 存入 session:点击顺序的字符串(如 "天地人和")
const clickOrder = selected.join('');
ctx.session.captcha = clickOrder;
// 打乱显示顺序
const displayOrder = [...selected].sort(() => Math.random() - 0.5);
ctx.body = ctx.helper.success({
chars: displayOrder,
hint: '请依次点击:' + selected.join(' → '),
}, '验证码获取成功');
}
/**
* 发送短信验证码(模拟)
*
* 前端传入身份证号作为 phone 参数,后端根据身份证号查找选手的真实手机号。
*
* 流程:
* 1. 校验图形验证码
* 2. 通过身份证号查找参赛选手
* 3. 生成 6 位随机数字验证码
* 4. 存入 ctx.session.smsCode / smsPhone / smsIdNumber
* 5. 返回真实手机号、姓名、组别和模拟验证码
*/
async sms() {
const { ctx, service } = this;
const { phone, captcha } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.sms, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 校验图形验证码
if (!ctx.session.captcha || captcha !== ctx.session.captcha) {
ctx.body = ctx.helper.error(1002, '图形验证码错误');
return;
}
// 通过身份证号查找选手
const contestant = await service.auth.findByAccount(phone);
if (!contestant) {
ctx.body = ctx.helper.error(1003, '该身份证号不在参赛名单中,请联系管理员');
return;
}
// 检查是否已激活
if (contestant.activated) {
ctx.body = ctx.helper.error(1004, '该账号已激活,请直接登录');
return;
}
// 生成 6 位随机数字验证码
const smsCode = ctx.helper.randomCode(6);
// 存入 session(5 分钟有效期)
ctx.session.smsCode = smsCode;
ctx.session.smsPhone = contestant.phone;
ctx.session.smsIdNumber = phone;
ctx.session.smsCodeExpire = Date.now() + 5 * 60 * 1000;
// 清除已使用的图形验证码
ctx.session.captcha = null;
// 交付验证码(生产环境不回传/不打印,仅走真实网关)
const exposeCode = await this._deliverSmsCode(contestant, smsCode, '账号激活');
const respData = {
phone: contestant.phone,
name: contestant.name,
group: contestant.group,
};
if (exposeCode) respData.smsCode = smsCode;
ctx.body = ctx.helper.success(respData, '短信验证码发送成功');
}
/**
* 激活账号
*
* 流程:
* 1. 校验身份证号、手机号、短信验证码
* 2. 通过身份证号查找选手
* 3. 检查账号是否已激活
* 4. SHA256 哈希密码,更新 activated=1
* 5. 清除 session 中的短信验证码
*/
async activate() {
const { ctx, service } = this;
const { id_number, phone, smsCode, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.activate, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 校验短信验证码
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
ctx.body = ctx.helper.error(2005, '短信验证码错误');
return;
}
// 校验短信验证码是否过期
if (ctx.session.smsCodeExpire && Date.now() > ctx.session.smsCodeExpire) {
ctx.body = ctx.helper.error(2006, '短信验证码已过期');
return;
}
// 校验手机号一致性
if (ctx.session.smsPhone !== phone && ctx.session.smsIdNumber !== id_number) {
ctx.body = ctx.helper.error(2007, '手机号与发送验证码时的手机号不一致');
return;
}
// 通过身份证号查找选手
const contestant = await service.auth.findByAccount(id_number);
if (!contestant) {
ctx.body = ctx.helper.error(2008, '该身份证号不在参赛名单中,请联系管理员');
return;
}
// 检查是否已激活
if (contestant.activated) {
ctx.body = ctx.helper.error(2009, '该账号已激活,请直接登录');
return;
}
// bcrypt 加盐哈希密码
const hashedPassword = await ctx.helper.hashPassword(password);
// 更新选手:激活状态 + 密码
await service.auth.activateAccount(contestant.id, hashedPassword);
// 清除 session 中的短信验证码
ctx.session.smsCode = null;
ctx.session.smsPhone = null;
ctx.session.smsIdNumber = null;
ctx.session.smsCodeExpire = null;
ctx.body = ctx.helper.success({
id: contestant.id,
id_number: contestant.id_number,
name: contestant.name,
group: contestant.group,
}, '账号激活成功');
}
/**
* 选手登录 - 使用手机号
*
* 流程:
* 1. 通过手机号查找选手
* 2. 检查账号是否激活
* 3. SHA256(输入密码) 与数据库密码比对
* 4. 生成 JWT Token,返回用户信息和 Token
*/
async login() {
const { ctx, service } = this;
const { phone, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.login, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
// 查找选手
const contestant = await service.auth.findByPhone(phone);
if (!contestant) {
ctx.body = ctx.helper.error(3003, '该手机号不在参赛名单中');
return;
}
// 检查是否激活
if (!contestant.activated) {
ctx.body = ctx.helper.error(3004, '账号未激活,请先激活');
return;
}
// 验证密码(兼容 bcrypt 与存量 SHA256)
const isPasswordValid = await service.auth.verifyPassword(password, contestant.password);
if (!isPasswordValid) {
ctx.body = ctx.helper.error(3005, '密码错误');
return;
}
// 若为旧版 SHA256 哈希,登录成功后透明升级为 bcrypt
await service.auth.upgradePasswordIfLegacy(contestant, password);
// 生成 JWT Token
const token = ctx.helper.generateToken({
id: contestant.id,
phone: contestant.phone,
role: 'contestant',
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
// 返回用户信息(排除密码)
const userData = contestant.toJSON();
delete userData.password;
ctx.body = ctx.helper.success({
token,
user: userData,
}, '登录成功');
}
/**
* 管理员登录
*
* 流程:
* 1. 通过用户名从 admins 表查找管理员
* 2. 密码比对(SHA256)
* 3. 生成管理员 JWT Token
*/
async adminLogin() {
const { ctx, service } = this;
const { username, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.adminLogin, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const admin = await service.auth.findAdmin(username);
if (!admin) {
ctx.body = ctx.helper.error(4003, '管理员不存在');
return;
}
// 管理员密码比对(兼容 bcrypt 与存量 SHA256)
const isValid = await service.auth.verifyPassword(password, admin.password);
if (!isValid) {
ctx.body = ctx.helper.error(4004, '密码错误');
return;
}
// P2-3 默认口令守卫:schema.sql 内置默认管理员(admin/admin123),
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
const DEFAULT_ADMIN_PASSWORDS = ['admin123', 'admin123456'];
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
const isProd = this.app.config.isProduction;
if (usingDefaultPassword && isProd) {
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
ctx.body = ctx.helper.error(4005, '生产环境禁止使用默认密码登录,请联系运维在数据库中重置为强密码后再试');
return;
}
if (usingDefaultPassword) {
ctx.logger.warn(`[安全提醒] 管理员 ${admin.username} 正在使用默认密码,请尽快修改`);
}
// 若为旧版 SHA256 哈希,登录成功后透明升级为 bcrypt
await service.auth.upgradePasswordIfLegacy(admin, password);
const token = ctx.helper.generateToken({
id: admin.id,
role: 'admin',
}, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
ctx.body = ctx.helper.success({
token,
user: {
id: admin.id,
username: admin.username,
role: 'admin',
},
// 使用默认口令时向前端透出改密提醒标记
mustChangePassword: usingDefaultPassword,
}, '管理员登录成功');
}
/**
* 获取当前登录用户信息(需认证)
*/
async currentUser() {
const { ctx } = this;
// 从 JWT token 中获取用户信息
const token = ctx.headers.authorization
? ctx.headers.authorization.replace('Bearer ', '')
: null;
if (!token) {
ctx.body = ctx.helper.error(5001, '未登录');
return;
}
let decoded;
try {
decoded = ctx.helper.verifyToken(token, this.app.config.jwt.secret);
} catch (e) {
ctx.body = ctx.helper.error(5002, 'Token 已过期,请重新登录');
return;
}
if (!decoded || !decoded.id) {
ctx.body = ctx.helper.error(5003, 'Token 无效');
return;
}
const contestant = await ctx.model.Contestant.findOne({
where: { id: decoded.id },
});
if (!contestant) {
ctx.body = ctx.helper.error(5001, '用户不存在');
return;
}
const userData = contestant.toJSON();
delete userData.password;
ctx.body = ctx.helper.success(userData, '获取用户信息成功');
}
/**
* 忘记密码 - 发送验证码
*/
async forgotPassword() {
const { ctx, service } = this;
const { id_number, captcha } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.forgotPassword, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
if (!ctx.session.captcha || captcha !== ctx.session.captcha) {
ctx.body = ctx.helper.error(6002, '图形验证码错误');
return;
}
const contestant = await service.auth.findByAccount(id_number);
if (!contestant) {
ctx.body = ctx.helper.error(6003, '该身份证号不在参赛名单中');
return;
}
if (!contestant.activated) {
ctx.body = ctx.helper.error(6004, '该账号尚未激活,请先激活');
return;
}
const smsCode = ctx.helper.randomCode(6);
ctx.session.smsCode = smsCode;
ctx.session.smsPhone = contestant.phone;
ctx.session.smsIdNumber = id_number;
ctx.session.smsCodeExpire = Date.now() + 5 * 60 * 1000;
ctx.session.captcha = null;
// 交付验证码(生产环境不回传/不打印,仅走真实网关)
const exposeCode = await this._deliverSmsCode(contestant, smsCode, '忘记密码');
const respData = { phone: contestant.phone };
if (exposeCode) respData.smsCode = smsCode;
ctx.body = ctx.helper.success(respData, '验证码已发送');
}
/**
* 重置密码
*/
async resetPassword() {
const { ctx, service } = this;
const { id_number, smsCode, password } = ctx.request.body;
// 统一输入校验(ctx.validate)
try {
ctx.validate(VALIDATION_RULES.resetPassword, ctx.request.body);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
if (!ctx.session.smsCode || smsCode !== ctx.session.smsCode) {
ctx.body = ctx.helper.error(7004, '短信验证码错误');
return;
}
if (ctx.session.smsCodeExpire && Date.now() > ctx.session.smsCodeExpire) {
ctx.body = ctx.helper.error(7005, '短信验证码已过期');
return;
}
const contestant = await service.auth.findByAccount(id_number);
if (!contestant) {
ctx.body = ctx.helper.error(7006, '账号不存在');
return;
}
const hashedPassword = await ctx.helper.hashPassword(password);
await contestant.update({ password: hashedPassword });
ctx.session.smsCode = null;
ctx.session.smsPhone = null;
ctx.session.smsIdNumber = null;
ctx.session.smsCodeExpire = null;
ctx.body = ctx.helper.success(null, '密码重置成功');
}
/**
* 退出登录
*/
async logout() {
const { ctx } = this;
ctx.body = ctx.helper.success(null, '退出登录成功');
}
/**
* 刷新 Token(静默续期,用户无感知)
*
* 设计目标:让活跃用户永远不会被"意外踢出登录"。
* - 接受可能已过期的令牌(verifyToken 忽略过期)。
* - 仅校验签名有效 + 用户仍真实存在且已激活。
* - 在宽限期内(refreshGrace)允许刷新;超过宽限期则必须重新登录。
* - 该接口刻意不挂 auth 中间件(因为携带的 token 可能已过期)。
*/
async refresh() {
const { ctx } = this;
const authorization = ctx.headers.authorization || '';
const token = authorization.replace(/^Bearer\s+/i, '') || null;
if (!token) {
ctx.body = ctx.helper.error(5001, '未登录');
return;
}
let decoded;
try {
// 忽略过期,仅校验签名与载荷结构,用于静默续期
decoded = ctx.helper.verifyToken(token, this.app.config.jwt.secret, { ignoreExpiration: true });
} catch (e) {
ctx.body = ctx.helper.error(5002, 'Token 无效,请重新登录');
return;
}
if (!decoded || !decoded.id) {
ctx.body = ctx.helper.error(5003, 'Token 无效,请重新登录');
return;
}
// 宽限期校验:仅允许在过期后 refreshGrace 秒内续期,防止会话永久不失效
const nowSec = Math.floor(Date.now() / 1000);
const expiredAgo = decoded.exp ? nowSec - decoded.exp : 99999999;
const refreshGrace = this.app.config.jwt.refreshGrace || 7 * 24 * 60 * 60;
if (expiredAgo > refreshGrace) {
ctx.body = ctx.helper.error(5004, '登录已过期太久,请重新登录');
return;
}
// 校验用户仍真实存在(且选手需已激活)
let account = null;
if (decoded.role === 'admin') {
account = await ctx.model.Admin.findOne({ where: { id: decoded.id } });
} else {
account = await ctx.model.Contestant.findOne({ where: { id: decoded.id } });
if (account && !account.activated) {
ctx.body = ctx.helper.error(5001, '账号未激活,请先激活');
return;
}
}
if (!account) {
ctx.body = ctx.helper.error(5001, '用户不存在,请重新登录');
return;
}
// 签发新令牌(沿用登录时的有效期配置)
const payload = decoded.role === 'admin'
? { id: account.id, role: 'admin' }
: { id: account.id, phone: account.phone, role: 'contestant' };
const newToken = ctx.helper.generateToken(payload, this.app.config.jwt.secret, this.app.config.jwt.expiresIn);
const userData = account.toJSON();
delete userData.password;
ctx.body = ctx.helper.success({
token: newToken,
user: userData,
}, '刷新成功');
}
}
module.exports = AuthController;

View File

@@ -0,0 +1,35 @@
'use strict';
const Controller = require('egg').Controller;
/**
* 轮播图控制器 - CarouselController
*
* 负责首页轮播图的查询和展示。
*
* 主要接口:
* - GET /api/carousel : 获取启用的轮播图列表(按 sort_order 升序排列)
*/
class CarouselController extends Controller {
/**
* 获取轮播图列表
*
* 查询条件:enabled = 1(仅返回启用状态的轮播图)
* 排序规则:sort_order ASC(序号越小越靠前)
*/
async index() {
const { ctx } = this;
const list = await ctx.model.Carousel.findAll({
where: { enabled: true },
order: [
['sort_order', 'ASC'],
],
attributes: ['id', 'image_url', 'title', 'content', 'button_text', 'button_link', 'sort_order', 'link_url', 'enabled', 'created_at', 'updated_at'],
});
ctx.body = ctx.helper.success(list, '获取轮播图列表成功');
}
}
module.exports = CarouselController;

813
app/controller/exam.js Normal file
View File

@@ -0,0 +1,813 @@
'use strict';
const Controller = require('egg').Controller;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
// 🔴 同源 collapse(缺口9 修复):文件发送统一走 sendFile util
const { sendFile } = require('../utils/sendFile');
/**
* 团队考试锁的进程内互斥(单实例部署适用)。
* 防止同一小组两名成员"同一瞬间"点击进入考试时,都越过 checkTeamLock
* 各自写入占位 submission 而导致两人同时进入正式考试的竞态(G1 修复)。
* 进入临界区的胜者写入占位记录并提交;落后者进入临界区时复检到该记录,
* 返回 4005。多实例部署需替换为 Redis 分布式锁。
*/
const teamLockChain = new Map();
async function withTeamLock(teamId, fn) {
const prev = teamLockChain.get(teamId) || Promise.resolve();
let release;
const next = new Promise((resolve) => { release = resolve; });
teamLockChain.set(teamId, prev.then(() => next));
try {
await prev;
return await fn();
} finally {
teamLockChain.delete(teamId);
release();
}
}
/**
* 考试控制器 - ExamController
*
* 负责在线答题相关功能,包括获取题目、提交答案、上传 Word 文件、
* 一体提交等。适用于中职组和高职组的在线考试模式。
*
* 主要接口:
* - GET /api/exam/:group/status : 获取考试时间状态
* - GET /api/exam/:group/questions : 获取随机题目列表
* - GET /api/exam/:group/word-template: 下载 Word 模板
* - POST /api/exam/:group/submit-answers : 提交答题答案
* - POST /api/exam/:group/upload-word : 上传 Word 操作文件
* - POST /api/exam/:group/submit-all : 一体提交(答案 + 文件)
*/
class ExamController extends Controller {
/**
* 校验组别参数是否有效
* @param {string} group - 组别
* @returns {boolean} 是否有效
*/
_validateGroup(group) {
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
this.ctx.body = this.ctx.helper.error(400, '无效的组别');
return false;
}
return true;
}
/**
* 获取用户的报名信息及组别
* 直接从 Contestant 表获取(简化版数据库无 accounts/aggregation 表)
* @returns {Promise<Object|null>}
*/
async _getUserAggregation() {
const user = this.ctx.state.currentUser;
if (!user || !user.id) return null;
const contestant = await this.ctx.model.Contestant.findOne({
where: { id: user.id },
attributes: ['id', 'id_number', 'group', 'name', 'team_id'],
});
if (!contestant) return null;
// 团队归属解析(兼容两种来源):
// 1) 首选 contestant.team_id(导入流程填充的整数团队 uid,G1 锁主依据);
// 2) 回退到聚合报名表 aggregation.team_uid(避免真实种子仅建了聚合、未回填 contestant.team_id 时锁失效)。
let teamId = contestant.team_id;
if (!teamId) {
const agg = await this.ctx.model.Aggregation.findOne({
where: { account_uid: contestant.id },
attributes: ['team_uid'],
});
if (agg && agg.team_uid) teamId = agg.team_uid;
}
return {
aggregation_uid: contestant.id,
team: { group_name: contestant.group, team_id: teamId },
contestant,
};
}
/**
* 团队考试锁统一入口(原子:检查 + 占位):同一小组仅允许一人进入正式考试。
* 用进程内按 team_id 互斥锁包裹「检查他人是否已进 + 为自己写占位记录」,
* 消除并发竞态与绕过 questions 直接提交的旁路(G1 修复)。
* @returns {Promise<{allowed:boolean, lockedBy?:string}>}
*/
async _enterExamWithTeamLock(aggregation) {
if (!aggregation.team || !aggregation.team.team_id) { return { allowed: true }; }
const teamId = aggregation.team.team_id;
// 🔴 team_uid 仅组内唯一:互斥锁与队友判定均须带组别,否则不同组的同号队伍
// 会被互相串行化 + 误判为队友(4005 误锁)。
const group = aggregation.team.group_name;
return withTeamLock(`${group}:${teamId}`, async () => {
const lockInfo = await this.ctx.service.exam.checkTeamLock(teamId, aggregation.aggregation_uid, group);
if (lockInfo.locked) return { allowed: false, lockedBy: lockInfo.lockedBy };
// 抢占成功:为自己写占位 submission(同组他人此后将被 4005 拦截)
const rec = await this.ctx.service.exam.setTeamLock(teamId, aggregation.aggregation_uid);
return { allowed: true };
});
}
/**
* 获取考试时间状态
* GET /api/exam/:group/status
*
* 返回比赛是否激活、开始/结束时间、剩余秒数
*/
async status() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取考试时间状态
const status = await service.exam.getExamStatus(group);
// 获取用户的聚合信息,确认组别匹配
const aggregation = await this._getUserAggregation();
if (aggregation && aggregation.team) {
const userGroup = aggregation.team.group_name;
if (userGroup !== group) {
ctx.body = ctx.helper.error(403, '您不属于该组别');
return;
}
}
// 如果已提交过,附加已提交状态
let submitted = false;
if (aggregation) {
try {
submitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
} catch (e) {
ctx.logger.warn('[Exam] 查询提交状态失败:', e.message);
}
}
// 检查团队锁:同一小组只能有一人进入考试
let teamLocked = false;
let teamLockedBy = null;
if (aggregation && aggregation.team && aggregation.team.team_id) {
try {
const lockInfo = await service.exam.checkTeamLock(
aggregation.team.team_id,
aggregation.aggregation_uid,
aggregation.team.group_name
);
teamLocked = lockInfo.locked;
teamLockedBy = lockInfo.lockedBy || null;
} catch (e) {
ctx.logger.warn('[Exam] 查询团队锁状态失败:', e.message);
}
}
ctx.body = ctx.helper.success({
isActive: status.isActive,
startTime: status.startTime,
endTime: status.endTime,
countdown: status.countdown,
status: status.status,
submitted,
teamLocked,
teamLockedBy,
}, '获取考试状态成功');
} catch (err) {
ctx.logger.error('获取考试状态失败:', err);
ctx.body = ctx.helper.error(500, '获取考试状态失败');
}
}
/**
* 获取随机题目列表
* GET /api/exam/:group/questions
*
* 从题库中随机抽取指定数量的题目,排除答案字段保证安全
*/
async questions() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 验证考试是否活跃
const examStatus = await service.exam.getExamStatus(group);
if (!examStatus.isActive) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 校验组别
if (aggregation.team && aggregation.team.group_name !== group) {
ctx.body = ctx.helper.error(403, '您不属于该组别');
return;
}
// 团队锁:同一小组只能一人进入正式考试(原子检查+占位,G1 修复)
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
return;
}
// 抽题数量以 competition_times 配置为准(题目数量不足时 LIMIT 自动按题库实际封顶)
let choiceCount = 50;
let judgeCount = 20;
try {
const ct = await ctx.model.CompetitionTime.findOne({
where: { group },
attributes: ['choice_count', 'judge_count'],
raw: true,
});
if (ct) {
choiceCount = ct.choice_count != null ? ct.choice_count : 50;
judgeCount = ct.judge_count != null ? ct.judge_count : 20;
}
} catch (e) { /* 配置缺失时使用默认 50/20 */ }
// 取题:按考生固化题目顺序(首次进入随机生成并持久化,之后重进复用同一顺序,
// 避免「重进考试题目与顺序变化」缺陷)。顺序固化在 exam_submissions.question_order。
const { questions } = await service.exam.getAssignedQuestions(
aggregation.aggregation_uid,
group,
{ choiceCount, judgeCount }
);
// 题库实际数量:用于前端提示「配置抽取 N / 题库实际 M」,体现自适应
const [availableChoice, availableJudge] = await Promise.all([
ctx.model.ChoiceQuestion.count({ where: { group } }),
ctx.model.JudgeQuestion.count({ where: { group } }),
]);
ctx.body = ctx.helper.success({
group,
count: questions.length,
questions,
configuredChoice: choiceCount,
configuredJudge: judgeCount,
availableChoice,
availableJudge,
}, '获取题目成功');
} catch (err) {
ctx.logger.error('获取题目失败:', err);
ctx.body = ctx.helper.error(500, '获取题目失败');
}
}
/**
* 下载 Word 模板文件
* GET /api/exam/:group/word-template
*
* 以流式方式下载该组别的 Word 操作模板
*/
async wordTemplate() {
const { ctx, service, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 鉴权:Word 模板是文件下载(前端通过 fetch 触发,可能只在 URL 上带 ?token= 查询参数,
// 也可能带 Authorization 请求头)。这里自行校验 token,兼容两种方式,
// 避免被「只认 Authorization 头的 auth 中间件」误判为 401「未提供认证令牌」。
const authHeader = ctx.get('Authorization');
const tokenFromQuery = ctx.query.token;
const token = (authHeader && authHeader.startsWith('Bearer '))
? authHeader.slice(7)
: tokenFromQuery;
if (!token) {
ctx.status = 401;
ctx.body = ctx.helper.error(401, '未提供认证令牌,请先登录');
return;
}
try {
const decoded = jwt.verify(token, app.config.jwt.secret);
ctx.state.currentUser = {
id: decoded.id || decoded.uid || 0,
uid: decoded.uid || decoded.id || 0,
id_number: decoded.id_number || '',
phone: decoded.phone || '',
role: decoded.role,
};
} catch (e) {
ctx.status = 401;
ctx.body = ctx.helper.error(401, '认证令牌无效或已过期,请重新登录');
return;
}
// 获取模板路径
const templatePath = await service.exam.getWordTemplatePath(group);
if (!templatePath) {
ctx.body = ctx.helper.error(4002, 'Word 模板尚未上传');
return;
}
// 构建完整文件路径(保留「模板未上传」早返回:service 可能返回不存在路径)
const fullPath = path.isAbsolute(templatePath)
? templatePath
: path.join(app.baseDir, 'app/public', templatePath);
// 检查文件是否存在(早返回,沿用原错误码 4003)
if (!fs.existsSync(fullPath)) {
ctx.body = ctx.helper.error(4003, '模板文件不存在');
return;
}
// 🔴 同源 collapse(缺口9 修复):统一走 sendFile(磁盘校验 + X-File-Size 哨兵 + X-Accel 卸载)
const fileName = path.basename(fullPath);
return sendFile(ctx, app, {
relativePath: path.isAbsolute(templatePath) ? undefined : templatePath,
absolutePath: path.isAbsolute(templatePath) ? fullPath : undefined,
fileName,
contentType: 'application/octet-stream',
});
} catch (err) {
ctx.logger.error('下载模板失败:', err);
ctx.body = ctx.helper.error(500, '下载模板失败');
}
}
/**
* 提交答题答案
* POST /api/exam/:group/submit-answers
*
* 接收答案数组,自动判分并保存记录
* Body: { answers: [{ question_uid, answer_data }] }
*/
async submitAnswers() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 验证考试是否活跃
const examStatus = await service.exam.getExamStatus(group);
if (!examStatus.isActive) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
return;
}
// 参数校验
const rule = {
answers: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { answers } = ctx.request.body;
// 验证每个答案的数据格式(需要 question_id 和 question_type)
// 容错:question_type 缺失时按 uid 前缀(choice_x/judge_x)推导,
// 避免旧版前端缓存逐题保存时被「答案格式错误」拦截。
for (const ans of answers) {
if (!ans.question_id && !ans.question_uid) {
ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_id 或 question_uid');
return;
}
if (!ans.question_type) {
const uid = String(ans.question_uid || ans.question_id || '');
if (uid.startsWith('choice')) ans.question_type = 'choice';
else if (uid.startsWith('judge')) ans.question_type = 'judge';
}
if (!ans.question_type) {
ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_type(choice/judge/word)');
return;
}
}
// 自动判分
const scoreResult = await service.exam.autoScore(answers);
// 构建保存数据
const saveData = scoreResult.details.map(d => ({
question_uid: d.question_id,
question_type: d.question_type,
answer_data: d.answer_data,
is_correct: d.is_correct,
score: d.score,
}));
// 保存答题记录
const savedRecords = await service.exam.saveAnswers(
aggregation.aggregation_uid,
saveData,
group
);
// ⚠️ 此处【不】调用 ensureExamSubmission:逐题保存只落库客观题答案(saveAnswers),
// 绝不写 exam_submissions.submit_time。占位记录由 _enterExamWithTeamLock→setTeamLock
// 以 submit_time=null 创建。若在此写入 submit_time,选手答一题即被 hasSubmitted 判为
// 「已提交」,导致意外退出后无法重新进入考试(考试组重进需求的直接根因)。
// 成绩门控:自动评分结果只落库供管理后台使用,绝不回传给考生。
// 历史实现直接返回 autoScore/total/details,考生在浏览器网络面板即可看到客观题得分,
// 与「管理后台评分并发布后考生才能看到成绩」的业务约束冲突。
ctx.body = ctx.helper.success({
savedCount: savedRecords.length,
}, '答案提交成功');
} catch (err) {
ctx.logger.error('提交答案失败:', err);
ctx.body = ctx.helper.error(500, '提交答案失败');
}
}
/**
* 上传 Word 操作文件
* POST /api/exam/:group/upload-word
*
* 处理 Word 文件上传,保存到对应组别的目录中
*/
async uploadWord() {
const { ctx, service, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 获取上传的文件流
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到文件');
return;
}
// 验证文件扩展名(仅允许 .doc / .docx)
const ext = path.extname(stream.filename).toLowerCase();
if (!['.doc', '.docx'].includes(ext)) {
ctx.body = ctx.helper.error(400, '仅支持 .doc / .docx 格式的 Word 文件');
return;
}
// 验证文件大小(最大 50MB)
const maxSize = 50 * 1024 * 1024; // 50MB
// 从请求头获取文件大小
const contentLength = parseInt(ctx.get('content-length')) || 0;
if (contentLength > maxSize) {
ctx.body = ctx.helper.error(400, '文件大小超过限制(最大 50MB)');
return;
}
// 🔴 文件 IO 并发护栏:与分片上传/合并共享配额,极端并发下统一限流,
// 超出返回 429 由前端退避重试,避免单 worker fd/事件循环耗尽。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 构建存储目录:app/public/answers/{group}/
const uploadDir = path.join(app.baseDir, 'app/public/answers', group);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 构建文件名:{aggregation_uid}_{timestamp}_{原始文件名}
const timestamp = Date.now();
const safeFileName = `exam_${aggregation.aggregation_uid}_${timestamp}${ext}`;
const filePath = path.join(uploadDir, safeFileName);
// 将文件流写入磁盘
const writeStream = fs.createWriteStream(filePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
// 计算文件 MD5
const md5 = await service.exam.calculateFileMd5(filePath);
// 构建相对路径(相对于 app/public/)
const relativePath = path.join('answers', group, safeFileName).replace(/\\/g, '/');
// 从 query 或 body 获取关联的题目 ID
const questionUid = ctx.query.question_uid
|| (ctx.request.body && ctx.request.body.question_uid);
// 保存文件信息到数据库
const fileInfo = {
filePath: relativePath,
fileName: stream.filename,
md5,
};
const record = await service.exam.saveWordFile(
aggregation.aggregation_uid,
questionUid || 0,
group,
fileInfo
);
ctx.body = ctx.helper.success({
id: record.id,
fileName: stream.filename,
filePath: relativePath,
fileUrl: ctx.helper.fileUrl(relativePath),
fileSize: contentLength,
md5,
}, 'Word 文件上传成功');
} finally {
releaseFileIo();
}
} catch (err) {
ctx.logger.error('上传 Word 文件失败:', err);
ctx.body = ctx.helper.error(500, '上传 Word 文件失败');
}
}
/**
* 删除已上传的 Word 答题文件(中职/高职组)
* DELETE /api/exam/:group/word
*
* 仅允许删除当前登录用户本人的 Word 文件:清空 exam_submissions 中的
* word_file_path / word_file_name,并尽力删除磁盘物理文件(文件不存在时静默忽略)。
* 注意:删除 Word 文件不影响「正式提交」状态(submit_time 不变),仅移除附件。
*/
async deleteWord() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
if (!['zhongzhi', 'gaozhi'].includes(group)) {
ctx.body = this.ctx.helper.error(400, '仅中职组/高职组支持删除 Word 答题文件');
return;
}
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const record = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregation.aggregation_uid },
});
if (!record || !record.word_file_path) {
ctx.body = ctx.helper.error(404, '未找到已上传的 Word 文件');
return;
}
// 尽力删除磁盘物理文件(沙箱安全钩子可能拦截 unlink,文件不存在时忽略)
const absPath = path.join(app.baseDir, 'app/public', record.word_file_path);
try {
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
} catch (e) {
ctx.logger.warn('删除 Word 物理文件失败(忽略):', e.message);
}
// 清空数据库中的文件信息
await record.update({ word_file_path: null, word_file_name: null });
ctx.body = ctx.helper.success({ deleted: true }, 'Word 文件已删除');
} catch (err) {
ctx.logger.error('删除 Word 文件失败:', err);
ctx.body = ctx.helper.error(500, '删除 Word 文件失败');
}
}
/**
* 一体提交:答案 + Word 文件
* POST /api/exam/:group/submit-all
*
* 验证考试活跃状态,处理答案提交并确保 Word 文件已上传
* Body: { answers: [{ question_uid, answer_data }] }
*/
async submitAll() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库,
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。
const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true;
// 验证考试是否活跃
const examStatus = await service.exam.getExamStatus(group);
if (!examStatus.isActive && !forceSubmit) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
const teamGate = await this._enterExamWithTeamLock(aggregation);
if (!teamGate.allowed) {
ctx.body = ctx.helper.error(4005, '您的小组已进入考试');
return;
}
// 参数校验
const rule = {
answers: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
} catch (validateErr) {
ctx.body = ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message);
return;
}
const { answers } = ctx.request.body;
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
// 容错:question_type 缺失时按 uid 前缀(choice_x/judge_x)推导,
// 避免恢复会话等场景 questions 未加载导致整卷被「答案格式错误」拦截。
for (const ans of answers) {
if (!ans.question_uid || ans.answer_data === undefined) {
ctx.body = ctx.helper.error(400, '答案格式错误,需要 question_uid 和 answer_data');
return;
}
if (!ans.question_type) {
const uid = String(ans.question_uid);
if (uid.startsWith('choice')) ans.question_type = 'choice';
else if (uid.startsWith('judge')) ans.question_type = 'judge';
}
if (!ans.question_type || !['choice', 'judge'].includes(ans.question_type)) {
ctx.body = ctx.helper.error(400, '答案格式错误,需要合法的 question_type(choice/judge)');
return;
}
}
// 校验是否所有题目都已作答:以【出题数量】为基准(前端回传 totalQuestions),
// 避免与随机抽题基准不一致。原先使用全库 999 比对,会导致只抽到 N 题却要求答满全库的悖论。
const expectedCount = parseInt(ctx.request.body.totalQuestions) || answers.length;
if (answers.length < expectedCount && !forceSubmit) {
ctx.body = ctx.helper.error(4004, `还有 ${expectedCount - answers.length} 道题目未作答`);
return;
}
// 检查是否已上传 Word 文件
const existingSubmission = await service.exam.getUserAnswers(
aggregation.aggregation_uid,
group
);
const hasWordFile = existingSubmission.some(s => s.word_file_path);
// 自动判分
const scoreResult = await service.exam.autoScore(answers);
// 构建保存数据
const saveData = scoreResult.details.map(d => ({
question_uid: d.question_id,
question_type: d.question_type,
answer_data: d.answer_data,
is_correct: d.is_correct,
score: d.score,
}));
// 保存答题记录
const savedRecords = await service.exam.saveAnswers(
aggregation.aggregation_uid,
saveData,
group
);
// 服务端三重断言:事务提交 ≠ 一定落库(MariaDB 不支持 RETURNING / 死锁回滚等
// 都会导致「内存返回成功实例、磁盘 0 行」的静默丢数据)。故提交后实查持久化条数,
// 与实际应保存条数比对,缺失即视为提交失败,绝不明文返回「提交成功」。
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const persisted = await SubmissionModel.count({
where: { aggregation_uid: aggregation.aggregation_uid },
});
if (!persisted || persisted < answers.length) {
ctx.logger.error(`[submitAll] 落库不完整 agg=${aggregation.aggregation_uid} persisted=${persisted} expected=${answers.length}`);
ctx.body = ctx.helper.error(500, `提交失败:答案保存不完整(已保存 ${persisted || 0}/${answers.length} 题),请重试`);
return;
}
// 同步写入 exam_submissions 汇总行(answers JSON + submit_time),
// 供选手端 hasSubmitted / 管理后台「考生答题记录」提交状态 / 试卷回看使用。
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
ctx.body = ctx.helper.success({
answeredCount: answers.length,
totalQuestions: expectedCount,
hasWordFile,
savedCount: persisted,
submitted: true,
submitTime: new Date().toISOString(),
}, '提交成功');
} catch (err) {
ctx.logger.error('一体提交失败:', err);
ctx.body = ctx.helper.error(500, '提交失败');
}
}
/**
* 获取已有答案
* GET /api/exam/:group/answers
*
* 获取用户已提交的答案,用于考试重新进入时恢复状态
*/
async getAnswers() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 获取已有答案
const answers = await service.exam.getUserAnswers(
aggregation.aggregation_uid,
group
);
// 成绩门控:未发布前,答案回看不得携带逐题得分与对错标记。
// 否则考生把 score 累加即可得出客观题总分,完全绕过「管理后台发布后才可见」的约束。
// 答案内容本身保留(前端用于恢复/回看作答)。
const isPublished = await service.admin.isScorePublished(group);
const safeAnswers = answers.map(row => {
const item = typeof row.toJSON === 'function' ? row.toJSON() : { ...row };
if (!isPublished) {
delete item.score;
delete item.is_correct;
}
// 还原 question_uid 为前端键格式(choice_x / judge_x):DB 中 zhongzhi/gaozhi_submissions
// .question_uid 经 _extractRawId 存为数字 id(int 列),而前端 answers 以 'choice_x' 字符串为键,
// 若直接回传数字 id,重进恢复答案时键对不上 → 已答题目全部丢失。补回题型前缀保证可恢复;
// 同时保留 raw_question_uid 供后台阅卷/回看按数字 id 定位题目。
if (item.question_type && item.question_uid != null) {
const prefix = item.question_type === 'judge' ? 'judge_' : 'choice_';
item.raw_question_uid = item.question_uid;
item.question_uid = prefix + item.question_uid;
}
return item;
});
ctx.body = ctx.helper.success({
answers: safeAnswers,
scoreVisible: isPublished,
}, '获取已有答案成功');
} catch (err) {
ctx.logger.error('获取已有答案失败:', err);
ctx.body = ctx.helper.error(500, '获取已有答案失败');
}
}
}
module.exports = ExamController;

42
app/controller/health.js Normal file
View File

@@ -0,0 +1,42 @@
'use strict';
const Controller = require('egg').Controller;
/**
* 健康检查控制器 - HealthController
*
* 提供容器探针 / 负载均衡探活使用的轻量级接口。
* 接口:GET /api/health(公开,无需认证)
* 返回统一信封 { code: 0, data: { status, db, timestamp, uptime } }。
*
* 设计说明:
* - 进程存活即返回 HTTP 200;
* - db 字段反映数据库连通性('ok' / 'down'),便于运维监控但不会导致探针因瞬时抖动而误重启。
*/
class HealthController extends Controller {
async check() {
const { ctx, app } = this;
let db = 'ok';
try {
// egg-sequelize 将 Sequelize 实例直接挂载在 app.model 上(即 delegate)
await app.model.authenticate();
} catch (e) {
db = 'down';
ctx.logger.warn('[健康检查] 数据库连通性异常:', e.message);
}
ctx.status = 200;
ctx.body = ctx.helper.success(
{
status: 'ok',
db,
timestamp: Date.now(),
uptime: Math.floor(process.uptime()),
},
'ok'
);
}
}
module.exports = HealthController;

119
app/controller/materials.js Normal file
View File

@@ -0,0 +1,119 @@
'use strict';
const Controller = require('egg').Controller;
const fs = require('fs');
const path = require('path');
// 🔴 同源 collapse(缺口9 修复):文件发送统一走 sendFile util
const { sendFile } = require('../utils/sendFile');
/**
* 资料下载控制器 - MaterialsController
*
* 负责比赛相关资料的查询和文件下载。
* 从 app/public/materials/{group}/ 目录读取管理员上传的备赛资料。
*
* 主要接口:
* - GET /api/materials?group={group} : 获取资料列表
* - GET /api/materials/:group/:fileName/download : 流式下载指定文件
*/
class MaterialsController extends Controller {
/**
* 获取资料列表
*
* 读取 app/public/materials/{group}/ 目录下的所有文件,
* 返回文件名、大小、上传时间等信息。
*/
async index() {
const { ctx, app } = this;
const { group } = ctx.query;
// 如果未指定组别,使用当前用户的组别
const userGroup = group || (ctx.state.currentUser && ctx.state.currentUser.group);
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
const targetGroup = validGroups.includes(userGroup) ? userGroup : null;
if (!targetGroup) {
ctx.body = ctx.helper.success([], '获取资料列表成功');
return;
}
const materialsDir = path.join(app.baseDir, 'app/public', 'materials', targetGroup);
// 防御性读取:目录不存在或读取过程抛出异常(如生产环境目录权限/EACCES、
// 坏软链 ENOENT 等)都优雅返回空列表,绝不向上抛出——
// 否则异常会被 auth 中间件的 try/catch 兜底,被误报为 401「认证失败」,
// 导致前端误以为登录过期而将用户踢回登录页。
try {
if (!fs.existsSync(materialsDir)) {
ctx.body = ctx.helper.success([], '获取资料列表成功');
return;
}
// 逐文件 stat:单个文件 stat 失败(坏软链/权限)只跳过并告警,
// 绝不让一个坏文件把整份列表拖空(否则「刚上传的文件也不显示」)。
const files = [];
for (const f of fs.readdirSync(materialsDir)) {
if (f.startsWith('.')) continue;
try {
const stat = fs.statSync(path.join(materialsDir, f));
const ext = path.extname(f).toLowerCase();
files.push({
id: encodeURIComponent(f),
fileName: f,
name: f,
size: stat.size,
type: ext.replace('.', ''),
uploadTime: stat.mtime.toISOString(),
});
} catch (e) {
app.logger.warn(
`[materials.index] 跳过无法 stat 的文件 group=${targetGroup} file=${f}:` + (e && e.message ? e.message : e)
);
}
}
files.sort((a, b) => new Date(b.uploadTime) - new Date(a.uploadTime));
ctx.body = ctx.helper.success(files, '获取资料列表成功');
} catch (err) {
// 记录真实错误到日志,方便运维定位(权限/软链/磁盘问题)
app.logger.error(
`[materials] 读取备赛资料目录失败 group=${targetGroup} path=${materialsDir}:` +
(err && err.stack ? err.stack : err)
);
// 不因磁盘异常影响整个页面,返回空列表(前端不再被误踢)
ctx.body = ctx.helper.success([], '获取资料列表成功');
}
}
/**
* 流式下载文件
*
* 通过 URL 参数 :group 和 :fileName 定位文件,
* 读取并通过流式响应发送给客户端。
*/
async download() {
const { ctx, app } = this;
const { group, fileName } = ctx.params;
const decodedFileName = decodeURIComponent(fileName);
// 防止路径穿越攻击
if (decodedFileName.includes('..') || decodedFileName.includes('/') || decodedFileName.includes('\\')) {
ctx.body = ctx.helper.error(400, '无效的文件名');
return;
}
const validGroups = ['zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
ctx.body = ctx.helper.error(400, '无效的组别');
return;
}
// 🔴 同源 collapse(缺口9 修复):统一走 sendFile(磁盘校验 + X-File-Size 哨兵 + X-Accel 卸载)
const relativePath = `materials/${group}/${decodedFileName}`;
return sendFile(ctx, app, { relativePath, fileName: decodedFileName, contentType: 'application/octet-stream' });
}
}
module.exports = MaterialsController;

70
app/controller/news.js Normal file
View File

@@ -0,0 +1,70 @@
'use strict';
const Controller = require('egg').Controller;
/**
* 新闻控制器 - NewsController
*
* 负责新闻通知的查询和展示。
* 支持按 group 组别过滤、按 pinned 置顶过滤、分页查询。
*
* 主要接口:
* - GET /api/news : 获取新闻列表(支持 ?group= & ?pinned= & ?page= & ?pageSize= 参数)
* - GET /api/news/:id : 获取新闻详情
*/
class NewsController extends Controller {
/**
* 获取新闻列表
*
* Query 参数:
* - group: 组别筛选(如 zhongzhi/gaozhi/benke/jiaoshi),不传则返回全部
* - pinned: 是否仅返回置顶新闻(true/false),不传则返回全部
* - page: 页码,默认 1
* - pageSize: 每页数量,默认 20
*
* 排序规则:置顶优先(pinned DESC),然后按发布时间降序
*/
async index() {
const { ctx, service } = this;
const { group, pinned, page, pageSize } = ctx.query;
const result = await service.news.list({ group, pinned, page, pageSize });
ctx.body = ctx.helper.success(result, '获取新闻列表成功');
}
/**
* 获取新闻详情
*
* 通过 URL 参数 :id 获取单条新闻的完整信息。
*/
async show() {
const { ctx, service } = this;
const { id } = ctx.params;
const news = await service.news.findById(id);
if (!news) {
ctx.body = ctx.helper.error(4001, '新闻不存在');
return;
}
ctx.body = ctx.helper.success(news, '获取新闻详情成功');
}
/**
* 获取各分组新闻计数
* GET /api/news/counts
*
* 返回各分组可见新闻数量,用于首页 Tab 显示
*/
async counts() {
const { ctx, service } = this;
const result = await service.news.getGroupCounts();
ctx.body = ctx.helper.success(result, '获取分组计数成功');
}
}
module.exports = NewsController;

230
app/controller/score.js Normal file
View File

@@ -0,0 +1,230 @@
'use strict';
const Controller = require('egg').Controller;
/**
* 成绩查询控制器 - ScoreController
*
* 负责选手查询个人成绩,包括答题得分、作品评分和总分。
* 成绩发布后才能查看。
*
* 主要接口:
* - GET /api/score : 获取当前选手的(队伍)成绩详情
*
* 设计目标:一人考试,全组(同队伍)都能查看该队伍成绩。
* - 考试以「队伍」为单位,仅一名成员(考试操作员)实际进入并作答;
* 成绩(客观题/作品/演示阅卷分)落在操作员对应的记录上。
* - 同队其他成员查看时,须解析到本队「考试操作员」的成绩并一致返回,
* 而非各自读取自己(未考试、得分恒为 0)的行。
* - 队伍身份以 aggregation.team_uid 为准(与考试锁 _enterExamWithTeamLock 一致),
* 回退到 contestants.team_id(与 teams.id 关联)。
*/
class ScoreController extends Controller {
/**
* 解析「队伍成绩来源」:
* 返回当前用户所在队伍中实际产生成绩的「考试操作员」信息,
* 包括其聚合键(用于客观题/提交记录归集)与分项分。
* @returns {Promise<Object|null>}
* { group, isExam, operatorId, operator, aggKeys, viewerName }
*/
async _resolveTeamScoreSource() {
const { ctx } = this;
const user = ctx.state.currentUser;
if (!user || !user.id) return null;
const me = await ctx.model.Contestant.findOne({
where: { id: user.id },
attributes: ['id', 'team_id', 'group', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'],
});
if (!me || !me.group) return null;
const group = me.group;
const isExam = group === 'zhongzhi' || group === 'gaozhi';
const Op = ctx.model.Sequelize.Op;
// 1) 收集同队成员 contestant id(队伍身份 = aggregation.team_uid,回退 contestants.team_id)
// 必须按 me.group 限定:运行库中 team_uid 仅在本组内唯一(不同组可能复用同一数值,
// 例如 benke 与 zhongzhi 都可能有 team_uid=1 但分属不同队),不限定 group 会把他组
// 成员错误并入本队,导致「一人考试全组看分」归集串组(见 verify_team_uid_consistency.py C2)。
let memberIds = [me.id];
const myAgg = await ctx.model.Aggregation.findOne({
where: { account_uid: me.id },
attributes: ['aggregation_uid', 'team_uid'],
});
if (myAgg && myAgg.team_uid) {
const aggs = await ctx.model.Aggregation.findAll({
where: { team_uid: myAgg.team_uid, group: me.group },
attributes: ['account_uid'],
});
const ids = aggs.map(a => a.account_uid).filter(Boolean);
if (ids.length) memberIds = ids;
} else if (me.team_id) {
const members = await ctx.model.Contestant.findAll({
where: { team_id: me.team_id, group: me.group },
attributes: ['id'],
});
const ids = members.map(m => m.id);
if (ids.length) memberIds = ids;
}
// 2) 找到「考试操作员」:同队中实际产生提交/成绩的选手
let operatorId = me.id;
if (isExam) {
// 客观题考试以 exam_submissions.contestant_id 唯一标记操作员(UNIQUE 约束,仅一人)
const sub = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: memberIds },
attributes: ['contestant_id'],
order: [['submit_time', 'DESC']],
});
if (sub) operatorId = sub.contestant_id;
} else {
// 本科/教师:作品/演示阅卷分落在「考试操作员」的 contestants 行。
// 操作员判定规则(用户决策 #1):
// 1) 优先以 benke_submissions / jiaoshi_submissions 的提交记录存在性定操作员;
// 2) 同队成员中无 / 多人有提交记录时,以 (word_score+ppt_score) 最高者兜底,保证确定性。
// 提交表以 aggregation_uid 归集(≠ contestant_id),须先反查 account_uid -> aggregation_uid。
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 取同队成员的 aggregation_uid 映射(按当前组过滤,避免跨组行干扰)
const memberAggs = await ctx.model.Aggregation.findAll({
where: { account_uid: memberIds, group },
attributes: ['account_uid', 'aggregation_uid'],
raw: true,
});
const aggUidByAccount = {};
for (const a of memberAggs) aggUidByAccount[a.account_uid] = a.aggregation_uid;
const graded = await ctx.model.Contestant.findAll({
where: { id: memberIds },
attributes: ['id', 'word_score', 'ppt_score'],
});
// 统计「有提交记录」的成员(提交记录以 aggregation_uid 存在为准)
const submittedIds = [];
for (const c of graded) {
const aggUid = aggUidByAccount[c.id];
if (!aggUid) continue;
const sub = await SubmissionModel.findOne({
where: { aggregation_uid: aggUid },
attributes: ['id'],
});
if (sub) submittedIds.push(c.id);
}
// 兜底:总分最高者(无 / 多人有提交记录时皆以此定操作员)
let best = { id: me.id, total: -1 };
for (const c of graded) {
const t = (c.word_score || 0) + (c.ppt_score || 0);
if (t > best.total) best = { id: c.id, total: t };
}
if (submittedIds.length >= 1) {
// 有提交记录:唯一则取其;多人(异常)取总分最高者兜底
operatorId = submittedIds.length === 1 ? submittedIds[0] : best.id;
} else {
operatorId = best.id;
}
}
// 3) 取操作员的聚合键(用于客观题/提交记录归集)与分项分
const opAgg = await ctx.model.Aggregation.findOne({
where: { account_uid: operatorId },
attributes: ['aggregation_uid'],
});
const operatorAggUid = opAgg ? opAgg.aggregation_uid : null;
const operator = await ctx.model.Contestant.findOne({
where: { id: operatorId },
attributes: ['id', 'name', 'word_score', 'ppt_score', 'score', 'exam_score'],
});
const keys = [String(operatorId)];
if (operatorAggUid) keys.push(String(operatorAggUid));
return { group, isExam, operatorId, operator, aggKeys: keys, viewerName: me.name };
}
/**
* 获取当前选手的(队伍)成绩
* GET /api/score
*
* 流程:先检查成绩是否已发布,再解析本队考试操作员并查询其得分。
* 返回:总分、计分式说明、是否已发布。
*/
async index() {
const { ctx, service } = this;
try {
const user = ctx.state.currentUser;
// 仅选手可查看成绩
if (!user || user.role !== 'contestant') {
ctx.body = ctx.helper.error(403, '无权限查看成绩');
return;
}
// 解析队伍成绩来源(含同队归集:一人考试全组可看)
const src = await this._resolveTeamScoreSource();
if (!src) {
ctx.body = ctx.helper.error(404, '未找到您的报名信息');
return;
}
const { group, isExam, operator, aggKeys, viewerName } = src;
// 检查该组成绩是否已发布(按组门控:发布本科组不得让中职组考生看到成绩)
const isPublished = await service.admin.isScorePublished(group);
if (!isPublished) {
ctx.body = ctx.helper.success({
score: null,
details: null,
published: false,
message: '成绩尚未发布,请耐心等待',
}, '获取成绩状态成功');
return;
}
// 按组别返回分项成绩(与后台评分公式一致)
let examScore = undefined;
if (isExam) {
// 客观题分数取逐题自动评分合计;聚合键用「操作员 contestant.id」与「操作员真实 aggregation_uid」双键归集。
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
const rows = await SubmissionModel.findAll({
where: { aggregation_uid: aggKeys },
attributes: ['score'],
raw: true,
});
examScore = rows.reduce((sum, r) => sum + (r.score || 0), 0);
}
// 分项分从「操作员」行取(其实际产生成绩),而非查看者本人。
const wordScore = operator.word_score || 0;
const pptScore = isExam ? undefined : (operator.ppt_score || 0);
// 总分 = 客观题 + Word 分(中职/高职);Word 分 + PPT 分(本科/教师)。
const score = isExam
? (examScore + wordScore)
: (wordScore + (pptScore || 0));
const formula = isExam
? '客观题系统评分 + Word 文档阅卷评分'
: 'Word 文档阅卷评分 + PPT 演示阅卷评分';
// 注意:考生端只回传「总分(score)」与计分式说明(formula),
// 不得回传分项分数(examScore/wordScore/pptScore)——分数分布仅管理后台可见。
// nameCn 用查看者本人姓名(各成员看到自己的名字 + 本队共享总分)。
ctx.body = ctx.helper.success({
score,
nameCn: viewerName || '',
group,
published: true,
details: null,
formula,
}, '获取成绩成功');
} catch (err) {
ctx.logger.error('获取成绩失败:', err);
ctx.body = ctx.helper.error(500, '获取成绩失败');
}
}
}
module.exports = ScoreController;

478
app/controller/upload.js Normal file
View File

@@ -0,0 +1,478 @@
'use strict';
const Controller = require('egg').Controller;
const path = require('path');
const fs = require('fs');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
/**
* 上传控制器 - UploadController
*
* 负责作品文件上传相关功能,包括普通上传、分片上传、分片查询、
* 合并分片、提交状态查询等。适用于本科组和教师组。
*
* 主要接口:
* - POST /api/upload/:group : 上传作品文件
* - POST /api/upload/:group/chunk : 上传分片
* - POST /api/upload/:group/merge : 合并分片
* - GET /api/upload/:group/chunk-status : 查询分片状态
* - GET /api/upload/:group/status : 查询提交状态
* - GET /api/upload/:group/submissions : 查询提交历史
*/
class UploadController extends Controller {
/**
* 校验组别参数
* @param {string} group - 组别
* @returns {boolean} 是否有效
*/
_validateGroup(group) {
const validGroups = ['benke', 'jiaoshi'];
if (!validGroups.includes(group)) {
this.ctx.body = this.ctx.helper.error(400, '无效的组别,仅支持 benke / jiaoshi');
return false;
}
return true;
}
/**
* 获取用户的聚合报名信息
* @returns {Promise<Object|null>}
*/
async _getUserAggregation() {
const user = this.ctx.state.currentUser;
if (!user || !user.uid) return null;
return await this.service.upload.getUserAggregation(user.uid);
}
/**
* 上传作品文件
* POST /api/upload/:group
*
* 支持 .pdf / .doc / .docx / .ppt / .pptx / .zip 格式,最大 50MB
*/
async upload() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
ctx.body = ctx.helper.error(6001, deadlineCheck.message);
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 验证组别匹配
if (aggregation.team && aggregation.team.group_name !== group) {
ctx.body = ctx.helper.error(403, '您不属于该组别');
return;
}
// 获取上传的文件流
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到文件');
return;
}
// 验证文件扩展名
const allowedExts = ['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.zip'];
const ext = path.extname(stream.filename).toLowerCase();
if (!allowedExts.includes(ext)) {
ctx.body = ctx.helper.error(6002, '不支持的文件格式,仅支持 pdf/doc/docx/ppt/pptx/zip');
return;
}
// 验证文件大小:zip 最大 200MB,其余格式最大 100MB(从请求头判断)
const contentLength = parseInt(ctx.get('content-length')) || 0;
const isZip = ext === '.zip';
const maxSize = isZip ? 200 * 1024 * 1024 : 100 * 1024 * 1024;
const limitLabel = isZip ? '200MB' : '100MB';
if (contentLength > maxSize) {
ctx.body = ctx.helper.error(6003, `文件大小超过限制(最大 ${limitLabel})`);
return;
}
// 保存文件
const fileInfo = await service.upload.saveWorkFile(stream, stream.filename, group);
// 获取作品名称(流式模式字段在 stream.fields 中)
const workName = (stream.fields && stream.fields.work_name) || stream.filename;
// 文件类型:word / ppt(前端上传时透传)
const fileType = (stream.fields && stream.fields.file_type) || 'word';
// 保存提交记录
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
group,
fileInfo,
workName,
fileType
);
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
filePath: fileInfo.filePath,
fileUrl: ctx.helper.fileUrl(fileInfo.filePath),
fileName: fileInfo.fileName,
fileSize: fileInfo.fileSize,
md5: fileInfo.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '文件上传成功');
} catch (err) {
ctx.logger.error('文件上传失败:', err);
const msg = (err && err.message) ? err.message : '未知错误';
ctx.body = ctx.helper.error(500, msg);
}
}
/**
* 上传分片
* POST /api/upload/:group/chunk
*
* Body: multipart/form-data 包含 chunk(文件分片)、chunkIndex、totalChunks、fileId、fileName
*/
async uploadChunk() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 🔴 文件 IO 并发护栏:先于读取请求体获取配额,提供连接级背压;
// 极端并发(无带宽限制 + 单 worker)下超出上限则返回 429,
// 由前端 uploadWithRetry 指数退避重试,避免 fd/事件循环被压垮雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 获取上传的文件流(流式模式下,非文件字段需在消费流后从 ctx.request.body 读取)
const stream = await ctx.getFileStream();
if (!stream) {
ctx.body = ctx.helper.error(400, '未接收到分片数据');
return;
}
// 流式模式下,非文件字段在 stream.fields 中(非 ctx.request.body)
const { chunkIndex, totalChunks, fileId, fileName } = stream.fields || {};
if (chunkIndex === undefined || !totalChunks || !fileId || !fileName) {
ctx.body = ctx.helper.error(400, '缺少必要参数:chunkIndex, totalChunks, fileId, fileName');
return;
}
// 构建临时分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// 保存分片文件
const chunkFilePath = path.join(tempDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
const writeStream = fs.createWriteStream(chunkFilePath);
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
stream.on('error', reject);
});
ctx.body = ctx.helper.success({
received: parseInt(chunkIndex),
totalChunks: parseInt(totalChunks),
fileId,
}, `分片 ${chunkIndex} 上传成功`);
} finally {
releaseFileIo();
}
} catch (err) {
ctx.logger.error('分片上传失败:', err);
ctx.body = ctx.helper.error(500, '分片上传失败');
}
}
/**
* 合并分片
* POST /api/upload/:group/merge
*
* Body: { fileId, fileName, totalChunks, work_name }
*/
async mergeChunks() {
const { ctx, service, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 验证截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
if (!deadlineCheck.valid) {
ctx.body = ctx.helper.error(6001, deadlineCheck.message);
return;
}
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const { fileId, fileName, totalChunks, work_name, file_type } = ctx.request.body;
if (!fileId || !fileName) {
ctx.body = ctx.helper.error(400, '缺少必要参数:fileId, fileName');
return;
}
// 检查分片目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
ctx.body = ctx.helper.error(6004, '未找到分片文件');
return;
}
// 收集所有分片文件并按文件名排序
const chunkFiles = fs.readdirSync(tempDir)
.filter(f => f.startsWith('chunk_'))
.sort()
.map(f => path.join(tempDir, f));
const expectedCount = parseInt(totalChunks) || 0;
if (expectedCount > 0 && chunkFiles.length < expectedCount) {
ctx.body = ctx.helper.error(6005, `分片未完成,已收到 ${chunkFiles.length}/${expectedCount}`);
return;
}
// 构建目标文件路径
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const ext = path.extname(fileName);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const targetPath = path.join(uploadDir, safeFileName);
// 🔴 文件 IO 并发护栏:分片合并是重 IO,与 uploadChunk 共享同一配额,
// 极端并发(无带宽限制 + 单 worker)下被统一限流,避免 fd/事件循环耗尽雪崩。
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,上传通道暂满,请稍后重试');
return;
}
try {
// 合并分片
const mergeResult = await service.upload.mergeFileChunks(chunkFiles, targetPath);
// 🔴 删除临时目录:best-effort,**绝不能让清理失败把已成功的合并判成 500**。
// 旧代码是裸 fs.rmdirSync,删除失败(杀软锁文件/句柄未释放/安全钩子拦截)会
// 抛到外层 catch → 返回「分片合并失败」,而目标文件已落盘且 DB 记录还没写,
// 直接产出孤儿文件 + 数据不一致 + 用户重试再生孤儿。清理属副作用,不是业务结果。
ctx.helper.safeCleanup(tempDir, { label: 'mergeChunks-tmp' });
// 构建相对路径
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
// 保存提交记录
const fileInfo = {
filePath: relativePath,
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
};
const record = await service.upload.saveSubmissionRecord(
aggregation.aggregation_uid,
group,
fileInfo,
work_name || fileName,
file_type || 'word'
);
ctx.body = ctx.helper.success({
id: record.id,
workName: record.work_name,
filePath: relativePath,
fileUrl: ctx.helper.fileUrl(relativePath),
fileName,
fileSize: mergeResult.fileSize,
md5: mergeResult.md5,
uploadTime: record.upload_time || new Date().toISOString(),
}, '分片合并成功');
} finally {
releaseFileIo();
}
} catch (err) {
ctx.logger.error('分片合并失败:', err);
ctx.body = ctx.helper.error(500, '分片合并失败');
}
}
/**
* 查询分片上传进度
* GET /api/upload/:group/chunk-status?fileId=xxx
*/
async chunkStatus() {
const { ctx, app } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
const { fileId } = ctx.query;
if (!fileId) {
ctx.body = ctx.helper.error(400, '缺少 fileId 参数');
return;
}
// 检查临时目录
const tempDir = path.join(app.baseDir, 'app/public/tmp', group, fileId);
if (!fs.existsSync(tempDir)) {
ctx.body = ctx.helper.success({
received: 0,
chunks: [],
}, '尚未收到分片');
return;
}
const chunkFiles = fs.readdirSync(tempDir)
.filter(f => f.startsWith('chunk_'))
.sort();
ctx.body = ctx.helper.success({
received: chunkFiles.length,
chunks: chunkFiles.map(f => f.replace('chunk_', '')),
}, '获取分片状态成功');
} catch (err) {
ctx.logger.error('查询分片状态失败:', err);
ctx.body = ctx.helper.error(500, '查询分片状态失败');
}
}
/**
* 查询当前用户的提交状态
* GET /api/upload/:group/status
*/
async status() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 检查提交状态
const submitted = await service.upload.hasSubmitted(aggregation.aggregation_uid, group);
// 检查截止时间
const deadlineCheck = await service.upload.validateDeadline(group);
ctx.body = ctx.helper.success({
submitted,
deadline: deadlineCheck.deadline,
canSubmit: deadlineCheck.valid,
}, '获取提交状态成功');
} catch (err) {
ctx.logger.error('查询提交状态失败:', err);
ctx.body = ctx.helper.error(500, '查询提交状态失败');
}
}
/**
* 删除已上传的作品文件
* DELETE /api/upload/:group/:id
*
* 仅允许删除当前登录用户本人的提交记录,并同步删除物理文件。
*/
async delete() {
const { ctx, service } = this;
try {
const { group, id } = ctx.params;
if (!this._validateGroup(group)) return;
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
const numId = parseInt(id, 10);
if (!Number.isInteger(numId)) {
ctx.body = ctx.helper.error(400, '无效的文件 ID');
return;
}
const ok = await service.upload.deleteSubmission(aggregation.aggregation_uid, group, numId);
if (!ok) {
ctx.body = ctx.helper.error(404, '未找到该文件或无权删除');
return;
}
ctx.body = ctx.helper.success({ id: numId }, '文件已删除');
} catch (err) {
ctx.logger.error('删除提交文件失败:', err);
ctx.body = ctx.helper.error(500, '删除失败');
}
}
/**
* 查询当前用户的提交历史
* GET /api/upload/:group/submissions
*/
async submissions() {
const { ctx, service } = this;
try {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// 获取用户聚合信息
const aggregation = await this._getUserAggregation();
if (!aggregation) {
ctx.body = ctx.helper.error(403, '未找到您的报名信息');
return;
}
// 获取提交历史
const submissionList = await service.upload.getSubmissions(
aggregation.aggregation_uid,
group
);
ctx.body = ctx.helper.success({
list: submissionList,
total: submissionList.length,
}, '获取提交历史成功');
} catch (err) {
ctx.logger.error('查询提交历史失败:', err);
ctx.body = ctx.helper.error(500, '查询提交历史失败');
}
}
}
module.exports = UploadController;

273
app/extend/helper.js Normal file
View File

@@ -0,0 +1,273 @@
'use strict';
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const bcrypt = require('bcryptjs');
// bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点)
const BCRYPT_ROUNDS = 10;
/**
* 应用扩展工具函数 - Helper
*
* 提供加密、JWT、文件哈希、统一响应等工具函数。
* 在控制器中通过 ctx.helper.xxx() 调用。
*/
module.exports = {
/**
* SHA256 哈希计算(可选盐值)
*
* @param {string} password - 明文密码
* @param {string} [salt=''] - 盐值(可选,不传则不加盐)
* @returns {string} SHA256 哈希后的密码字符串
*
* 示例:
* const hashed = ctx.helper.sha256('123456', 'abc123salt');
* const hashed2 = ctx.helper.sha256('123456'); // 无盐值
*/
sha256(password, salt) {
const hash = crypto.createHash('sha256');
hash.update(password + (salt || ''));
return hash.digest('hex');
},
/**
* 生成随机盐值
*
* @returns {string} 32 位随机十六进制字符串
*
* 示例:
* const salt = ctx.helper.generateSalt();
* // => 'a1b2c3d4e5f6...' (32位)
*/
generateSalt() {
return crypto.randomBytes(16).toString('hex');
},
/**
* 判断给定哈希是否为 bcrypt 格式($2a$ / $2b$ / $2y$ 开头)
*
* @param {string} hash - 待判断的哈希字符串
* @returns {boolean} 是否为 bcrypt 哈希
*/
isBcryptHash(hash) {
return typeof hash === 'string' && /^\$2[aby]\$/.test(hash);
},
/**
* 使用 bcrypt 生成加盐密码哈希(新密码统一走此方法)
*
* @param {string} plain - 明文密码
* @returns {Promise<string>} bcrypt 哈希字符串
*/
async hashPassword(plain) {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
},
/**
* 校验明文密码与存储哈希是否匹配。
* 兼容 bcrypt 哈希与存量 SHA256(无盐)哈希,实现平滑迁移。
*
* @param {string} plain - 明文密码
* @param {string} hash - 存储的哈希(bcrypt 或旧版 SHA256)
* @returns {Promise<boolean>} 是否匹配
*/
async comparePassword(plain, hash) {
if (!hash) return false;
if (this.isBcryptHash(hash)) {
return bcrypt.compare(plain, hash);
}
// 兼容存量 SHA256(无盐)密码
return this.sha256(plain) === hash;
},
/**
* 生成 JWT Token
*
* @param {Object} payload - 要编码的载荷数据
* @param {string} secret - JWT 签名密钥
* @param {string} [expiresIn] - 有效期,默认使用配置文件中的 '24h'
* @returns {string} 生成的 JWT Token 字符串
*
* 示例:
* const token = ctx.helper.generateToken({ uid: 1, role: 'contestant' }, secret);
*/
generateToken(payload, secret, expiresIn) {
const finalExpiresIn = expiresIn || this.config.jwt.expiresIn;
return jwt.sign(payload, secret, {
expiresIn: finalExpiresIn,
});
},
/**
* 验证 JWT Token
*
* @param {string} token - JWT Token 字符串
* @param {string} secret - JWT 签名密钥
* @returns {Object} 解码后的载荷数据,验证失败抛出异常
*
* 示例:
* const decoded = ctx.helper.verifyToken(token, secret);
*/
verifyToken(token, secret, options = {}) {
return jwt.verify(token, secret, options);
},
/**
* 统一成功响应
*
* @param {*} data - 响应数据,默认 null
* @param {string} message - 响应消息,默认 'success'
* @returns {Object} 统一格式的成功响应 { code: 0, message, data }
*
* 示例:
* ctx.body = ctx.helper.success({ id: 1 }, '操作成功');
* // => { code: 0, message: '操作成功', data: { id: 1 } }
*/
success(data = null, message = 'success') {
return {
code: 0,
message,
data,
};
},
/**
* 统一失败/错误响应
*
* @param {number} code - 错误码
* @param {string} message - 错误消息
* @returns {Object} 统一格式的错误响应 { code, message }
*
* 示例:
* ctx.body = ctx.helper.error(1001, '用户不存在');
* // => { code: 1001, message: '用户不存在' }
*/
error(code, message) {
return {
code,
message,
};
},
/**
* 生成随机数字验证码
*
* @param {number} length - 验证码长度,默认 6
* @returns {string} 指定长度的数字验证码字符串
*
* 示例:
* ctx.helper.randomCode(6);
* // => '372819'
*/
randomCode(length = 6) {
let code = '';
for (let i = 0; i < length; i++) {
code += Math.floor(Math.random() * 10);
}
return code;
},
/**
* 生成文件 MD5 哈希
*
* @param {string} filePath - 文件完整路径
* @returns {Promise<string>} 文件的 MD5 哈希字符串
*
* 示例:
* const md5 = await ctx.helper.generateFileMd5('/path/to/file.pdf');
* // => 'd41d8cd98f00b204e9800998ecf8427e'
*/
async generateFileMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => {
hash.update(chunk);
});
stream.on('end', () => {
resolve(hash.digest('hex'));
});
stream.on('error', (err) => {
reject(err);
});
});
},
/**
* 构建文件访问 URL
*
* 将 app/public 下的相对路径转换为完整的 HTTP 访问 URL。
* 前端通过此 URL 可直接访问上传的文件。
*
* @param {string} relativePath - 相对路径(如 'carousel/banner.jpg' 或 'templates/zhongzhi/template.docx')
* @returns {string} 完整的外部访问 URL
*
* 示例:
* ctx.helper.fileUrl('carousel/banner.jpg');
* // => 'http://127.0.0.1:7001/public/carousel/banner.jpg'
*
* ctx.helper.fileUrl('templates/zhongzhi/template.docx');
* // => 'http://127.0.0.1:7001/public/templates/zhongzhi/template.docx'
*/
fileUrl(relativePath) {
const { app } = this.ctx;
// 支持通过环境变量配置域名,默认使用 localhost:端口
const baseUrl = app.config.staticBaseUrl || `http://127.0.0.1:${app.config.cluster.listen.port}`;
// 确保路径以 /public/ 开头
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
return `${baseUrl}/public/${normalizedPath}`;
},
/**
* 🔴 best-effort 清理:删除临时文件/目录,**任何失败都不抛错**。
*
* 【为什么必须存在】临时目录/半截文件的清理是**副作用**,不是业务结果。
* 若把它写成裸 fs.rmdirSync / fs.unlinkSync 放在成功路径里,一旦删除失败
* (杀毒软件锁文件、Windows 句柄未释放、只读挂载、沙箱安全钩子拦截……),
* 整个请求就会抛异常走到 catch → 返回 500,而此时:
* · 目标文件其实已经落盘 → 产生**孤儿文件**
* · DB 记录尚未写入 → **文件与数据不一致**
* · 用户看到"失败"后重试 → 再生成一个孤儿文件,越滚越多
* 实证:controller/upload.js:mergeChunks 与 service/admin.js:createBatchZip
* 都曾因临时目录删除失败而整体 500(common-error.log 有完整堆栈)。
*
* 【使用约定】
* · 清理副作用 → 一律用本函数,永不裸调 fs 删除 API。
* · "删除即业务动作"(如管理员删除备赛资料)→ 不要用本函数,
* 那种场景删除失败必须让用户知道,应当照常抛错/返回错误码。
*
* @param {string} targetPath - 待删除的文件或目录绝对路径
* @param {object} [opts] - { label:'用于日志的场景名' }
* @returns {'removed'|'missing'|'skipped'} 删除结果(不抛错)
*/
safeCleanup(targetPath, opts = {}) {
const label = opts.label || 'cleanup';
const logger = (this.ctx && this.ctx.logger) || console;
if (!targetPath) return 'missing';
try {
if (!fs.existsSync(targetPath)) return 'missing';
const st = fs.statSync(targetPath);
if (st.isDirectory()) {
// fs.rm 优先(Node 14.14+ 官方推荐,force 语义更稳);老版本回退 rmdirSync
if (typeof fs.rmSync === 'function') {
fs.rmSync(targetPath, { recursive: true, force: true });
} else {
fs.rmdirSync(targetPath, { recursive: true });
}
} else {
fs.unlinkSync(targetPath);
}
return 'removed';
} catch (e) {
// 只告警不抛错:清理失败绝不能污染主业务结果
logger.warn(`[safeCleanup:${label}] 清理失败(已忽略,不影响主流程): ${targetPath} -> ${e && e.message}`);
return 'skipped';
}
},
};

83
app/lib/file_io_guard.js Normal file
View File

@@ -0,0 +1,83 @@
'use strict';
// ==================== 文件 IO 并发护栏 ====================
// 背景(2026-08-11 压测结论):
// 场景「无带宽限制 + 单 worker + 极端并发」(大量考生持续上传大文件 +
// 管理后台同时持续下载大文件)下,单 worker 的 fd / 事件循环会被海量
// 并发文件流(分片写入、分片合并、Word 写入)压垮,触发
// MaxListenersExceededWarning 并最终雪崩。
//
// 本护栏把「同时进行的文件 IO 操作数」限制在上限内:
// - 未超限:立即放行,计数 +1;
// - 已超限:进入有界队列等待(提供连接级背压,而非崩溃);
// - 队列满 / 等待超时:抛出 busy 错误,调用方返回 429,由前端
// uploadWithRetry / 下载侧重试指数退避重试——保证后端存活。
//
// 覆盖范围(同一全局配额,按进程单例共享):
// - 上传:分片写入、分片合并、Word 写入(controller/upload.js、exam.js)
// - 下载:管理后台作品/Word 下载(controller/admin.js 的 _sendFile)
// 生产经 X-Accel-Redirect 卸载给 nginx,Node 仅发头、瞬间释放配额;
// 开发回退 Node 流式时,配额随「流生命周期」(end/close/error)释放。
const LIMIT = parseInt(process.env.FILE_IO_CONCURRENCY || '20', 10);
const MAX_QUEUE = parseInt(process.env.FILE_IO_MAX_QUEUE || '300', 10);
const WAIT_TIMEOUT = parseInt(process.env.FILE_IO_WAIT_TIMEOUT || '8000', 10);
let active = 0;
const queue = [];
function remove(entry) {
const i = queue.indexOf(entry);
if (i !== -1) queue.splice(i, 1);
}
/**
* 获取一个文件 IO 配额。
* 成功(或排到队首被放行)resolve;队列满 / 超时则 reject({ busy: true })。
* @returns {Promise<void>}
*/
function acquire() {
return new Promise((resolve, reject) => {
if (active < LIMIT) {
active++;
return resolve();
}
if (queue.length >= MAX_QUEUE) {
const err = new Error('FILE_IO_BUSY');
err.busy = true;
return reject(err);
}
let timer = null;
const entry = {
run() {
if (timer) clearTimeout(timer);
active++;
resolve();
},
cancel() {
if (timer) clearTimeout(timer);
const err = new Error('FILE_IO_BUSY');
err.busy = true;
reject(err);
},
};
timer = setTimeout(() => {
remove(entry);
entry.cancel();
}, WAIT_TIMEOUT);
queue.push(entry);
});
}
/**
* 释放一个文件 IO 配额(须在 finally 中调用)。
*/
function release() {
if (active > 0) active--;
if (active < LIMIT && queue.length) {
const entry = queue.shift();
entry.run();
}
}
module.exports = { acquire, release, LIMIT, MAX_QUEUE, WAIT_TIMEOUT };

View File

@@ -0,0 +1,47 @@
'use strict';
/**
* 下载前 file_path 短缓存(进程内,默认 30s)。
*
* 收益:高并发下载同一文件时,避免每次请求都回查 DB 取 file_path / file_name。
*
* 取舍(任务 #23 已确认):文件替换 / 删除后,≤ TTL 内可能命中旧路径。
* - 下游 _sendFile 仍按实际文件做 fs.existsSync 校验,旧路径不存在 → 404,不会吐错误内容;
* - 被替换文件的旧路径在 TTL 内至多吐出旧版本一小段时间(非安全敏感、非实时场景,可接受)。
* - 因此本缓存为「只读穿透 + TTL 自愈」,不强制接入上传/删除失效,
* 以最小化改动面与回归风险;如需更强一致可后续在上传/删除成功处调用 invalidate()。
*
* 键约定:调用方自行拼装唯一 key,如 `word:${group}:${id}` / `sub:${group}:${id}`。
*/
const TTL_MS = 30 * 1000;
const store = new Map(); // key -> { filePath, fileName, expire }
function _now() {
return Date.now();
}
function getFull(key) {
if (!key) return null;
const hit = store.get(key);
if (!hit) return null;
if (hit.expire < _now()) {
store.delete(key);
return null;
}
return { filePath: hit.filePath, fileName: hit.fileName };
}
function setFull(key, filePath, fileName) {
if (!key || filePath == null || filePath === '') return;
store.set(key, {
filePath: String(filePath),
fileName: fileName == null ? '' : String(fileName),
expire: _now() + TTL_MS,
});
}
function invalidate(key) {
if (key) store.delete(key);
}
module.exports = { getFull, setFull, invalidate, TTL_MS };

116
app/middleware/auth.js Normal file
View File

@@ -0,0 +1,116 @@
'use strict';
const jwt = require('jsonwebtoken');
/**
* JWT 认证中间件
*
* 功能:
* 1. 从请求头 Authorization 中提取 Bearer Token
* 2. 验证 JWT 的有效性
* 3. 将解码后的用户信息挂载到 ctx.state.currentUser
* 4. 支持管理员角色校验(admin 路由专用)
* 5. 验证失败返回统一错误响应
*
* 使用方式:
* - 选手认证:app.middleware.auth()
* - 管理员认证:app.middleware.auth({ adminOnly: true })
*
* ctx.state.currentUser 数据结构:
* {
* uid: 账号 uid,
* id_number: 身份证号,
* role: 'contestant' | 'admin'
* }
*/
module.exports = (options = {}, app) => {
return async function authMiddleware(ctx, next) {
// 从 ctx.app.config 中获取 JWT 密钥
const appConfig = app || ctx.app;
const { secret } = appConfig.config.jwt;
// 获取 Authorization 请求头;同时支持 Query ?token= 回退(用于跨设备直链下载,
// 如 iOS Safari 不响应 blob + a.download 时改用浏览器原生导航下载的场景)。
const authorization = ctx.get('Authorization') ||
(ctx.query && ctx.query.token ? `Bearer ${ctx.query.token}` : '');
// 检查 Authorization 头是否存在
if (!authorization) {
ctx.status = 401;
ctx.body = {
code: 401,
message: '未提供认证令牌,请先登录',
};
return;
}
// 提取 Bearer Token(格式:Bearer <token>)
const tokenParts = authorization.split(' ');
if (tokenParts.length !== 2 || tokenParts[0] !== 'Bearer') {
ctx.status = 401;
ctx.body = {
code: 401,
message: '认证令牌格式错误,应为 Bearer <token>',
};
return;
}
const token = tokenParts[1];
// 验证 JWT Token
try {
const decoded = jwt.verify(token, secret);
// 将解码后的用户信息挂载到 ctx.state.currentUser
ctx.state.currentUser = {
id: decoded.id || decoded.uid || 0,
uid: decoded.uid || decoded.id || 0,
id_number: decoded.id_number || '',
phone: decoded.phone || '',
role: decoded.role,
};
// 如果启用了管理员模式,校验当前用户是否为管理员
if (options.adminOnly) {
if (ctx.state.currentUser.role !== 'admin') {
ctx.status = 403;
ctx.body = {
code: 403,
message: '无权限访问,需要管理员权限',
};
return;
}
}
// 继续执行后续中间件和控制器
await next();
} catch (err) {
// 仅当错误确实是 JWT 校验问题(过期/签名无效)时,才返回 401「认证失败」。
// 注意:try 内部包含 `await next()`,即后续中间件与控制器也在同一 try 中。
// 若下游控制器/服务抛出异常(如文件读取失败、数据库异常等),绝不能被
// 这里误吞并伪装成 401,否则前端会误判「登录过期」把用户踢回登录页。
// 因此:非 JWT 类错误必须重新抛出,交由 Egg 全局错误处理(返回 500 并写日志)。
if (err.name === 'TokenExpiredError') {
// Token 已过期
ctx.status = 401;
ctx.body = {
code: 401,
message: '认证令牌已过期,请重新登录',
};
return;
}
if (err.name === 'JsonWebTokenError') {
// Token 无效(签名错误、格式错误等)
ctx.status = 401;
ctx.body = {
code: 401,
message: '无效的认证令牌',
};
return;
}
// 其它异常(下游业务逻辑抛出):原样抛出,避免被误报为 401
throw err;
}
};
};

View File

@@ -0,0 +1,53 @@
'use strict';
// ==================== 请求级并发闸门(大文件上传/下载) ====================
// 背景(2026-08-11 压测结论):
// 场景「无带宽限制 + 单 worker + 极端并发」(大量考生同时上传大文件 +
// 管理后台同时下载大文件)下,单 worker 即使有文件 IO 护栏(file_io_guard),
// 仍会在「被接受的并发重请求数」过高时被海量大文件流压垮(OOM 被系统杀掉,
// 无任何 Node 异常日志)。原因是 file_io_guard 只限制「同时进行的文件流」,
// 而 90 个并发请求的连接已先被 TCP 接受、请求体已在传输中。
//
// 本闸门在「读取请求体之前」对重 IO 接口做全局并发上限:
// - 命中重路由且 active < MAX:放行(active++),请求结束(finally)后 active--;
// - 命中重路由且 active >= MAX:立即回 429(连接级背压),请求体不会被解析/缓冲,
// 由前端 uploadWithRetry / 下载侧重试指数退避重试 —— 保证后端存活而非雪崩。
// - 非重路由:直接放行,不影响登录/统计等轻接口。
//
// 与 file_io_guard 互补:本闸门限制「被接受的并发重请求数」(防连接/内存被打满),
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
const HEAVY_PATTERNS = [
/^\/api\/upload\/[^/]+\/chunk$/i,
/^\/api\/upload\/[^/]+\/merge$/i,
/^\/api\/exam\/[^/]+\/word$/i,
/^\/api\/admin\/submission-file\//i,
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
];
module.exports = (options, app) => {
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
let active = 0;
return async function concurrencyGuard(ctx, next) {
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) {
return next();
}
if (active >= MAX) {
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
return;
}
active++;
try {
await next();
} finally {
active--;
}
};
};

View File

@@ -0,0 +1,73 @@
'use strict';
/**
* 登录限流中间件 - loginRateLimit
*
* 面向 /api/auth/login 与 /api/auth/admin-login,防止暴力破解。
*
* 策略:
* - 以「客户端 IP + 登录标识(phone/username)」为维度,在滑动窗口内统计「失败」次数。
* - 达到阈值后,在窗口结束前拒绝该维度的后续登录尝试(返回业务错误码 4290)。
* - 登录成功(响应 code === 0)立即清空该维度的失败计数。
*
* 说明:采用进程内内存计数(Map),零外部依赖、不影响本地/无 Redis 环境。
* 多进程(egg-cluster)下各 worker 各自计数,属于可接受的基线防护;
* 如需全局精确限流,可后续替换为基于 Redis 的实现。
*
* @param {object} options - 配置项
* @param {number} [options.windowMs=900000] - 统计窗口(毫秒),默认 15 分钟
* @param {number} [options.maxAttempts=10] - 窗口内允许的最大失败次数
* @return {Function} Egg 中间件
*/
module.exports = (options = {}) => {
const windowMs = options.windowMs || 15 * 60 * 1000;
const maxAttempts = options.maxAttempts || 10;
// key -> { count, first }
const store = new Map();
const cleanup = (now) => {
for (const [ k, v ] of store) {
if (now - v.first >= windowMs) store.delete(k);
}
};
return async function loginRateLimit(ctx, next) {
const body = ctx.request.body || {};
const identifier = body.phone || body.username || 'unknown';
const key = `${ctx.ip}|${identifier}`;
const now = Date.now();
let rec = store.get(key);
// 窗口已过期则视为无记录
if (rec && now - rec.first >= windowMs) {
store.delete(key);
rec = undefined;
}
// 已超过阈值:直接拒绝
if (rec && rec.count >= maxAttempts) {
const remainMin = Math.ceil((windowMs - (now - rec.first)) / 60000);
ctx.logger.warn(`[登录限流] 拒绝来自 ${key} 的登录尝试(已失败 ${rec.count} 次)`);
ctx.body = ctx.helper.error(4290, `登录尝试过于频繁,请约 ${remainMin} 分钟后再试`);
return;
}
await next();
// 依据响应判定成败:统一响应体 { code: 0 } 为成功
const succeeded = ctx.body && ctx.body.code === 0;
if (succeeded) {
store.delete(key);
} else {
if (rec) {
rec.count += 1;
} else {
store.set(key, { count: 1, first: now });
}
}
// 惰性清理,避免 Map 无限增长
if (store.size > 5000) cleanup(now);
};
};

139
app/middleware/rateLimit.js Normal file
View File

@@ -0,0 +1,139 @@
'use strict';
/**
* API 限流中间件
* 基于 token bucket 的简单限流实现,适用于考试并发场景
* 针对不同端点使用不同限流策略:
* - 通用 API: 100次/分钟
* - 登录/激活: 10次/分钟(防暴力破解)
* - 考试提交: 30次/分钟(高峰期保护)
* - 管理后台: 200次/分钟
*/
// 请求计数器存储(内存实现,生产环境建议换 Redis)
const counters = new Map();
// 每60秒自动清理过期计数器
setInterval(() => {
const now = Date.now();
for (const [key, entry] of counters) {
if (now - entry.resetTime > 60000) {
counters.delete(key);
}
}
}, 60000);
/**
* 限流配置预设
*/
const LIMITS = {
default: { max: 100, window: 60 }, // 通用: 100次/分钟
auth: { max: 10, window: 60 }, // 登录/激活: 10次/分钟
exam: { max: 30, window: 60 }, // 考试提交: 30次/分钟
upload: { max: 20, window: 60 }, // 文件上传: 20次/分钟
admin: { max: 200, window: 60 }, // 管理后台: 200次/分钟
};
/**
* 获取客户端标识(优先用用户ID,其次IP)
*/
function getClientId(ctx) {
if (ctx.state.currentUser && ctx.state.currentUser.uid) {
return `user:${ctx.state.currentUser.uid}`;
}
return `ip:${ctx.ip}`;
}
/**
* 根据路由自动判断限流等级
*/
function getLimitLevel(ctx) {
const path = ctx.path;
if (path.startsWith('/api/auth/login') || path.startsWith('/api/auth/activate') ||
path.startsWith('/api/auth/forgot-password') || path.startsWith('/api/auth/admin-login')) {
return 'auth';
}
if (path.startsWith('/api/exam')) {
return 'exam';
}
if (path.startsWith('/api/upload')) {
return 'upload';
}
if (path.startsWith('/api/admin')) {
return 'admin';
}
return 'default';
}
module.exports = (options = {}) => {
const level = options.level || 'default';
const limit = options.max || LIMITS[level]?.max || LIMITS.default.max;
const windowSec = options.window || LIMITS[level]?.window || LIMITS.default.window;
return async function rateLimit(ctx, next) {
// 自动检测限流级别
const effectiveLevel = options.level || getLimitLevel(ctx);
const effectiveLimit = options.max || LIMITS[effectiveLevel]?.max || LIMITS.default.max;
const effectiveWindow = options.window || LIMITS[effectiveLevel]?.window || LIMITS.default.window;
const clientId = getClientId(ctx);
const routeKey = `${effectiveLevel}:${clientId}:${ctx.path}`;
const now = Date.now();
let entry = counters.get(routeKey);
// 初始化或重置过期计数器
if (!entry || now - entry.resetTime > effectiveWindow * 1000) {
entry = { count: 0, resetTime: now + effectiveWindow * 1000 };
counters.set(routeKey, entry);
}
entry.count++;
// 设置响应头告知限流状态
const remaining = Math.max(0, effectiveLimit - entry.count);
const resetTime = new Date(entry.resetTime).toISOString();
ctx.set('X-RateLimit-Limit', effectiveLimit);
ctx.set('X-RateLimit-Remaining', remaining);
ctx.set('X-RateLimit-Reset', resetTime);
// 超限拒绝
if (entry.count > effectiveLimit) {
ctx.status = 429;
ctx.body = {
code: 429,
message: `请求过于频繁,请${effectiveWindow}秒后重试。限流级别: ${effectiveLevel},上限: ${effectiveLimit}次/${effectiveWindow}秒`,
data: {
retryAfter: effectiveWindow,
limit: effectiveLimit,
remaining: 0,
resetTime,
},
};
ctx.logger.warn(`[RateLimit] ${effectiveLevel} 限流触发: ${clientId} -> ${ctx.path} (${entry.count}/${effectiveLimit})`);
return;
}
ctx.logger.debug(`[RateLimit] ${effectiveLevel}: ${clientId} -> ${ctx.path} (${entry.count}/${effectiveLimit})`);
await next();
};
};
/**
* 获取当前限流统计(调试用)
*/
module.exports.getStats = () => {
const stats = {};
for (const [key, entry] of counters) {
stats[key] = { count: entry.count, resetAt: new Date(entry.resetTime).toISOString() };
}
return stats;
};
/**
* 清除所有计数器(测试/重置用)
*/
module.exports.reset = () => {
counters.clear();
};

167
app/model/account.js Normal file
View File

@@ -0,0 +1,167 @@
'use strict';
/**
* 账号模型 - Account
*
* 存储参赛者账号的全部信息,包括个人资料、联系方式和密码等。
* 表名:accounts
*/
module.exports = app => {
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
const Account = app.model.define('account', {
// 主键,自增
uid: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '账号唯一标识',
},
// 角色:contestant / admin
role: {
type: STRING(20),
allowNull: false,
defaultValue: 'contestant',
comment: '账号角色',
},
// 中文姓名
name_cn: {
type: STRING(50),
allowNull: false,
comment: '中文姓名',
},
// 英文姓名
name_en: {
type: STRING(100),
allowNull: true,
defaultValue: null,
comment: '英文姓名',
},
// 性别:male / female
gender: {
type: STRING(10),
allowNull: true,
defaultValue: null,
comment: '性别',
},
// 手机号码
phone: {
type: STRING(20),
allowNull: true,
defaultValue: null,
comment: '手机号码',
},
// 电子邮箱
email: {
type: STRING(100),
allowNull: true,
defaultValue: null,
comment: '电子邮箱',
},
// 身份证号,唯一
id_number: {
type: STRING(18),
allowNull: false,
unique: true,
comment: '身份证号',
},
// 登录密码(SHA256 哈希存储)
password: {
type: STRING(100),
allowNull: false,
comment: '登录密码',
},
// 密码盐值
salt: {
type: STRING(32),
allowNull: false,
comment: '密码盐值',
},
// 职位
position: {
type: STRING(100),
allowNull: true,
defaultValue: null,
comment: '职位',
},
// 专业
major: {
type: STRING(100),
allowNull: true,
defaultValue: null,
comment: '专业',
},
// 国家
country: {
type: STRING(50),
allowNull: true,
defaultValue: null,
comment: '国家',
},
// 地区
region: {
type: STRING(50),
allowNull: true,
defaultValue: null,
comment: '地区',
},
// 机构/单位(中文)
organization_cn: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '机构/单位(中文)',
},
// 机构/单位(英文)
organization_en: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '机构/单位(英文)',
},
// 账号是否已激活
activated: {
type: BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '账号激活状态',
},
// 总分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '账号总分',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'accounts',
timestamps: false,
underscored: true,
indexes: [
{
unique: true,
fields: ['id_number'],
},
{
fields: ['role'],
},
],
});
return Account;
};

57
app/model/admin.js Normal file
View File

@@ -0,0 +1,57 @@
'use strict';
/**
* 管理员模型 - Admin
*
* 存储后台管理员账号信息,管理员通过用户名和密码进行登录。
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const Admin = app.model.define('admin', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '管理员唯一标识',
},
// 管理员用户名,唯一
username: {
type: STRING(50),
allowNull: false,
unique: true,
comment: '管理员用户名',
},
// 管理员密码(明文存储,生产环境应使用 bcrypt 加密)
password: {
type: STRING(100),
allowNull: false,
comment: '管理员密码',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
// 表名
tableName: 'admins',
// 时间戳字段
timestamps: false,
// 下划线命名
underscored: true,
});
return Admin;
};

135
app/model/aggregation.js Normal file
View File

@@ -0,0 +1,135 @@
'use strict';
/**
* 聚合报名模型 - Aggregation
*
* 存储参赛报名聚合信息,关联竞赛、团队和账号。
* 表名:aggregation
*/
module.exports = app => {
const { STRING, INTEGER, BOOLEAN, ENUM, DATE } = app.Sequelize;
const Aggregation = app.model.define('aggregation', {
// 主键,自增
aggregation_uid: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '聚合唯一标识',
},
// 外键:竞赛 ID
competition_uid: {
type: INTEGER,
allowNull: false,
comment: '关联竞赛 ID',
},
// 外键:团队 ID(允许为空:单人参赛/未分组的选手仍可建立聚合报名,
// 以便 benke/jiaoshi 作品上传等依赖 aggregation 的流程正常工作)
team_uid: {
type: INTEGER,
allowNull: true,
comment: '关联团队 ID',
},
// 外键:账号 ID
account_uid: {
type: INTEGER,
allowNull: false,
comment: '关联账号 ID',
},
// 身份:队长/队员/指导教师
identity: {
type: STRING(20),
allowNull: false,
comment: '身份',
},
// 是否为报名负责人
is_registrant: {
type: BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否为报名负责人',
},
// 报名表文件路径
registration_form: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '报名表文件路径',
},
// 盖章报名表文件路径
stamped_form: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '盖章报名表文件路径',
},
// 审核状态:pending/approved/rejected
audit_status: {
type: STRING(20),
allowNull: false,
defaultValue: 'pending',
comment: '审核状态',
},
// 驳回原因
reject_reason: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '驳回原因',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'aggregation',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['competition_uid'],
},
{
fields: ['team_uid'],
},
{
fields: ['account_uid'],
},
{
fields: ['audit_status'],
},
],
});
// 关联定义
Aggregation.associate = function() {
app.model.Aggregation.belongsTo(app.model.CompetitionInfo, {
foreignKey: 'competition_uid',
targetKey: 'competition_uid',
as: 'competition',
});
app.model.Aggregation.belongsTo(app.model.Team, {
foreignKey: 'team_uid',
targetKey: 'team_uid',
as: 'team',
});
app.model.Aggregation.belongsTo(app.model.Account, {
foreignKey: 'account_uid',
targetKey: 'uid',
as: 'account',
});
};
return Aggregation;
};

View File

@@ -0,0 +1,115 @@
'use strict';
/**
* 本科组作品提交模型 - BenkeSubmission
*
* 存储本科组参赛选手的作品提交记录,包括文件信息和评分。
* 表名:benke_submissions
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const BenkeSubmission = app.model.define('benke_submission', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '提交记录唯一标识',
},
// 外键:关联聚合报名 ID
aggregation_uid: {
type: INTEGER,
allowNull: false,
comment: '关联聚合报名 ID',
},
// 文件类型:word(文档)/ ppt(演示文稿)
file_type: {
type: STRING(20),
allowNull: true,
defaultValue: null,
comment: '文件类型 word/ppt',
},
// 作品名称
work_name: {
type: STRING(200),
allowNull: false,
comment: '作品名称',
},
// 文件存储路径
file_path: {
type: STRING(500),
allowNull: false,
comment: '文件存储路径',
},
// 文件原始名称
file_name: {
type: STRING(200),
allowNull: false,
comment: '文件原始名称',
},
// 文件大小(字节)
file_size: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '文件大小(字节)',
},
// 文件 MD5
file_md5: {
type: STRING(32),
allowNull: true,
defaultValue: null,
comment: '文件 MD5',
},
// 上传时间
upload_time: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '上传时间',
},
// 评分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '评分',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'benke_submissions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['aggregation_uid'],
},
],
});
// 关联定义
BenkeSubmission.associate = function() {
app.model.BenkeSubmission.belongsTo(app.model.Aggregation, {
foreignKey: 'aggregation_uid',
targetKey: 'aggregation_uid',
as: 'aggregation',
});
};
return BenkeSubmission;
};

105
app/model/carousel.js Normal file
View File

@@ -0,0 +1,105 @@
'use strict';
/**
* 轮播图模型 - Carousel
*
* 存储首页轮播图信息,支持标题、排序和启用/禁用控制。
* 表名:carousel
*/
module.exports = app => {
const { STRING, INTEGER, BOOLEAN, DATE, TEXT } = app.Sequelize;
const Carousel = app.model.define('carousel', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '轮播图唯一标识',
},
// 图片 URL 地址(可为空时前端回退渐变背景)
image_url: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '图片 URL 地址(可为空回退渐变)',
},
// 轮播图标题
title: {
type: STRING(200),
allowNull: true,
comment: '轮播图标题',
},
// 富文本正文(HTML,由 wangEditor 生成,前端 v-html 渲染)
content: {
type: TEXT,
allowNull: true,
defaultValue: null,
comment: '富文本正文(HTML)',
},
// 按钮文字(为空则不显示按钮)
button_text: {
type: STRING(100),
allowNull: true,
defaultValue: null,
comment: '按钮文字(为空则不显示按钮)',
},
// 按钮链接(内部路由如 /activate,或外部 http(s):// 链接)
button_link: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '按钮链接(内部路由/外部URL)',
},
// 排序序号(越小越靠前)
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
// 点击跳转链接
link_url: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '点击跳转链接',
},
// 是否启用
enabled: {
type: BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否启用',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'carousel',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['sort_order'],
},
{
fields: ['enabled'],
},
],
});
return Carousel;
};

View File

@@ -0,0 +1,67 @@
'use strict';
/**
* 选择题模型 - ChoiceQuestion
*
* 对应 choice_questions 表,存储所有组别的选择题(单选 + 多选)。
* 通过 group 字段区分中职组(zhongzhi)和高职组(gaozhi)。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, JSON: JSON_TYPE, TEXT } = app.Sequelize;
return app.model.define('ChoiceQuestion', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
},
group: {
type: STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
question: {
type: TEXT,
allowNull: false,
comment: '题目内容',
},
options: {
type: JSON_TYPE,
allowNull: false,
comment: '选项列表 [{ key: "A", text: "..." }, ...]',
},
answer: {
type: STRING(10),
allowNull: false,
comment: '正确答案(如 A / AB / ABCD)',
},
score: {
type: INTEGER,
allowNull: false,
defaultValue: 5,
comment: '分值',
},
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
created_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '创建时间',
},
updated_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '更新时间',
},
}, {
tableName: 'choice_questions',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
};

View File

@@ -0,0 +1,61 @@
'use strict';
/**
* 竞赛信息模型 - CompetitionInfo
*
* 存储竞赛的基本信息,包括竞赛名称和代码。
* 表名:competition_info
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const CompetitionInfo = app.model.define('competition_info', {
// 主键,自增
competition_uid: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '竞赛唯一标识',
},
// 竞赛名称
competition_name: {
type: STRING(200),
allowNull: false,
comment: '竞赛名称',
},
// 竞赛代码(唯一标识码)
competition_code: {
type: STRING(50),
allowNull: false,
unique: true,
comment: '竞赛代码',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'competition_info',
timestamps: false,
underscored: true,
indexes: [
{
unique: true,
fields: ['competition_code'],
},
],
});
return CompetitionInfo;
};

View File

@@ -0,0 +1,93 @@
'use strict';
/**
* 比赛时间配置模型 - CompetitionTime
*
* 存储各参赛组别的比赛时间配置,包括开始时间、结束时间、提交截止时间和Word模板路径。
* 每个组别只有一条配置记录。
* 表名:competition_times
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const CompetitionTime = app.model.define('competition_time', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '时间配置唯一标识',
},
// 组别:zhongzhi/gaozhi/benke/jiaoshi,唯一约束
group: {
type: STRING(20),
allowNull: false,
unique: true,
comment: '组别',
},
// 比赛开始时间
start_time: {
type: DATE,
allowNull: true,
defaultValue: null,
comment: '比赛开始时间',
},
// 比赛结束时间
end_time: {
type: DATE,
allowNull: true,
defaultValue: null,
comment: '比赛结束时间',
},
// 作品提交截止时间
deadline: {
type: DATE,
allowNull: true,
defaultValue: null,
comment: '作品提交截止时间',
},
// Word 操作题模板文件路径(相对于 app/public/ 的相对路径)
word_template: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: 'Word 操作题模板文件路径',
},
// 客观题(选择题)抽取数量:考试时从该组别题库随机抽取的数量上限;
// 若题库实际数量不足,则自动按实际数量封顶(题目序号自适应题目数量)。
choice_count: {
type: INTEGER,
allowNull: false,
defaultValue: 50,
comment: '选择题抽取数量',
},
// 客观题(判断题)抽取数量,语义同 choice_count
judge_count: {
type: INTEGER,
allowNull: false,
defaultValue: 20,
comment: '判断题抽取数量',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'competition_times',
timestamps: false,
underscored: true,
});
return CompetitionTime;
};

161
app/model/contestant.js Normal file
View File

@@ -0,0 +1,161 @@
'use strict';
/**
* 选手模型 - Contestant
*
* 存储参赛选手的基本信息,包括身份认证、组别、成绩等。
* 选手通过身份证号和密码进行登录认证。
*/
module.exports = app => {
const { STRING, INTEGER, BOOLEAN, DATE } = app.Sequelize;
const Contestant = app.model.define('contestant', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '选手唯一标识',
},
// 身份证号,唯一,长度 18,用于选手登录
id_number: {
type: STRING(18),
allowNull: false,
unique: true,
comment: '身份证号(登录账号)',
validate: {
len: [18, 18], // 严格校验 18 位身份证号
},
},
// 选手姓名
name: {
type: STRING(50),
allowNull: false,
comment: '选手姓名',
},
// 手机号码
phone: {
type: STRING(20),
allowNull: false,
comment: '手机号码',
},
// 参赛组别:zhongzhi(中职) / gaozhi(高职) / benke(本科) / jiaoshi(教师)
group: {
type: STRING(20),
allowNull: false,
comment: '参赛组别',
validate: {
isIn: [['zhongzhi', 'gaozhi', 'benke', 'jiaoshi']],
},
},
// 登录密码(存储明文,生产环境应使用 bcrypt 加密存储)
password: {
type: STRING(100),
allowNull: false,
defaultValue: '',
comment: '登录密码',
},
// 账号是否已激活(激活后才可以登录)
activated: {
type: BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '账号激活状态',
},
// 总分(管理员打分后更新,NULL 表示尚未评分)
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '选手总分(exam_score + word_score)',
},
// 卷面分(选择题+判断题自动计算)
exam_score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '卷面考试成绩',
},
// Word题分数(管理员手动打分)
word_score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: 'Word题得分',
},
// PPT分数(本科/教师组管理员手动打分)
ppt_score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: 'PPT得分(本科/教师组)',
},
// 所属团队 ID(外键关联 teams 表)
team_id: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '所属团队ID',
},
// 测试数据标记:0=生产数据,1=测试数据(重置时仅重置测试数据)
test_flag: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '测试数据标记:0=生产数据,1=测试数据',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
// 表名保持为 contestants(原样,不做复数变换)
tableName: 'contestants',
// 不自动生成 createdAt / updatedAt(我们手动定义 created_at / updated_at)
timestamps: false,
// 下划线命名
underscored: true,
// 索引
indexes: [
{
unique: true,
fields: ['id_number'],
},
{
fields: ['group'],
},
{
fields: ['team_id'],
},
],
});
Contestant.associate = () => {
app.model.Contestant.belongsTo(app.model.Team, {
foreignKey: 'team_id',
targetKey: 'team_uid',
as: 'team',
});
app.model.Contestant.hasMany(app.model.ExamSubmission, {
foreignKey: 'contestant_id',
as: 'examSubmissions',
});
app.model.Contestant.hasMany(app.model.WorkSubmission, {
foreignKey: 'contestant_id',
as: 'workSubmissions',
});
};
return Contestant;
};

84
app/model/examQuestion.js Normal file
View File

@@ -0,0 +1,84 @@
'use strict';
/**
* 试卷题目模型 - ExamQuestion
*
* 存储各参赛组别的试卷题目,按组别区分,支持排序。
* 表名:exam_questions
*/
module.exports = app => {
const { STRING, TEXT, INTEGER, DATE } = app.Sequelize;
const ExamQuestion = app.model.define('exam_question', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '题目唯一标识',
},
// 所属组别:zhongzhi/gaozhi/benke/jiaoshi
group: {
type: STRING(20),
allowNull: false,
comment: '题目所属组别',
validate: {
isIn: [['zhongzhi', 'gaozhi', 'benke', 'jiaoshi']],
},
},
// 题目标题
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,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'exam_questions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['group'],
},
{
fields: ['group', 'sort_order'],
},
],
});
return ExamQuestion;
};

110
app/model/examSubmission.js Normal file
View File

@@ -0,0 +1,110 @@
'use strict';
/**
* 答题记录模型 - ExamSubmission
*
* 存储选手的试卷答题记录,包括答题内容、提交时间和得分。
* 每个选手在其组别的比赛中只允许提交一次。
* 表名:exam_submissions
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT, JSON: JSON_TYPE } = app.Sequelize;
const ExamSubmission = app.model.define('exam_submission', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '答题记录唯一标识',
},
// 选手 ID,外键关联 contestants 表
contestant_id: {
type: INTEGER,
allowNull: false,
comment: '选手 ID',
references: {
model: 'contestants',
key: 'id',
},
},
// 答题内容(JSON 格式存储,如 [{question_id: 1, answer: "..."}, ...])
answers: {
type: JSON_TYPE,
allowNull: false,
comment: '答题内容(JSON)',
},
// 该考生固化后的题目顺序(question_uid 数组 JSON 字符串):首次进入随机生成并持久化,
// 之后重进复用,避免每次进入都 RAND() 重新打乱顺序(重进题目顺序变乱 Bug 修复)。
question_order: {
type: TEXT,
allowNull: true,
defaultValue: null,
comment: '该考生固化题目顺序(question_uid JSON 数组),重进复用',
},
// Word 答案文件路径
word_file_path: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: 'Word答案文件路径',
},
// Word 答案文件名
word_file_name: {
type: STRING(255),
allowNull: true,
defaultValue: null,
comment: 'Word答案文件名',
},
// 提交时间
submit_time: {
type: DATE,
allowNull: true,
comment: '提交时间(占位/未提交为 NULL,正式提交后为具体时间)',
},
// 得分(管理员评分后更新,NULL 表示尚未评分)
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '试卷得分',
},
// 创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'exam_submissions',
timestamps: false,
underscored: true,
indexes: [
{
// 唯一约束:每选手仅一条答题提交记录,并发创建时折叠为一条(B12)。
unique: true,
name: 'uk_exam_submission_contestant',
fields: ['contestant_id'],
},
],
});
// 关联关系:一条答题记录属于一名选手
ExamSubmission.associate = () => {
app.model.ExamSubmission.belongsTo(app.model.Contestant, {
foreignKey: 'contestant_id',
as: 'contestant',
});
};
return ExamSubmission;
};

View File

@@ -0,0 +1,89 @@
'use strict';
/**
* 高职组题库模型 - GaozhiQuestion
*
* 存储高职组比赛题目,支持单选、多选、判断等多种题型。
* 表名:gaozhi_questions
*/
module.exports = app => {
const { STRING, INTEGER, JSON, DATE } = app.Sequelize;
const GaozhiQuestion = app.model.define('gaozhi_question', {
// 主键,自增
question_uid: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '题目唯一标识',
},
// 题目内容
question: {
type: STRING(1000),
allowNull: false,
comment: '题目内容',
},
// 正确答案
answer: {
type: STRING(500),
allowNull: false,
comment: '正确答案',
},
// 题目类型:single(单选) / multiple(多选) / judge(判断)
question_type: {
type: STRING(20),
allowNull: false,
comment: '题目类型',
},
// 选项(JSON 格式)
options: {
type: JSON,
allowNull: true,
defaultValue: null,
comment: '选项',
},
// 题目分值
score: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '题目分值',
},
// 排序序号
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'gaozhi_questions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['question_type'],
},
{
fields: ['sort_order'],
},
],
});
return GaozhiQuestion;
};

View File

@@ -0,0 +1,133 @@
'use strict';
/**
* 高职组答题记录模型 - GaozhiSubmission
*
* 存储高职组参赛选手的答题记录,包括答题数据、判分和Word文件上传。
* 表名:gaozhi_submissions
*/
module.exports = app => {
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
const GaozhiSubmission = app.model.define('gaozhi_submission', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '提交记录唯一标识',
},
// 外键:关联聚合报名 ID
aggregation_uid: {
type: INTEGER,
allowNull: false,
comment: '关联聚合报名 ID',
},
// 外键:关联题目 ID
question_uid: {
type: INTEGER,
allowNull: false,
comment: '关联题目 ID',
},
// 题型(choice / judge):与 question_uid 联合唯一标识一道题。
// 选择题与判断题的 id 在不同表中可能数值重叠(如 choice#5 与 judge#5),
// 仅凭 question_uid 无法区分,必须结合 question_type 存储,否则会折叠丢失答案(见 B11)。
question_type: {
type: STRING(16),
allowNull: false,
defaultValue: '',
comment: '题型:choice/judge,与 question_uid 联合区分题目',
},
// 答题数据(选手提交的答案)
answer_data: {
type: TEXT,
allowNull: true,
defaultValue: null,
comment: '答题数据',
},
// 是否判为正确
is_correct: {
type: BOOLEAN,
allowNull: true,
defaultValue: null,
comment: '是否判为正确',
},
// 得分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '得分',
},
// Word 文件存储路径
word_file_path: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: 'Word 文件存储路径',
},
// Word 文件原始名称
word_file_name: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: 'Word 文件原始名称',
},
// Word 文件 MD5
word_file_md5: {
type: STRING(32),
allowNull: true,
defaultValue: null,
comment: 'Word 文件 MD5',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'gaozhi_submissions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['aggregation_uid'],
},
{
fields: ['question_uid'],
},
{
// 唯一约束:同一选手同一题(题型+题号)只保留一行,杜绝并发作答重复落库(B12)。
unique: true,
name: 'uk_gaozhi_submission',
fields: ['aggregation_uid', 'question_uid', 'question_type'],
},
],
});
// 关联定义
GaozhiSubmission.associate = function() {
app.model.GaozhiSubmission.belongsTo(app.model.Aggregation, {
foreignKey: 'aggregation_uid',
targetKey: 'aggregation_uid',
as: 'aggregation',
});
app.model.GaozhiSubmission.belongsTo(app.model.GaozhiQuestion, {
foreignKey: 'question_uid',
targetKey: 'question_uid',
as: 'gaozhiQuestion',
});
};
return GaozhiSubmission;
};

View File

@@ -0,0 +1,115 @@
'use strict';
/**
* 教师组作品提交模型 - JiaoshiSubmission
*
* 存储教师组参赛选手的作品提交记录,包括文件信息和评分。
* 表名:jiaoshi_submissions
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const JiaoshiSubmission = app.model.define('jiaoshi_submission', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '提交记录唯一标识',
},
// 外键:关联聚合报名 ID
aggregation_uid: {
type: INTEGER,
allowNull: false,
comment: '关联聚合报名 ID',
},
// 文件类型:word(文档)/ ppt(演示文稿)
file_type: {
type: STRING(20),
allowNull: true,
defaultValue: null,
comment: '文件类型 word/ppt',
},
// 作品名称
work_name: {
type: STRING(200),
allowNull: false,
comment: '作品名称',
},
// 文件存储路径
file_path: {
type: STRING(500),
allowNull: false,
comment: '文件存储路径',
},
// 文件原始名称
file_name: {
type: STRING(200),
allowNull: false,
comment: '文件原始名称',
},
// 文件大小(字节)
file_size: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '文件大小(字节)',
},
// 文件 MD5
file_md5: {
type: STRING(32),
allowNull: true,
defaultValue: null,
comment: '文件 MD5',
},
// 上传时间
upload_time: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '上传时间',
},
// 评分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '评分',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'jiaoshi_submissions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['aggregation_uid'],
},
],
});
// 关联定义
JiaoshiSubmission.associate = function() {
app.model.JiaoshiSubmission.belongsTo(app.model.Aggregation, {
foreignKey: 'aggregation_uid',
targetKey: 'aggregation_uid',
as: 'aggregation',
});
};
return JiaoshiSubmission;
};

View File

@@ -0,0 +1,62 @@
'use strict';
/**
* 判断题模型 - JudgeQuestion
*
* 对应 judge_questions 表,存储所有组别的判断题。
* 通过 group 字段区分中职组(zhongzhi)和高职组(gaozhi)。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
return app.model.define('JudgeQuestion', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
},
group: {
type: STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
question: {
type: TEXT,
allowNull: false,
comment: '题目内容',
},
answer: {
type: STRING(10),
allowNull: false,
comment: '正确答案:true 或 false',
},
score: {
type: INTEGER,
allowNull: false,
defaultValue: 5,
comment: '分值',
},
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
created_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '创建时间',
},
updated_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '更新时间',
},
}, {
tableName: 'judge_questions',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
};

117
app/model/news.js Normal file
View File

@@ -0,0 +1,117 @@
'use strict';
/**
* 新闻模型 - News
*
* 存储新闻通知信息,支持置顶、微信公众号链接和按组别过滤显示。
* 表名:news
*/
module.exports = app => {
const { STRING, TEXT, BOOLEAN, DATE, INTEGER } = app.Sequelize;
const News = app.model.define('news', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '新闻唯一标识',
},
// 新闻标题
title: {
type: STRING(200),
allowNull: false,
comment: '新闻标题',
},
// 新闻正文内容(支持 HTML)
content: {
type: TEXT,
allowNull: true,
defaultValue: null,
comment: '新闻正文(微信链接型新闻可为空)',
},
// 微信公众号文章链接
wx_link: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '微信公众号文章链接',
},
// 新闻类型:content(内容型)/ wx_link(微信链接型)
news_type: {
type: STRING(20),
allowNull: false,
defaultValue: 'content',
comment: '新闻类型:content=内容型, wx_link=微信链接型',
},
// 发布时间
publish_time: {
type: DATE,
allowNull: false,
comment: '发布时间',
},
// 是否置顶
pinned: {
type: BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否置顶',
},
// 排序序号
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号,越小越靠前',
},
// 目标可见组别
group: {
type: STRING(20),
allowNull: false,
defaultValue: 'all',
comment: '目标可见组别',
},
// 标签(逗号分隔)
tags: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '标签,多个标签用逗号分隔',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'news',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['publish_time'],
},
{
fields: ['pinned', 'publish_time'],
},
{
fields: ['group'],
},
{
fields: ['sort_order'],
},
],
});
return News;
};

55
app/model/systemConfig.js Normal file
View File

@@ -0,0 +1,55 @@
'use strict';
/**
* 系统配置模型 - SystemConfig
*
* 存储系统级别的配置项,以键值对形式存储。
* 表名:system_configs
*/
module.exports = app => {
const { STRING, TEXT, INTEGER, DATE } = app.Sequelize;
const SystemConfig = app.model.define('system_config', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '配置唯一标识',
},
// 配置键名,唯一
config_key: {
type: STRING(100),
allowNull: false,
unique: true,
comment: '配置键名',
},
// 配置值(文本存储,可存储 JSON 字符串)
config_value: {
type: TEXT,
allowNull: false,
comment: '配置值',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'system_configs',
timestamps: false,
underscored: true,
});
return SystemConfig;
};

78
app/model/team.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
/**
* 参赛团队模型 - Team
*
* 存储参赛团队的基本信息,包括组别和团队编号等。
* 表名:teams
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const Team = app.model.define('team', {
// 主键(业务唯一标识),由应用层显式赋值,不依赖数据库自增。
// 说明:运行库中 teams 表真实自增主键为 id,team_uid 仅是普通 INT 列(非自增)。
// 此处声明 autoIncrement:false,使模型与运行库一致——create 时必须显式传入 team_uid,
// 避免 Sequelize 误以为 DB 会生成 team_uid 而省去该列(旧 bug:写入 NULL 导致 G1 团队锁失效)。
team_uid: {
type: INTEGER,
primaryKey: true,
comment: '团队唯一标识(由应用层显式生成,非 DB 自增)',
},
// 组别名称:zhongzhi/gaozhi/benke/jiaoshi
group_name: {
type: STRING(20),
allowNull: false,
comment: '参赛组别',
},
// 团队 ID(外部编号)
team_id: {
type: STRING(50),
allowNull: false,
comment: '团队编号',
},
// 团队序号(人类可读队伍编号,如 BRICS2026-ST-027-91001,含字母与连字符,必须为字符串)
team_number: {
type: STRING(50),
allowNull: true,
defaultValue: null,
comment: '团队序号(人类可读队伍编号,如 BRICS2026-ST-027-91001)',
},
// 团队地址/地区
address: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '团队地址',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'teams',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['group_name'],
},
{
fields: ['team_id'],
},
],
});
return Team;
};

67
app/model/wordQuestion.js Normal file
View File

@@ -0,0 +1,67 @@
'use strict';
/**
* Word操作题模型 - WordQuestion
*
* 对应 word_questions 表,存储所有组别的 Word 操作题。
* 通过 group 字段区分中职组(zhongzhi)和高职组(gaozhi)。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT } = app.Sequelize;
return app.model.define('WordQuestion', {
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
},
group: {
type: STRING(20),
allowNull: false,
comment: '组别:zhongzhi/gaozhi',
},
title: {
type: STRING(200),
allowNull: false,
comment: '题目标题',
},
description: {
type: TEXT,
allowNull: true,
comment: '题目描述/操作要求',
},
score: {
type: INTEGER,
allowNull: false,
defaultValue: 30,
comment: '分值',
},
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序',
},
template_path: {
type: STRING(500),
allowNull: true,
comment: 'Word 模板文件路径',
},
created_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '创建时间',
},
updated_at: {
type: DATE,
defaultValue: app.Sequelize.NOW,
comment: '更新时间',
},
}, {
tableName: 'word_questions',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
});
};

109
app/model/workSubmission.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict';
/**
* 作品提交模型 - WorkSubmission
*
* 存储选手上传的作品文件信息,包括文件类型、名称、路径等。
* 支持 plan(计划书)和 ppt(演示文稿)两种文件类型。
* 表名:work_submissions
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const WorkSubmission = app.model.define('work_submission', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '作品提交唯一标识',
},
// 选手 ID,外键关联 contestants 表
contestant_id: {
type: INTEGER,
allowNull: false,
comment: '选手 ID',
references: {
model: 'contestants',
key: 'id',
},
},
// 文件类型:plan(计划书)/ ppt(演示文稿)
file_type: {
type: STRING(20),
allowNull: false,
comment: '文件类型',
validate: {
isIn: [['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: '提交时间',
},
// 得分(管理员评分后更新,NULL 表示尚未评分)
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '作品得分',
},
// 创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'work_submissions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['contestant_id'],
},
{
fields: ['file_type'],
},
],
});
// 关联关系:一条作品提交记录属于一名选手
WorkSubmission.associate = () => {
app.model.WorkSubmission.belongsTo(app.model.Contestant, {
foreignKey: 'contestant_id',
as: 'contestant',
});
};
return WorkSubmission;
};

View File

@@ -0,0 +1,89 @@
'use strict';
/**
* 中职组题库模型 - ZhongzhiQuestion
*
* 存储中职组比赛题目,支持单选、多选、判断等多种题型。
* 表名:zhongzhi_questions
*/
module.exports = app => {
const { STRING, INTEGER, JSON, DATE } = app.Sequelize;
const ZhongzhiQuestion = app.model.define('zhongzhi_question', {
// 主键,自增
question_uid: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '题目唯一标识',
},
// 题目内容
question: {
type: STRING(1000),
allowNull: false,
comment: '题目内容',
},
// 正确答案
answer: {
type: STRING(500),
allowNull: false,
comment: '正确答案',
},
// 题目类型:single(单选) / multiple(多选) / judge(判断)
question_type: {
type: STRING(20),
allowNull: false,
comment: '题目类型',
},
// 选项(JSON 格式,如 ["A. xxx", "B. xxx", "C. xxx", "D. xxx"])
options: {
type: JSON,
allowNull: true,
defaultValue: null,
comment: '选项',
},
// 题目分值
score: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '题目分值',
},
// 排序序号
sort_order: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序序号',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'zhongzhi_questions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['question_type'],
},
{
fields: ['sort_order'],
},
],
});
return ZhongzhiQuestion;
};

View File

@@ -0,0 +1,133 @@
'use strict';
/**
* 中职组答题记录模型 - ZhongzhiSubmission
*
* 存储中职组参赛选手的答题记录,包括答题数据、判分和Word文件上传。
* 表名:zhongzhi_submissions
*/
module.exports = app => {
const { STRING, INTEGER, TEXT, BOOLEAN, DATE } = app.Sequelize;
const ZhongzhiSubmission = app.model.define('zhongzhi_submission', {
// 主键 ID,自增
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '提交记录唯一标识',
},
// 外键:关联聚合报名 ID
aggregation_uid: {
type: INTEGER,
allowNull: false,
comment: '关联聚合报名 ID',
},
// 外键:关联题目 ID
question_uid: {
type: INTEGER,
allowNull: false,
comment: '关联题目 ID',
},
// 题型(choice / judge):与 question_uid 联合唯一标识一道题。
// 选择题与判断题的 id 在不同表中可能数值重叠(如 choice#5 与 judge#5),
// 仅凭 question_uid 无法区分,必须结合 question_type 存储,否则会折叠丢失答案(见 B11)。
question_type: {
type: STRING(16),
allowNull: false,
defaultValue: '',
comment: '题型:choice/judge,与 question_uid 联合区分题目',
},
// 答题数据(选手提交的答案)
answer_data: {
type: TEXT,
allowNull: true,
defaultValue: null,
comment: '答题数据',
},
// 是否判为正确
is_correct: {
type: BOOLEAN,
allowNull: true,
defaultValue: null,
comment: '是否判为正确',
},
// 得分
score: {
type: INTEGER,
allowNull: true,
defaultValue: null,
comment: '得分',
},
// Word 文件存储路径
word_file_path: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: 'Word 文件存储路径',
},
// Word 文件原始名称
word_file_name: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: 'Word 文件原始名称',
},
// Word 文件 MD5
word_file_md5: {
type: STRING(32),
allowNull: true,
defaultValue: null,
comment: 'Word 文件 MD5',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '更新时间',
},
}, {
tableName: 'zhongzhi_submissions',
timestamps: false,
underscored: true,
indexes: [
{
fields: ['aggregation_uid'],
},
{
fields: ['question_uid'],
},
{
// 唯一约束:同一选手同一题(题型+题号)只保留一行,杜绝并发作答重复落库(B12)。
unique: true,
name: 'uk_zhongzhi_submission',
fields: ['aggregation_uid', 'question_uid', 'question_type'],
},
],
});
// 关联定义
ZhongzhiSubmission.associate = function() {
app.model.ZhongzhiSubmission.belongsTo(app.model.Aggregation, {
foreignKey: 'aggregation_uid',
targetKey: 'aggregation_uid',
as: 'aggregation',
});
app.model.ZhongzhiSubmission.belongsTo(app.model.ZhongzhiQuestion, {
foreignKey: 'question_uid',
targetKey: 'question_uid',
as: 'zhongzhiQuestion',
});
};
return ZhongzhiSubmission;
};

View File

@@ -0,0 +1 @@

158
app/router.js Normal file
View File

@@ -0,0 +1,158 @@
'use strict';
/**
* 路由配置 - Router
*
* 通信与导航应用技术创新赛 - 线上选拔赛网站后端路由定义
*
* 路由分组说明:
* - /api/auth/* : 认证相关(登录、激活、验证码等)
* - /api/news/* : 新闻通知(公开访问)
* - /api/carousel : 轮播图(公开访问)
* - /api/materials/* : 资料下载(需认证)
* - /api/exam/* : 比赛答题(需认证)
* - /api/upload/* : 文件上传(需认证)
* - /api/admin/* : 管理后台(需管理员认证)
* - /api/score/* : 成绩查询(需认证)
*/
module.exports = app => {
const { router, controller, middleware } = app;
// 获取 JWT 认证中间件实例
const auth = middleware.auth();
// 管理员认证中间件(需要 admin 角色)
const adminAuth = middleware.auth({ adminOnly: true });
// 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败
const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 });
// ==================== 公开接口(无需认证) ====================
// 健康检查(容器探针 / 负载均衡探活,无需认证)
router.get('/api/health', controller.health.check);
// 认证相关 - 公开接口
router.post('/api/auth/captcha', controller.auth.captcha);
router.post('/api/auth/sms', controller.auth.sms);
router.post('/api/auth/activate', controller.auth.activate);
router.post('/api/auth/login', loginRateLimit, controller.auth.login);
router.post('/api/auth/admin-login', loginRateLimit, controller.auth.adminLogin);
router.post('/api/auth/forgot-password', controller.auth.forgotPassword);
router.post('/api/auth/reset-password', controller.auth.resetPassword);
// ==================== 选手认证接口(需要 JWT,不需要 admin 角色) ====================
// 认证相关 - 受保护接口
router.get('/api/auth/current-user', auth, controller.auth.currentUser);
router.post('/api/auth/logout', auth, controller.auth.logout);
// 静默刷新令牌(不挂 auth 中间件,因为携带的 token 可能已过期)
router.post('/api/auth/refresh', controller.auth.refresh);
// ==================== 公开内容接口 ====================
// 新闻通知
router.get('/api/news', controller.news.index);
router.get('/api/news/counts', controller.news.counts);
router.get('/api/news/:id', controller.news.show);
// 轮播图
router.get('/api/carousel', controller.carousel.index);
// ==================== 资料下载(需认证) ====================
router.get('/api/materials', auth, controller.materials.index);
router.get('/api/materials/:group/:fileName/download', auth, controller.materials.download);
// ==================== 比赛答题(需认证) ====================
router.get('/api/exam/:group/status', auth, controller.exam.status);
router.get('/api/exam/:group/questions', auth, controller.exam.questions);
router.get('/api/exam/:group/answers', auth, controller.exam.getAnswers);
router.get('/api/exam/:group/word-template', controller.exam.wordTemplate);
router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers);
router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord);
router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord);
router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll);
// ==================== 文件上传(需认证) ====================
router.post('/api/upload/:group', auth, controller.upload.upload);
router.get('/api/upload/:group/status', auth, controller.upload.status);
router.get('/api/upload/:group/submissions', auth, controller.upload.submissions);
router.delete('/api/upload/:group/:id', auth, controller.upload.delete);
// 分片上传(需认证)
router.post('/api/upload/:group/chunk', auth, controller.upload.uploadChunk);
router.post('/api/upload/:group/merge', auth, controller.upload.mergeChunks);
router.get('/api/upload/:group/chunk-status', auth, controller.upload.chunkStatus);
// ==================== 管理后台(需管理员认证) ====================
// 数据统计
router.get('/api/admin/stats', adminAuth, controller.admin.stats);
// 账号管理
router.get('/api/admin/accounts', adminAuth, controller.admin.accounts);
router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel);
// 提交管理
router.get('/api/admin/submissions/:group', adminAuth, controller.admin.submissions);
router.get('/api/admin/submission-file/:group/:id', adminAuth, controller.admin.downloadSubmissionFile);
router.get('/api/admin/export-csv/:group', adminAuth, controller.admin.exportCsv);
router.get('/api/admin/batch-download/:group', adminAuth, controller.admin.batchDownload);
router.get('/api/admin/works/:group', adminAuth, controller.admin.works);
// 时间设置
router.get('/api/admin/times', adminAuth, controller.admin.getTimes);
router.put('/api/admin/times', adminAuth, controller.admin.setTimes);
// 分数管理
router.get('/api/admin/scores/:group', adminAuth, controller.admin.getScores);
router.put('/api/admin/scores/:group', adminAuth, controller.admin.setScores);
// 试题管理
router.get('/api/admin/exam/:group/questions', adminAuth, controller.admin.getQuestions);
router.put('/api/admin/exam/:group/questions', adminAuth, controller.admin.setQuestions);
router.post('/api/admin/exam/:group/questions/import-excel', adminAuth, controller.admin.importQuestionsExcel);
router.get('/api/admin/exam/:group/questions/template', adminAuth, controller.admin.downloadQuestionTemplate);
router.put('/api/admin/exam/:group/word-template', adminAuth, controller.admin.uploadWordTemplate);
router.get('/api/admin/exam/:group/word-template', adminAuth, controller.admin.getWordTemplate);
router.delete('/api/admin/exam/:group/word-template', adminAuth, controller.admin.deleteWordTemplate);
// 备赛资料管理
router.get('/api/admin/materials/:group', adminAuth, controller.admin.getMaterials);
router.post('/api/admin/materials/:group', adminAuth, controller.admin.uploadMaterial);
router.delete('/api/admin/materials/:group/:fileName', adminAuth, controller.admin.deleteMaterial);
// 成绩发布
router.put('/api/admin/publish-scores', adminAuth, controller.admin.publishScores);
router.get('/api/admin/publish-scores', adminAuth, controller.admin.getPublishStatus);
// 新闻管理
router.get('/api/admin/news', adminAuth, controller.admin.listNews);
router.post('/api/admin/news', adminAuth, controller.admin.createNews);
router.put('/api/admin/news/order', adminAuth, controller.admin.updateNewsOrder);
router.put('/api/admin/news/:id', adminAuth, controller.admin.updateNews);
router.delete('/api/admin/news/:id', adminAuth, controller.admin.deleteNews);
// 轮播图管理
router.get('/api/admin/carousel', adminAuth, controller.admin.listCarousel);
router.post('/api/admin/carousel', adminAuth, controller.admin.addCarousel);
router.put('/api/admin/carousel/:id', adminAuth, controller.admin.updateCarousel);
router.delete('/api/admin/carousel/:id', adminAuth, controller.admin.deleteCarousel);
router.put('/api/admin/carousel/order', adminAuth, controller.admin.updateCarouselOrder);
// 图片上传(轮播图等)
router.post('/api/admin/upload/image', adminAuth, controller.admin.uploadImage);
router.post('/api/admin/upload/news-image', adminAuth, controller.admin.uploadNewsImage);
// 下载考生答案 Word 文件
router.get('/api/admin/submissions/:group/:id/word', adminAuth, controller.admin.downloadWord);
// 测试数据标记与重置
router.post('/api/admin/toggle-test-flag', adminAuth, controller.admin.toggleTestFlag);
router.post('/api/admin/reset-test-data', adminAuth, controller.admin.resetTestData);
// ==================== 成绩查询(需认证) ====================
router.get('/api/score', auth, controller.score.index);
};

1912
app/service/admin.js Normal file

File diff suppressed because it is too large Load Diff

116
app/service/auth.js Normal file
View File

@@ -0,0 +1,116 @@
'use strict';
const Service = require('egg').Service;
/**
* 认证服务 - AuthService
*
* 封装认证相关的数据库操作和业务逻辑。
* 使用 Contestant 模型进行选手认证,Admin 模型进行管理员认证。
* 密码采用 bcrypt 加盐哈希存储;存量 SHA256(无盐)密码在下次成功登录时透明升级为 bcrypt。
*/
class AuthService extends Service {
/**
* 根据手机号查找选手
* @param {string} phone - 手机号
* @returns {object|null} 选手信息
*/
async findByPhone(phone) {
const { ctx } = this;
const contestant = await ctx.model.Contestant.findOne({
where: { phone },
attributes: ['id', 'id_number', 'name', 'phone', 'group', 'password', 'activated', 'score'],
});
return contestant;
}
/**
* 根据身份证号查找选手(用于激活和忘记密码)
* @param {string} idNumber - 身份证号
* @returns {object|null} 选手信息
*/
async findByAccount(idNumber) {
const { ctx } = this;
return await ctx.model.Contestant.findOne({
where: { id_number: idNumber },
});
}
/**
* 根据用户名查找管理员
*
* @param {string} username - 管理员用户名
* @returns {Promise<Model|null>} Admin 模型对象或 null
*/
async findAdmin(username) {
const { ctx } = this;
return await ctx.model.Admin.findOne({
where: { username },
});
}
/**
* 验证密码
*
* 兼容 bcrypt 哈希与存量 SHA256(无盐)哈希,便于平滑迁移。
*
* @param {string} inputPassword - 用户输入的明文密码
* @param {string} hashedPassword - 数据库中存储的哈希密码
* @returns {Promise<boolean>} 密码是否匹配
*/
async verifyPassword(inputPassword, hashedPassword) {
const { ctx } = this;
return ctx.helper.comparePassword(inputPassword, hashedPassword);
}
/**
* 透明升级:若账号仍使用旧版 SHA256 哈希,在验证通过后重写为 bcrypt。
* 存量账号会在下一次成功登录时自动完成迁移,无需批量刷库。
*
* @param {Model} account - 含 password 字段的模型实例(contestant 或 admin)
* @param {string} plainPassword - 明文密码
* @returns {Promise<void>}
*/
async upgradePasswordIfLegacy(account, plainPassword) {
const { ctx } = this;
if (account && account.password && !ctx.helper.isBcryptHash(account.password)) {
try {
const newHash = await ctx.helper.hashPassword(plainPassword);
await account.update({ password: newHash });
} catch (e) {
// 升级失败不影响本次登录,仅记录日志,下次登录再尝试
ctx.logger.warn('[密码迁移] bcrypt 升级失败:', e.message);
}
}
}
/**
* 激活账号
*
* 将指定 id 的选手设置为已激活状态,并更新密码。
*
* @param {number} id - 选手 id
* @param {string} password - SHA256 哈希后的密码
* @returns {Promise<Model>} 更新后的 Contestant 模型对象
*/
async activateAccount(id, password) {
const { ctx } = this;
const contestant = await ctx.model.Contestant.findOne({
where: { id },
});
if (!contestant) {
return null;
}
return await contestant.update({
activated: 1,
password,
});
}
}
module.exports = AuthService;

745
app/service/exam.js Normal file
View File

@@ -0,0 +1,745 @@
'use strict';
const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
/**
* 考试服务 - ExamService
*
* 封装考试相关业务逻辑,包括题目随机获取、答案校验、自动判分、
* 比赛状态检查、答题记录保存等功能。
*/
class ExamService extends Service {
/**
* 根据用户的 id_number 获取聚合报名信息
* @param {string} idNumber - 身份证号
* @returns {Promise<Object|null>} 聚合报名记录
*/
async getUserAggregation(idNumber) {
const { ctx } = this;
// 先通过 id_number 找到 Account,再通过 Account.uid 找到 Aggregation
const account = await ctx.model.Account.findOne({
where: { id_number: idNumber },
attributes: ['uid'],
});
if (!account) return null;
return await ctx.model.Aggregation.findOne({
where: { account_uid: account.uid },
include: [
{ model: ctx.model.Team, as: 'team', attributes: ['team_uid', 'group_name', 'team_id', 'team_number'] },
],
});
}
/**
* 根据题型获取对应的模型
* @param {string} questionType - 题型:choice / judge / word
* @returns {Object} Sequelize 模型
*/
_getQuestionModel(questionType) {
const { ctx } = this;
switch (questionType) {
case 'choice': return ctx.model.ChoiceQuestion;
case 'judge': return ctx.model.JudgeQuestion;
case 'word': return ctx.model.WordQuestion;
default: throw new Error(`未知题型: ${questionType}`);
}
}
/**
* 从可能带题型前缀的 question_uid 中提取原始自增 id。
* 兼容三种形态:
* - 'choice_12' / 'judge_3'(新:题型前缀,保证跨题型全局唯一)
* - '12'(旧:纯数字字符串)
* - 12(number)
* @param {string|number} uid
* @returns {string|number} 原始自增 id
*/
_extractRawId(uid) {
if (uid === undefined || uid === null) return uid;
const s = String(uid);
const idx = s.lastIndexOf('_');
if (idx >= 0) {
const tail = s.slice(idx + 1);
return /^\d+$/.test(tail) ? tail : s;
}
return s;
}
/**
* 随机获取指定组别的题目(选择题 + 判断题,按各自数量抽取)
* @param {string} group - 组别:zhongzhi / gaozhi
* @param {Object} options - 抽题数量配置
* @param {number} options.choiceCount - 选择题数量,默认 50
* @param {number} options.judgeCount - 判断题数量,默认 20
* @returns {Promise<Array>} 题目列表(不含 answer 字段),顺序:选择题在前、判断题在后
*/
async getRandomQuestions(group, options = {}) {
const { ctx } = this;
// 严格按题型数量分别抽取,确保分布符合「50 道选择题 + 20 道判断题」的赛制要求,
// 而不是把单个总数对半切(否则会变成 35+35 等错误分布)。
const choiceCount = options.choiceCount != null ? options.choiceCount : 50;
const judgeCount = options.judgeCount != null ? options.judgeCount : 20;
const [choiceQuestions, judgeQuestions] = await Promise.all([
ctx.model.ChoiceQuestion.findAll({
where: { group },
order: ctx.model.Sequelize.literal('RAND()'),
limit: choiceCount,
attributes: { exclude: ['answer'] },
raw: true,
}),
ctx.model.JudgeQuestion.findAll({
where: { group },
order: ctx.model.Sequelize.literal('RAND()'),
limit: judgeCount,
attributes: { exclude: ['answer'] },
raw: true,
}),
]);
// 合并并标记题型,并规范化输出字段
// 顺序约定:选择题全部在前、判断题全部在后(各自内部仍由 RAND() 随机),
// 前端按全局索引编号,选择题 1~N、判断题 N+1~M,Word 综合题接续 M+1,题号严格连续。
const questions = [
...choiceQuestions.map(q => normalizeQuestion(q, 'choice')),
...judgeQuestions.map(q => normalizeQuestion(q, 'judge')),
];
return questions;
}
/**
* 按指定的题目 UID 顺序取题(不随机),用于「重进复用同一套题目与顺序」。
* @param {string} group - 组别
* @param {Array<string>} questionUids - 固化顺序的 question_uid 数组,如 ['choice_12','judge_3',...]
* @returns {Promise<Array>} 严格按 questionUids 顺序的题目列表(缺失题目自动跳过)
*/
async getQuestionsByFixedOrder(group, questionUids) {
const { ctx } = this;
const buckets = { choice: [], judge: [] };
for (const uid of (questionUids || [])) {
const s = String(uid);
const idx = s.lastIndexOf('_');
if (idx < 0) continue;
const type = s.slice(0, idx);
const rawId = s.slice(idx + 1);
if ((type === 'choice' || type === 'judge') && /^\d+$/.test(rawId)) {
buckets[type].push(parseInt(rawId, 10));
}
}
const out = [];
for (const type of ['choice', 'judge']) {
const ids = buckets[type];
if (!ids.length) continue;
const Model = type === 'choice' ? ctx.model.ChoiceQuestion : ctx.model.JudgeQuestion;
const rows = await Model.findAll({
where: { id: { [ctx.model.Sequelize.Op.in]: ids }, group },
attributes: { exclude: ['answer'] },
raw: true,
});
const map = new Map(rows.map(r => [r.id, r]));
for (const id of ids) {
const r = map.get(id);
if (r) out.push(normalizeQuestion(r, type));
}
}
return out;
}
/**
* 获取/固化该考生的题目顺序:首次进入随机生成并持久化到 exam_submissions.question_order;
* 之后重进(或已提交后重进)一律复用同一顺序,彻底消除「重进题目顺序变乱」缺陷。
* 兼容历史已提交但无 question_order 的记录:从其 answers 推导顺序(答案顺序即作答顺序)。
* @param {number} aggregationUid - 聚合报名 ID(exam_submissions 以它为 contestant_id 存储)
* @param {string} group - 组别
* @param {Object} options - 抽题数量配置 { choiceCount, judgeCount }
* @returns {Promise<{questions: Array, questionUids: Array<string>}>}
*/
async getAssignedQuestions(aggregationUid, group, options = {}) {
const { ctx } = this;
// 确保该考生有一条记录(占位,submit_time 保持 null,不影响 hasSubmitted 判定)。
const row = await this._ensureExamSubmissionSafe(aggregationUid, {
contestant_id: aggregationUid,
answers: [],
submit_time: null,
});
// 1) 已固化顺序 → 直接复用
const saved = this._readQuestionOrder(row);
if (saved && saved.length) {
return {
questions: await this.getQuestionsByFixedOrder(group, saved),
questionUids: saved,
};
}
// 2) 历史已提交但无固化顺序:从 answers 推导(答案顺序即作答顺序)
const savedAnswers = row.answers;
if (Array.isArray(savedAnswers) && savedAnswers.length) {
const derived = savedAnswers
.map(a => a.question_uid || (a.question_type && a.question_id ? `${a.question_type}_${a.question_id}` : null))
.filter(Boolean);
if (derived.length) {
await row.update({ question_order: JSON.stringify(derived) });
return {
questions: await this.getQuestionsByFixedOrder(group, derived),
questionUids: derived,
};
}
}
// 3) 首次进入:随机生成并持久化(同一考生只发生一次)
const questions = await this.getRandomQuestions(group, options);
const uids = questions.map(q => q.question_uid);
await row.update({ question_order: JSON.stringify(uids) });
return { questions, questionUids: uids };
}
/**
* 从 exam_submissions 记录中安全读取 question_order(JSON 字符串/对象 → 数组)
* @param {Object} row - 记录实例或原始对象
* @returns {Array<string>|null}
*/
_readQuestionOrder(row) {
if (!row) return null;
const v = row.question_order;
if (!v) return null;
try {
const a = typeof v === 'string' ? JSON.parse(v) : v;
return Array.isArray(a) ? a : null;
} catch (e) {
return null;
}
}
/**
* 核对单道题目的答案
* @param {number} questionId - 题目 ID
* @param {string} questionType - 题型:choice / judge / word
* @param {string} answerData - 选手提交的答案
* @returns {Promise<Object>} { isCorrect, score, question }
*/
async checkAnswer(questionId, questionType, answerData) {
const QuestionModel = this._getQuestionModel(questionType);
// question_uid 现在是「题型_自增id」的全局唯一形式(choice_12 / judge_3),
// 判分时需还原为原始自增 id 再 findByPk;同时兼容历史纯数字形态。
const rawId = this._extractRawId(questionId);
const question = await QuestionModel.findByPk(rawId);
if (!question) {
return { isCorrect: false, score: 0, question: null, error: '题目不存在' };
}
// 比对答案(去除首尾空格,统一大小写)
const correctAnswer = String(question.answer).trim().toLowerCase();
const userAnswer = String(answerData).trim().toLowerCase();
const isCorrect = correctAnswer === userAnswer;
return {
isCorrect,
score: isCorrect ? question.score : 0,
question: {
id: question.id,
question: question.question,
question_type: questionType,
correctAnswer: question.answer,
maxScore: question.score,
},
};
}
/**
* 批量自动判分
* @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }]
* @returns {Promise<Object>} { autoScore, total, details }
*/
async autoScore(answers) {
const details = [];
let autoScore = 0;
let total = 0;
for (const item of answers) {
const result = await this.checkAnswer(
item.question_id || item.question_uid,
item.question_type,
item.answer_data
);
if (result.question) {
total += result.question.maxScore;
autoScore += result.score;
details.push({
question_id: item.question_id || item.question_uid,
question_type: item.question_type,
answer_data: item.answer_data,
is_correct: result.isCorrect,
score: result.score,
max_score: result.question.maxScore,
});
}
}
return { autoScore, total, details };
}
/**
* 查询比赛时间状态
* @param {string} group - 组别
* @returns {Promise<Object>} { isActive, startTime, endTime, countdown, status }
*/
async getExamStatus(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group: group },
});
// 未配置时间
if (!timeConfig || !timeConfig.start_time || !timeConfig.end_time) {
return {
isActive: false,
startTime: null,
endTime: null,
countdown: 0,
status: 'not_set',
message: '考试时间尚未配置',
};
}
const now = new Date();
const startTime = parseLocal(timeConfig.start_time);
const endTime = parseLocal(timeConfig.end_time);
// 考试未开始
if (now < startTime) {
return {
isActive: false,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
countdown: 0,
status: 'not_started',
message: '考试尚未开始',
};
}
// 考试进行中
if (now >= startTime && now <= endTime) {
const countdown = Math.max(0, Math.floor((endTime - now) / 1000));
return {
isActive: true,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
countdown,
status: 'in_progress',
message: '考试正在进行中',
};
}
// 考试已结束
return {
isActive: false,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
countdown: 0,
status: 'ended',
message: '考试已结束',
};
}
/**
* 获取 Word 模板文件路径
* @param {string} group - 组别
* @returns {Promise<string|null>} 模板文件路径
*/
async getWordTemplatePath(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group: group },
attributes: ['id', 'word_template'],
});
return timeConfig ? timeConfig.word_template : null;
}
/**
* 保存答题记录(逐题存储)
* @param {number} aggregationUid - 聚合报名 ID
* @param {Array} answers - 答案数组 [{ question_uid, answer_data, is_correct, score }]
* @param {string} group - 组别
* @returns {Promise<Array>} 保存后的记录列表
*/
async saveAnswers(aggregationUid, answers, group) {
const { ctx } = this;
// 根据组别选择对应的提交模型
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
// 死锁判定:并发 upsert 抢同一唯一索引(uk_zhongzhi_submission)时会瞬时死锁,
// 属于可重试的瞬时冲突,整体重试事务即可(而非丢数据)。
const isDeadlock = (e) => {
if (!e) return false;
if (e.code === 'ER_LOCK_DEADLOCK') return true;
if (e.parent && e.parent.code === 'ER_LOCK_DEADLOCK') return true;
return /ER_LOCK_DEADLOCK/.test(String(e.message || ''));
};
// 死锁重试(最多 3 次,指数退避)。事务整体提交/回滚是原子的,要么全落要么全不落。
const MAX_ATTEMPTS = 3;
let lastErr = null;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await ctx.model.transaction(async (t) => {
for (const answer of answers) {
// 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束,
// 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求
// 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。
//
// ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id,
// 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行
// 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。
//
// ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize
// 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉
// returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。
const rawQuestionId = this._extractRawId(answer.question_uid);
await SubmissionModel.upsert({
aggregation_uid: aggregationUid,
question_uid: rawQuestionId,
question_type: answer.question_type,
answer_data: answer.answer_data,
is_correct: answer.is_correct,
score: answer.score,
}, {
conflictFields: ['aggregation_uid', 'question_uid', 'question_type'],
transaction: t,
});
}
});
return answers.length;
} catch (err) {
lastErr = err;
ctx.logger.error(`[saveAnswers] attempt ${attempt}/${MAX_ATTEMPTS} failed agg=${aggregationUid}:`, err && err.message);
if (isDeadlock(err) && attempt < MAX_ATTEMPTS) {
await new Promise(r => setTimeout(r, 50 * attempt));
continue;
}
throw err;
}
}
throw lastErr;
}
/**
* 并发安全地获取/创建 exam_submissions 提交记录。
* 依赖 contestant_id 唯一约束:并发下若另一请求已插入,findOrCreate 的 create 会触发
* ER_DUP_ENTRY,此时直接取回已有记录,避免产生重复行(B12 修复)。
* @param {number} contestantId - 选手 ID
* @param {Object} defaults - 新建时的默认值
* @returns {Promise<Object>} 提交记录实例
*/
async _ensureExamSubmissionSafe(contestantId, defaults) {
const { ctx } = this;
try {
const [rec] = await ctx.model.ExamSubmission.findOrCreate({
where: { contestant_id: contestantId },
defaults,
});
return rec;
} catch (e) {
if (e.name === 'SequelizeUniqueConstraintError' ||
/ER_DUP_ENTRY/.test(String(e.message))) {
return await ctx.model.ExamSubmission.findOne({ where: { contestant_id: contestantId } });
}
throw e;
}
}
/**
* 获取/创建当前选手的 exam_submissions 提交记录(并发安全)
* @param {number} contestantId - 选手 ID
* @param {Array} answers - 答题内容
* @returns {Promise<Object>} 提交记录实例
*/
async ensureExamSubmission(contestantId, answers) {
const rec = await this._ensureExamSubmissionSafe(contestantId, {
contestant_id: contestantId,
answers: answers || [],
submit_time: new Date(),
});
// 记录可能是团队锁占位 / Word 上传时创建的(submit_time 为 null,表示未正式提交)。
// findOrCreate 命中已有行时不会更新字段,须显式补写 submit_time —— 否则
// 正式提交后 hasSubmitted / 管理后台仍显示「未提交」(提交状态不一致修复)。
if (rec && !rec.submit_time) {
await rec.update({ submit_time: new Date(), answers: answers || [] });
}
return rec;
}
/**
* 检查用户是否已正式提交全部考试
* 仅当 exam_submissions 表中有 submit_time 记录时才判定为已提交
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<boolean>} 是否已正式提交
*/
async hasSubmitted(aggregationUid, group) {
const { ctx } = this;
// 提交组(本科/教师)的提交记录落在 benke_submissions / jiaoshi_submissions,
// 与考试组(中职/高职)的 exam_submissions 不同表。统一在此按组别判定,
// 使 GET /api/exam/:group/status 返回的 submitted 对全部组别一致生效
// (前端 Dashboard「已提交」标签 + 进入拦截方可对提交组正确工作)。无新表列。
if (group === 'benke' || group === 'jiaoshi') {
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 🔴 聚合键铁律:上传链路(service.upload.getUserAggregation)写入的
// benke/jiaoshi_submissions.aggregation_uid = aggregation 表的 aggregation_uid,
// 而本方法收到的 aggregationUid 来自 exam 控制器 = contestant.id,真实选手
// 两者不相等(如郑佳怡 contestant.id=11 / aggregation_uid=1)。须按
// account_uid → aggregation_uid 双向映射后再查(与 getScores 口径一致),
// 否则真实选手提交后 submitted 恒 false → 标签仍「进行中」+ 可重复进入。
const keys = [ aggregationUid ];
const agg = await ctx.model.Aggregation.findOne({
where: { account_uid: aggregationUid },
attributes: [ 'aggregation_uid' ],
});
if (agg && agg.aggregation_uid && !keys.includes(agg.aggregation_uid)) {
keys.push(agg.aggregation_uid);
}
const count = await SubmissionModel.count({
where: { aggregation_uid: keys },
});
return count > 0;
}
const record = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
attributes: ['submit_time'],
});
// 统一返回布尔(原为 record && ...,无记录时为 null),与提交组分支保持一致,
// 避免调用方对 submitted 字段类型判断不一致。
return !!(record && record.submit_time !== null);
}
/**
* 检查团队考试锁:同一小组是否已有人进入考试
*
* 🔴 跨组 team_uid 复用防护(组内一人参赛锁的核心正确性前提):
* team_uid / team_id 仅在「组内唯一」,不同组别可复用同一个整数团队号
* (实测:team_uid=1 同时存在于 zhongzhi 与 benke)。若不按组别限定队友,
* benke 组选手的 exam_submissions 行会把 zhongzhi 组选手误判为「队友已进入考试」,
* 导致 questions / submit-all 无故返回 4005「您的小组已进入考试」。
* 因此队友候选集必须用 contestants.group 复筛为同组(选手组别的权威来源与
* controller._getUserAggregation 的 team.group_name 一致,均取 contestant.group)。
*
* @param {number} teamId - 团队 ID
* @param {number} excludeContestantId - 排除的选手 ID(当前用户)
* @param {string} [group] - 当前选手组别;传入时按组别限定队友(防跨组误锁)
* @returns {Promise<Object>} { locked: boolean, lockedBy: string|null }
*/
async checkTeamLock(teamId, excludeContestantId, group) {
const { ctx } = this;
if (!teamId) return { locked: false, lockedBy: null };
// 队友识别兼容两种团队来源:
// - contestants.team_id = teamId(导入流程填充)
// - aggregation.team_uid = teamId(聚合报名表填充,兼容旧数据)
const contestantWhere = { team_id: teamId };
if (group) contestantWhere.group = group;
const contestants = await ctx.model.Contestant.findAll({
where: contestantWhere,
attributes: ['id', 'name'],
});
const contestantIds = contestants.map(c => c.id);
// aggregation 表无组别列声明(Sequelize 模型未定义 group),故此来源先取全量
// 候选,再由下方 contestants.group 复筛统一过滤跨组污染。
const aggRows = await ctx.model.Aggregation.findAll({
where: { team_uid: teamId },
attributes: ['account_uid'],
});
const aggIds = aggRows.map(a => a.account_uid);
let allIds = Array.from(new Set([...contestantIds, ...aggIds]))
.filter(id => id !== excludeContestantId);
if (allIds.length === 0) return { locked: false, lockedBy: null };
// 🔴 同组复筛:剔除跨组复用同一 team_uid 的他组选手(见方法头注释)
if (group) {
const sameGroup = await ctx.model.Contestant.findAll({
where: { id: allIds, group },
attributes: ['id'],
});
allIds = sameGroup.map(r => r.id);
if (allIds.length === 0) return { locked: false, lockedBy: null };
}
const existingSubmission = await ctx.model.ExamSubmission.findOne({
where: {
contestant_id: allIds,
},
include: [{
model: ctx.model.Contestant,
as: 'contestant',
attributes: ['name'],
}],
order: [['id', 'ASC']],
});
if (existingSubmission) {
return {
locked: true,
lockedBy: existingSubmission.contestant ? existingSubmission.contestant.name : '队友',
};
}
return { locked: false, lockedBy: null };
}
/**
* 设置团队考试锁(创建 exam_submission 占位记录)
* @param {number} teamId - 团队 ID
* @param {number} contestantId - 选手 ID
* @returns {Promise<void>}
*/
async setTeamLock(teamId, contestantId) {
const { ctx } = this;
// 使用并发安全方式获取/创建(唯一约束下避免重复行,B12 修复)。
// ⚠️ 占位记录 submit_time 必须为 null:占位 ≠ 正式提交,否则选手一进考试
// 就被 hasSubmitted 判为「已提交」(提交状态不一致 Bug 根源之一)。
const rec = await this._ensureExamSubmissionSafe(contestantId, {
contestant_id: contestantId,
answers: [],
submit_time: null,
});
return rec;
}
/**
* 保存 Word 文件上传记录
* @param {number} aggregationUid - 聚合报名 ID
* @param {number} questionUid - 题目 ID
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath, fileName, md5 }
* @returns {Promise<Object>} 保存后的记录
*/
async saveWordFile(aggregationUid, questionUid, group, fileInfo) {
const { ctx } = this;
// 查找该选手的答题记录
let record = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
});
// 不存在则并发安全地创建(唯一约束下避免重复行,B12 修复)。
// ⚠️ 仅上传 Word ≠ 正式提交,submit_time 保持 null,正式提交由
// ensureExamSubmission 统一补写(提交状态不一致修复)。
if (!record) {
record = await this._ensureExamSubmissionSafe(aggregationUid, {
contestant_id: aggregationUid,
answers: [],
submit_time: null,
});
}
// 更新 Word 文件信息
await record.update({
word_file_path: fileInfo.filePath,
word_file_name: fileInfo.fileName,
});
return record;
}
/**
* 获取某用户的所有答题记录
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<Array>} 答题记录列表
*/
async getUserAnswers(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission
: ctx.model.GaozhiSubmission;
return await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
});
}
/**
* 计算 Word 文件 MD5
* @param {string} filePath - 文件路径
* @returns {Promise<string>} MD5 值
*/
async calculateFileMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
}
/**
* 将数据库中的时间字符串(如 '2026-07-22 09:00:00',无时区标记)按本地时区解析。
* 注意:new Date('2026-07-22 09:00:00') 在 V8 中会按 UTC 解析,导致与业务库
* 本地时间产生约 8 小时的偏移;将空格替换为 'T' 后按本地时区解析可修正该偏差。
* @param {string|Date} ts - 时间字符串或 Date 对象
* @returns {Date}
*/
function parseLocal(ts) {
if (!ts) return new Date(NaN);
if (ts instanceof Date) return ts;
const s = String(ts).trim().replace(' ', 'T');
return new Date(s);
}
/**
* 规范化题目输出字段
* - 补齐 question_uid(铁律2:前端统一以 question_uid 作为题目稳定标识,
* 库表主键为自增 id,question_uid 以其字符串形式暴露)
* - options 在库中以 JSON 字符串存储,规范化为数组返回(judge 题型无 options)
* @param {Object} q - 原始题目(raw:true 结果)
* @param {string} questionType - choice / judge
* @returns {Object} 规范化后的题目
*/
function normalizeQuestion(q, questionType) {
const out = { ...q };
out.question_type = questionType;
if (out.question_uid === undefined || out.question_uid === null) {
// 题号全局唯一(关键修复):选择题与判断题各自的自增 id 都从 1 开始、会重复,
// 若仅用 id 作为 question_uid,choice#1 与 judge#1 会碰撞,导致:
// ① 前端 findIndex 命中错误位置 → 序号重复(如判断题里出现「1」)、真实序号丢失(如「55」不见);
// ② answers 以 question_uid 为键 → 两题相互覆盖 → 答完仍提示「未作答」/题目丢失。
// 因此以「题型_自增id」作为跨题型全局唯一的稳定标识。
out.question_uid = questionType + '_' + out.id;
}
if (questionType === 'choice') {
if (typeof out.options === 'string') {
try {
const parsed = JSON.parse(out.options);
out.options = Array.isArray(parsed) ? parsed : [];
} catch (e) {
out.options = [];
}
} else if (!Array.isArray(out.options)) {
out.options = [];
}
} else {
// 判断题无选项
out.options = [];
}
return out;
}
module.exports = ExamService;

132
app/service/news.js Normal file
View File

@@ -0,0 +1,132 @@
'use strict';
const Service = require('egg').Service;
/**
* 新闻服务 - NewsService
*
* 封装新闻相关的数据库查询操作。
* 支持按组别过滤、按置顶过滤、分页查询。
*/
class NewsService extends Service {
/**
* 查询新闻列表(支持分页和过滤)
*
* 过滤参数:
* - group: 组别筛选(如 zhongzhi, gaozhi, benke, jiaoshi),同时包含 group='all' 的通用新闻
* - pinned: 是否仅查询置顶新闻(true/false)
*
* 排序规则:pinned DESC, publish_time DESC
*
* @param {Object} params - 查询参数
* @param {string} [params.group] - 目标组别
* @param {string|boolean} [params.pinned] - 是否只查置顶
* @param {number} [params.page=1] - 页码
* @param {number} [params.pageSize=20] - 每页数量
* @returns {Promise<{list: Array, total: number, page: number, pageSize: number}>} 分页结果
*/
async list(params = {}) {
const { ctx } = this;
const { Sequelize } = ctx.app;
const { group, pinned, page, pageSize } = params;
// 分页参数,默认第 1 页,每页 20 条
const currentPage = parseInt(page) || 1;
const currentPageSize = parseInt(pageSize) || 20;
const offset = (currentPage - 1) * currentPageSize;
// 构建查询条件
const where = {};
// 组别过滤:同时包含指定组别和通用(all)新闻
if (group) {
where.group = {
[Sequelize.Op.in]: [group, 'all'],
};
}
// 置顶过滤
if (pinned !== undefined && pinned !== null && pinned !== '') {
where.pinned = pinned === 'true' || pinned === true;
}
// 构建查询
const queryOptions = {
where,
order: [
['pinned', 'DESC'],
['sort_order', 'ASC'],
['publish_time', 'DESC'],
],
attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'created_at', 'updated_at'],
offset,
limit: currentPageSize,
};
// 查询新闻列表和总数
const [list, total] = await Promise.all([
ctx.model.News.findAll(queryOptions),
ctx.model.News.count({ where }),
]);
return {
list,
total,
page: currentPage,
pageSize: currentPageSize,
};
}
/**
* 获取各分组新闻计数(用于首页 Tab 显示数量)
* @returns {Promise<Object>} { all: number, zhongzhi: number, gaozhi: number, benke: number, jiaoshi: number }
*/
async getGroupCounts() {
const { ctx } = this;
const { Sequelize } = ctx.app;
const groups = ['all', 'zhongzhi', 'gaozhi', 'benke', 'jiaoshi'];
const counts = { all: 0, zhongzhi: 0, gaozhi: 0, benke: 0, jiaoshi: 0 };
// 并行查询各分组计数
await Promise.all(groups.map(async group => {
const where = {};
// 对于具体组别,同时包含该组别和 all 通用新闻
if (group !== 'all') {
where.group = { [Sequelize.Op.in]: [group, 'all'] };
}
counts[group] = await ctx.model.News.count({ where });
}));
return counts;
}
/**
* 根据 ID 查询新闻详情
*
* @param {number} id - 新闻 ID
* @returns {Promise<Model|null>} 新闻模型对象或 null
*/
async findById(id) {
const { ctx } = this;
return await ctx.model.News.findOne({
where: { id },
attributes: ['id', 'title', 'content', 'publish_time', 'pinned', 'group', 'tags', 'sort_order', 'created_at', 'updated_at'],
});
}
/**
* 批量更新新闻排序
* @param {Array<{id: number, sort_order: number}>} orders - 排序信息数组
* @returns {Promise<void>}
*/
async updateSortOrder(orders) {
const { ctx } = this;
await Promise.all(orders.map(async ({ id, sort_order }) => {
await ctx.model.News.update({ sort_order }, { where: { id } });
}));
}
}
module.exports = NewsService;

78
app/service/sms.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
const Service = require('egg').Service;
/**
* 短信网关服务(阿里云 Dysmsapi)
*
* 仅负责把"服务端已生成的验证码"下发给手机号,不参与验证码的存证与校验
* (校验仍由 ctx.session 在 auth 控制器中完成,契约见 verify_sms_and_import_lock 测试)。
*
* 依赖 SDK(生产环境需 npm install):
* @alicloud/openapi-client
* @alicloud/dysmsapi20170525
* @alicloud/tea-util
*/
class SmsService extends Service {
/**
* 发送短信验证码
* @param {string} phone - 接收短信的手机号
* @param {string} code - 6 位验证码(由调用方生成并存入 session)
* @returns {Promise<{ok:boolean, message:string}>}
*/
async sendVerifyCode(phone, code) {
const { ctx, app } = this;
const cfg = app.config.sms && app.config.sms.aliyun;
if (!cfg || !cfg.accessKeyId || !cfg.accessKeySecret) {
ctx.logger.error('[短信网关] 未配置阿里云密钥,无法发送');
return { ok: false, message: '短信网关未配置' };
}
let OpenApi;
let Dysmsapi;
let Util;
try {
OpenApi = require('@alicloud/openapi-client');
Dysmsapi = require('@alicloud/dysmsapi20170525');
Util = require('@alicloud/tea-util');
} catch (e) {
ctx.logger.error('[短信网关] 未安装阿里云 SDK:' + e.message);
return { ok: false, message: '短信网关 SDK 未安装' };
}
try {
const config = new OpenApi.Config({
accessKeyId: cfg.accessKeyId,
accessKeySecret: cfg.accessKeySecret,
endpoint: cfg.endpoint,
});
const client = new Dysmsapi.default(config);
const request = new Dysmsapi.SendSmsRequest({
phoneNumbers: String(phone),
signName: cfg.signName,
templateCode: cfg.templateCode,
templateParam: JSON.stringify({ code: String(code) }),
});
const runtime = new Util.RuntimeOptions({});
runtime.readTimeout = 5000;
runtime.connectTimeout = 5000;
const response = await client.sendSmsWithOptions(request, runtime);
const body = response && response.body;
if (body && body.code === 'OK') {
ctx.logger.info(`[短信网关] 验证码已下发至 ${phone}`);
return { ok: true, message: '验证码发送成功' };
}
const reason = (body && (body.message || body.code)) || '未知错误';
ctx.logger.error(`[短信网关] 下发失败:${reason}`);
return { ok: false, message: reason };
} catch (err) {
ctx.logger.error('[短信网关] 调用异常:' + (err && err.message));
return { ok: false, message: (err && err.message) || '短信网关调用异常' };
}
}
}
module.exports = SmsService;

368
app/service/upload.js Normal file
View File

@@ -0,0 +1,368 @@
'use strict';
const Service = require('egg').Service;
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
/**
* 上传服务 - UploadService
*
* 封装作品文件上传相关业务逻辑,包括截止时间校验、文件保存、
* 分片上传与合并等功能。适用于本科组和教师组的作品提交。
*/
class UploadService extends Service {
/**
* 获取用户的聚合报名信息
* @param {number} accountUid - 账号 UID
* @returns {Promise<Object|null>} 聚合报名记录
*/
async getUserAggregation(accountUid) {
const { ctx } = this;
return await ctx.model.Aggregation.findOne({
where: { account_uid: accountUid },
include: [
{
model: ctx.model.Team,
as: 'team',
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
},
],
});
}
/**
* 验证提交截止时间
* @param {string} group - 组别:benke / jiaoshi
* @returns {Promise<Object>} { valid, message, deadline }
*/
async validateDeadline(group) {
const { ctx } = this;
const timeConfig = await ctx.model.CompetitionTime.findOne({
where: { group },
});
// 权威截止时间 = end_time(管理后台「提交截止时间」输入框绑定的正是该列)。
// 历史遗留的 deadline 列仅作后备,避免出现「状态标签(按 end_time)显示进行中、
// 上传却按旧 deadline 判定已截止」的两条时间线不一致问题。
const effective = (timeConfig && (timeConfig.end_time || timeConfig.deadline)) || null;
if (!effective) {
return {
valid: false,
message: '提交截止时间尚未设置',
deadline: null,
};
}
const now = new Date();
const deadline = new Date(effective);
if (now > deadline) {
return {
valid: false,
message: '提交已截止',
deadline: deadline.toISOString(),
};
}
return {
valid: true,
message: '可以提交',
deadline: deadline.toISOString(),
};
}
/**
* 保存作品文件
* @param {ReadableStream} stream - 文件流
* @param {string} filename - 原始文件名
* @param {string} group - 组别
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/
async saveWorkFile(stream, filename, group) {
const { app } = this;
// 确保上传目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
const uploadDir = path.join(app.baseDir, 'app/public/works', group);
try {
fs.mkdirSync(uploadDir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[saveWorkFile] mkdir failed', { uploadDir, message: mkErr.message });
throw mkErr;
}
// 构建安全文件名
const ext = path.extname(filename);
const timestamp = Date.now();
const safeFileName = `work_${timestamp}_${Math.random().toString(36).substring(2, 8)}${ext}`;
const filePath = path.join(uploadDir, safeFileName);
// 将文件流写入磁盘(用 pipe 自动处理背压,避免大文件内存暴涨 / 写入被截断)
const writeStream = fs.createWriteStream(filePath);
let fileSize = 0;
await new Promise((resolve, reject) => {
stream.pipe(writeStream);
stream.on('data', chunk => { fileSize += chunk.length; });
writeStream.on('finish', resolve);
stream.on('error', reject);
writeStream.on('error', reject);
});
// 🔴 落盘完整性校验(F17-B 防「假成功」):
// 文件必须真实存在且字节数与流读数一致,否则视为上传失败,
// 绝不允许把「写了一半 / 没写出来的文件」记成成功——那会导致管理后台下载时 404。
const stat = fs.statSync(filePath);
if (stat.size !== fileSize) {
// 🔴 清理半截文件用 best-effort(同源缺陷族第三处):
// 裸 unlinkSync 一旦失败会抛出「删除失败」,把下面那条真正有诊断价值的
// 「文件写入不完整:期望 X 实际 Y」**顶掉**,排障时被引向错误方向。
// 清理成不成功都不影响结论——写入不完整这件事必须原样抛出。
this.ctx.helper.safeCleanup(filePath, { label: 'partialFile' });
throw new Error(`文件写入不完整:期望 ${fileSize} 字节,实际 ${stat.size} 字节`);
}
// 计算文件 MD5
const md5 = await this._calculateMd5(filePath);
// 构建相对路径(相对于 app/public/)
const relativePath = path.join('works', group, safeFileName).replace(/\\/g, '/');
return {
filePath: relativePath,
fileName: filename,
fileSize,
md5,
};
}
/**
* 合并文件分片
* @param {Array} chunks - 分片文件路径数组(按索引排序)
* @param {string} targetPath - 目标文件路径
* @returns {Promise<Object>} { filePath, fileName, fileSize, md5 }
*/
async mergeFileChunks(chunks, targetPath) {
// 确保目标目录存在(无条件递归创建,避免父目录偶发缺失 / TOCTOU 导致 ENOENT)
const targetDir = path.dirname(targetPath);
try {
fs.mkdirSync(targetDir, { recursive: true });
} catch (mkErr) {
this.app.logger.error('[mergeFileChunks] mkdir failed', { targetDir, message: mkErr.message });
throw mkErr;
}
// 按顺序合并所有分片。
// 🔴 并发要点(2026-08-10 修):这里过去用 fs.readFileSync 把整个分片同步读进内存,
// 再 writeStream.write(buf) 且不等 drain。后果有二:
// ① 同步读盘会「冻结 event loop」——一个 50MB 文件合并期间,同一 worker 上
// 其它考生的上传/答题请求全部排队,几十人同时交卷时表现为集体卡死;
// ② 不理会背压,N 个分片 Buffer 全堆在写队列里,单次合并瞬时吃掉整份文件的内存。
// 现改为逐分片流式 pipe({ end: false } 续写),全程异步 + 自动背压,
// 内存占用恒定在一个 highWaterMark(64KB),event loop 不再被阻塞。
//
// 🔴 监听器修复(2026-08-11):原实现在「循环内」对每个分片都执行
// `writeStream.on('error', reject)`,单一 writeStream 会被挂上 ≈分片数 个
// error 监听(10 分片 → 11 个),极端并发下触发 MaxListenersExceededWarning
// 并拖垮单 worker。现改为 writeStream.error 只挂【一次】,并用 settled 防重入。
let totalSize = 0;
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(targetPath);
let settled = false;
const fail = (err) => {
if (settled) return;
settled = true;
writeStream.destroy();
reject(err);
};
// 仅挂一次:writeStream 自身的错误
writeStream.on('error', fail);
(async () => {
try {
for (const chunkPath of chunks) {
await new Promise((res, rej) => {
const readStream = fs.createReadStream(chunkPath);
// 每个 readStream 是独立 emitter,挂一个 error 监听是合理的
readStream.on('error', rej);
readStream.on('data', buf => { totalSize += buf.length; });
readStream.on('end', res);
readStream.pipe(writeStream, { end: false });
});
// 删除已合并的分片文件(异步,避免同步 unlink 累积阻塞)
await fs.promises.unlink(chunkPath).catch(() => {});
}
writeStream.end();
writeStream.on('finish', () => {
if (settled) return;
settled = true;
resolve();
});
} catch (e) {
fail(e);
}
})();
}).catch((mergeErr) => {
// 清理半截目标文件,绝不把不完整文件记为成功(F17-B 防假成功)
return fs.promises.unlink(targetPath).catch(() => {}).then(() => { throw mergeErr; });
});
// 落盘完整性校验:合并后字节数必须与读入总量一致
const mergedStat = await fs.promises.stat(targetPath);
if (mergedStat.size !== totalSize) {
await fs.promises.unlink(targetPath).catch(() => {});
throw new Error(`分片合并不完整:期望 ${totalSize} 字节,实际 ${mergedStat.size} 字节`);
}
// 计算合并后文件的 MD5
const md5 = await this._calculateMd5(targetPath);
const fileName = path.basename(targetPath);
return {
filePath: targetPath,
fileName,
fileSize: totalSize,
md5,
};
}
/**
* 计算文件 MD5 值
* @param {string} filePath - 文件路径
* @returns {Promise<string>} MD5 哈希值
*/
async _calculateMd5(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* 保存作品提交记录到数据库
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @param {Object} fileInfo - 文件信息 { filePath, fileName, fileSize, md5 }
* @param {string} workName - 作品名称(可选)
* @returns {Promise<Object>} 保存的记录
*/
async saveSubmissionRecord(aggregationUid, group, fileInfo, workName = '', fileType = 'word') {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 按 (aggregation_uid, file_type) 定位记录:Word / PPT 各自独立存储,
// 避免后上传的文件覆盖先上传的文件(修复 PPT 顶掉 Word 的问题)。
const existing = await SubmissionModel.findOne({
where: { aggregation_uid: aggregationUid, file_type: fileType },
});
const payload = {
work_name: workName || fileInfo.fileName,
file_path: fileInfo.filePath,
file_name: fileInfo.fileName,
file_size: fileInfo.fileSize,
file_md5: fileInfo.md5,
upload_time: new Date(),
file_type: fileType,
};
if (existing) {
await existing.update({
...payload,
score: null, // 重新上传后清除评分
});
return existing;
}
return await SubmissionModel.create({
aggregation_uid: aggregationUid,
...payload,
});
}
/**
* 查询用户的提交状态
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<boolean>} 是否已提交
*/
async hasSubmitted(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
const count = await SubmissionModel.count({
where: { aggregation_uid: aggregationUid },
});
return count > 0;
}
/**
* 获取用户的提交历史列表
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @returns {Promise<Array>} 提交记录列表
*/
async getSubmissions(aggregationUid, group) {
const { ctx } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
return await SubmissionModel.findAll({
where: { aggregation_uid: aggregationUid },
order: [['upload_time', 'DESC']],
include: [
{
model: ctx.model.Aggregation,
as: 'aggregation',
attributes: ['aggregation_uid', 'team_uid', 'account_uid', 'identity'],
},
],
});
}
/**
* 删除用户的提交记录及其物理文件
* @param {number} aggregationUid - 聚合报名 ID
* @param {string} group - 组别
* @param {number} id - 提交记录 ID
* @returns {Promise<boolean>} 删除成功返回 true,未找到(或无权限)返回 false
*/
async deleteSubmission(aggregationUid, group, id) {
const { ctx, app } = this;
const SubmissionModel = group === 'benke'
? ctx.model.BenkeSubmission
: ctx.model.JiaoshiSubmission;
// 仅允许删除本人名下的记录(聚合键强约束,防越权)
const record = await SubmissionModel.findOne({
where: { id, aggregation_uid: aggregationUid },
});
if (!record) return false;
// 删除物理文件(file_path 为相对 app/public 的路径,如 works/benke/xxx.docx)
if (record.file_path) {
const absPath = path.join(app.baseDir, 'app/public', record.file_path);
try {
if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
} catch (e) {
ctx.logger.error('[deleteSubmission] 删除物理文件失败:', e.message);
}
}
await record.destroy();
return true;
}
}
module.exports = UploadService;

62
app/utils/filename.js Normal file
View File

@@ -0,0 +1,62 @@
'use strict'
/**
* 上传文件名归一化。
*
* 背景(生产事故复盘 2026-08-10):
* 备赛资料上传曾出现「列表读取 ENOENT」——磁盘上的文件名是乱码(GBK 字节被按
* UTF-8 误解码)+ 前导空格,导致 admin.getMaterials 在 readdirSync().map(statSync)
* 时 stat 不到该文件而整列抛错。根因是 uploadMaterial 直接拿 stream.filename 当
* 磁盘名、未做编码/空白归一化。
*
* 本函数对传入的「客户端原始文件名」做最小但充分的清洗,保证落盘名:
* - 是合法 UTF-8(NFC 归一化,消除组合字符/变体差异)
* - 不含控制字符与路径分隔符(防目录穿越 + 防 stat 失败)
* - 不含前导/多余空白(折叠为单空格并 trim —— 直接修复「前导空格 + 乱码」类 ENOENT)
* - 长度受限(保留扩展名)
* 若清洗后为空(极端情况),调用方应回退到时间戳名,避免写出空名文件。
*
* 注意:本函数不负责「还原」客户端用错误编码(如 GBK)发来的文件名——那是客户端
* 规范问题;它的职责是「让落盘名干净、可往返、不会让读盘崩溃」。已落盘的旧乱码文件
* 需运维侧手动 rm(见 diag_orphan + 人工清理)。
*/
function sanitizeUploadFileName(rawName, fallbackExt = '') {
if (!rawName || typeof rawName !== 'string') return ''
let name = rawName
// 1) 客户端可能预编码(如 %E5%89%8D...),先尝试解码
if (name.includes('%') && /%[0-9A-Fa-f]{2}/.test(name)) {
try { name = decodeURIComponent(name) } catch (e) { /* 解码失败保留原串 */ }
}
// 2) Unicode 归一化(NFC),消除同一字符的不同编码形态
try {
if (typeof name.normalize === 'function') name = name.normalize('NFC')
} catch (e) { /* 兜底保留 */ }
// 3) 去除控制字符(C0: 0x00-0x1F,DEL/0x7F)与路径分隔符/危险字符
// 控制字符在正则里用 \x00-\x1f 与 \x7f 表示,避免源码里写裸控制字节
name = name.replace(/[\x00-\x1f\x7f]/g, '')
name = name.replace(/[\/\\:*?"<>|]/g, '_')
// 4) 折叠所有空白(含全角/不间断空格等 \s 覆盖的字符)为单空格并 trim
name = name.replace(/\s+/g, ' ').trim()
// 5) 去除首尾点(避免 ".name" / "name." / ".." 类)
name = name.replace(/^[. ]+/, '').replace(/[. ]+$/, '')
// 6) 长度限制(保留扩展名)
const MAX = 120
if (name.length > MAX) {
const dot = name.lastIndexOf('.')
const ext = dot > 0 ? name.slice(dot) : ''
const safeExt = ext || fallbackExt || ''
name = name.slice(0, MAX - safeExt.length) + safeExt
}
return name
}
module.exports = { sanitizeUploadFileName }

109
app/utils/sendFile.js Normal file
View File

@@ -0,0 +1,109 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { acquire: acquireFileIo, release: releaseFileIo } = require('../lib/file_io_guard');
/**
* 统一文件发送出口(X-Accel-Redirect 根治方案 + 完整性哨兵)。
*
* 所有「把磁盘文件交给调用方」的通道都必须经此函数,杜绝同源漏网。
*
* 2026-08-12 同源扩大化测试审计「缺口9」修复:materials.js(备赛资料) / exam.js(Word 模板)
* / admin.js(batchDownload ZIP 导出) 此前各自直写 fs.createReadStream,缺 X-File-Size 哨兵头,
* 导致前端 assertRealFile 的大小完整性校验收不到哨兵而失效(truncate / nginx fallback 替换
* 无法被前端侦测)。重构进本函数后,未来任意通道的逻辑升级都会自动同步。
*
* @param {object} ctx Egg 上下文
* @param {object} app Egg app(读 config.xAccelRedirect / baseDir)
* @param {object} opts
* - relativePath {string} 相对 app/public 的路径(如 works/benke/x.docx);X-Accel 用此拼内部路径
* - absolutePath {string} 绝对路径(如临时 zip);提供则忽略 relativePath,且强制 Node 流
* - fileName {string} 原始文件名(Content-Disposition)
* - contentType {string} MIME 类型
* - useXAccel {boolean} 是否允许 X-Accel 卸载(默认跟随 cfg.enabled;临时文件务必 false)
*/
async function sendFile(ctx, app, opts) {
const { absolutePath, relativePath, fileName, contentType, useXAccel } = opts;
const cfg = (app.config && app.config.xAccelRedirect) || { enabled: false, internalPrefix: '/internal-downloads/' };
// 路径解析
let absPath;
if (absolutePath) {
absPath = absolutePath;
} else {
// 🔴 路径穿越防护:只允许 app/public 下的安全相对路径(字母/数字/_./- 且不含 ..)
if (!relativePath || !/^[A-Za-z0-9_.\/-]+$/.test(relativePath) || relativePath.includes('..')) {
ctx.status = 400;
ctx.body = ctx.helper.error(400, '非法文件路径');
return;
}
absPath = path.join(app.baseDir, 'app/public', relativePath);
}
// 🔴🔴 磁盘存在性校验(F17-B 防「假成功」· 2026-08-12 生产事故根因)
// 发头之前先确认磁盘上真有这个文件,缺失一律回干净 404(业务错误信封),
// 绝不把「下载」交给一个不存在的路径去碰运气。必须在 acquireFileIo 之前,避免 404 还配额。
let fileStat;
try {
fileStat = fs.statSync(absPath);
} catch (e) {
ctx.logger.error(`[sendFile] 文件缺失,拒绝下载 abs=${absPath}:${e && e.message}`);
ctx.status = 200; // 业务错误统一 200 + 信封,前端 safeDownload / fetchBlobGuarded 会识别并弹错
ctx.body = ctx.helper.error(404, `文件不存在(期望路径: ${relativePath || absolutePath})`);
return;
}
if (!fileStat.isFile()) {
ctx.body = ctx.helper.error(400, '目标不是一个有效文件');
return;
}
// 🔴 文件 IO 并发护栏(与上传通道共享同一全局配额):把「同时进行的文件流」限制在上限,
// 防止单 worker 在极端并发下被海量文件流压垮。配额必须在「打开/持有流」之前获取。
let released = false;
const rel = () => { if (!released) { released = true; releaseFileIo(); } };
try {
await acquireFileIo();
} catch (e) {
ctx.status = 429;
ctx.body = ctx.helper.error(429, '服务器繁忙,下载通道暂满,请稍后重试');
return;
}
// X-Accel 决策:absolutePath(临时文件)强制 Node 流,避免「发头即 unlink」竞态损坏;
// 即使调用方误传 useXAccel:true + absolutePath,也因无 relativePath 拼内部路径而降级为 Node 流。
const xAccel = (useXAccel === undefined ? cfg.enabled : useXAccel) && !!relativePath;
if (xAccel) {
// 🔴 X-Accel-Redirect:把文件交给 nginx 内部 location 直接从磁盘流式吐出,
// Node 只发响应头、不再持有文件流 → 极端并发下几乎零占用 worker。
const internalPath = cfg.internalPrefix.replace(/\/+$/, '/') + relativePath.replace(/^\/+/, '');
ctx.set('Content-Type', contentType);
// 🔴 下载完整性哨兵:把「服务端认定的真实字节数」透出给前端。
// X-Accel 分支下真正吐字节的是 nginx,Node 无从得知 nginx 是否吐对了东西
// (内部 location 缺失时会吐前端 index.html)。前端会比对 blob.size 与本头,不一致即拒落盘。
// nginx 的 X-Accel-Redirect 会保留上游自定义响应头,故此头可穿透到浏览器。
ctx.set('X-File-Size', String(fileStat.size));
// 中文名按 RFC 5987 编码(filename*=UTF-8''<encodeURIComponent>),浏览器自动解码;
// 切勿再包 encodeURIComponent 造成双重编码。
ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`);
ctx.set('X-Accel-Redirect', internalPath);
ctx.set('X-Accel-Buffering', 'no'); // 大文件边收边发,避免 nginx 缓冲撑爆内存
ctx.status = 200;
ctx.body = null;
rel(); // X-Accel 路径无 Node 文件流,立即释放配额
} else {
// 开发回退 / 临时文件:Node 直接流式(与改造前行为一致)。配额须随流「生命周期」释放,
// 不能放在方法返回时的 finally —— 流的读取发生在控制器返回之后。
ctx.attachment(fileName);
ctx.set('Content-Type', contentType);
// 与 X-Accel 分支保持一致,前端可统一按 X-File-Size 校验完整性
ctx.set('X-File-Size', String(fileStat.size));
const stream = fs.createReadStream(absPath);
stream.on('end', rel);
stream.on('close', rel);
stream.on('error', rel);
ctx.body = stream;
}
}
module.exports = { sendFile };

86
app/validate/index.js Normal file
View File

@@ -0,0 +1,86 @@
'use strict';
/**
* 统一输入校验规则集(基于 egg-validate / parameter)
*
* 对应各接口请求体的字段级校验。校验失败由控制器以
* `ctx.helper.error(400, '参数校验失败:' + validateErr.errors[0].message)`
* 统一封装为应用错误信封,与 admin 控制器既有模式保持一致。
*
* 规则说明:
* - type: 'string' + required: true + allowEmpty: false:字段必填且不可为空串/纯空白
* - trim: true:校验前去除首尾空白
* - min: 6:字符串最小长度(用于密码强度底线)
* - message:为每个校验项提供中文错误文案,parameter 会在 errors[] 中携带对应 message
*/
const required = (msg) => ({
type: 'string',
required: true,
allowEmpty: false,
trim: true,
message: { required: msg, empty: msg },
});
module.exports = {
// 选手登录:手机号 + 密码
login: {
phone: required('请输入手机号'),
password: required('请输入密码'),
},
// 管理员登录:用户名 + 密码
adminLogin: {
username: required('请输入管理员用户名'),
password: required('请输入密码'),
},
// 发送短信验证码:身份证号 + 图形验证码
sms: {
phone: required('请输入身份证号'),
captcha: required('请输入图形验证码'),
},
// 账号激活:身份证号 + 手机号 + 短信验证码 + 密码(>=6)
activate: {
id_number: required('请输入身份证号'),
phone: required('请输入手机号'),
smsCode: required('请输入短信验证码'),
password: {
type: 'string',
required: true,
allowEmpty: false,
trim: true,
min: 6,
message: {
required: '请输入密码',
empty: '请输入密码',
min: '密码长度不能少于 6 位',
},
},
},
// 忘记密码-发码:身份证号 + 图形验证码
forgotPassword: {
id_number: required('请输入身份证号'),
captcha: required('请输入图形验证码'),
},
// 重置密码:身份证号 + 短信验证码 + 密码(>=6)
resetPassword: {
id_number: required('请输入身份证号'),
smsCode: required('请输入短信验证码'),
password: {
type: 'string',
required: true,
allowEmpty: false,
trim: true,
min: 6,
message: {
required: '请输入密码',
empty: '请输入密码',
min: '密码长度不能少于 6 位',
},
},
},
};

65
auto-patch-egg.js Normal file
View File

@@ -0,0 +1,65 @@
// auto-patch-egg.js (v3)
const fs = require('fs');
const path = require('path');
const BASE = path.resolve(__dirname, 'node_modules');
const TARGET_DIRS = ['egg', 'egg-cluster', 'cluster-client', 'egg-sequelize', 'egg-logger', 'egg-cors', 'egg-security', 'egg-onerror', 'egg-session', 'egg-view', 'egg-validate', 'egg-jsonp'];
const BUILTINS = new Set([
'fs', 'path', 'os', 'util', 'events', 'stream', 'crypto', 'url', 'http', 'https',
'net', 'tls', 'buffer', 'assert', 'process', 'v8', 'vm', 'zlib', 'querystring',
'child_process', 'cluster', 'dns', 'dgram', 'readline', 'repl', 'string_decoder',
'timers', 'tty', 'domain', 'module', 'console', 'perf_hooks', 'async_hooks',
'worker_threads', 'inspector', 'trace_events', 'wasi',
]);
function findRequires(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const re = /require\(['"]([^./][^'"]*)['"]\)/g;
const modules = new Set(); let match;
while ((match = re.exec(content)) !== null) {
const name = match[1].split('/')[0];
if (!BUILTINS.has(name)) modules.add(name);
}
return modules;
} catch { return new Set(); }
}
function moduleExists(name) {
try { require.resolve(name, { paths: [BASE] }); return true; } catch { return false; }
}
function walkDir(dir) {
const results = [];
try {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory() && e.name !== 'node_modules') results.push(...walkDir(full));
else if (e.isFile() && /\.(js|cjs)$/.test(e.name) && !e.name.includes('.test.')) results.push(full);
}
} catch {}
return results;
}
function createShim(name) {
const dir = path.join(BASE, name);
if (fs.existsSync(dir)) return false;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version: '0.0.1-shim', main: 'index.js' }, null, 2));
fs.writeFileSync(path.join(dir, 'index.js'), `'use strict';module.exports={};`);
return true;
}
console.log('=== 扫描依赖树 ===');
const allRequires = new Set();
for (const target of TARGET_DIRS) {
const targetDir = path.join(BASE, target);
if (!fs.existsSync(targetDir)) continue;
const files = walkDir(targetDir);
for (const f of files) { const reqs = findRequires(f); reqs.forEach(r => allRequires.add(r)); }
}
let count = 0;
for (const mod of allRequires) {
if (!moduleExists(mod)) { if (createShim(mod)) count++; }
}
console.log(`=== 创建 ${count} 个 shim ===`);

409
config/config.default.js Normal file
View File

@@ -0,0 +1,409 @@
'use strict';
// ---------------------------------------------------------------------------
// 部署环境变量自加载(兜底)
// 背景:Egg.js 默认不会读取任何 .env 文件,生产密钥(JWT_SECRET / APP_KEYS 等)必须
// 靠 systemd EnvironmentFile= 或 shell export 注入。当注入环节出错(文件解析失败、
// 路径不匹配、或根本不是用 systemd 启动)时,process.env 拿不到值,会触发下方 config
// 里的生产安全守卫误报「未配置的密钥」,且极难排查。
// 这里在配置加载前主动从磁盘读取 deploy/.env.production(沿 __dirname / process.cwd()
// 向上查找,并支持 DEPLOY_ENV_FILE 显式指定),填充 process.env;仅当变量尚未设置时
// 生效(外部注入优先),保证幂等。
// 注意:APP_ENV / NODE_ENV 不从此处加载,避免本地开发误判为生产(真实生产由启动器注入)。
// ---------------------------------------------------------------------------
(function loadDeployEnv() {
try {
const fs = require('fs');
const path = require('path');
const SKIP_KEYS = new Set(['APP_ENV', 'NODE_ENV']);
const targets = [];
// 1) 显式指定(最高优先级)
if (process.env.DEPLOY_ENV_FILE) targets.push(process.env.DEPLOY_ENV_FILE);
// 2) 已知裸机部署绝对路径
targets.push('/home/root/production/agent-code/deploy/competition-website/.env.production');
// 3) 从 __dirname 与 process.cwd() 向上查找
const starts = [__dirname, process.cwd()];
for (const start of starts) {
let dir = start;
while (dir && dir !== path.dirname(dir)) {
targets.push(
path.join(dir, 'deploy', 'competition-website', '.env.production'),
path.join(dir, 'deploy', '.env.production'),
path.join(dir, '.env.production'),
);
dir = path.dirname(dir);
}
}
let loadedFrom = null;
for (const file of targets) {
let text;
try {
text = fs.readFileSync(file, 'utf8');
} catch (_) {
continue; // 该候选不存在,尝试下一个
}
for (const raw of text.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#') || !line.includes('=')) continue;
const eq = line.indexOf('=');
const key = line.slice(0, eq).trim();
let val = line.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key && !SKIP_KEYS.has(key) && process.env[key] === undefined) {
process.env[key] = val;
}
}
loadedFrom = file;
break; // 第一个存在的文件即为权威来源
}
if (loadedFrom) {
console.log('[deploy-env] 已从 ' + loadedFrom + ' 加载部署环境变量');
} else {
console.warn('[deploy-env] 未找到 .env.production,将完全依赖进程环境变量注入(缺失生产密钥会触发安全守卫)');
}
} catch (e) {
console.warn('[deploy-env] 加载部署环境变量失败:' + (e && e.message));
}
})();
module.exports = (appInfo) => {
const config = (exports = {});
const isProd = process.env.NODE_ENV === 'production' || appInfo.env === 'prod';
// -----------------------------------------------------------------
// 运行模式(测试 / 生产)—— 单一环境变量 APP_ENV 控制,用于最小化
// 测试版本与生产版本的行为差异:两个版本使用同一套代码,仅通过 APP_ENV
// 与环境变量切换。
// - 生产部署:APP_ENV=production
// - 测试 / 本地部署:APP_ENV=test(缺省值,未设置且非 egg prod 环境时默认 test)
// 作用:① 驱动生产安全守卫;② 驱动短信行为(test=不真实发送+前端弹窗,
// production=真实下发且不回传验证码)。
// -----------------------------------------------------------------
const runMode = process.env.APP_ENV
|| (appInfo.env === 'prod' ? 'production' : 'test');
config.runMode = runMode;
config.isProduction = runMode === 'production';
config.isTest = runMode !== 'production';
// 测试数据默认口令:重置测试数据 / 标记为测试数据导入 时,统一把密码设为该口令的
// bcrypt 哈希,方便测试人员重置后、或用 Excel「标记为测试数据」导入后,直接以该口令登录重测
// (无需再走激活流程)。仅作用于 test_flag=1 的测试数据,生产数据(test_flag=0)不受影响。
// 【测试模拟值 '123456';如需变更可设环境变量 TEST_DEFAULT_PASSWORD】
config.testDefaultPassword = process.env.TEST_DEFAULT_PASSWORD || '123456';
// Cookie 签名密钥:优先读取环境变量 APP_KEYS,缺省时回退到内置值(仅供本地开发)
// 【测试模拟值,生产请通过环境变量 APP_KEYS 替换为随机字符串,否则启动被守卫阻断】
const DEFAULT_APP_KEYS = appInfo.name + '_competition_website_secret_key_2026';
config.keys = process.env.APP_KEYS || DEFAULT_APP_KEYS;
// add your config here
config.middleware = ['concurrencyGuard'];
// 🔴 请求级并发闸门(大文件上传/下载):在读取请求体之前限制「被接受的并发重请求数」,
// 极端并发下超出上限立即回 429(连接级背压),防止单 worker 被海量请求体缓冲压垮 OOM。
// 与 app/lib/file_io_guard 互补(后者按文件流生命周期限流)。可按压测/生产调参:
// 单 worker 极端并发场景建议保持较低值(20);生产多 worker 下每 worker 独立计数,
// 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。
config.concurrencyGuard = {
max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20,
};
// -----------------------------------------------------------------
// sequelize - MariaDB/MySQL 数据库配置
// -----------------------------------------------------------------
config.sequelize = {
dialect: 'mysql',
database: process.env.DB_NAME || 'competition_db',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT, 10) || 3306,
// 兼容 docker-compose/.env.example 中的 DB_USER 与代码历史使用的 DB_USERNAME
username: process.env.DB_USERNAME || process.env.DB_USER || 'root',
// 【测试模拟值 '74191221',仅供本地/测试部署;生产请通过 DB_PASSWORD 环境变量注入真实密码】
password: process.env.DB_PASSWORD || '74191221',
timezone: '+08:00',
define: {
underscored: true,
freezeTableName: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
charset: 'utf8mb4',
collate: 'utf8mb4_unicode_ci',
},
dialectOptions: {
// 注:mysql2 连接级只接受 charset,不接受 collate;collate 是表/模型级选项,
// 由上方 define.collate 负责。此处传 collate 会触发
// "Ignoring invalid configuration option passed to Connection: collate" 警告。
charset: 'utf8mb4',
},
pool: {
// 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」
// 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖),
// 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。
// 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker,
// 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151,
// 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。
max: parseInt(process.env.DB_POOL_MAX, 10) || 20,
min: 2,
acquire: 45000,
idle: 10000,
// 🔴 连接回收(2026-08-13 推进 #16):maxUses 限制单连接累计查询次数,
// 归还池时物理关闭并新建,避免 MySQL wait_timeout(默认 8h)静默杀掉长期空闲
// 连接后被复用 → 生产偶发 "Connection lost / ECONNRESET / Cannot enqueue Query
// after fatal error"。5000 次 ≈ 中小赛事体量下数小时寿命,远小于 wait_timeout。
// 可用 DB_POOL_MAX_USES 覆盖。
maxUses: parseInt(process.env.DB_POOL_MAX_USES, 10) || 5000,
},
// 仅在开发环境开启 SQL 日志,生产环境自动关闭
logging: process.env.NODE_ENV === 'production' ? false : console.log,
};
// -----------------------------------------------------------------
// X-Accel-Redirect 内部下载开关(根治管理后台大文件下载压垮 Node)
// -----------------------------------------------------------------
// 生产(NODE_ENV=production)默认开启:管理端作品/Word 下载改由后端只回一个
// X-Accel-Redirect 内部重定向头,nginx 直接从磁盘流式吐文件,Node 不再持有文件流,
// 极端并发下几乎零占用 worker fd/事件循环。
// 开发环境默认关闭(回退为 Node 直接流式,便于本地调试);可用 X_ACCEL_REDIRECT=1
// 临时开启以验证重定向头是否正确。
config.xAccelRedirect = {
enabled: process.env.NODE_ENV === 'production' || process.env.X_ACCEL_REDIRECT === '1',
// nginx 内部 location 前缀(须与 deploy/competition_website.conf 的一致)
internalPrefix: '/internal-downloads/',
};
// -----------------------------------------------------------------
// Redis 缓存配置
// -----------------------------------------------------------------
config.redis = {
client: {
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
host: process.env.REDIS_HOST || '127.0.0.1',
password: process.env.REDIS_PASSWORD || '74191221',
db: 0,
},
};
// -----------------------------------------------------------------
// JWT 鉴权配置
// -----------------------------------------------------------------
const DEFAULT_JWT_SECRET = 'competition_jwt_secret_key_2026_br!cs';
config.jwt = {
// JWT 签名密钥:优先读取环境变量 JWT_SECRET,缺省时回退到内置值(仅供本地开发)
// 【测试模拟值,生产请通过环境变量 JWT_SECRET 替换为 64 位以上随机串,否则启动被守卫阻断】
secret: process.env.JWT_SECRET || DEFAULT_JWT_SECRET,
enable: true,
// Token 有效期(秒),默认 7 天
expiresIn: 7 * 24 * 60 * 60,
// 静默刷新宽限期(秒):token 过期后在此窗口内仍可被 /api/auth/refresh 续期,
// 用于实现"活跃用户永不被意外踢出"的滑动会话;超过则必须重新登录。
refreshGrace: 7 * 24 * 60 * 60,
// 匹配需要鉴权的路由规则(排除登录/注册等公开接口)
match: (ctx) => {
const publicPaths = [
'/api/auth/login',
'/api/auth/register',
'/api/public/',
];
return !publicPaths.some((p) => ctx.path.startsWith(p));
},
};
// -----------------------------------------------------------------
// multipart 文件上传配置
// -----------------------------------------------------------------
config.multipart = {
mode: 'stream',
fileSize: '100mb',
whitelist: [
// 文档类
'.pdf',
'.doc',
'.docx',
'.ppt',
'.pptx',
'.xls',
'.xlsx',
'.zip',
// 图片类
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
],
// 自定义文件名正则,允许中文、字母、数字、空格、连字符、下划线、括号
// Egg 默认不允许 hyphen in Chinese filename, fix this
filenameRegex: /^[\u4e00-\u9fa5a-zA-Z0-9\s\-_\(\)]+\.[a-z0-9]+$/i,
// 上传文件临时存放目录
tmpdir: require('path').join(appInfo.baseDir, 'app/public/tmp'),
// 清理临时文件的间隔时间
cleanSchedule: {
cron: '0 30 4 * * *',
},
};
// -----------------------------------------------------------------
// CORS 跨域配置(白名单收敛)
// -----------------------------------------------------------------
// 通过环境变量 CORS_ORIGINS 配置允许的来源(逗号分隔),例如:
// CORS_ORIGINS=https://competition.example.com,https://admin.example.com
// 规则:
// - 配置了白名单:仅反射白名单内的 origin;
// - 未配置白名单:开发环境反射请求来源(便于本地联调);
// 生产环境不反射任意源(生产走 nginx 同源代理 /api,无需跨域)。
const corsOrigins = (process.env.CORS_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
config.cors = {
origin: (ctx) => {
const reqOrigin = ctx.get('origin');
if (corsOrigins.length > 0) {
return corsOrigins.includes(reqOrigin) ? reqOrigin : '';
}
return isProd ? '' : reqOrigin;
},
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH',
credentials: true,
};
// -----------------------------------------------------------------
// Security 安全配置
// -----------------------------------------------------------------
// CSRF 说明:本项目前后端分离,鉴权全部通过 Authorization: Bearer <JWT>
// 请求头携带(不依赖 Cookie 会话),浏览器跨站请求无法自动附带该头,
// 因此传统 CSRF 攻击面不成立,保持关闭;若未来引入 Cookie 会话需重新启用。
config.security = {
csrf: {
enable: false,
},
// 跳转/资源域名白名单与 CORS 白名单保持一致(未配置时为空 = 仅同源)
domainWhiteList: corsOrigins,
};
// -----------------------------------------------------------------
// 静态资源服务配置
// -----------------------------------------------------------------
config.static = {
prefix: '/public/',
dir: require('path').join(appInfo.baseDir, 'app/public'),
maxAge: 31536000,
};
// 静态资源外部访问基础 URL(用于 helper.fileUrl() 拼接完整 URL)
// 生产环境通过环境变量 STATIC_BASE_URL 配置为实际域名,如 'https://competition.example.com'
// 未配置时回退为 null,helper.fileUrl() 会退化到 http://127.0.0.1:{port}
config.staticBaseUrl = process.env.STATIC_BASE_URL || null;
// -----------------------------------------------------------------
// bodyParser 配置
// -----------------------------------------------------------------
config.bodyParser = {
jsonLimit: '10mb',
formLimit: '10mb',
};
// -----------------------------------------------------------------
// 日志配置
// -----------------------------------------------------------------
config.logger = {
dir: require('path').join(appInfo.baseDir, 'logs'),
appLogName: 'competition-web.log',
coreLogName: 'egg-web.log',
agentLogName: 'egg-agent.log',
errorLogName: 'common-error.log',
level: 'INFO',
};
// -----------------------------------------------------------------
// 日志轮转配置(egg-logrotator 内置插件)
// - 按体积轮转:单文件超过 maxFileSize 即滚动
// - maxDays:保留最近 N 天日志,到期后自动清理(设为 0 则永久保留)
// 说明:默认已按天轮转;此处显式收口为"按体积 + 保留 14 天",
// 避免长期运行后日志无限增长占满磁盘。
// -----------------------------------------------------------------
config.rotator = {
filesRotateBySize: [
'competition-web.log',
'common-error.log',
'egg-web.log',
'egg-agent.log',
],
maxFileSize: 50 * 1024 * 1024, // 50MB
maxDays: 14,
};
// -----------------------------------------------------------------
// 生产环境安全守卫:禁止使用内置默认密钥上线
// (由 APP_ENV=production 触发;测试环境允许使用模拟密钥)
// -----------------------------------------------------------------
if (config.isProduction) {
const insecure = [];
if (config.jwt.secret === DEFAULT_JWT_SECRET) insecure.push('JWT_SECRET');
if (config.keys === DEFAULT_APP_KEYS) insecure.push('APP_KEYS');
if (insecure.length > 0) {
// 生产环境仍在使用硬编码默认密钥属于严重安全隐患,直接阻断启动
throw new Error(
`[安全阻断] 生产环境检测到未配置的密钥: ${insecure.join(', ')}。` +
'请通过环境变量设置这些值后再启动(参见 deploy/.env.example)。'
);
}
}
// -----------------------------------------------------------------
// 短信网关配置(阿里云 Dysmsapi)
// -----------------------------------------------------------------
// 设计要点(测试 / 生产差异最小化):
// - 验证码的校验仍走 ctx.session(与现有激活/重置流程一致,已被 26 项边界测试覆盖),
// 短信网关只负责"把服务端已生成的 6 位验证码下发给手机号",不改动校验契约。
// - 所有网关参数均通过环境变量注入(SMS_ENABLED / ALIYUN_SMS_*)。
// - 阿里云 accessKeyId / accessKeySecret 为敏感凭证,源码不再保留任何硬编码值,
// 必须且只能通过环境变量注入;生产开启短信时若缺失将触发安全守卫阻断启动。
// - endpoint / signName / templateCode 为非敏感配置,保留合理默认值以便本地/测试快速起服。
// - realSend(真实下发开关)= 生产环境 且 SMS_ENABLED=1。
// · 测试环境(APP_ENV=test):即使配置了网关也【不真实发送】,后端随响应回传
// 验证码(字段 smsCode),由前端弹窗展示,便于测试人员直接获取。
// · 生产环境且 realSend=true:真实下发,绝不回传/打印验证码明文。
// · 生产环境且 realSend=false:安全起见不下发、不回传,仅记录告警。
const smsEnabled = process.env.SMS_ENABLED === '1' || process.env.SMS_ENABLED === 'true';
config.sms = {
enabled: smsEnabled,
// 真实下发:仅生产环境 + 网关开启时成立;测试环境恒为 false(不真发)
realSend: config.isProduction && smsEnabled,
provider: 'aliyun',
aliyun: {
// 敏感凭证:仅从环境变量读取,源码零硬编码(已轮换,杜绝泄露)
accessKeyId: process.env.ALIYUN_SMS_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIYUN_SMS_ACCESS_KEY_SECRET,
endpoint: process.env.ALIYUN_SMS_ENDPOINT || 'dysmsapi.aliyuncs.com',
// 非敏感配置,保留合理默认值
signName: process.env.ALIYUN_SMS_SIGN_NAME || '华云智联',
templateCode: process.env.ALIYUN_SMS_TEMPLATE_CODE || 'SMS_487315108',
},
};
// 生产环境 + 开启短信时,阿里云密钥必须来自环境变量(源码已无硬编码凭证)
if (config.isProduction && smsEnabled) {
const missing = [];
if (!config.sms.aliyun.accessKeyId) missing.push('ALIYUN_SMS_ACCESS_KEY_ID');
if (!config.sms.aliyun.accessKeySecret) missing.push('ALIYUN_SMS_ACCESS_KEY_SECRET');
if (missing.length > 0) {
throw new Error(
`[安全阻断] 生产环境开启短信但缺少阿里云密钥: ${missing.join(', ')}。` +
'请通过环境变量注入真实密钥后再启动(参见 deploy/.env.production)。'
);
}
}
return config;
};

32
config/plugin.js Normal file
View File

@@ -0,0 +1,32 @@
'use strict';
/** @type Egg.EggPlugin */
module.exports = {
// 启用 Sequelize ORM 插件(MariaDB/MySQL)
sequelize: {
enable: true,
package: 'egg-sequelize',
},
// 启用 CORS 跨域插件
cors: {
enable: true,
package: 'egg-cors',
},
// 启用参数校验插件
validate: {
enable: true,
package: 'egg-validate',
},
// 禁用开发环境热重载(Node 22 兼容性)
development: {
enable: false,
},
// 禁用文件监听(Node 22 兼容性)
watcher: {
enable: false,
},
};

View File

@@ -0,0 +1,207 @@
-- =====================================================================
-- 种子数据:选择题(50题×1分) + 判断题(20题×1分) + 综合性任务(1题×30分)
-- 总分:100分,符合"卫星通信与导航应用技术创新赛线上选拔赛"题型结构
-- =====================================================================
-- 清空现有数据
DELETE FROM choice_questions;
DELETE FROM judge_questions;
DELETE FROM word_questions;
ALTER TABLE choice_questions AUTO_INCREMENT = 1;
ALTER TABLE judge_questions AUTO_INCREMENT = 1;
ALTER TABLE word_questions AUTO_INCREMENT = 1;
-- =====================================================================
-- 第一篇:卫星通信技术(第1-6章)——选择题 (16题)
-- =====================================================================
INSERT INTO choice_questions (`group`, question, options, answer, score, sort_order, created_at, updated_at) VALUES
('zhongzhi', '卫星通信定义为?', '[{"key":"A","text":"利用卫星在地面站之间进行的通信"},{"key":"B","text":"利用卫星转发无线电信号实现多个地球站之间的通信"},{"key":"C","text":"卫星与飞机之间的通信"},{"key":"D","text":"卫星之间的宇宙通信"}]', 'B', 1, 1, NOW(), NOW()),
('zhongzhi', '卫星通信中,地球站向卫星发射信号的链路称为?', '[{"key":"A","text":"下行链路"},{"key":"B","text":"上行链路"},{"key":"C","text":"前向链路"},{"key":"D","text":"反向链路"}]', 'B', 1, 2, NOW(), NOW()),
('zhongzhi', '卫星通信的主要优点不包括以下哪项?', '[{"key":"A","text":"覆盖范围大"},{"key":"B","text":"通信容量大"},{"key":"C","text":"建站成本低"},{"key":"D","text":"不受地理条件限制"}]', 'C', 1, 3, NOW(), NOW()),
('zhongzhi', '通信系统中,将原始信号转换为适合信道传输的信号的过程称为?', '[{"key":"A","text":"编码"},{"key":"B","text":"调制"},{"key":"C","text":"解码"},{"key":"D","text":"解调"}]', 'B', 1, 4, NOW(), NOW()),
('zhongzhi', 'GEO卫星的轨道高度约为?', '[{"key":"A","text":"500-1500 km"},{"key":"B","text":"5000-12000 km"},{"key":"C","text":"20200 km"},{"key":"D","text":"35786 km"}]', 'D', 1, 5, NOW(), NOW()),
('zhongzhi', 'MEO卫星的轨道高度范围是?', '[{"key":"A","text":"低于500 km"},{"key":"B","text":"500-2000 km"},{"key":"C","text":"5000-20000 km"},{"key":"D","text":"35786 km"}]', 'C', 1, 6, NOW(), NOW()),
('zhongzhi', 'LEO卫星的轨道倾角通常为?', '[{"key":"A","text":"0度"},{"key":"B","text":"近极地轨道"},{"key":"C","text":"赤道轨道"},{"key":"D","text":"地球同步轨道"}]', 'B', 1, 7, NOW(), NOW()),
('zhongzhi', '卫星通信中,频率复用技术的主要作用是?', '[{"key":"A","text":"提高频率利用率"},{"key":"B","text":"降低发射功率"},{"key":"C","text":"减少卫星数量"},{"key":"D","text":"提高信号质量"}]', 'A', 1, 8, NOW(), NOW()),
('zhongzhi', '以下哪种不是卫星通信的多址方式?', '[{"key":"A","text":"FDMA"},{"key":"B","text":"TDMA"},{"key":"C","text":"CDMA"},{"key":"D","text":"ADMA"}]', 'D', 1, 9, NOW(), NOW()),
('zhongzhi', '卫星物联网中,星上处理的主要目的是?', '[{"key":"A","text":"增加卫星重量"},{"key":"B","text":"提高传输效率"},{"key":"C","text":"降低功率"},{"key":"D","text":"增加轨道高度"}]', 'B', 1, 10, NOW(), NOW()),
('zhongzhi', '低轨卫星星座的特点是?', '[{"key":"A","text":"单星覆盖大,但需要星上处理"},{"key":"B","text":"全球覆盖,大容量低时延"},{"key":"C","text":"只有一颗卫星"},{"key":"D","text":"不需要切换"}]', 'B', 1, 11, NOW(), NOW()),
('zhongzhi', '卫星通信系统的转发器作用是?', '[{"key":"A","text":"存储数据"},{"key":"B","text":"放大变频转发信号"},{"key":"C","text":"计算信号"},{"key":"D","text":"控制卫星姿态"}]', 'B', 1, 12, NOW(), NOW()),
('zhongzhi', '卫星天线的指向精度影响?', '[{"key":"A","text":"信号接收功率"},{"key":"B","text":"卫星重量"},{"key":"C","text":"轨道高度"},{"key":"D","text":"卫星寿命"}]', 'A', 1, 13, NOW(), NOW()),
('zhongzhi', '卫星电源系统主要采用?', '[{"key":"A","text":"燃料电池"},{"key":"B","text":"太阳能电池阵+蓄电池组"},{"key":"C","text":"核能发电"},{"key":"D","text":"化学电池"}]', 'B', 1, 14, NOW(), NOW()),
('zhongzhi', '以下哪个频段常用作卫星通信上行?', '[{"key":"A","text":"UHF"},{"key":"B","text":"C-band"},{"key":"C","text":"Ku-band"},{"key":"D","text":"Ka-band"}]', 'D', 1, 15, NOW(), NOW()),
('zhongzhi', '卫星姿态控制的目的是?', '[{"key":"A","text":"改变轨道高度"},{"key":"B","text":"保持天线指向目标区域"},{"key":"C","text":"改变速度"},{"key":"D","text":"减少功耗"}]', 'B', 1, 16, NOW(), NOW());
-- =====================================================================
-- 第二篇:卫星导航技术(第1-8章)——选择题 (34题)
-- =====================================================================
INSERT INTO choice_questions (`group`, question, options, answer, score, sort_order, created_at, updated_at) VALUES
('zhongzhi', 'GNSS的全称是?', '[{"key":"A","text":"Global Navigation Satellite System"},{"key":"B","text":"Global Network Service System"},{"key":"C","text":"General Navigation Support System"},{"key":"D","text":"Geo Navigation Satellite System"}]', 'A', 1, 17, NOW(), NOW()),
('zhongzhi', '以下哪个不属于四大全球导航卫星星座?', '[{"key":"A","text":"GPS"},{"key":"B","text":"北斗"},{"key":"C","text":"GLONASS"},{"key":"D","text":"WAAS"}]', 'D', 1, 18, NOW(), NOW()),
('zhongzhi', '北斗三号全球系统提供多少颗卫星?', '[{"key":"A","text":"24"},{"key":"B","text":"30"},{"key":"C","text":"35"},{"key":"D","text":"40"}]', 'C', 1, 19, NOW(), NOW()),
('zhongzhi', 'GPS卫星导航系统至少需要几颗卫星才能实现三维定位?', '[{"key":"A","text":"2颗"},{"key":"B","text":"3颗"},{"key":"C","text":"4颗"},{"key":"D","text":"5颗"}]', 'C', 1, 20, NOW(), NOW()),
('zhongzhi', '伪距测量测量的是?', '[{"key":"A","text":"卫星到接收机的几何距离"},{"key":"B","text":"卫星信号传播时间乘以光速得到的距离"},{"key":"C","text":"卫星到地心的距离"},{"key":"D","text":"接收机到地面的距离"}]', 'B', 1, 21, NOW(), NOW()),
('zhongzhi', '卫星定位误差中,属于系统误差的是?', '[{"key":"A","text":"电离层延迟误差"},{"key":"B","text":"接收机噪声"},{"key":"C","text":"多径效应随机误差"},{"key":"D","text":"测量噪声"}]', 'A', 1, 22, NOW(), NOW()),
('zhongzhi', '差分定位技术中,RTK的全称是?', '[{"key":"A","text":"Real-Time Kinematic"},{"key":"B","text":"Rapid Tracking Kinematic"},{"key":"C","text":"Real-Time Kalman"},{"key":"D","text":"Radio Transmission Kinematic"}]', 'A', 1, 23, NOW(), NOW()),
('zhongzhi', 'NMEA-0183协议中,GGA语句主要提供什么信息?', '[{"key":"A","text":"卫星星历"},{"key":"B","text":"定位数据"},{"key":"C","text":"卫星状态"},{"key":"D","text":"速度信息"}]', 'B', 1, 24, NOW(), NOW()),
('zhongzhi', '北斗系统独具特色的服务是?', '[{"key":"A","text":"仅定位"},{"key":"B","text":"短报文通信"},{"key":"C","text":"仅授时"},{"key":"D","text":"仅测速"}]', 'B', 1, 25, NOW(), NOW()),
('zhongzhi', '高精度差分定位中,NTRIP协议用于?', '[{"key":"A","text":"卫星轨道计算"},{"key":"B","text":"通过互联网传输差分改正数据"},{"key":"C","text":"信号调制"},{"key":"D","text":"误差计算"}]', 'B', 1, 26, NOW(), NOW()),
('zhongzhi', '卫星导航接收机的灵敏度主要影响?', '[{"key":"A","text":"捕获卫星信号的能力"},{"key":"B","text":"发射功率"},{"key":"C","text":"卫星轨道高度"},{"key":"D","text":"计算速度"}]', 'A', 1, 27, NOW(), NOW()),
('zhongzhi', '组合导航GNSS+IMU中,IMU的作用是?', '[{"key":"A","text":"提供加速度和角速度"},{"key":"B","text":"提供定位结果"},{"key":"C","text":"提供时间信息"},{"key":"D","text":"提供卫星星历"}]', 'A', 1, 28, NOW(), NOW()),
('zhongzhi', '多路径效应产生的原因是?', '[{"key":"A","text":"卫星信号经过反射后与直达信号干涉"},{"key":"B","text":"多颗卫星信号叠加"},{"key":"C","text":"多个接收机同时工作"},{"key":"D","text":"电离层反射信号"}]', 'A', 1, 29, NOW(), NOW()),
('zhongzhi', 'RTCM标准主要用于?', '[{"key":"A","text":"卫星导航电文标准"},{"key":"B","text":"差分改正数据标准"},{"key":"C","text":"NMEA输出标准"},{"key":"D","text":"接口通信标准"}]', 'B', 1, 30, NOW(), NOW()),
('zhongzhi', 'PDOP值越大表示?', '[{"key":"A","text":"定位精度越好"},{"key":"B","text":"定位几何误差越大"},{"key":"C","text":"卫星越多"},{"key":"D","text":"温度越高"}]', 'B', 1, 31, NOW(), NOW()),
('zhongzhi', '北斗卫星导航系统的服务区覆盖?', '[{"key":"A","text":"仅中国"},{"key":"B","text":"亚太地区"},{"key":"C","text":"全球"},{"key":"D","text":"仅北半球"}]', 'C', 1, 32, NOW(), NOW()),
('zhongzhi', 'GPS卫星星历更新周期通常为?', '[{"key":"A","text":"30秒"},{"key":"B","text":"2小时"},{"key":"C","text":"1天"},{"key":"D","text":"1周"}]', 'B', 1, 33, NOW(), NOW()),
('zhongzhi', '静态相对定位主要用于?', '[{"key":"A","text":"需要高精度大地测量"},{"key":"B","text":"车辆导航"},{"key":"C","text":"船舶导航"},{"key":"D","text":"飞机导航"}]', 'A', 1, 34, NOW(), NOW()),
('zhongzhi', '载波相位测量相比伪距测量?', '[{"key":"A","text":"精度更高"},{"key":"B","text":"精度更低"},{"key":"C","text":"精度相同"},{"key":"D","text":"无法比较"}]', 'A', 1, 35, NOW(), NOW()),
('zhongzhi', '电离层延迟对伪距测量的影响?', '[{"key":"A","text":"夜间比白天大"},{"key":"B","text":"白天比夜间大"},{"key":"C","text":"没有影响"},{"key":"D","text":"只在冬季有影响"}]', 'B', 1, 36, NOW(), NOW()),
('zhongzhi', '周跳发生在?', '[{"key":"A","text":"载波相位测量中"},{"key":"B","text":"伪距测量中"},{"key":"C","text":"信号发射时"},{"key":"D","text":"卫星星历计算时"}]', 'A', 1, 37, NOW(), NOW()),
('zhongzhi', 'GPS系统由多少颗卫星组成?', '[{"key":"A","text":"24"},{"key":"B","text":"31"},{"key":"C","text":"35"},{"key":"D","text":"40"}]', 'A', 1, 38, NOW(), NOW()),
('zhongzhi', '以下哪种不属于北斗增强系统?', '[{"key":"A","text":"SBAS"},{"key":"B","text":"GBAS"},{"key":"C","text":"BDS-IS"},{"key":"D","text":"GLONASS"}]', 'D', 1, 39, NOW(), NOW()),
('zhongzhi', '单点定位精度通常为?', '[{"key":"A","text":"厘米级"},{"key":"B","text":"米级"},{"key":"C","text":"分米级"},{"key":"D","text":"毫米级"}]', 'B', 1, 40, NOW(), NOW()),
('zhongzhi', 'RTK定位精度通常达到?', '[{"key":"A","text":"厘米级"},{"key":"B","text":"米级"},{"key":"C","text":"千米级"},{"key":"D","text":"十米级"}]', 'A', 1, 41, NOW(), NOW()),
('zhongzhi', 'NMEA-0183语句的结束标志是?', '[{"key":"A","text":"换行"},{"key":"B","text":"校验和"},{"key":"C","text":"星号"},{"key":"D","text":"回车"}]', 'B', 1, 42, NOW(), NOW()),
('zhongzhi', '以下哪个不是北斗卫星导航系统的特点?', '[{"key":"A","text":"有源定位"},{"key":"B","text":"短报文通信"},{"key":"C","text":"区域服务不能全球覆盖"},{"key":"D","text":"三频信号"}]', 'C', 1, 43, NOW(), NOW()),
('zhongzhi', '卫星导航接收机捕获阶段的主要作用是?', '[{"key":"A","text":"搜索卫星信号并确定频率码相位"},{"key":"B","text":"跟踪信号"},{"key":"C","text":"计算位置"},{"key":"D","text":"输出速度"}]', 'A', 1, 44, NOW(), NOW()),
('zhongzhi', '对流层延迟改正模型中,通常使用?', '[{"key":"A","text":"Hopfield模型"},{"key":"B","text":"Klobuchar模型"},{"key":"C","text":"Black模型"},{"key":"D","text":"高斯模型"}]', 'A', 1, 45, NOW(), NOW()),
('zhongzhi', '全球定位系统中,UTC表示?', '[{"key":"A","text":"世界时"},{"key":"B","text":"协调世界时"},{"key":"C","text":"原子时"},{"key":"D","text":"恒星时"}]', 'B', 1, 46, NOW(), NOW()),
('zhongzhi', '整周模糊度解算属于?', '[{"key":"A","text":"伪距测量问题"},{"key":"B","text":"载波相位测量问题"},{"key":"C","text":"卫星轨道问题"},{"key":"D","text":"电离层问题"}]', 'B', 1, 47, NOW(), NOW()),
('zhongzhi', '以下哪个频率是北斗B1I信号频率?', '[{"key":"A","text":"1561.098 MHz"},{"key":"B","text":"1268.52 MHz"},{"key":"C","text":"1176.45 MHz"},{"key":"D","text":"1575.42 MHz"}]', 'A', 1, 48, NOW(), NOW()),
('zhongzhi', 'GPS选择的PRN码特点是?', '[{"key":"A","text":"自相关特性好,互相关特性弱"},{"key":"B","text":"自相关特性弱,互相关特性强"},{"key":"C","text":"长度固定不变"},{"key":"D","text":"只有一个码"}]', 'A', 1, 49, NOW(), NOW()),
('zhongzhi', '下列哪项不属于卫星导航的应用?', '[{"key":"A","text":"车辆导航"},{"key":"B","text":"工程测量"},{"key":"C","text":"火力发电"},{"key":"D","text":"时间同步"}]', 'C', 1, 50, NOW(), NOW());
-- =====================================================================
-- 高职组补充选择题(同样50题)
-- =====================================================================
INSERT INTO choice_questions (`group`, question, options, answer, score, sort_order, created_at, updated_at) VALUES
('gaozhi', '卫星通信中,FDMA的含义是?', '[{"key":"A","text":"频分多址"},{"key":"B","text":"时分多址"},{"key":"C","text":"码分多址"},{"key":"D","text":"空分多址"}]', 'A', 1, 1, NOW(), NOW()),
('gaozhi', 'GEO卫星的星下点位置相对于地面观察者?', '[{"key":"A","text":"不断高速移动"},{"key":"B","text":"基本静止"},{"key":"C","text":"每天环绕一次"},{"key":"D","text":"每月环绕一次"}]', 'B', 1, 2, NOW(), NOW()),
('gaozhi', 'OFDM技术中,子载波之间保持正交性的目的是?', '[{"key":"A","text":"提高发射功率"},{"key":"B","text":"避免子载波间干扰"},{"key":"C","text":"增加带宽"},{"key":"D","text":"降低频率"}]', 'B', 1, 3, NOW(), NOW()),
('gaozhi', '北斗三号全球卫星导航系统具备以下哪种特色服务?', '[{"key":"A","text":"仅定位服务"},{"key":"B","text":"短报文通信"},{"key":"C","text":"仅授时服务"},{"key":"D","text":"仅测速服务"}]', 'B', 1, 4, NOW(), NOW()),
('gaozhi', '5G网络中,Massive MIMO技术的主要优势是?', '[{"key":"A","text":"降低功耗"},{"key":"B","text":"提升频谱效率和容量"},{"key":"C","text":"减少天线数量"},{"key":"D","text":"简化网络架构"}]', 'B', 1, 5, NOW(), NOW()),
('gaozhi', '网络切片技术属于5G的哪个核心特性?', '[{"key":"A","text":"eMBB"},{"key":"B","text":"URLLC"},{"key":"C","text":"mMTC"},{"key":"D","text":"网络能力开放"}]', 'D', 1, 6, NOW(), NOW()),
('gaozhi', '在通信安全中,以下哪种攻击属于被动攻击?', '[{"key":"A","text":"篡改"},{"key":"B","text":"窃听"},{"key":"C","text":"拒绝服务"},{"key":"D","text":"伪装"}]', 'B', 1, 7, NOW(), NOW()),
('gaozhi', 'NTRIP协议主要用于什么场景?', '[{"key":"A","text":"卫星遥测"},{"key":"B","text":"差分数据网络传输"},{"key":"C","text":"卫星轨道控制"},{"key":"D","text":"信号加密传输"}]', 'B', 1, 8, NOW(), NOW()),
('gaozhi', '组合导航(GNSS+IMU)的核心优势是什么?', '[{"key":"A","text":"降低成本"},{"key":"B","text":"无缝切换与误差互补"},{"key":"C","text":"减少卫星数量"},{"key":"D","text":"提高发射功率"}]', 'B', 1, 9, NOW(), NOW()),
('gaozhi', '5G网络中,URLLC场景的主要特征是?', '[{"key":"A","text":"高速率大带宽"},{"key":"B","text":"低时延高可靠"},{"key":"C","text":"海量连接"},{"key":"D","text":"广域覆盖"}]', 'B', 1, 10, NOW(), NOW()),
('gaozhi', '卫星定位中,伪距测量的误差来源不包括?', '[{"key":"A","text":"电离层延迟"},{"key":"B","text":"对流层延迟"},{"key":"C","text":"卫星轨道误差"},{"key":"D","text":"用户设备重量"}]', 'D', 1, 11, NOW(), NOW()),
('gaozhi', '低轨卫星物联网相比GEO卫星的主要优势是?', '[{"key":"A","text":"覆盖范围更大"},{"key":"B","text":"传输时延更低"},{"key":"C","text":"单星容量更大"},{"key":"D","text":"使用寿命更长"}]', 'B', 1, 12, NOW(), NOW()),
('gaozhi', 'QPSK调制属于?', '[{"key":"A","text":"模拟调制"},{"key":"B","text":"数字相位调制"},{"key":"C","text":"幅度调制"},{"key":"D","text":"频率调制"}]', 'B', 1, 13, NOW(), NOW()),
('gaozhi', '信道编码的主要目的是?', '[{"key":"A","text":"提高传输速率"},{"key":"B","text":"对抗传输错误"},{"key":"C","text":"增加功率"},{"key":"D","text":"压缩数据"}]', 'B', 1, 14, NOW(), NOW()),
('gaozhi', 'CDMA技术的特点是?', '[{"key":"A","text":"所有用户同时占用相同频段"},{"key":"B","text":"每个用户占用不同频率"},{"key":"C","text":"每个用户占用不同时隙"},{"key":"D","text":"用户按空间分隔"}]', 'A', 1, 15, NOW(), NOW()),
('gaozhi', 'M-QAM调制中,M越大?', '[{"key":"A","text":"频谱效率越高,误码性能越好"},{"key":"B","text":"频谱效率越高,误码性能越差"},{"key":"C","text":"频谱效率越低,误码性能越好"},{"key":"D","text":"频谱效率越低,误码性能越差"}]', 'B', 1, 16, NOW(), NOW()),
('gaozhi', '香农公式给出了?', '[{"key":"A","text":"信道容量与带宽信噪比的关系"},{"key":"B","text":"信号功率与频率的关系"},{"key":"C","text":"噪声功率与温度的关系"},{"key":"D","text":"带宽与频率的关系"}]', 'A', 1, 17, NOW(), NOW()),
('gaozhi', '均衡技术主要用于补偿?', '[{"key":"A","text":"信道衰落引起的码间干扰"},{"key":"B","text":"噪声干扰"},{"key":"C","text":"频率偏移"},{"key":"D","text":"相位噪声"}]', 'A', 1, 18, NOW(), NOW()),
('gaozhi', '编码效率等于?', '[{"key":"A","text":"信息位长度/总码长"},{"key":"B","text":"总码长/信息位长度"},{"key":"C","text":"监督位长度/总码长"},{"key":"D","text":"信息位长度/监督位长度"}]', 'A', 1, 19, NOW(), NOW()),
('gaozhi', 'RS编码属于?', '[{"key":"A","text":"分组码"},{"key":"B","text":"卷积码"},{"key":"C","text":"循环码"},{"key":"D","text":"RS不是编码"}]', 'A', 1, 20, NOW(), NOW()),
('gaozhi', 'TCM编码是?', '[{"key":"A","text":"网格编码调制"},{"key":"B","text":" Turbo编码"},{"key":"C","text":"卷积编码"},{"key":"D","text":"里德所罗门编码"}]', 'A', 1, 21, NOW(), NOW()),
('gaozhi', '扩频通信的主要优点不包括?', '[{"key":"A","text":"抗干扰能力强"},{"key":"B","text":"隐蔽性好"},{"key":"C","text":"功率大"},{"key":"D","text":"多址能力强"}]', 'C', 1, 22, NOW(), NOW()),
('gaozhi', 'DSSS表示?', '[{"key":"A","text":"跳频扩频"},{"key":"B","text":"直接序列扩频"},{"key":"C","text":"跳时扩频"},{"key":"D","text":"线性扩频"}]', 'B', 1, 23, NOW(), NOW()),
('gaozhi', 'AOS协议主要用于?', '[{"key":"A","text":"地面互联网传输"},{"key":"B","text":"星地链路 packet 传输"},{"key":"C","text":"卫星轨道测量"},{"key":"D","text":"信号调制"}]', 'B', 1, 24, NOW(), NOW()),
('gaozhi', 'CCSDS标准是?', '[{"key":"A","text":"空间数据系统咨询委员会标准"},{"key":"B","text":"通信系统标准"},{"key":"C","text":"卫星导航标准"},{"key":"D","text":"互联网标准"}]', 'A', 1, 25, NOW(), NOW()),
('gaozhi', 'LDPC编码属于?', '[{"key":"A","text":"低密度奇偶校验码"},{"key":"B","text":"低密度分组码"},{"key":"C","text":"循环码"},{"key":"D","text":"卷积码"}]', 'A', 1, 26, NOW(), NOW()),
('gaozhi', 'MIMO技术可以提高?', '[{"key":"A","text":"信道容量"},{"key":"B","text":"发射功率"},{"key":"C","text":"噪声功率"},{"key":"D","text":"传播距离"}]', 'A', 1, 27, NOW(), NOW()),
('gaozhi', 'OFDM循环前缀的作用是?', '[{"key":"A","text":"增加功率"},{"key":"B","text":"消除符号间干扰"},{"key":"C","text":"提高频率"},{"key":"D","text":"降低速度"}]', 'B', 1, 28, NOW(), NOW()),
('gaozhi', 'Turbo码的主要优点是?', '[{"key":"A","text":"接近香农极限的性能"},{"key":"B","text":"编码简单"},{"key":"C","text":"译码简单"},{"key":"D","text":"速率很高"}]', 'A', 1, 29, NOW(), NOW()),
('gaozhi', 'ARQ协议的作用是?', '[{"key":"A","text":"自动重传请求"},{"key":"B","text":"自动编码"},{"key":"C","text":"自动应答"},{"key":"D","text":"自动加密"}]', 'A', 1, 30, NOW(), NOW()),
('gaozhi', 'TCP/IP协议中,TCP提供?', '[{"key":"A","text":"面向连接可靠服务"},{"key":"B","text":"无连接不可靠服务"},{"key":"C","text":"广播服务"},{"key":"D","text":"组播服务"}]', 'A', 1, 31, NOW(), NOW()),
('gaozhi', 'UDP协议提供?', '[{"key":"A","text":"面向连接可靠服务"},{"key":"B","text":"无连接不可靠服务"},{"key":"C","text":"广播服务"},{"key":"D","text":"组播服务"}]', 'B', 1, 32, NOW(), NOW()),
('gaozhi', 'HTTPS协议使用什么端口?', '[{"key":"A","text":"80"},{"key":"B","text":"443"},{"key":"C","text":"8080"},{"key":"D","text":"22"}]', 'B', 1, 33, NOW(), NOW()),
('gaozhi', 'IPv6地址长度为?', '[{"key":"A","text":"32位"},{"key":"B","text":"64位"},{"key":"C","text":"128位"},{"key":"D","text":"256位"}]', 'C', 1, 34, NOW(), NOW()),
('gaozhi', 'OSI模型有几层?', '[{"key":"A","text":"5"},{"key":"B","text":"6"},{"key":"C","text":"7"},{"key":"D","text":"8"}]', 'C', 1, 35, NOW(), NOW()),
('gaozhi', '调制的主要作用是?', '[{"key":"A","text":"将基带信号搬移到高频载波"},{"key":"B","text":"将高频信号搬移到基带"},{"key":"C","text":"放大信号"},{"key":"D","text":"滤波信号"}]', 'A', 1, 36, NOW(), NOW()),
('gaozhi', '解调的主要作用是?', '[{"key":"A","text":"从高频载波提取基带信号"},{"key":"B","text":"放大信号"},{"key":"C","text":"滤波信号"},{"key":"D","text":"变频信号"}]', 'A', 1, 37, NOW(), NOW()),
('gaozhi', '信源编码的主要目的是?', '[{"key":"A","text":"提高传输可靠性"},{"key":"B","text":"压缩数据率"},{"key":"C","text":"增加冗余"},{"key":"D","text":"提高功率"}]', 'B', 1, 38, NOW(), NOW()),
('gaozhi', '信道编码的主要目的是?', '[{"key":"A","text":"压缩数据"},{"key":"B","text":"差错控制"},{"key":"C","text":"提高速率"},{"key":"D","text":"降低功率"}]', 'B', 1, 39, NOW(), NOW()),
('gaozhi', 'AM调制中,调幅指数m满足?', '[{"key":"A","text":"0 ≤ m ≤ 1"},{"key":"B","text":"m > 1"},{"key":"C","text":"m < 0"},{"key":"D","text":"m = 0"}]', 'A', 1, 40, NOW(), NOW()),
('gaozhi', 'FM调制相比AM调制?', '[{"key":"A","text":"抗噪声性能更好"},{"key":"B","text":"功率更小"},{"key":"C","text":"带宽更小"},{"key":"D","text":"实现更简单"}]', 'A', 1, 41, NOW(), NOW()),
('gaozhi', 'PCM编码三个步骤依次是?', '[{"key":"A","text":"抽样、量化、编码"},{"key":"B","text":"量化、抽样、编码"},{"key":"C","text":"编码、抽样、量化"},{"key":"D","text":"抽样、编码、量化"}]', 'A', 1, 42, NOW(), NOW()),
('gaozhi', '奈奎斯特第一准则解决什么问题?', '[{"key":"A","text":"无码间干扰的传输特性"},{"key":"B","text":"信噪比计算"},{"key":"C","text":"信道容量计算"},{"key":"D","text":"功率分配"}]', 'A', 1, 43, NOW(), NOW()),
('gaozhi', '匹配滤波器输出信噪比在什么时候最大?', '[{"key":"A","text":"抽样时刻"},{"key":"B","text":"发送时刻"},{"key":"C","text":"接收开始"},{"key":"D","text":"接收结束"}]', 'A', 1, 44, NOW(), NOW()),
('gaozhi', '升余弦滚降滤波器的作用是?', '[{"key":"A","text":"降低码间干扰"},{"key":"B","text":"增加码间干扰"},{"key":"C","text":"提高功率"},{"key":"D","text":"改变频率"}]', 'A', 1, 45, NOW(), NOW()),
('gaozhi', '希尔伯特变换主要用于?', '[{"key":"A","text":"产生解析信号"},{"key":"B","text":"滤波信号"},{"key":"C","text":"放大信号"},{"key":"D","text":"变频信号"}]', 'A', 1, 46, NOW(), NOW()),
('gaozhi', '随机过程的均值反映了?', '[{"key":"A","text":"信号的平均强度"},{"key":"B","text":"信号的起伏程度"},{"key":"C","text":"信号的相关性"},{"key":"D","text":"信号的频率"}]', 'A', 1, 47, NOW(), NOW()),
('gaozhi', '傅里叶变换将信号变换到?', '[{"key":"A","text":"时域"},{"key":"B","text":"频域"},{"key":"C","text":"空域"},{"key":"D","text":"码域"}]', 'B', 1, 48, NOW(), NOW()),
('gaozhi', '信噪比等于?', '[{"key":"A","text":"信号功率除以噪声功率"},{"key":"B","text":"噪声功率除以信号功率"},{"key":"C","text":"信号电压除以噪声电压"},{"key":"D","text":"信号电流除以噪声电流"}]', 'A', 1, 49, NOW(), NOW()),
('gaozhi', '纠错编码的主要作用是?', '[{"key":"A","text":"降低误码率"},{"key":"B","text":"提高误码率"},{"key":"C","text":"增加传输速率"},{"key":"D","text":"降低带宽占用"}]', 'A', 1, 50, NOW(), NOW());
-- =====================================================================
-- 判断题 (20题 × 中职组 + 20题 × 高职组 = 40题)
-- =====================================================================
INSERT INTO judge_questions (`group`, question, answer, score, sort_order, created_at, updated_at) VALUES
-- 中职组 第1-20题
('zhongzhi', '调制的目的是将低频信号搬移到高频载波上,以便于天线辐射和信道传输。', 'true', 1, 1, NOW(), NOW()),
('zhongzhi', 'GPS接收机只需要接收到3颗卫星的信号即可实现三维定位。', 'false', 1, 2, NOW(), NOW()),
('zhongzhi', 'AM调制的抗干扰能力优于FM调制。', 'false', 1, 3, NOW(), NOW()),
('zhongzhi', '卫星通信中的上行频率通常高于下行频率。', 'true', 1, 4, NOW(), NOW()),
('zhongzhi', 'NMEA-0183协议中每条语句都以"$"开头,以校验和结尾。', 'true', 1, 5, NOW(), NOW()),
('zhongzhi', '北斗卫星导航系统由空间段、地面段、用户段三部分组成。', 'true', 1, 6, NOW(), NOW()),
('zhongzhi', 'RTK定位可以达到厘米级精度。', 'true', 1, 7, NOW(), NOW()),
('zhongzhi', '电离层延迟误差与太阳活动无关。', 'false', 1, 8, NOW(), NOW()),
('zhongzhi', '差分定位可以提高定位精度。', 'true', 1, 9, NOW(), NOW()),
('zhongzhi', 'GPS卫星本身就是原子钟,提供精确的时间标准。', 'true', 1, 10, NOW(), NOW()),
('zhongzhi', '编码与调制是一回事,都是将信号变换为适合传输的形式。', 'false', 1, 11, NOW(), NOW()),
('zhongzhi', 'GEO卫星的星下点始终在赤道上空一个固定点的正上方。', 'true', 1, 12, NOW(), NOW()),
('zhongzhi', 'FDMA中,每个用户使用相同的频率。', 'false', 1, 13, NOW(), NOW()),
('zhongzhi', '电磁波在自由空间中的传播损耗与距离的平方成正比。', 'true', 1, 14, NOW(), NOW()),
('zhongzhi', 'NTRIP协议用于实时传输差分改正数据。', 'true', 1, 15, NOW(), NOW()),
('zhongzhi', '多路径效应可以通过提高接收机灵敏度来消除。', 'false', 1, 16, NOW(), NOW()),
('zhongzhi', '卫星导航中,PDOP值越小定位精度越好。', 'true', 1, 17, NOW(), NOW()),
('zhongzhi', 'GNSS接收机输出符合NMEA-0183标准的数据。', 'true', 1, 18, NOW(), NOW()),
('zhongzhi', 'GPS卫星信号包含两个频率L1和L2。', 'true', 1, 19, NOW(), NOW()),
('zhongzhi', '卫星通信时延与卫星高度无关。', 'false', 1, 20, NOW(), NOW()),
-- 高职组 第21-40题
('gaozhi', 'OFDM系统中,循环前缀的长度必须大于信道的最大时延扩展。', 'true', 1, 21, NOW(), NOW()),
('gaozhi', '北斗系统只能提供区域导航定位服务,不能提供全球服务。', 'false', 1, 22, NOW(), NOW()),
('gaozhi', '边缘计算可以将计算任务从云端下沉到网络边缘,降低时延。', 'true', 1, 23, NOW(), NOW()),
('gaozhi', '数字签名技术可以防止通信内容被窃听。', 'false', 1, 24, NOW(), NOW()),
('gaozhi', 'Massive MIMO技术通过增加天线数量来提升系统容量。', 'true', 1, 25, NOW(), NOW()),
('gaozhi', '香农公式说明信道容量与带宽和信噪比都正相关。', 'true', 1, 26, NOW(), NOW()),
('gaozhi', 'LDPC码是一种高性能的信道编码。', 'true', 1, 27, NOW(), NOW()),
('gaozhi', 'TCP协议提供面向连接的可靠服务。', 'true', 1, 28, NOW(), NOW()),
('gaozhi', 'UDP协议提供流量控制和拥塞控制。', 'false', 1, 29, NOW(), NOW()),
('gaozhi', '奈奎斯特第一准则告诉我们理想低通信道的最高码元传输速率等于2B。', 'true', 1, 30, NOW(), NOW()),
('gaozhi', 'CRC校验可以检测出所有的传输错误。', 'false', 1, 31, NOW(), NOW()),
('gaozhi', 'BPSK的误码性能优于QPSK。', 'false', 1, 32, NOW(), NOW()),
('gaozhi', '协方差函数反映了两个随机变量之间的相关程度。', 'true', 1, 33, NOW(), NOW()),
('gaozhi', '瑞利分布常用于描述没有直射径的多径衰落信道。', 'true', 1, 34, NOW(), NOW()),
('gaozhi', 'NMEA-0183的GGA语句包含卫星数量和HDOP信息。', 'true', 1, 35, NOW(), NOW()),
('gaozhi', '卡尔曼滤波可以用于组合导航中的状态估计。', 'true', 1, 36, NOW(), NOW()),
('gaozhi', '对流层延迟对L1和L2频段信号的影响相同。', 'false', 1, 37, NOW(), NOW()),
('gaozhi', 'AOS协议是CCSDS标准中用于空间通信的链路层协议。', 'true', 1, 38, NOW(), NOW()),
('gaozhi', '扩频通信中,扩频码速率越高,抗干扰能力越强。', 'true', 1, 39, NOW(), NOW()),
('gaozhi', '卷积码的译码通常使用维特比算法。', 'true', 1, 40, NOW(), NOW());
-- =====================================================================
-- 综合性任务 (1题 × 中职组 + 1题 × 高职组)
-- =====================================================================
INSERT INTO word_questions (`group`, title, description, score, sort_order, created_at, updated_at) VALUES
('zhongzhi', '卫星通信系统基本原理', '请结合第一篇卫星通信技术,简述卫星通信系统的基本组成,包括空间段、地面段、用户段三大部分,并说明各部分的主要功能。分析GEO/MEO/LEO三种轨道的特点及其适用场景,举例说明典型应用。', 30, 1, NOW(), NOW()),
('gaozhi', '卫星导航融合定位方案设计', '请结合第二篇卫星导航技术,设计一个GNSS/INS组合导航定位方案。要求:(1) 分析组合导航的必要性;(2) 说明GNSS和INS各自优缺点;(3) 给出卡尔曼滤波融合框架;(4) 分析预期性能改进。', 30, 1, NOW(), NOW());
-- =====================================================================
-- 统计报告
-- =====================================================================
-- 每组:选择题 50 × 1 = 50, 判断题 20 × 1 = 20, 综合性任务 1 × 30 = 30 → 总分 100
-- =====================================================================
SELECT
(SELECT COUNT(*) FROM choice_questions WHERE `group`='zhongzhi') AS 'zhongzhi_choice',
(SELECT COUNT(*) FROM judge_questions WHERE `group`='zhongzhi') AS 'zhongzhi_judge',
(SELECT COUNT(*) FROM word_questions WHERE `group`='zhongzhi') AS 'zhongzhi_word',
(SELECT SUM(score) FROM choice_questions WHERE `group`='zhongzhi') +
(SELECT SUM(score) FROM judge_questions WHERE `group`='zhongzhi') +
(SELECT SUM(score) FROM word_questions WHERE `group`='zhongzhi') AS 'zhongzhi_total',
(SELECT COUNT(*) FROM choice_questions WHERE `group`='gaozhi') AS 'gaozhi_choice',
(SELECT COUNT(*) FROM judge_questions WHERE `group`='gaozhi') AS 'gaozhi_judge',
(SELECT COUNT(*) FROM word_questions WHERE `group`='gaozhi') AS 'gaozhi_word',
(SELECT SUM(score) FROM choice_questions WHERE `group`='gaozhi') +
(SELECT SUM(score) FROM judge_questions WHERE `group`='gaozhi') +
(SELECT SUM(score) FROM word_questions WHERE `group`='gaozhi') AS 'gaozhi_total';

71
database/config.json Normal file
View File

@@ -0,0 +1,71 @@
{
"development": {
"username": "root",
"password": "74191221",
"database": "competition_db",
"host": "localhost",
"port": 3306,
"dialect": "mysql",
"dialectOptions": {
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci"
},
"define": {
"underscored": true,
"freezeTableName": true,
"timestamps": true,
"createdAt": "created_at",
"updatedAt": "updated_at",
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci"
},
"timezone": "+08:00",
"seederStorage": "sequelize"
},
"test": {
"username": "root",
"password": "74191221",
"database": "competition_db_test",
"host": "localhost",
"port": 3306,
"dialect": "mysql",
"dialectOptions": {
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci"
},
"define": {
"underscored": true,
"freezeTableName": true,
"timestamps": true,
"createdAt": "created_at",
"updatedAt": "updated_at",
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci"
},
"timezone": "+08:00",
"seederStorage": "sequelize"
},
"production": {
"username": "root",
"password": null,
"database": "competition_db",
"host": "localhost",
"port": 3306,
"dialect": "mysql",
"dialectOptions": {
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci"
},
"define": {
"underscored": true,
"freezeTableName": true,
"timestamps": true,
"createdAt": "created_at",
"updatedAt": "updated_at",
"charset": "utf8mb4",
"collate": "utf8mb4_unicode_ci"
},
"timezone": "+08:00",
"seederStorage": "sequelize"
}
}

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 地址',
});
},
};

View File

@@ -0,0 +1,21 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('competition_info', [
{
competition_name: '金砖国家职业技能大赛-卫星导航技术应用赛项',
competition_code: 'BRICS2026-ST-027',
created_at: now,
updated_at: now,
},
], {});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('competition_info', {
competition_code: 'BRICS2026-ST-027',
}, {});
},
};

View File

@@ -0,0 +1,50 @@
'use strict';
/**
* 种子数据:初始化选手数据
* 包含 20 名选手,中职/高职/本科/教师各 5 名
* 账号未激活,密码为空
*/
module.exports = {
up: async (queryInterface) => {
const contestants = [
// ==================== 中职组(5名) ====================
{ id_number: '110101200501010101', name: '张明远', phone: '13800010001', group: 'zhongzhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200502020102', name: '李雨桐', phone: '13800010002', group: 'zhongzhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200503030103', name: '王浩然', phone: '13800010003', group: 'zhongzhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200504040104', name: '赵思琪', phone: '13800010004', group: 'zhongzhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200505050105', name: '陈子涵', phone: '13800010005', group: 'zhongzhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
// ==================== 高职组(5名) ====================
{ id_number: '110101200301010201', name: '刘博文', phone: '13800020001', group: 'gaozhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200302020202', name: '杨紫萱', phone: '13800020002', group: 'gaozhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200303030203', name: '黄俊杰', phone: '13800020003', group: 'gaozhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200304040204', name: '周雅琪', phone: '13800020004', group: 'gaozhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200305050205', name: '吴天宇', phone: '13800020005', group: 'gaozhi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
// ==================== 本科组(5名) ====================
{ id_number: '110101200101010301', name: '郑佳怡', phone: '13800030001', group: 'benke', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200102020302', name: '冯子辰', phone: '13800030002', group: 'benke', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200103030303', name: '褚文博', phone: '13800030003', group: 'benke', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200104040304', name: '林晓萱', phone: '13800030004', group: 'benke', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101200105050305', name: '何志远', phone: '13800030005', group: 'benke', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
// ==================== 教师组(5名) ====================
{ id_number: '110101198501010401', name: '韩建国', phone: '13800040001', group: 'jiaoshi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101198602020402', name: '唐慧芳', phone: '13800040002', group: 'jiaoshi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101198703030403', name: '沈志强', phone: '13800040003', group: 'jiaoshi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101198804040404', name: '许美玲', phone: '13800040004', group: 'jiaoshi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
{ id_number: '110101198905050405', name: '曹永康', phone: '13800040005', group: 'jiaoshi', password: '', activated: false, created_at: new Date(), updated_at: new Date() },
];
await queryInterface.bulkInsert('contestants', contestants, {});
},
/**
* 回滚种子数据:删除所有选手
*/
down: async (queryInterface) => {
await queryInterface.bulkDelete('contestants', null, {});
},
};

View File

@@ -0,0 +1,36 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
const now = new Date();
await queryInterface.bulkInsert('accounts', [
{
role: 'admin',
name_cn: '系统管理员',
name_en: 'Administrator',
gender: null,
phone: null,
email: 'admin@competition.com',
id_number: '000000000000000000',
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
salt: '$2b$10$Nc6PCJItIwuOzpM22dTiAe',
position: '系统管理员',
major: null,
country: '中国',
region: null,
organization_cn: '赛事组委会',
organization_en: 'Competition Committee',
activated: 1,
score: null,
created_at: now,
updated_at: now,
},
], {});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('accounts', {
id_number: '000000000000000000',
}, {});
},
};

View File

@@ -0,0 +1,36 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.bulkInsert('carousel', [
{
image_url: 'https://images.unsplash.com/photo-1446776811953-b23d57bd21aa?w=1200&h=480&fit=crop',
sort_order: 1,
link_url: '',
enabled: 1,
created_at: new Date(),
updated_at: new Date(),
},
{
image_url: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1200&h=480&fit=crop',
sort_order: 2,
link_url: '',
enabled: 1,
created_at: new Date(),
updated_at: new Date(),
},
{
image_url: 'https://images.unsplash.com/photo-1614728263952-84ea256f9679?w=1200&h=480&fit=crop',
sort_order: 3,
link_url: '',
enabled: 1,
created_at: new Date(),
updated_at: new Date(),
},
]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.bulkDelete('carousel', null, {});
},
};

View File

@@ -0,0 +1,85 @@
'use strict';
module.exports = {
up: async (queryInterface) => {
const now = new Date();
const newsItems = [
{
title: '2026年通信与导航应用技术创新赛线上选拔赛通知',
content: '<p>为深入贯彻落实国家创新驱动发展战略,推动通信与导航技术领域的创新应用,现举办2026年通信与导航应用技术创新赛线上选拔赛。本次比赛面向全国职业院校和高等院校师生,欢迎广大师生踊跃报名参赛。</p>',
publish_time: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
pinned: true,
group: 'all',
created_at: now,
updated_at: now,
},
{
title: '中职组比赛时间安排通知',
content: '<p>中职组线上选拔赛将于2026年7月15日上午9:00准时开始,请各位参赛选手提前做好准备。</p>',
publish_time: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000),
pinned: false,
group: 'zhongzhi',
created_at: now,
updated_at: now,
},
{
title: '高职组比赛时间安排通知',
content: '<p>高职组线上选拔赛将于2026年7月16日上午9:00准时开始,比赛时长3小时。</p>',
publish_time: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000),
pinned: false,
group: 'gaozhi',
created_at: now,
updated_at: now,
},
{
title: '本科组作品提交截止日期提醒',
content: '<p>本科组线上选拔赛作品提交截止日期为2026年7月20日23:59。</p>',
publish_time: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000),
pinned: false,
group: 'benke',
created_at: now,
updated_at: now,
},
{
title: '教师组作品提交截止日期提醒',
content: '<p>教师组线上选拔赛作品提交截止日期为2026年7月20日23:59。</p>',
publish_time: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000),
pinned: false,
group: 'jiaoshi',
created_at: now,
updated_at: now,
},
{
title: '关于账号激活说明',
content: '<p>各位参赛选手请使用身份证号进行账号激活。激活流程:输入身份证号验证,获取验证码,设置密码。</p>',
publish_time: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000),
pinned: true,
group: 'all',
created_at: now,
updated_at: now,
},
{
title: '比赛系统操作指南',
content: '<p>为帮助各位选手熟悉比赛系统,请仔细阅读操作指南。中职/高职组在线答题,本科/教师组上传作品。</p>',
publish_time: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000),
pinned: false,
group: 'all',
created_at: now,
updated_at: now,
},
{
title: '温馨提示:比赛期间注意事项',
content: '<p>为确保比赛顺利进行,请使用Chrome或Edge浏览器最新版本,保持网络稳定,比赛过程中请勿刷新页面。</p>',
publish_time: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000),
pinned: false,
group: 'all',
created_at: now,
updated_at: now,
},
];
await queryInterface.bulkInsert('news', newsItems, {});
},
down: async (queryInterface) => {
await queryInterface.bulkDelete('news', null, {});
},
};

View File

@@ -0,0 +1,23 @@
'use strict';
module.exports = {
up: async (queryInterface) => {
const now = new Date();
const questions = [
{ group: 'zhongzhi', title: '通信系统的基本组成', content: '请简述通信系统的基本组成,包括信源、发送设备、信道、接收设备和信宿五大组成部分,并简要说明各部分的功能。', score: 20, sort_order: 1, created_at: now, updated_at: now },
{ group: 'zhongzhi', title: 'GPS导航原理', content: '请阐述GPS全球定位系统的基本工作原理,包括卫星星座、信号结构、伪距测量和定位解算等关键技术。', score: 20, sort_order: 2, created_at: now, updated_at: now },
{ group: 'zhongzhi', title: '信号调制与解调', content: '请比较分析AM调制、FM调制和数字调制(如QPSK)的基本原理、优缺点及适用场景。', score: 20, sort_order: 3, created_at: now, updated_at: now },
{ group: 'zhongzhi', title: '移动通信发展历程', content: '请简述移动通信从1G到5G的发展历程,重点说明各代通信技术的主要特点和技术突破。', score: 20, sort_order: 4, created_at: now, updated_at: now },
{ group: 'zhongzhi', title: '创新应用方案设计', content: '请结合通信与导航技术,设计一个创新应用方案,需包含应用场景描述、技术方案选型、预期效果分析等。', score: 20, sort_order: 5, created_at: now, updated_at: now },
{ group: 'gaozhi', title: 'OFDM技术原理与应用', content: '请详细阐述OFDM(正交频分复用)技术的基本原理,包括子载波正交性、循环前缀的作用以及在4G/5G中的应用。', score: 20, sort_order: 1, created_at: now, updated_at: now },
{ group: 'gaozhi', title: '北斗卫星导航系统', content: '请分析北斗卫星导航系统的发展历程、系统组成、服务类型,并与GPS系统进行对比分析。', score: 20, sort_order: 2, created_at: now, updated_at: now },
{ group: 'gaozhi', title: '5G网络架构与关键技术', content: '请描述5G网络的核心架构(包括核心网和接入网),并分析Massive MIMO、网络切片、边缘计算等关键技术。', score: 20, sort_order: 3, created_at: now, updated_at: now },
{ group: 'gaozhi', title: '通信网络安全', content: '请分析通信网络中常见的安全威胁(如窃听、篡改、拒绝服务攻击等),并提出相应的防护措施。', score: 20, sort_order: 4, created_at: now, updated_at: now },
{ group: 'gaozhi', title: '综合创新项目设计', content: '请设计一个通信与导航融合的创新应用项目,需包含项目背景、技术路线、实施方案、预期成果和可行性分析。', score: 20, sort_order: 5, created_at: now, updated_at: now },
];
await queryInterface.bulkInsert('exam_questions', questions, {});
},
down: async (queryInterface) => {
await queryInterface.bulkDelete('exam_questions', null, {});
},
};

View File

@@ -0,0 +1,61 @@
'use strict';
/**
* 种子数据:初始化比赛时间配置
* 中职组和高职组设置起止时间(在线答题模式)
* 本科组和教师组设置截止时间(作品提交模式)
*/
module.exports = {
up: async (queryInterface) => {
const now = new Date();
const times = [
// 中职组:在线答题,有起止时间
{
group: 'zhongzhi',
start_time: new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000), // 1天后开始
end_time: new Date(now.getTime() + 1 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000), // 持续3小时
deadline: null,
created_at: now,
updated_at: now,
},
// 高职组:在线答题,有起止时间
{
group: 'gaozhi',
start_time: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000), // 2天后开始
end_time: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000), // 持续3小时
deadline: null,
created_at: now,
updated_at: now,
},
// 本科组:作品提交,只有截止时间
{
group: 'benke',
start_time: null,
end_time: null,
deadline: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), // 7天后截止
created_at: now,
updated_at: now,
},
// 教师组:作品提交,只有截止时间
{
group: 'jiaoshi',
start_time: null,
end_time: null,
deadline: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), // 7天后截止
created_at: now,
updated_at: now,
},
];
await queryInterface.bulkInsert('competition_times', times, {});
},
/**
* 回滚种子数据:删除所有时间配置
*/
down: async (queryInterface) => {
await queryInterface.bulkDelete('competition_times', null, {});
},
};

View File

@@ -0,0 +1,176 @@
'use strict';
/**
* 种子文件:写入选择题、判断题、综合性任务模拟数据
*
* 参考考试大纲"题型结构":
* - 专业基础任务:选择题 50题 × 1分 = 50分,判断题 20题 × 1分 = 20分
* - 综合性任务:1题 × 30分 = 30分
* - 总分:100分
*
* 考试内容覆盖:
* - 第一篇 卫星通信技术(第1-6章)→ 16题
* - 第二篇 卫星导航技术(第1-8章)→ 25题
* - 第三篇 设备实操命令 → 29题(选择+判断总计)
*/
module.exports = {
up: async (queryInterface) => {
const now = new Date();
// ==================== 选择题 ====================
// 总分:50题 × 1分 = 50分
// 覆盖:第1篇(16题)+ 第2篇(25题)+ 第3篇(9题)= 50题
const choiceQuestions = [
// ==================== 第一篇:卫星通信技术(第1-6章)====================
// 第1章:卫星通信基础概论
{ group: 'zhongzhi', question: '卫星通信定义为?', options: '[{"key":"A","text":"利用卫星在地面站之间进行的通信"},{"key":"B","text":"利用卫星转发无线电信号实现多个地球站之间的通信"},{"key":"C","text":"卫星与飞机之间的通信"},{"key":"D","text":"卫星之间的宇宙通信"}]', answer: 'B', score: 1, sort_order: 1, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星通信中,地球站向卫星发射信号的链路称为?', options: '[{"key":"A","text":"下行链路"},{"key":"B","text":"上行链路"},{"key":"C","text":"前向链路"},{"key":"D","text":"反向链路"}]', answer: 'B', score: 1, sort_order: 2, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星通信的主要优点不包括以下哪项?', options: '[{"key":"A","text":"覆盖范围大"},{"key":"B","text":"通信容量大"},{"key":"C","text":"建站成本低"},{"key":"D","text":"不受地理条件限制"}]', answer: 'C', score: 1, sort_order: 3, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '通信系统中,将原始信号转换为适合信道传输的信号的过程称为?', options: '[{"key":"A","text":"编码"},{"key":"B","text":"调制"},{"key":"C","text":"解码"},{"key":"D","text":"解调"}]', answer: 'B', score: 1, sort_order: 4, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GEO卫星的轨道高度约为?', options: '[{"key":"A","text":"500-1500 km"},{"key":"B","text":"5000-12000 km"},{"key":"C","text":"20200 km"},{"key":"D","text":"35786 km"}]', answer: 'D', score: 1, sort_order: 5, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'MEO卫星的轨道高度范围是?', options: '[{"key":"A","text":"低于500 km"},{"key":"B","text":"500-2000 km"},{"key":"C","text":"5000-20000 km"},{"key":"D","text":"35786 km"}]', answer: 'C', score: 1, sort_order: 6, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'LEO卫星的轨道倾角通常为?', options: '[{"key":"A","text":"0度"},{"key":"B","text":"近极地轨道"},{"key":"C","text":"赤道轨道"},{"key":"D","text":"地球同步轨道"}]', answer: 'B', score: 1, sort_order: 7, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星通信中,频率复用技术的主要作用是?', options: '[{"key":"A","text":"提高频率利用率"},{"key":"B","text":"降低发射功率"},{"key":"C","text":"减少卫星数量"},{"key":"D","text":"提高信号质量"}]', answer: 'A', score: 1, sort_order: 8, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '以下哪种不是卫星通信的多址方式?', options: '[{"key":"A","text":"FDMA"},{"key":"B","text":"TDMA"},{"key":"C","text":"CDMA"},{"key":"D","text":"ADMA"}]', answer: 'D', score: 1, sort_order: 9, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星物联网中,星上处理的主要目的是?', options: '[{"key":"A","text":"增加卫星重量"},{"key":"B","text":"提高传输效率"},{"key":"C","text":"降低功率"},{"key":"D","text":"增加轨道高度"}]', answer: 'B', score: 1, sort_order: 10, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '低轨卫星星座的特点是?', options: '[{"key":"A","text":"单星覆盖大,但需要星上处理"},{"key":"B","text":"全球覆盖,大容量低时延"},{"key":"C","text":"只有一颗卫星"},{"key":"D","text":"不需要切换"}]', answer: 'B', score: 1, sort_order: 11, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星通信系统的转发器作用是?', options: '[{"key":"A","text":"存储数据"},{"key":"B","text":"放大变频转发信号"},{"key":"C","text":"计算信号"},{"key":"D","text":"控制卫星姿态"}]', answer: 'B', score: 1, sort_order: 12, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星天线的指向精度影响?', options: '[{"key":"A","text":"信号接收功率"},{"key":"B","text":"卫星重量"},{"key":"C","text":"轨道高度"},{"key":"D","text":"卫星寿命"}]', answer: 'A', score: 1, sort_order: 13, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星电源系统主要采用?', options: '[{"key":"A","text":"燃料电池"},{"key":"B","text":"太阳能电池阵+蓄电池组"},{"key":"C","text":"核能发电"},{"key":"D","text":"化学电池"}]', answer: 'B', score: 1, sort_order: 14, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '以下哪个频段常用作卫星通信上行?', options: '[{"key":"A","text":"UHF"},{"key":"B","text":"C-band"},{"key":"C","text":"Ku-band"},{"key":"D","text":"Ka-band"}]', answer: 'D', score: 1, sort_order: 15, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星姿态控制的目的是?', options: '[{"key":"A","text":"改变轨道高度"},{"key":"B","text":"保持天线指向目标区域"},{"key":"C","text":"改变速度"},{"key":"D","text":"减少功耗"}]', answer: 'B', score: 1, sort_order: 16, created_at: now, updated_at: now },
// ==================== 第二篇:卫星导航技术(第1-8章)====================
// 第1章:GNSS概述
{ group: 'zhongzhi', question: 'GNSS的全称是?', options: '[{"key":"A","text":"Global Navigation Satellite System"},{"key":"B","text":"Global Network Service System"},{"key":"C","text":"General Navigation Support System"},{"key":"D","text":"Geo Navigation Satellite System"}]', answer: 'A', score: 1, sort_order: 17, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '以下哪个不属于四大全球导航卫星星座?', options: '[{"key":"A","text":"GPS"},{"key":"B","text":"北斗"},{"key":"C","text":"GLONASS"},{"key":"D","text":"WAAS"}]', answer: 'D', score: 1, sort_order: 18, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '北斗三号全球系统提供多少颗卫星?', options: '[{"key":"A","text":"24"},{"key":"B","text":"30"},{"key":"C","text":"35"},{"key":"D","text":"40"}]', answer: 'C', score: 1, sort_order: 19, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GPS卫星导航系统至少需要几颗卫星才能实现三维定位?', options: '[{"key":"A","text":"2颗"},{"key":"B","text":"3颗"},{"key":"C","text":"4颗"},{"key":"D","text":"5颗"}]', answer: 'C', score: 1, sort_order: 20, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '伪距测量测量的是?', options: '[{"key":"A","text":"卫星到接收机的几何距离"},{"key":"B","text":"卫星信号传播时间乘以光速得到的距离"},{"key":"C","text":"卫星到地心的距离"},{"key":"D","text":"接收机到地面的距离"}]', answer: 'B', score: 1, sort_order: 21, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星定位误差中,属于系统误差的是?', options: '[{"key":"A","text":"电离层延迟误差"},{"key":"B","text":"接收机噪声"},{"key":"C","text":"多径效应随机误差"},{"key":"D","text":"测量噪声"}]', answer: 'A', score: 1, sort_order: 22, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '差分定位技术中,RTK的全称是?', options: '[{"key":"A","text":"Real-Time Kinematic"},{"key":"B","text":"Rapid Tracking Kinematic"},{"key":"C","text":"Real-Time Kalman"},{"key":"D","text":"Radio Transmission Kinematic"}]', answer: 'A', score: 1, sort_order: 23, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'NMEA-0183协议中,GGA语句主要提供什么信息?', options: '[{"key":"A","text":"卫星星历"},{"key":"B","text":"定位数据"},{"key":"C","text":"卫星状态"},{"key":"D","text":"速度信息"}]', answer: 'B', score: 1, sort_order: 24, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '北斗系统独具特色的服务是?', options: '[{"key":"A","text":"仅定位"},{"key":"B","text":"短报文通信"},{"key":"C","text":"仅授时"},{"key":"D","text":"仅测速"}]', answer: 'B', score: 1, sort_order: 25, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '高精度差分定位中,NTRIP协议用于?', options: '[{"key":"A","text":"卫星轨道计算"},{"key":"B","text":"通过互联网传输差分改正数据"},{"key":"C","text":"信号调制"},{"key":"D","text":"误差计算"}]', answer: 'B', score: 1, sort_order: 26, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星导航接收机的灵敏度主要影响?', options: '[{"key":"A","text":"捕获卫星信号的能力"},{"key":"B","text":"发射功率"},{"key":"C","text":"卫星轨道高度"},{"key":"D","text":"计算速度"}]', answer: 'A', score: 1, sort_order: 27, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '组合导航GNSS+IMU中,IMU的作用是?', options: '[{"key":"A","text":"提供加速度和角速度"},{"key":"B","text":"提供定位结果"},{"key":"C","text":"提供时间信息"},{"key":"D","text":"提供卫星星历"}]', answer: 'A', score: 1, sort_order: 28, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '多路径效应产生的原因是?', options: '[{"key":"A","text":"卫星信号经过反射后与直达信号干涉"},{"key":"B","text":"多颗卫星信号叠加"},{"key":"C","text":"多个接收机同时工作"},{"key":"D","text":"电离层反射信号"}]', answer: 'A', score: 1, sort_order: 29, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'RTCM标准主要用于?', options: '[{"key":"A","text":"卫星导航电文标准"},{"key":"B","text":"差分改正数据标准"},{"key":"C","text":"NMEA输出标准"},{"key":"D","text":"接口通信标准"}]', answer: 'B', score: 1, sort_order: 30, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'PDOP值越大表示?', options: '[{"key":"A","text":"定位精度越好"},{"key":"B","text":"定位几何误差越大"},{"key":"C","text":"卫星越多"},{"key":"D","text":"温度越高"}]', answer: 'B', score: 1, sort_order: 31, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '北斗卫星导航系统的服务区覆盖?', options: '[{"key":"A","text":"仅中国"},{"key":"B","text":"亚太地区"},{"key":"C","text":"全球"},{"key":"D","text":"仅北半球"}]', answer: 'C', score: 1, sort_order: 32, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GPS卫星星历更新周期通常为?', options: '[{"key":"A","text":"30秒"},{"key":"B","text":"2小时"},{"key":"C","text":"1天"},{"key":"D","text":"1周"}]', answer: 'B', score: 1, sort_order: 33, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '静态相对定位主要用于?', options: '[{"key":"A","text":"需要高精度大地测量"},{"key":"B","text":"车辆导航"},{"key":"C","text":"船舶导航"},{"key":"D","text":"飞机导航"}]', answer: 'A', score: 1, sort_order: 34, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '载波相位测量相比伪距测量?', options: '[{"key":"A","text":"精度更高"},{"key":"B","text":"精度更低"},{"key":"C","text":"精度相同"},{"key":"D","text":"无法比较"}]', answer: 'A', score: 1, sort_order: 35, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '电离层延迟对伪距测量的影响?', options: '[{"key":"A","text":"夜间比白天大"},{"key":"B","text":"白天比夜间大"},{"key":"C","text":"没有影响"},{"key":"D","text":"只在冬季有影响"}]', answer: 'B', score: 1, sort_order: 36, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '周跳发生在?', options: '[{"key":"A","text":"载波相位测量中"},{"key":"B","text":"伪距测量中"},{"key":"C","text":"信号发射时"},{"key":"D","text":"卫星星历计算时"}]', answer: 'A', score: 1, sort_order: 37, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GPS系统由多少颗卫星组成?', options: '[{"key":"A","text":"24"},{"key":"B","text":"31"},{"key":"C","text":"35"},{"key":"D","text":"40"}]', answer: 'A', score: 1, sort_order: 38, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '以下哪种不属于北斗增强系统?', options: '[{"key":"A","text":"SBAS"},{"key":"B","text":"GBAS"},{"key":"C","text":"BDS-IS"},{"key":"D","text":"GLONASS"}]', answer: 'D', score: 1, sort_order: 39, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '单点定位精度通常为?', options: '[{"key":"A","text":"厘米级"},{"key":"B","text":"米级"},{"key":"C","text":"分米级"},{"key":"D","text":"毫米级"}]', answer: 'B', score: 1, sort_order: 40, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'RTK定位精度通常达到?', options: '[{"key":"A","text":"厘米级"},{"key":"B","text":"米级"},{"key":"C","text":"千米级"},{"key":"D","text":"十米级"}]', answer: 'A', score: 1, sort_order: 41, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'NMEA-0183语句的结束标志是?', options: '[{"key":"A","text":"换行"},{"key":"B","text":"校验和"},{"key":"C","text":"星号"},{"key":"D","text":"回车"}]', answer: 'B', score: 1, sort_order: 42, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '以下哪个不是北斗卫星导航系统的特点?', options: '[{"key":"A","text":"有源定位"},{"key":"B","text":"短报文通信"},{"key":"C","text":"区域服务不能全球覆盖"},{"key":"D","text":"三频信号"}]', answer: 'C', score: 1, sort_order: 43, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星导航接收机捕获阶段的主要作用是?', options: '[{"key":"A","text":"搜索卫星信号并确定频率码相位"},{"key":"B","text":"跟踪信号"},{"key":"C","text":"计算位置"},{"key":"D","text":"输出速度"}]', answer: 'A', score: 1, sort_order: 44, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '对流层延迟改正模型中,通常使用?', options: '[{"key":"A","text":"Hopfield模型"},{"key":"B","text":"Klobuchar模型"},{"key":"C","text":"Black模型"},{"key":"D","text":"高斯模型"}]', answer: 'A', score: 1, sort_order: 45, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '全球定位系统中,UTC表示?', options: '[{"key":"A","text":"世界时"},{"key":"B","text":"协调世界时"},{"key":"C","text":"原子时"},{"key":"D","text":"恒星时"}]', answer: 'B', score: 1, sort_order: 46, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '整周模糊度解算属于?', options: '[{"key":"A","text":"伪距测量问题"},{"key":"B","text":"载波相位测量问题"},{"key":"C","text":"卫星轨道问题"},{"key":"D","text":"电离层问题"}]', answer: 'B', score: 1, sort_order: 47, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '以下哪个频率是北斗B1I信号频率?', options: '[{"key":"A","text":"1561.098 MHz"},{"key":"B","text":"1268.52 MHz"},{"key":"C","text":"1176.45 MHz"},{"key":"D","text":"1575.42 MHz"}]', answer: 'A', score: 1, sort_order: 48, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GPS选择的PRN码特点是?', options: '[{"key":"A","text":"自相关特性好,互相关特性弱"},{"key":"B","text":"自相关特性弱,互相关特性强"},{"key":"C","text":"长度固定不变"},{"key":"D","text":"只有一个码"}]', answer: 'A', score: 1, sort_order: 49, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '下列哪项不属于卫星导航的应用?', options: '[{"key":"A","text":"车辆导航"},{"key":"B","text":"工程测量"},{"key":"C","text":"火力发电"},{"key":"D","text":"时间同步"}]', answer: 'C', score: 1, sort_order: 50, created_at: now, updated_at: now },
// 高职组继续补充题目(总计50题,上述中职组已有50题,高职组继续扩展)
{ group: 'gaozhi', question: '卫星通信中,FDMA的含义是?', options: '[{"key":"A","text":"频分多址"},{"key":"B","text":"时分多址"},{"key":"C","text":"码分多址"},{"key":"D","text":"空分多址"}]', answer: 'A', score: 1, sort_order: 1, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'GEO卫星的星下点位置相对于地面观察者?', options: '[{"key":"A","text":"不断高速移动"},{"key":"B","text":"基本静止"},{"key":"C","text":"每天环绕一次"},{"key":"D","text":"每月环绕一次"}]', answer: 'B', score: 1, sort_order: 2, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'OFDM技术中,子载波之间保持正交性的目的是?', options: '[{"key":"A","text":"提高发射功率"},{"key":"B","text":"避免子载波间干扰"},{"key":"C","text":"增加带宽"},{"key":"D","text":"降低频率"}]', answer: 'B', score: 1, sort_order: 3, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '北斗三号全球卫星导航系统具备以下哪种特色服务?', options: '[{"key":"A","text":"仅定位服务"},{"key":"B","text":"短报文通信"},{"key":"C","text":"仅授时服务"},{"key":"D","text":"仅测速服务"}]', answer: 'B', score: 1, sort_order: 4, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '5G网络中,Massive MIMO技术的主要优势是?', options: '[{"key":"A","text":"降低功耗"},{"key":"B","text":"提升频谱效率和容量"},{"key":"C","text":"减少天线数量"},{"key":"D","text":"简化网络架构"}]', answer: 'B', score: 1, sort_order: 5, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '网络切片技术属于5G的哪个核心特性?', options: '[{"key":"A","text":"eMBB"},{"key":"B","text":"URLLC"},{"key":"C","text":"mMTC"},{"key":"D","text":"网络能力开放"}]', answer: 'D', score: 1, sort_order: 6, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '在通信安全中,以下哪种攻击属于被动攻击?', options: '[{"key":"A","text":"篡改"},{"key":"B","text":"窃听"},{"key":"C","text":"拒绝服务"},{"key":"D","text":"伪装"}]', answer: 'B', score: 1, sort_order: 7, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'NTRIP协议主要用于什么场景?', options: '[{"key":"A","text":"卫星遥测"},{"key":"B","text":"差分数据网络传输"},{"key":"C","text":"卫星轨道控制"},{"key":"D","text":"信号加密传输"}]', answer: 'B', score: 1, sort_order: 8, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '组合导航(GNSS+IMU)的核心优势是什么?', options: '[{"key":"A","text":"降低成本"},{"key":"B","text":"无缝切换与误差互补"},{"key":"C","text":"减少卫星数量"},{"key":"D","text":"提高发射功率"}]', answer: 'B', score: 1, sort_order: 9, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '5G网络中,URLLC场景的主要特征是?', options: '[{"key":"A","text":"高速率大带宽"},{"key":"B","text":"低时延高可靠"},{"key":"C","text":"海量连接"},{"key":"D","text":"广域覆盖"}]', answer: 'B', score: 1, sort_order: 10, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '卫星定位中,伪距测量的误差来源不包括?', options: '[{"key":"A","text":"电离层延迟"},{"key":"B","text":"对流层延迟"},{"key":"C","text":"卫星轨道误差"},{"key":"D","text":"用户设备重量"}]', answer: 'D', score: 1, sort_order: 11, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '低轨卫星物联网相比GEO卫星的主要优势是?', options: '[{"key":"A","text":"覆盖范围更大"},{"key":"B","text":"传输时延更低"},{"key":"C","text":"单星容量更大"},{"key":"D","text":"使用寿命更长"}]', answer: 'B', score: 1, sort_order: 12, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'QPSK调制属于?', options: '[{"key":"A","text":"模拟调制"},{"key":"B","text":"数字相位调制"},{"key":"C","text":"幅度调制"},{"key":"D","text":"频率调制"}]', answer: 'B', score: 1, sort_order: 13, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '信道编码的主要目的是?', options: '[{"key":"A","text":"提高传输速率"},{"key":"B","text":"对抗传输错误"},{"key":"C","text":"增加功率"},{"key":"D","text":"压缩数据"}]', answer: 'B', score: 1, sort_order: 14, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'CDMA技术的特点是?', options: '[{"key":"A","text":"所有用户同时占用相同频段"},{"key":"B","text":"每个用户占用不同频率"},{"key":"C","text":"每个用户占用不同时隙"},{"key":"D","text":"用户按空间分隔"}]', answer: 'A', score: 1, sort_order: 15, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'M-QAM调制中,M越大?', options: '[{"key":"A","text":"频谱效率越高,误码性能越好"},{"key":"B","text":"频谱效率越高,误码性能越差"},{"key":"C","text":"频谱效率越低,误码性能越好"},{"key":"D","text":"频谱效率越低,误码性能越差"}]', answer: 'B', score: 1, sort_order: 16, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '香农公式给出了?', options: '[{"key":"A","text":"信道容量与带宽信噪比的关系"},{"key":"B","text":"信号功率与频率的关系"},{"key":"C","text":"噪声功率与温度的关系"},{"key":"D","text":"带宽与频率的关系"}]', answer: 'A', score: 1, sort_order: 17, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '均衡技术主要用于补偿?', options: '[{"key":"A","text":"信道衰落引起的码间干扰"},{"key":"B","text":"噪声干扰"},{"key":"C","text":"频率偏移"},{"key":"D","text":"相位噪声"}]', answer: 'A', score: 1, sort_order: 18, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '编码效率等于?', options: '[{"key":"A","text":"信息位长度/总码长"},{"key":"B","text":"总码长/信息位长度"},{"key":"C","text":"监督位长度/总码长"},{"key":"D","text":"信息位长度/监督位长度"}]', answer: 'A', score: 1, sort_order: 19, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'RS编码属于?', options: '[{"key":"A","text":"分组码"},{"key":"B","text":"卷积码"},{"key":"C","text":"循环码"},{"key":"D","text":"RS不是编码"}]', answer: 'A', score: 1, sort_order: 20, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'TCM编码是?', options: '[{"key":"A","text":"网格编码调制"},{"key":"B","text":" Turbo编码"},{"key":"C","text":"卷积编码"},{"key":"D","text":"里德所罗门编码"}]', answer: 'A', score: 1, sort_order: 21, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '扩频通信的主要优点不包括?', options: '[{"key":"A","text":"抗干扰能力强"},{"key":"B","text":"隐蔽性好"},{"key":"C","text":"功率大"},{"key":"D","text":"多址能力强"}]', answer: 'C', score: 1, sort_order: 22, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'DSSS表示?', options: '[{"key":"A","text":"跳频扩频"},{"key":"B","text":"直接序列扩频"},{"key":"C","text":"跳时扩频"},{"key":"D","text":"线性扩频"}]', answer: 'B', score: 1, sort_order: 23, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'AOS协议主要用于?', options: '[{"key":"A","text":"地面互联网传输"},{"key":"B","text":"星地链路 packet 传输"},{"key":"C","text":"卫星轨道测量"},{"key":"D","text":"信号调制"}]', answer: 'B', score: 1, sort_order: 24, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'CCSDS标准是?', options: '[{"key":"A","text":"空间数据系统咨询委员会标准"},{"key":"B","text":"通信系统标准"},{"key":"C","text":"卫星导航标准"},{"key":"D","text":"互联网标准"}]', answer: 'A', score: 1, sort_order: 25, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'LDPC编码属于?', options: '[{"key":"A","text":"低密度奇偶校验码"},{"key":"B","text":"低密度分组码"},{"key":"C","text":"循环码"},{"key":"D","text":"卷积码"}]', answer: 'A', score: 1, sort_order: 26, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'MIMO技术可以提高?', options: '[{"key":"A","text":"信道容量"},{"key":"B","text":"发射功率"},{"key":"C","text":"噪声功率"},{"key":"D","text":"传播距离"}]', answer: 'A', score: 1, sort_order: 27, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'OFDM循环前缀的作用是?', options: '[{"key":"A","text":"增加功率"},{"key":"B","text":"消除符号间干扰"},{"key":"C","text":"提高频率"},{"key":"D","text":"降低速度"}]', answer: 'B', score: 1, sort_order: 28, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'Turbo码的主要优点是?', options: '[{"key":"A","text":"接近香农极限的性能"},{"key":"B","text":"编码简单"},{"key":"C","text":"译码简单"},{"key":"D","text":"速率很高"}]', answer: 'A', score: 1, sort_order: 29, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'ARQ协议的作用是?', options: '[{"key":"A","text":"自动重传请求"},{"key":"B","text":"自动编码"},{"key":"C","text":"自动应答"},{"key":"D","text":"自动加密"}]', answer: 'A', score: 1, sort_order: 30, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'TCP/IP协议中,TCP提供?', options: '[{"key":"A","text":"面向连接可靠服务"},{"key":"B","text":"无连接不可靠服务"},{"key":"C","text":"广播服务"},{"key":"D","text":"组播服务"}]', answer: 'A', score: 1, sort_order: 31, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'UDP协议提供?', options: '[{"key":"A","text":"面向连接可靠服务"},{"key":"B","text":"无连接不可靠服务"},{"key":"C","text":"广播服务"},{"key":"D","text":"组播服务"}]', answer: 'B', score: 1, sort_order: 32, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'HTTPS协议使用什么端口?', options: '[{"key":"A","text":"80"},{"key":"B","text":"443"},{"key":"C","text":"8080"},{"key":"D","text":"22"}]', answer: 'B', score: 1, sort_order: 33, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'IPv6地址长度为?', options: '[{"key":"A","text":"32位"},{"key":"B","text":"64位"},{"key":"C","text":"128位"},{"key":"D","text":"256位"}]', answer: 'C', score: 1, sort_order: 34, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'OSI模型有几层?', options: '[{"key":"A","text":"5"},{"key":"B","text":"6"},{"key":"C","text":"7"},{"key":"D","text":"8"}]', answer: 'C', score: 1, sort_order: 35, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '调制的主要作用是?', options: '[{"key":"A","text":"将基带信号搬移到高频载波"},{"key":"B","text":"将高频信号搬移到基带"},{"key":"C","text":"放大信号"},{"key":"D","text":"滤波信号"}]', answer: 'A', score: 1, sort_order: 36, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '解调的主要作用是?', options: '[{"key":"A","text":"从高频载波提取基带信号"},{"key":"B","text":"放大信号"},{"key":"C","text":"滤波信号"},{"key":"D","text":"变频信号"}]', answer: 'A', score: 1, sort_order: 37, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '信源编码的主要目的是?', options: '[{"key":"A","text":"提高传输可靠性"},{"key":"B","text":"压缩数据率"},{"key":"C","text":"增加冗余"},{"key":"D","text":"提高功率"}]', answer: 'B', score: 1, sort_order: 38, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '信道编码的主要目的是?', options: '[{"key":"A","text":"压缩数据"},{"key":"B","text":"差错控制"},{"key":"C","text":"提高速率"},{"key":"D","text":"降低功率"}]', answer: 'B', score: 1, sort_order: 39, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'AM调制中,调幅指数m满足?', options: '[{"key":"A","text":"0 ≤ m ≤ 1"},{"key":"B","text":"m > 1"},{"key":"C","text":"m < 0"},{"key":"D","text":"m = 0"}]', answer: 'A', score: 1, sort_order: 40, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'FM调制相比AM调制?', options: '[{"key":"A","text":"抗噪声性能更好"},{"key":"B","text":"功率更小"},{"key":"C","text":"带宽更小"},{"key":"D","text":"实现更简单"}]', answer: 'A', score: 1, sort_order: 41, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'PCM编码三个步骤依次是?', options: '[{"key":"A","text":"抽样、量化、编码"},{"key":"B","text":"量化、抽样、编码"},{"key":"C","text":"编码、抽样、量化"},{"key":"D","text":"抽样、编码、量化"}]', answer: 'A', score: 1, sort_order: 42, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '奈奎斯特第一准则解决什么问题?', options: '[{"key":"A","text":"无码间干扰的传输特性"},{"key":"B","text":"信噪比计算"},{"key":"C","text":"信道容量计算"},{"key":"D","text":"功率分配"}]', answer: 'A', score: 1, sort_order: 43, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '匹配滤波器输出信噪比在什么时候最大?', options: '[{"key":"A","text":"抽样时刻"},{"key":"B","text":"发送时刻"},{"key":"C","text":"接收开始"},{"key":"D","text":"接收结束"}]', answer: 'A', score: 1, sort_order: 44, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '升余弦滚降滤波器的作用是?', options: '[{"key":"A","text":"降低码间干扰"},{"key":"B","text":"增加码间干扰"},{"key":"C","text":"提高功率"},{"key":"D","text":"改变频率"}]', answer: 'A', score: 1, sort_order: 45, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '希尔伯特变换主要用于?', options: '[{"key":"A","text":"产生解析信号"},{"key":"B","text":"滤波信号"},{"key":"C","text":"放大信号"},{"key":"D","text":"变频信号"}]', answer: 'A', score: 1, sort_order: 46, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '随机过程的均值反映了?', options: '[{"key":"A","text":"信号的平均强度"},{"key":"B","text":"信号的起伏程度"},{"key":"C","text":"信号的相关性"},{"key":"D","text":"信号的频率"}]', answer: 'A', score: 1, sort_order: 47, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '傅里叶变换将信号变换到?', options: '[{"key":"A","text":"时域"},{"key":"B","text":"频域"},{"key":"C","text":"空域"},{"key":"D","text":"码域"}]', answer: 'B', score: 1, sort_order: 48, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '信噪比等于?', options: '[{"key":"A","text":"信号功率除以噪声功率"},{"key":"B","text":"噪声功率除以信号功率"},{"key":"C","text":"信号电压除以噪声电压"},{"key":"D","text":"信号电流除以噪声电流"}]', answer: 'A', score: 1, sort_order: 49, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '纠错编码的主要作用是?', options: '[{"key":"A","text":"降低误码率"},{"key":"B","text":"提高误码率"},{"key":"C","text":"增加传输速率"},{"key":"D","text":"降低带宽占用"}]', answer: 'A', score: 1, sort_order: 50, created_at: now, updated_at: now },
];
await queryInterface.bulkInsert('choice_questions', choiceQuestions, {});
// ==================== 判断题 ====================
// 总分:20题 × 1分 = 20分
const judgeQuestions = [
{ group: 'zhongzhi', question: '调制的目的是将低频信号搬移到高频载波上,以便于天线辐射和信道传输。', answer: 'true', score: 1, sort_order: 1, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GPS接收机只需要接收到3颗卫星的信号即可实现三维定位。', answer: 'false', score: 1, sort_order: 2, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'AM调制的抗干扰能力优于FM调制。', answer: 'false', score: 1, sort_order: 3, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '卫星通信中的上行频率通常高于下行频率。', answer: 'true', score: 1, sort_order: 4, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'NMEA-0183协议中每条语句都以"$"开头,以校验和结尾。', answer: 'true', score: 1, sort_order: 5, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '北斗卫星导航系统由空间段、地面段、用户段三部分组成。', answer: 'true', score: 1, sort_order: 6, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'RTK定位可以达到厘米级精度。', answer: 'true', score: 1, sort_order: 7, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '电离层延迟误差与太阳活动无关。', answer: 'false', score: 1, sort_order: 8, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: '差分定位可以提高定位精度。', answer: 'true', score: 1, sort_order: 9, created_at: now, updated_at: now },
{ group: 'zhongzhi', question: 'GPS卫星本身就是原子钟,提供精确的时间标准。', answer: 'true', score: 1, sort_order: 10, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'OFDM系统中,循环前缀的长度必须大于信道的最大时延扩展。', answer: 'true', score: 1, sort_order: 11, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '北斗系统只能提供区域导航定位服务,不能提供全球服务。', answer: 'false', score: 1, sort_order: 12, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '边缘计算可以将计算任务从云端下沉到网络边缘,降低时延。', answer: 'true', score: 1, sort_order: 13, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '数字签名技术可以防止通信内容被窃听。', answer: 'false', score: 1, sort_order: 14, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'Massive MIMO技术通过增加天线数量来提升系统容量。', answer: 'true', score: 1, sort_order: 15, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '香农公式说明信道容量与带宽和信噪比都正相关。', answer: 'true', score: 1, sort_order: 16, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'LDPC码是一种高性能的信道编码。', answer: 'true', score: 1, sort_order: 17, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'TCP协议提供面向连接的可靠服务。', answer: 'true', score: 1, sort_order: 18, created_at: now, updated_at: now },
{ group: 'gaozhi', question: 'UDP协议提供流量控制和拥塞控制。', answer: 'false', score: 1, sort_order: 19, created_at: now, updated_at: now },
{ group: 'gaozhi', question: '奈奎斯特第一准则告诉我们理想低通信道的最高码元传输速率等于2B。', answer: 'true', score: 1, sort_order: 20, created_at: now, updated_at: now },
];
await queryInterface.bulkInsert('judge_questions', judgeQuestions, {});
// ==================== 综合性任务 ====================
// 总分:1题 × 30分 = 30分
const wordQuestions = [
{ group: 'zhongzhi', title: '卫星通信系统基本原理', description: '请结合第一篇卫星通信技术,简述卫星通信系统的基本组成,包括空间段、地面段、用户段三大部分,并说明各部分的主要功能。分析GEO/MEO/LEO三种轨道的特点及其适用场景,举例说明典型应用。', score: 30, sort_order: 1, template_path: null, created_at: now, updated_at: now },
{ group: 'gaozhi', title: '卫星导航融合定位方案设计', description: '请结合第二篇卫星导航技术,设计一个GNSS/INS组合导航定位方案。要求:(1) 分析组合导航的必要性;(2) 说明GNSS和INS各自优缺点;(3) 给出卡尔曼滤波融合框架;(4) 分析预期性能改进。', score: 30, sort_order: 1, template_path: null, created_at: now, updated_at: now },
];
await queryInterface.bulkInsert('word_questions', wordQuestions, {});
},
down: async (queryInterface) => {
await queryInterface.bulkDelete('choice_questions', null, {});
await queryInterface.bulkDelete('judge_questions', null, {});
await queryInterface.bulkDelete('word_questions', null, {});
},
};

Some files were not shown because too many files have changed in this diff Show More