chore: 后端首次基线提交

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

1664
app/controller/admin.js Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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