SpringBoot开发企业后台-操作日志记录的最佳实践
AOP 很适合记录"谁在什么时候调用了什么接口",却无法天然知道一次修改改变了哪些业务字段、为什么改变以及是否与事务一起成功。接口日志与业务审计并不是同一份数据。
关键风险: 只依赖通用切面,审计记录会停留在请求 JSON;把所有审计手写进 Service,又会散落和遗漏。企业系统需要把通用调用事实与业务变更事实分层采集。
MetaLite 使用切面统一记录入口事实,同时允许业务层补充修改前后值和业务语义。本文先划清操作日志与审计日志,再用 BaseAspectLogger 和字段变更链说明两类信息怎样汇合。
一、操作日志和业务审计不是同一件事
后台各业务 Service 在创建、更新、删除和状态变化成功后调用:
java
recordOperate(
operateType,
remark,
recordId,
changeData
);
例如外部调用方更新完成后记录:
java
sysUserOperateLogService.recordOperate(
OperateTypeEnum.UPDATE,
"外部调用方管理-更新外部调用方: " + appId,
appId,
FastJson.obj2Json(update.getSetMap())
);
业务代码明确知道资源类型、业务主键和变化字段,因此比通用切面猜测参数含义更准确。
二、一条操作日志保存哪些字段
SysUserOperateLogEntity 包含:
| 字段 | 含义 |
|---|---|
userId / userName |
操作者 |
operateType |
新增、修改、删除、登录等动作 |
recordId |
被操作业务记录 |
changeData |
变更内容 |
operateIp |
请求来源 IP |
remark |
面向人的操作描述 |
operateTime |
实际操作时间 |
查询接口可以按用户、用户名、操作类型、记录 ID 和时间区间筛选,并按操作时间倒序分页。
这已经形成了基础的后台审计查询闭环。
三、操作者如何从请求上下文补齐
普通操作日志从 ThreadContext 获取登录用户 ID:
java
String loginUserId = ThreadContext.getLoginUserId();
随后从 Redis 登录缓存读取用户名,再通过 IpUtil.getRemoteIp() 获取来源 IP。
这种方式让业务调用只提供"动作与对象",不必每次重复传操作者信息。
但它也有明确边界:如果线程上下文没有登录用户 ID,recordOperate 会直接返回,不写日志。因此该入口只适用于同步接口请求链路。
定时任务、消息消费、异步线程和系统自动操作需要专门的操作者模型,例如:
text
operatorType=SYSTEM
operatorId=job:xxx
不能默默跳过。
四、登录与登出为什么使用专用方法
登录成功之前,ThreadContext 中通常还没有登录用户 ID;登出也涉及缓存清理顺序。
因此 MetaLite 提供:
java
recordOperateLogin(userId, userName)
recordOperateLogout(userId, userName)
调用方显式传入身份,日志类型固定为登录或登出。
这说明通用上下文并不能覆盖所有审计场景。认证边界上的身份来源,应由认证流程本身提供。
五、changeData 应记录结果还是原始参数
记录整个请求参数最省事,但它可能包含:
- 没有实际变化的字段;
- 前端附带但服务端忽略的字段;
- 密码、密钥和 Token;
- 大段无关数据。
更好的来源是实际写入集合。例如差异比较得到:
json
{
"status": 0,
"remark": "已审核"
}
它比原始请求更接近数据库事实。
如果同时保留 before 和 after,还可以表达:
json
{
"status": {"before": 1, "after": 0}
}
当前不同 Service 的 changeData 口径并不完全一致:有的记录新实体,有的记录更新 Map,有的删除时记录旧实体。查询端需要知道操作类型才能解释内容。
后续可以统一成带版本的变更协议。
六、敏感字段是当前最重要的边界
显式记录并不会自动安全。
当前创建或删除外部调用方时可能序列化整个实体;修改密码与重置密码也会把 Update 序列化为 changeData,其中包含加密后的密码值。
密文依然是敏感数据。它可能被离线破解、重放,或在密钥泄漏后恢复明文。
审计写入前应建立字段策略:
text
密码、私钥、Secret、Token → 完全不记录值
手机号、证件号、邮箱 → 脱敏
普通配置 → 记录前后值
大文本 → 摘要或截断
不能依赖序列化框架猜测哪些字段敏感。
七、业务成功与日志成功如何保持一致
当前调用通常是:
text
先写业务表
再插入操作日志表
需要明确两个问题:
- 日志插入失败,业务是否应该回滚?
- 业务回滚时,日志是否也会回滚?
如果二者在同一数据库和同一本地事务中,可以获得强一致;如果没有事务包裹,可能出现业务成功但日志缺失,或日志存在但业务最终失败。
对高强度审计场景,可以采用:
- 同库同事务写入;
- Outbox 与异步投递;
- 独立审计存储并记录投递状态;
- 失败告警与补偿任务。
MetaLite 的日志 Service 本身没有声明一套独立事务语义,最终一致性取决于外层业务事务如何组织。
八、只记录成功操作够不够
业务 Service 通常在数据库变更成功后记录日志,因此当前日志主要表达成功操作。
安全审计还经常需要失败事件:
- 登录失败;
- 无权限访问;
- 密码重置被拒绝;
- 敏感配置修改校验失败;
- 批量操作部分失败。
失败日志不能简单沿用成功变更结构,应额外记录:
text
result=FAIL
errorCode
failureReason(脱敏)
target
requestId / TraceId
同时要限制重复失败日志造成的写入放大和恶意刷盘。
九、为什么还需要 TraceId 和客户端信息
当前实体保存操作者、IP 和时间,但没有单独保存 TraceId、AppId、User-Agent 或服务实例。
当一次操作跨越网关、Admin 服务和多个内部服务时,TraceId 能把业务审计与技术日志串起来;AppId 能说明操作来自 Web 管理端还是其他受信应用。
建议将以下字段作为审计上下文:
text
traceId
appId
operatorType
resourceType
recordId
result
operateIp
这比把所有信息揉进 remark 更容易查询和统计。
十、AOP 适合做什么,业务代码适合做什么
两种方式并不冲突。
AOP 适合统一:
- TraceId、耗时和异常;
- 请求入口与响应结果;
- 审计注解元数据;
- 通用上下文补齐。
业务代码适合提供:
- 资源类型与业务记录 ID;
- 操作类型;
- 实际字段差异;
- 面向业务的描述;
- 敏感字段策略。
可以让业务方法返回或发布一个 AuditEvent,再由统一组件持久化,但不应让切面通过反射猜测每个参数的业务含义。
企业操作日志的价值不是"每个方法都留下一行",而是任何关键数据变化都能回答谁、何时、从哪里、对什么、改了什么,以及最终是否成功。
十一、一次用户状态修改应留下怎样的审计证据
以"管理员停用用户"为例,审计记录至少要回答:谁在什么时间操作了哪个用户、修改前后状态是什么、请求来自哪个 IP、是否成功,以及对应 TraceId。只记录 recordId=userId 和"修改用户"四个字,不足以支持事后调查。
建议把可审计字段分为三层:固定元数据由框架补齐;业务对象变化由 Service 显式提供;密码、Token、密钥等敏感字段在进入 changeData 前直接排除。失败操作也应留下独立事件,但不能与业务事务绑定到一起后因为回滚而消失。
这说明 AOP 适合统一操作者、IP、时间和 TraceId,业务 Service 仍必须告诉日志组件"改了什么"。
框架简介 MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线 JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。
作者简介 15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新 MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示 演示地址: admin.metalite.top/ 演示账号: guess 演示密码: admin@2026