feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结

- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
This commit is contained in:
2026-10-20 11:06:29 +08:00
parent 5392345078
commit 459f1adf6d
21 changed files with 2886 additions and 76 deletions

View File

@@ -402,6 +402,53 @@ class ExamService extends Service {
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>} 保存后的记录列表
*/
/**
* 交卷冻结守卫(R178·B①)——**唯一判据源**
*
* 缺陷背景:逐题保存此前只校验「考试窗口 isActive」,不校验是否已正式交卷;
* `submit-all` 的 4006 只挡整卷重交。于是「提前交卷 → 窗口关闭前」这段时间内,
* `POST /submit-answers` 仍可改写答案并重新判分 —— 即「已提交但分数还能变」。
* 典型触发并非恶意:考生开了两个标签页(或手机+电脑),交卷后另一页仍在自动保存。
*
* 判据:`exam_submissions.submit_time` 非空即视为已正式交卷(与选手端 hasSubmitted 同源)。
*
* 调用点(两处,共用本函数,⛔ 不得各写一套条件):
* ① controller.exam.submitAnswers —— 前置快速失败,避免对已冻结答卷做无谓判分;
* ② service.exam.saveAnswers —— 兜底,保证任何调用方(含未来新增)都无法绕过。
* ⚠️ 门禁 `verify_exam_freeze.py` 的变异体要求**两处同改**才允许变红,否则属前道抢答。
*
* 为什么不缓存:逐题保存约 59 次/场,主键单列查询为微秒级;
* 换来「交卷状态变更立即生效」,无需判断缓存失效时机。
*
* @param {number} aggregationUid - 聚合报名 ID(中职/高职即 contestant.id)
* @returns {Promise<void>} 未交卷则静默返回
* @throws {Error} bizCode=4006
*/
async assertNotSubmitted(aggregationUid) {
const { ctx } = this;
if (!aggregationUid) return;
const submitted = await ctx.model.ExamSubmission.findOne({
where: { contestant_id: aggregationUid },
attributes: ['contestant_id', 'submit_time'],
raw: true,
});
if (submitted && submitted.submit_time) {
const err = new Error('答卷已提交,不能再修改答案');
// bizCode 由 controller 翻译成用户可见的业务错误码(4006 与本项目既有「已提交」口径一致)
err.bizCode = 4006;
err.bizMessage = '您已交卷,答卷已冻结,不能再修改答案;如需重考请联系管理员';
throw err;
}
}
/**
* 保存答题记录(逐题存储)
* @param {number} aggregationUid - 聚合报名 ID
@@ -412,6 +459,12 @@ class ExamService extends Service {
async saveAnswers(aggregationUid, answers, group) {
const { ctx } = this;
// 交卷冻结兜底:所有写答案的路径都经此函数,故此处是本守卫的最后一道闸。
// 不会挡住交卷自身 —— submitAll 是「先 saveAnswers、后 ensureExamSubmission 写
// submit_time」,执行到本函数时 submit_time 仍为 NULL;整卷重交在更早的 4006 已返回;
// settleOnly 结算通道刻意不调用本函数。
await this.assertNotSubmitted(aggregationUid);
// 根据组别选择对应的提交模型
const SubmissionModel = group === 'zhongzhi'
? ctx.model.ZhongzhiSubmission