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:
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;
|
||||
Reference in New Issue
Block a user