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:
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal 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
10
.sequelizerc
Normal 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
23
add_word_cols.js
Normal 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
23
add_wx_link.js
Normal 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
88
app.js
Normal 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
1664
app/controller/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
610
app/controller/auth.js
Normal file
610
app/controller/auth.js
Normal 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;
|
||||||
35
app/controller/carousel.js
Normal file
35
app/controller/carousel.js
Normal 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
813
app/controller/exam.js
Normal 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
42
app/controller/health.js
Normal 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
119
app/controller/materials.js
Normal 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
70
app/controller/news.js
Normal 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
230
app/controller/score.js
Normal 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
478
app/controller/upload.js
Normal 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
273
app/extend/helper.js
Normal 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
83
app/lib/file_io_guard.js
Normal 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 };
|
||||||
47
app/lib/file_path_cache.js
Normal file
47
app/lib/file_path_cache.js
Normal 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
116
app/middleware/auth.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
53
app/middleware/concurrency_guard.js
Normal file
53
app/middleware/concurrency_guard.js
Normal 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--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
73
app/middleware/loginRateLimit.js
Normal file
73
app/middleware/loginRateLimit.js
Normal 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
139
app/middleware/rateLimit.js
Normal 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
167
app/model/account.js
Normal 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
57
app/model/admin.js
Normal 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
135
app/model/aggregation.js
Normal 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;
|
||||||
|
};
|
||||||
115
app/model/benkeSubmission.js
Normal file
115
app/model/benkeSubmission.js
Normal 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
105
app/model/carousel.js
Normal 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;
|
||||||
|
};
|
||||||
67
app/model/choiceQuestion.js
Normal file
67
app/model/choiceQuestion.js
Normal 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',
|
||||||
|
});
|
||||||
|
};
|
||||||
61
app/model/competitionInfo.js
Normal file
61
app/model/competitionInfo.js
Normal 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;
|
||||||
|
};
|
||||||
93
app/model/competitionTime.js
Normal file
93
app/model/competitionTime.js
Normal 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
161
app/model/contestant.js
Normal 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
84
app/model/examQuestion.js
Normal 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
110
app/model/examSubmission.js
Normal 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;
|
||||||
|
};
|
||||||
89
app/model/gaozhiQuestion.js
Normal file
89
app/model/gaozhiQuestion.js
Normal 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;
|
||||||
|
};
|
||||||
133
app/model/gaozhiSubmission.js
Normal file
133
app/model/gaozhiSubmission.js
Normal 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;
|
||||||
|
};
|
||||||
115
app/model/jiaoshiSubmission.js
Normal file
115
app/model/jiaoshiSubmission.js
Normal 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;
|
||||||
|
};
|
||||||
62
app/model/judgeQuestion.js
Normal file
62
app/model/judgeQuestion.js
Normal 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
117
app/model/news.js
Normal 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
55
app/model/systemConfig.js
Normal 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
78
app/model/team.js
Normal 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
67
app/model/wordQuestion.js
Normal 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
109
app/model/workSubmission.js
Normal 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;
|
||||||
|
};
|
||||||
89
app/model/zhongzhiQuestion.js
Normal file
89
app/model/zhongzhiQuestion.js
Normal 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;
|
||||||
|
};
|
||||||
133
app/model/zhongzhiSubmission.js
Normal file
133
app/model/zhongzhiSubmission.js
Normal 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;
|
||||||
|
};
|
||||||
1
app/public/templates/.gitkeep
Normal file
1
app/public/templates/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
app/public/templates/gaozhi/word_template_1785117066172.docx
Normal file
BIN
app/public/templates/gaozhi/word_template_1785117066172.docx
Normal file
Binary file not shown.
BIN
app/public/templates/gaozhi/word_template_1785207535307.docx
Normal file
BIN
app/public/templates/gaozhi/word_template_1785207535307.docx
Normal file
Binary file not shown.
BIN
app/public/templates/gaozhi/word_template_1785207663577.docx
Normal file
BIN
app/public/templates/gaozhi/word_template_1785207663577.docx
Normal file
Binary file not shown.
BIN
app/public/templates/gaozhi/word_template_1785207668867.docx
Normal file
BIN
app/public/templates/gaozhi/word_template_1785207668867.docx
Normal file
Binary file not shown.
BIN
app/public/templates/zhongzhi/word_template_1785117066098.docx
Normal file
BIN
app/public/templates/zhongzhi/word_template_1785117066098.docx
Normal file
Binary file not shown.
BIN
app/public/templates/zhongzhi/word_template_1785139347982.docx
Normal file
BIN
app/public/templates/zhongzhi/word_template_1785139347982.docx
Normal file
Binary file not shown.
158
app/router.js
Normal file
158
app/router.js
Normal 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
1912
app/service/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
116
app/service/auth.js
Normal file
116
app/service/auth.js
Normal 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
745
app/service/exam.js
Normal 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
132
app/service/news.js
Normal 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
78
app/service/sms.js
Normal 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
368
app/service/upload.js
Normal 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
62
app/utils/filename.js
Normal 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
109
app/utils/sendFile.js
Normal 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
86
app/validate/index.js
Normal 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
65
auto-patch-egg.js
Normal 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
409
config/config.default.js
Normal 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
32
config/plugin.js
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
207
database/006-seed-50-20-30.sql
Normal file
207
database/006-seed-50-20-30.sql
Normal 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
71
database/config.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
125
database/migrations/001-create-accounts.js
Normal file
125
database/migrations/001-create-accounts.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
105
database/migrations/001-create-contestants.js
Normal file
105
database/migrations/001-create-contestants.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
61
database/migrations/002-create-admins.js
Normal file
61
database/migrations/002-create-admins.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
56
database/migrations/002-create-teams.js
Normal file
56
database/migrations/002-create-teams.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
42
database/migrations/003-create-competition-info.js
Normal file
42
database/migrations/003-create-competition-info.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
94
database/migrations/004-create-aggregation.js
Normal file
94
database/migrations/004-create-aggregation.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
69
database/migrations/004-create-exam-questions.js
Normal file
69
database/migrations/004-create-exam-questions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
67
database/migrations/005-create-exam-submissions.js
Normal file
67
database/migrations/005-create-exam-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
68
database/migrations/005-create-news.js
Normal file
68
database/migrations/005-create-news.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
58
database/migrations/006-create-carousel.js
Normal file
58
database/migrations/006-create-carousel.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
85
database/migrations/006-create-work-submissions.js
Normal file
85
database/migrations/006-create-work-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
69
database/migrations/007-create-zhongzhi-questions.js
Normal file
69
database/migrations/007-create-zhongzhi-questions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
69
database/migrations/008-create-gaozhi-questions.js
Normal file
69
database/migrations/008-create-gaozhi-questions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
82
database/migrations/009-create-zhongzhi-submissions.js
Normal file
82
database/migrations/009-create-zhongzhi-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
82
database/migrations/010-create-gaozhi-submissions.js
Normal file
82
database/migrations/010-create-gaozhi-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
77
database/migrations/011-create-benke-submissions.js
Normal file
77
database/migrations/011-create-benke-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
77
database/migrations/012-create-jiaoshi-submissions.js
Normal file
77
database/migrations/012-create-jiaoshi-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
57
database/migrations/013-create-competition-times.js
Normal file
57
database/migrations/013-create-competition-times.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
42
database/migrations/014-create-system-configs.js
Normal file
42
database/migrations/014-create-system-configs.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
68
database/migrations/015-create-choice-questions.js
Normal file
68
database/migrations/015-create-choice-questions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
63
database/migrations/016-create-judge-questions.js
Normal file
63
database/migrations/016-create-judge-questions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
68
database/migrations/017-create-word-questions.js
Normal file
68
database/migrations/017-create-word-questions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
23
database/migrations/018-add-test-flag-to-contestants.js
Normal file
23
database/migrations/018-add-test-flag-to-contestants.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
17
database/migrations/019-add-title-to-carousel.js
Normal file
17
database/migrations/019-add-title-to-carousel.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
40
database/migrations/020-add-question-type-to-submissions.js
Normal file
40
database/migrations/020-add-question-type-to-submissions.js
Normal 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
99
database/migrations/021-unique-submission-indexes.js
Normal file
99
database/migrations/021-unique-submission-indexes.js
Normal 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'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
56
database/migrations/022-add-content-button-to-carousel.js
Normal file
56
database/migrations/022-add-content-button-to-carousel.js
Normal 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 地址',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
21
database/seeders/001-seed-competition-info.js
Normal file
21
database/seeders/001-seed-competition-info.js
Normal 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',
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
50
database/seeders/001-seed-contestants.js
Normal file
50
database/seeders/001-seed-contestants.js
Normal 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, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
36
database/seeders/002-seed-admin.js
Normal file
36
database/seeders/002-seed-admin.js
Normal 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',
|
||||||
|
}, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
36
database/seeders/003-seed-carousel.js
Normal file
36
database/seeders/003-seed-carousel.js
Normal 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, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
85
database/seeders/003-seed-news.js
Normal file
85
database/seeders/003-seed-news.js
Normal 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, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
23
database/seeders/004-seed-exam-questions.js
Normal file
23
database/seeders/004-seed-exam-questions.js
Normal 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, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
61
database/seeders/005-seed-competition-times.js
Normal file
61
database/seeders/005-seed-competition-times.js
Normal 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, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
176
database/seeders/006-seed-choice-judge-word-questions.js
Normal file
176
database/seeders/006-seed-choice-judge-word-questions.js
Normal 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
Reference in New Issue
Block a user