写在前面
审计日志很容易做成"给日志再起个名字"——反正都是记操作,往日志文件里多打一行不就完了?
真出过一次事才知道差别。同事反馈"我昨天上传的合同文件找不到了",日志文件里翻不到任何线索:是谁删的?什么时候?是主动删除还是清理任务误伤?日志文件里那些按时间滚动、几十万行的文本,在这种质询面前约等于零。
从那天起我把审计当成一个功能模块来做:结构化、可查询、分层次。这篇记录这套设计。
一、审计和应用日志是两个物种
先划清界限。应用日志(log.info/warn/error)面向开发者,回答"程序执行到了哪、内部状态是什么";审计日志面向质询,回答"谁在什么时候对什么东西做了什么、结果如何"。
| 应用日志 | 审计日志 | |
|---|---|---|
| 受众 | 开发排障 | 管理员、合规、用户 |
| 形态 | 文本行,按时间滚动 | 结构化表,可检索 |
| 内容 | 上下文、堆栈、中间状态 | 操作者、对象、动作、结果 |
| 生命周期 | 过期即弃 | 长期保留 |
最关键的差别是最后一行:应用日志天然是易失的,审计必须落库。一个"操作记录"如果只存在于日志文件里,它既不能按操作者查(“这个人这个月删过什么”),也不能按对象查(“这个文件被动过几次”),更没法放进管理页面给非技术人员看。
二、一张通用表:字段即提问方式
系统级的操作记录进 sys_audit_log,写入器简单到只有一条 INSERT:
public void write(String operatorType, Long operatorId, String actionType,
String targetType, Long targetId, String result, String detailJson) {
jdbcTemplate.update("""
INSERT INTO sys_audit_log
(id, operator_type, operator_id, action_type,
target_type, target_id, result, detail_json)
VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))
""", IdGenerator.nextId(), operatorType, operatorId,
actionType, targetType, targetId, result, detailJson);
}
字段设计就是一次"预演质询":出事时你会怎么提问,字段就怎么设。
operator_type + operator_id:谁干的。 操作者不只有人——USER(员工)、ADMIN(管理员)、SYSTEM(定时任务、超时收口)、AGENT(AI 助手)都在这个字段里区分。AI 进入系统之后这一点尤其重要:"这条状态是谁改的?"的答案可能是模型,审计必须能如实回答。
target_type + target_id:对什么干的。 多态引用:文件、会话、任务、审批单都往这两个字段里装。代价是无法做外键约束,收益是一张表装下全系统的操作史——"查这个文件的所有操作"是一个固定模式的 SQL,不用在十几张业务表里翻。
action_type + result:干了什么、成没成。 动作用稳定的枚举字符串(FILE_TRASHED、OUTBOX_RETRY、TASK_TIMEOUT_CANCELLED),结果简单到只有成功失败。检索时这两个字段就是 WHERE 条件的主力。
detail_json:不确定的部分,进 JSON。 每种操作的细节参数不一样,硬塞进固定列会让表越来越宽。JSON 列放"这个动作特有的上下文",查询时用 JSON 函数按需取。确定的高频检索字段用列,不确定的低频展示字段用 JSON——这个分界让表结构七年不用改(好吧,项目才一年,但至少一张表没改过)。
三、另一层审计:AI 任务的生命流水
通用表之外,Agent 模块有自己专属的 agt_audit_log,多了一个字段:
public void insertAuditLog(Long logId, Long taskId, String actionType,
String targetType, Long targetId,
String decisionSummary, boolean riskFlag) {
// INSERT INTO agt_audit_log
// (id, task_id, action_type, target_type, target_id, decision_summary, risk_flag)
}
risk_flag 是关键差异。AI 任务的审计不只是"记录发生了什么",还要标记哪些事值得警觉:任务失败、超时取消、执行异常,写入时 riskFlag 置真。管理端的失败聚合页按这个字段过滤,巡检时先看红旗再看白旗。
为什么不用一张表加个 type 区分?因为两种审计的读模式完全不同:sys 审计是"按操作者或对象回溯历史",agt 审计是"按任务维度看完整生命周期"——task_id 前缀索引扫出来就是一条任务的从生到死。读模式决定表结构,比"能合就合"的洁癖更重要。
四、写进去的东西要过两道闸
审计内容本身也要治理,两道闸都在写入前的工具方法里:
public static String safeSummary(String value, String defaultValue) {
if (!StringUtils.hasText(value)) return defaultValue;
String trimmed = value.trim();
return trimmed.length() > 512 ? trimmed.substring(0, 512) : trimmed;
}
第一道闸:截断。 decision_summary 最多 512 字符。异常消息、模型返回值这些内容长度不可控——一条 50KB 的异常堆栈塞进审计表,一次事故就能把表撑出几 GB。审计要的是"摘要",不是"全文",全文请去应用日志里找。
第二道闸:脱敏。 登录名进日志前过 maskLoginName,邮箱过 maskEmail。审计表是留存最久的数据,泄露风险随时间累积——**今天你觉得内部数据无所谓,一年后的合规检查不这么觉得。**脱敏放在写入端(而不是查询端)的好处是:脏数据根本不进表,之后无论谁导出、谁授权查看,暴露面都是收敛的。
五、审计要能被"看见"
存进表里的审计如果只能用 SQL 查,等于没做。两个出口让数据活起来:
管理端的查询页。 按操作者、动作类型、对象、时间范围组合过滤——上面那组字段就是为这几个下拉框设计的。管理员不需要懂 SQL,但需要在他怀疑"这个文件不对劲"时,五秒内看到它的全部操作史。
超时任务的自我审计。 这条最容易被忽略:后台任务本身也是"操作者"。Agent 超时收口任务每次运行都留一条 job 流水——开始时间、扫描量、取消量、失败原因,然后逐条任务写 TASK_TIMEOUT_CANCELLED 审计。没有人盯着的定时任务最需要审计,它出问题的形式永远是安静的:静默失败三个月,某天有人问"为什么这个任务没被取消",答案全在流水里。
六、小结
三点体会:
- 审计是回答质询的,不是记录执行的。 "谁、何时、对什么、做了什么、结果如何"五要素齐了才叫审计,缺任何一环,出事时它都接不住问题。
- 一张通用表加一张专用表,好过一张万能表。 读模式不同就分开建表,
risk_flag和task_id各自服务于各自的查询,别为了收敛表数量牺牲查询体验。 - 写入端的治理最划算。 截断和脱敏都发生在 INSERT 之前,脏数据不进表——数据的卫生水平,取决于它进门时有没有被检查。
现在系统每天产生几千条审计记录,绝大多数永远不会有人看。但它们都在那里——审计的价值不在于被阅读,在于被需要时一定在。
默认评论
Halo系统提供的评论