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

78
app/lib/auditLog.js Normal file
View File

@@ -0,0 +1,78 @@
'use strict';
/**
* 管理员高危操作审计(R178)
* ============================================================================
*
* 三种写法被刻意排除:
* 1. **不在控制器里直接 create** —— 控制器有 60+ 个方法,散落写入必然漏;
* 统一走本函数,写入范围只由「谁调用它」决定,容易被审阅。
* 2. **不做全量写操作审计** —— 见 model/adminAuditLog.js 头注:约 30 个写接口,
* 日志量增长快且无清理策略;真正要回答的是「谁改了成绩 / 谁动了账号」。
* 3. **不阻断业务** —— 审计失败只 warn。日志表故障不该让「发布成绩」失败,
* 否则为可观测性牺牲了主流程可用性。
*
* ⚠️ detail 只放「变更前后的非敏感差异」。⛔ 绝不传口令、令牌、身份证号明文。
*/
/**
* 写入一条审计记录(best-effort,永不抛出)
*
* @param {Object} ctx - Egg 上下文(需 ctx.state.currentAdmin 已由 adminAuth 写入)
* @param {string} action - 动作标识,点号分层,如 'score.publish' / 'admin.create'
* @param {string} [target] - 操作对象,如组别、用户名、选手 ID
* @param {Object|string} [detail] - 补充信息(对象会被 JSON 序列化)
* @returns {Promise<void>}
*/
async function recordAudit(ctx, action, target, detail) {
try {
const admin = (ctx && ctx.state && ctx.state.currentAdmin) || null;
// 没有操作人就不写:宁可缺一条,也不要写出一条「admin_id=0」的假归属,
// 那会让人误以为系统账号做过操作。
if (!admin || !admin.id) {
ctx && ctx.logger && ctx.logger.warn(`[audit] 缺少 currentAdmin,跳过记录 action=${action}`);
return;
}
let detailText = null;
if (detail !== undefined && detail !== null) {
try {
detailText = typeof detail === 'string' ? detail : JSON.stringify(detail);
} catch (e) {
detailText = '[unserializable]';
}
if (detailText && detailText.length > 4000) detailText = detailText.slice(0, 4000) + '…';
}
await ctx.model.AdminAuditLog.create({
admin_id: admin.id,
admin_username: admin.username,
action: String(action || 'unknown').slice(0, 64),
target: target === undefined || target === null ? null : String(target).slice(0, 200),
detail: detailText,
ip: String(ctx.ip || '').slice(0, 64) || null,
created_at: new Date(),
});
} catch (err) {
// 审计失败不得影响业务:只记日志
if (ctx && ctx.logger) {
ctx.logger.warn(`[audit] 写入失败 action=${action}:`, err && err.message);
}
}
}
/** 审计动作常量(避免各处手写字符串导致同一动作被记成两个名字) */
const ACTIONS = {
SCORE_PUBLISH: 'score.publish',
SCORE_UNPUBLISH: 'score.unpublish',
DATA_RESET_TEST: 'data.resetTest',
CONTESTANT_DELETE: 'contestant.delete',
EXAM_UNSUBMIT: 'exam.unsubmit',
ADMIN_CREATE: 'admin.create',
ADMIN_PERMISSIONS: 'admin.permissions',
ADMIN_PASSWORD: 'admin.password',
ADMIN_STATUS: 'admin.status',
ADMIN_DELETE: 'admin.delete',
};
module.exports = { recordAudit, ACTIONS };