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:
78
app/lib/auditLog.js
Normal file
78
app/lib/auditLog.js
Normal 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 };
|
||||
Reference in New Issue
Block a user