Spring Boot 操作日志踩坑记:从手动标注到全局自动捕获,彻底解决审计盲区

做企业级管理系统的同学,估计都踩过操作日志的坑——要么手动加注解加得手麻,要么漏加导致审计出现盲区,查问题时抓瞎。最近我们团队就在JDM系统上栽了这个跟头,折腾了一圈终于搞出了一套全局自动捕获方案,今天就把整个踩坑过程和解决方案分享给大家,避开那些没必要的弯路。

先说说我们的项目背景:JDM系统是基于Spring Boot + MyBatis-Plus构建的,包含System、CRM、ERP、BPM、Mall、Pay等好几个业务模块,核心需求之一就是操作日志(也就是审计日志)要齐全,毕竟涉及到企业数据安全和合规要求,一点都不能马虎。

一开始,我们用的是mzt-logapi的@LogRecord注解,思路很简单:开发者在每个需要记录日志的Service方法上手动标注,就能实现精细的日志记录,还支持SpEL模板和字段Diff,看起来挺完美。

直到前段时间做安全审计,我们排查了一圈才发现,事情根本没那么简单——日志覆盖率低得离谱,大半模块的操作都是“静默”的,管理员在日志页面根本看不到任何记录。给大家看一组我们当时排查的真实数据,说多了都是泪:

模块

@LogRecord 覆盖情况

System(用户、角色)

✅ 已覆盖

CRM(客户、联系人、商机、合同等)

✅ 已覆盖

ERP

❌ 完全未覆盖

BPM(工作流)

❌ 完全未覆盖

Mall(商城)

❌ 完全未覆盖

Pay(支付)

❌ 完全未覆盖

Infra(基础设施)

❌ 完全未覆盖

System(部门、菜单、字典、租户等)

❌ 未覆盖

一眼就能看出来,超过一半的写操作都没有日志记录。这要是出了安全问题,根本没法追溯,合规检查更是直接翻车。

痛点拆解:为什么手动标注的日志方案不靠谱?

痛定思痛,我们分析了@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会不会拖慢系统性能?我们做了实测,结果很放心,给大家分享一下:

维度

实测分析

触发频率

只拦截POST/PUT/DELETE,我们系统90%+的流量都是GET查询,触发频率很低

切面开销

都是内存操作(读ThreadLocal、读注解、构建DTO),纳秒级,几乎可以忽略

日志写入

用的是createOperateLogAsync异步写入,不阻塞业务响应,不影响接口性能

关闭状态

enabled=false时,直接放行,零开销,对系统没有任何影响

我们在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项目,也遇到了操作日志覆盖率不足、手动标注麻烦的问题,不妨试试这个方案,亲测有效~ 有什么疑问或者更好的优化思路,欢迎在评论区交流!


Spring Boot 操作日志踩坑记:从手动标注到全局自动捕获,彻底解决审计盲区
https://www.hellojustin.cn/archives/AOP_Skills
作者
Justin_Tang
发布于
2026年03月13日
许可协议