feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8)

R4 登录/交卷背压闸门
- app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式,
  不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2'
  (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。
  配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。
- config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。

R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt
- 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。
- bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。
- app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧
  SHA256 注释修正)。

答题链路与综合大题
- app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer,
  逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。
- app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。
- app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口,
  未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。
- app/router.js: 新增大题与管理端路由。
- app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。
- app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。

验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9);
真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
This commit is contained in:
2026-09-22 09:16:55 +08:00
parent 8ef18da395
commit 23604f2718
12 changed files with 535 additions and 91 deletions

View File

@@ -190,7 +190,7 @@ class AuthController extends Controller {
* 1. 校验身份证号、手机号、短信验证码
* 2. 通过身份证号查找选手
* 3. 检查账号是否已激活
* 4. SHA256 哈希密码,更新 activated=1
* 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1
* 5. 清除 session 中的短信验证码
*/
async activate() {
@@ -263,7 +263,8 @@ class AuthController extends Controller {
* 流程:
* 1. 通过手机号查找选手
* 2. 检查账号是否激活
* 3. SHA256(输入密码) 与数据库密码比对
* 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希,
* 并在验证成功后透明升级为 bcrypt)
* 4. 生成 JWT Token,返回用户信息和 Token
*/
async login() {
@@ -324,7 +325,7 @@ class AuthController extends Controller {
*
* 流程:
* 1. 通过用户名从 admins 表查找管理员
* 2. 密码比对(SHA256)
* 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希)
* 3. 生成管理员 JWT Token
*/
async adminLogin() {

View File

@@ -704,14 +704,15 @@ class ExamController extends Controller {
}
const body = ctx.request.body || {};
const forceSubmit = body.forceSubmit === true;
// 与逐题保存(submitAnswers)同口径的考试窗口门控;forceSubmit 为自动交卷兜底通道
// 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构):
// 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体,
// 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答;
// 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。
// 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted),
// 绝不接受请求体内容;库内无作答行则明确拒绝。
const examStatus = await service.exam.getExamStatus(group);
if (!examStatus.isActive && !forceSubmit) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
const windowOpen = examStatus.isActive === true;
const aggregation = await this._getUserAggregation();
if (!aggregation) {
@@ -719,6 +720,24 @@ class ExamController extends Controller {
return;
}
if (!windowOpen) {
// 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝)
const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
if (!settled) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
ctx.body = ctx.helper.success({
filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ]
.filter((v) => v && String(v).trim().length > 0).length,
subCount: service.bigQuestion.subQuestionCount(),
maxChars: service.bigQuestion.answerMaxChars(),
savedAt: new Date().toISOString(),
settled: true,
}, '考试已结束,已按已保存内容结算');
return;
}
const result = await service.bigQuestion.saveAnswers(
aggregation.aggregation_uid,
group,
@@ -762,35 +781,93 @@ class ExamController extends Controller {
const { group } = ctx.params;
if (!this._validateGroup(group)) return;
// forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、
// 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库,
// 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。
// forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方
// 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。
//
// 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。
// 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体,
// 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。
// 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。
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;
// 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论)
//
// 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒
// (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**;
// 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit,
// 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空,
// 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。
//
// 因此窗口关闭后开放**服务端结算通道**(settleOnly):
// - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交;
// - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后
// 继续作答刷分,公平性受损;
// - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。
const windowOpen = examStatus.isActive === true;
let settleOnly = false;
let storedAnswers = null;
if (!windowOpen) {
const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group);
storedAnswers = (existing || []).filter(
(r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== ''
);
if (!storedAnswers.length) {
ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中');
return;
}
settleOnly = true;
// 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→
// 直接返回成功并回吐真实 submit_time,不重复写库。
// 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。
const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group);
if (alreadySubmitted) {
const rec = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregation.aggregation_uid },
attributes: [ 'submit_time' ],
});
ctx.body = ctx.helper.success({
answeredCount: storedAnswers.length,
totalQuestions: storedAnswers.length,
hasWordFile: storedAnswers.some((s) => s.word_file_path),
savedCount: storedAnswers.length,
submitted: true,
settled: true,
submitTime: (rec && rec.submit_time)
? new Date(rec.submit_time).toISOString()
: new Date().toISOString(),
}, '提交成功(此前已提交)');
return;
}
}
// 参数校验
// 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交)
// ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用,
// 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。
if (!settleOnly) {
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 },
answers: settleOnly
? { type: 'array', required: false }
: { type: 'array', required: true, min: 1 },
};
try {
ctx.validate(rule);
@@ -799,6 +876,34 @@ class ExamController extends Controller {
return;
}
// ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ----------
// 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时
// 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分,
// 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。
if (settleOnly) {
const snapshot = storedAnswers.map((r) => ({
question_uid: r.question_uid,
question_type: r.question_type,
answer_data: r.answer_data,
}));
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot);
// 大题作答同样补 submit_time(结算通道只补标记、不改内容)
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
ctx.logger.warn(
`[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}`
);
ctx.body = ctx.helper.success({
answeredCount: snapshot.length,
totalQuestions: snapshot.length,
hasWordFile: storedAnswers.some((s) => s.word_file_path),
savedCount: snapshot.length,
submitted: true,
settled: true,
submitTime: new Date().toISOString(),
}, '提交成功(考试已结束,按已保存答案结算)');
return;
}
const { answers } = ctx.request.body;
// 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分)
@@ -874,6 +979,14 @@ class ExamController extends Controller {
// 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。
await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData);
// 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。
// 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码),
// 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」
// → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时
// 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护,
// 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。
await service.bigQuestion.markSubmitted(aggregation.aggregation_uid);
// 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。
// 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。
ctx.body = ctx.helper.success({