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