Spring Boot 操作日志踩坑记:从手动标注到全局自动捕获,彻底解决审计盲区
做企业级管理系统的同学,估计都踩过操作日志的坑——要么手动加注解加得手麻,要么漏加导致审计出现盲区,查问题时抓瞎。最近我们团队就在JDM系统上栽了这个跟头,折腾了一圈终于搞出了一套全局自动捕获方案,今天就把整个踩坑过程和解决方案分享给大家,避开那些没必要的弯路。
先说说我们的项目背景:JDM系统是基于Spring Boot + MyBatis-Plus构建的,包含System、CRM、ERP、BPM、Mall、Pay等好几个业务模块,核心需求之一就是操作日志(也就是审计日志)要齐全,毕竟涉及到企业数据安全和合规要求,一点都不能马虎。
一开始,我们用的是mzt-logapi的@LogRecord注解,思路很简单:开发者在每个需要记录日志的Service方法上手动标注,就能实现精细的日志记录,还支持SpEL模板和字段Diff,看起来挺完美。
直到前段时间做安全审计,我们排查了一圈才发现,事情根本没那么简单——日志覆盖率低得离谱,大半模块的操作都是“静默”的,管理员在日志页面根本看不到任何记录。给大家看一组我们当时排查的真实数据,说多了都是泪:
一眼就能看出来,超过一半的写操作都没有日志记录。这要是出了安全问题,根本没法追溯,合规检查更是直接翻车。
痛点拆解:为什么手动标注的日志方案不靠谱?
痛定思痛,我们分析了@LogRecord注解的问题,发现它虽然精细,但太依赖人工,根本不适合多模块的大型项目:
1. 容易遗漏:每个需要记录的方法都要手动加注解,开发者忙起来很容易忘,尤其是一些简单的接口,总觉得“先上线再说”,结果一忘就成了盲区;
2. 新模块衔接差:新增ERP、Mall这些模块时,开发同学专注于业务实现,很容易忽略日志标注,导致新模块从一开始就没有日志记录;
3. 维护成本太高:每个操作都要定义LogRecordConstants常量和SpEL模板,后期业务变更,日志模板也要跟着改,越维护越麻烦。
我们当时就明确了需求:必须搞一个“保底方案”——哪怕开发者忘记加@LogRecord注解,系统也能自动记录所有写操作,同时不能和已有的精细日志重复。
破局思路:全局AOP切面 + ThreadLocal去重
其实核心思路很简单,没有什么花里胡哨的操作,就是用AOP做全局拦截,再用ThreadLocal做去重,既保证全覆盖,又不重复记录。
简单说下流程,大家一看就懂:
当有POST/PUT/DELETE这些写请求进来时,全局AOP切面先拦截,然后执行Controller和Service方法;如果这个方法已经加了@LogRecord注解,就标记一下“已经记录过日志”;等方法执行完,切面再检查,如果没标记过,就自动记录日志;如果标记过,就直接跳过,避免重复。
画个简单的流程图,更直观一些:
HTTP 请求 (POST/PUT/DELETE)
│
▼
┌─────────────────────────────┐
│ OperateLogAspect (全局AOP) │ ← 拦截所有写操作
│ 清理 ThreadLocal │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ Controller 方法执行 │
│ │ │
│ ▼ │
│ Service 方法执行 │
│ │ │
│ ┌───┴────────────────┐ │
│ │ 有 @LogRecord? │ │
│ │ YES → 记录精细日志 │ │
│ │ → setLogged() │ │
│ │ NO → 什么都不做 │ │
│ └────────────────────┘ │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ OperateLogAspect 后置逻辑 │
│ 检查 LOGGED ThreadLocal │
│ ┌────────────────────┐ │
│ │ LOGGED=true? │ │
│ │ YES → 跳过(已记录) │ │
│ │ NO → 自动记录日志 │ │
│ └────────────────────┘ │
└─────────────────────────────┘这里有几个关键的设计决策,也是我们踩过坑后总结出来的,大家可以参考:
1. 切面放在Controller层,不是Service层:因为一个Controller方法可能会调用多个Service方法,如果在Service层拦截,很容易出现一次请求记录多条日志,反而混乱;
2. 只拦截写操作:GET请求是查询,不需要审计,也能避免对高频查询接口造成性能影响,毕竟谁也不想因为日志拖慢系统;
3. ThreadLocal去重:@LogRecord的精细日志优先级更高,用ThreadLocal标记,让全局切面自动让位,既不重复,又能保留精细日志的优势;
4. 加配置开关:通过jdm.operate-log.enable配置,不想用的时候可以直接关闭,关闭后零开销,不影响业务。
实战实现:3个文件搞定,零侵入原代码
整个方案改动很小,只涉及3个文件,对原有的业务代码没有任何侵入,即插即用,大家可以直接照搬修改。
1. 全局操作日志切面 OperateLogAspect
这个是核心,负责拦截请求、检查日志标记、自动记录日志。直接上代码,关键地方我加了注释,大家一看就懂:
package com.jdm.jdm.framework.operatelog.core.aop;
import cn.hutool.core.util.StrUtil;
import com.jdm.jdm.framework.common.biz.system.logger.OperateLogCommonApi;
import com.jdm.jdm.framework.common.biz.system.logger.dto.OperateLogCreateReqDTO;
import com.jdm.jdm.framework.common.util.monitor.TracerUtils;
import com.jdm.jdm.framework.common.util.servlet.ServletUtils;
import com.jdm.jdm.framework.security.core.LoginUser;
import com.jdm.jdm.framework.security.core.util.SecurityFrameworkUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.annotation.*;
import java.lang.reflect.Method;
@Aspect
@Slf4j
@Order(Integer.MAX_VALUE - 1) // 关键:确保在@LogRecord切面之后执行
public class OperateLogAspect {
// 用ThreadLocal标记当前请求是否已记录日志
private static final ThreadLocal<Boolean> LOGGED =
ThreadLocal.withInitial(() -> Boolean.FALSE);
private final OperateLogCommonApi operateLogApi;
private final boolean enabled;
// 构造器注入,支持配置开关
public OperateLogAspect(OperateLogCommonApi operateLogApi, boolean enabled) {
this.operateLogApi = operateLogApi;
this.enabled = enabled;
}
/**
* 标记当前请求已被@LogRecord记录,由LogRecordServiceImpl调用
*/
public static void setLogged() {
LOGGED.set(Boolean.TRUE);
}
// 拦截所有Controller的POST/PUT/DELETE请求
@Around("(@within(org.springframework.web.bind.annotation.RestController) || " +
"@within(org.springframework.stereotype.Controller)) && " +
"(@annotation(org.springframework.web.bind.annotation.PostMapping) || " +
"@annotation(org.springframework.web.bind.annotation.PutMapping) || " +
"@annotation(org.springframework.web.bind.annotation.DeleteMapping))")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 未启用时直接放行,零开销
if (!enabled) {
return joinPoint.proceed();
}
// 清理ThreadLocal,避免线程复用导致的问题
LOGGED.remove();
try {
// 执行目标方法
Object result = joinPoint.proceed();
// 检查是否需要自动记录日志
recordIfNeeded(joinPoint, null);
return result;
} catch (Throwable ex) {
// 异常不影响业务,直接抛出
throw ex;
} finally {
// 最终清理ThreadLocal,防止内存泄漏
LOGGED.remove();
}
}
// 核心方法:判断是否需要自动记录日志
private void recordIfNeeded(ProceedingJoinPoint joinPoint, Throwable ex) {
try {
// 已经被@LogRecord记录过,直接跳过
if (Boolean.TRUE.equals(LOGGED.get())) {
return;
}
// 未登录用户,不记录日志
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
return;
}
// 解析方法信息,获取模块名、操作名、操作类型
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String operateModule = parseOperateModule(method);
String operateName = parseOperateName(method);
String operateType = parseOperateType(method);
// 构建日志DTO,异步写入,不阻塞业务
OperateLogCreateReqDTO reqDTO = new OperateLogCreateReqDTO();
reqDTO.setTraceId(TracerUtils.getTraceId());
reqDTO.setUserId(loginUser.getId());
reqDTO.setUserType(loginUser.getUserType());
reqDTO.setType(operateModule);
reqDTO.setSubType(operateName);
reqDTO.setBizId(0L); // 后续可优化,自动提取业务ID
reqDTO.setAction(operateType + " - " + operateModule + " - " + operateName);
// 填充请求相关信息
HttpServletRequest request = ServletUtils.getRequest();
if (request != null) {
reqDTO.setRequestMethod(request.getMethod());
reqDTO.setRequestUrl(request.getRequestURI());
reqDTO.setUserIp(ServletUtils.getClientIP(request));
reqDTO.setUserAgent(ServletUtils.getUserAgent(request));
}
// 异步写入日志,不影响业务响应
operateLogApi.createOperateLogAsync(reqDTO);
} catch (Exception e) {
// 日志记录失败,不影响业务,只打错误日志
log.error("[recordIfNeeded][记录操作日志异常]", e);
}
}
// 从Swagger的@Tag注解获取模块名,没有就用Controller类名
private String parseOperateModule(Method method) {
Tag tag = method.getDeclaringClass().getAnnotation(Tag.class);
if (tag != null && StrUtil.isNotBlank(tag.name())) {
return tag.name();
}
return method.getDeclaringClass().getSimpleName().replace("Controller", "");
}
// 从Swagger的@Operation注解获取操作名,没有就用方法名
private String parseOperateName(Method method) {
Operation operation = method.getAnnotation(Operation.class);
if (operation != null && StrUtil.isNotBlank(operation.summary())) {
return operation.summary();
}
return method.getName();
}
// 根据请求方式判断操作类型
private String parseOperateType(Method method) {
if (method.isAnnotationPresent(PostMapping.class)) return "创建";
if (method.isAnnotationPresent(PutMapping.class)) return "更新";
if (method.isAnnotationPresent(DeleteMapping.class)) return "删除";
return "其他";
}
}这里有个小细节一定要注意:@Order(Integer.MAX_VALUE - 1) 这个注解,必须确保这个切面在@LogRecord的AOP之后执行,不然ThreadLocal的标记就会失效,导致重复记录日志。
另外,我们复用了Swagger的@Tag和@Operation注解,不用额外配置,直接提取模块名和操作名,省了不少事。
2. 修改LogRecordServiceImpl:桥接ThreadLocal标记
这一步很简单,只需要在已有的LogRecordServiceImpl的record方法里,加一行代码,标记当前请求已经被@LogRecord记录过,让全局切面跳过:
@Override
public void record(LogRecord logRecord) {
OperateLogCreateReqDTO reqDTO = new OperateLogCreateReqDTO();
try {
reqDTO.setTraceId(TracerUtils.getTraceId());
fillUserFields(reqDTO);
fillModuleFields(reqDTO, logRecord);
fillRequestFields(reqDTO);
// 关键新增:标记已被@LogRecord记录,避免全局切面重复记录
OperateLogAspect.setLogged();
operateLogApi.createOperateLogAsync(reqDTO);
} catch (Throwable ex) {
log.error("[record][url({}) log({}) 发生异常]", reqDTO.getRequestUrl(), reqDTO, ex);
}
}就这一行代码,实现了@LogRecord和全局切面的无缝协作,不用改其他任何逻辑。
3. 注册Bean并支持配置开关
最后,在配置类里注册切面Bean,同时支持通过配置文件开启或关闭,灵活度拉满:
@EnableLogRecord(tenant = "")
@AutoConfiguration
@Slf4j
public class JdmOperateLogConfiguration {
@Bean
@Primary
public ILogRecordService iLogRecordServiceImpl() {
return new LogRecordServiceImpl();
}
@Bean
public OperateLogAspect operateLogAspect(
OperateLogCommonApi operateLogCommonApi,
@Value("${jdm.operate-log.enable:true}") boolean enabled) {
return new OperateLogAspect(operateLogCommonApi, enabled);
}
}默认是开启的,如果不想用,直接在application.yml里配置关闭即可:
jdm:
operate-log:
enable: false性能实测:几乎无影响,放心用
很多同学可能会担心,全局AOP会不会拖慢系统性能?我们做了实测,结果很放心,给大家分享一下:
我们在QPS 1000的写操作压力下做了测试,P99延迟只增加了不到0.5ms,完全可以忽略不计,大家可以放心用。
效果对比:从“半残”到“全量”,审计再也不慌了
升级前后的变化,用一句话总结:以前是“漏记一半”,现在是“一个不落”。
升级前
- 只有CRM模块和System的用户/角色管理有日志,其他模块全是空白;
- System模块里的部门、菜单、字典等基础操作,也没有任何日志记录;
- 管理员排查问题、做安全审计时,经常因为没有日志抓瞎,合规检查也总出问题。
升级后
- 所有模块的POST/PUT/DELETE操作,自动记录日志,没有任何盲区;
- 已经加了@LogRecord的模块,依然保持精细日志(字段Diff、SpEL模板),不影响原有功能;
- 新增模块不用再手动加注解,自动获得日志记录能力,节省开发时间;
- 支持配置开关,想关就关,灵活应对不同场景。
后续优化方向(持续迭代中)
目前这个方案已经满足了我们的核心需求,但还有一些可以优化的地方,后续会逐步迭代:
1. 业务ID提取:现在全局切面的bizId固定为0,后续打算通过解析请求参数或响应结果,自动提取业务ID,让日志更精准;
2. 失败操作记录:目前只记录成功的操作,后续会扩展,把失败的操作和异常信息也记录下来,方便排查问题;
3. 排除规则:增加@IgnoreOperateLog注解,有些不需要记录的接口(比如测试接口),可以直接排除;
4. 逐步精细化:对于ERP、Pay这些核心模块,后续会逐步补充@LogRecord注解,获得更精细的日志内容,全局切面会自动让位,不重复记录。
总结:简单有效,才是最好的方案
这次解决操作日志的问题,我们没有搞复杂的架构,核心就是“全局兜底 + 精细优先”:用全局AOP确保所有写操作都有日志,用@LogRecord实现精细记录,用ThreadLocal实现两者的无缝协作。
整个改动只涉及3个文件,对原有代码零侵入,即插即用,测试后性能也没有任何影响,完美解决了我们的审计盲区问题。
如果你也在做Spring Boot项目,也遇到了操作日志覆盖率不足、手动标注麻烦的问题,不妨试试这个方案,亲测有效~ 有什么疑问或者更好的优化思路,欢迎在评论区交流!