'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} */ 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 };