Files
competition-website/app/lib/auditLog.js
qubiaobiao 459f1adf6d 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)
2026-10-20 11:06:29 +08:00

79 lines
3.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };