79 lines
3.2 KiB
JavaScript
79 lines
3.2 KiB
JavaScript
|
|
'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 };
|