Spring Boot + AOP 操作日志实战:自定义注解、切面编程、SecurityContext 全链路贯通,一次讲透

本文是阶段 3 AOP的技术复盘,涵盖 AOP 切面编程、自定义 @Log 注解、环绕通知 vs 前置通知的选择、JWT userId 全链路增强、SecurityContextHolder 取身份、Filter vs AOP 对比等实战要点。该篇从【为什么 → 怎么做 → 原理是什么 → 踩了什么坑】四个维度展开,适合正在学习 Spring AOP 和操作日志的同学阅读。


一、前言:从阶段 2 到阶段 3

上一篇文章我们给系统装上了【缓存便签本】------用 Spring Cache + Redis 缓存高频查询结果。系统快了,但还缺一个东西。

来看一个真实场景:

复制代码
管理员删除了张三的健康记录
家属投诉:"我的数据怎么没了?"
管理员:"我没有删过啊"
​
------死无对证,因为没有操作日志。

没有操作日志的系统,就像没有监控的超市------出事了只能靠嘴说。

阶段 3 的目标就是给系统装上这层【监控摄像头】:

  • 自定义 @Log 注解:像贴标签一样标记需要监控的方法

  • AOP 环绕通知:自动捕获操作人、操作类型、参数、结果

  • SecurityContext 取用户身份:日志中记录【谁】做了什么

  • 顺便修了一个安全漏洞:AuthController 的角色伪造漏洞

技术栈:Spring Boot 3.5 + Spring AOP + Spring Security + MyBatis Plus + Knife4j


二、整体架构:一次操作如何变成一条日志

在深入每个组件之前,先用一张图建立全局认知:

复制代码
HTTP 请求(POST /api/elderly 新增老人)
    │
    ▼
┌──────────────────────────────────────────┐
│  JwtAuthenticationFilter                 │
│    解析 Token → 写入 SecurityContext     │
│    username="admin", userId=1, role=ADMIN │
└──────────────┬───────────────────────────┘
               ▼
┌──────────────────────────────────────────┐
│  Controller                              │
│    ElderlyController.addElderly()        │
└──────────────┬───────────────────────────┘
               ▼
┌──────────────────────────────────────────┐
│  Service                                 │
│    ElderlyServiceImpl.addElderly()       │
│    ⬆ 贴了 @Log(action="添加", target="老人")│
│    ⬆ 贴了 @CacheEvict                    │
│                                          │
│    ← AOP 在这里拦截!                     │
└──────────────┬───────────────────────────┘
               ▼
┌──────────────────────────────────────────┐
│  LogAspect.@Around                       │
│    ├── ① 读 @Log 注解 → action, target    │
│    ├── ② SecurityContextHolder 取用户     │
│    ├── ③ RequestContextHolder 取 IP       │
│    ├── ④ joinPoint.proceed() 执行方法     │
│    │       → 成功 → result = "成功"       │
│    │       → 异常 → result = "失败"       │
│    └── ⑤ finally: 写 operation_log 表     │
└──────────────┬───────────────────────────┘
               ▼
         返回给前端

核心思路 :AOP 切面像一个【监控摄像头】,贴了 @Log 注解的方法就是【监控区域】。一旦有人进入监控区域(调用方法),摄像头自动记录:谁、什么时候、干了什么、结果如何。

几个关键角色:

角色 对应代码 职责
🏷️ 标签 @Log 注解 标记哪些方法需要记录,记录什么内容
📹 摄像头 LogAspect 切面类 拦截方法调用,采集上下文信息,写日志
🎫 访客牌 SecurityContextHolder 提供当前用户身份信息
📋 记录本 OperationLog + operation_log 持久化存储操作日志

下面逐个展开。


三、AOP 前置知识:三个核心概念一句话讲清

如果你第一次接触 AOP,先理解这三个概念:

3.1 切面(Aspect)= 监控摄像头

java 复制代码
@Aspect          // ← "我是一个切面(摄像头)"
@Component       // ← "把我交给 Spring 管理"
public class LogAspect { ... }

切面 = 在什么地方 + 什么时候 + 做什么事。

3.2 切点(Pointcut)= 监控区域

java 复制代码
@Pointcut("@annotation(com.liu.healthback.annotation.Log)")
public void logPointcut() {}

这个表达式翻译成人话:"所有贴了 @Log 注解的方法,就是我要监控的地方"

Spring AOP 支持多种切点表达式:

表达式 含义 示例
@annotation(...) 贴了某个注解的方法 @annotation(com.liu.healthback.annotation.Log)
execution(...) 按方法签名匹配 execution(* com.liu..service.*.*(..))
within(...) 某个包/类下所有方法 within(com.liu.healthback.services.*)
@within(...) 贴了某个注解的类下所有方法 @within(org.springframework.stereotype.Service)

项目中用 @annotation,因为最精确、最灵活------想让哪个方法被监控,贴个标签就行。

3.3 通知(Advice)= 具体拍什么

通知类型 注解 执行时机 能看到返回值 能捕获异常
前置通知 @Before 方法执行
后置通知 @After 方法执行(不管成败)
返回通知 @AfterReturning 方法成功返回
异常通知 @AfterThrowing 方法抛异常
环绕通知 @Around 全程包住方法

我们为什么选 @Around

因为操作日志需要记录执行结果 (成功 / 失败),@Before 看不到结果,@After 拿不到返回值。只有 @Around 能在方法执行后根据返回值或异常判断成功与否。


四、自定义注解:@Log

4.1 注解的本质

很多人写惯了注解但没想过注解到底是什么。一句话:

注解 = 标签(数据载体),不包含任何逻辑。

java 复制代码
@Log(action = "添加老人信息", target = "老人信息")
public boolean addElderly(Elderly elderly) { ... }

这行代码的意思是:【给 addElderly 方法贴个标签,标签上写着 action="添加老人信息"、target="老人信息"】。

标签本身不会做任何事。就像快递盒上的【易碎品】标签------标签不会让盒子变结实,是快递员看到了这个标签后采取了轻拿轻放的行为。

AOP 切面就是那个【快递员】------扫描到 @Log 标签后,执行对应的日志记录逻辑。

4.2 注解定义

java 复制代码
@Target(ElementType.METHOD)         // 只能贴在方法上
@Retention(RetentionPolicy.RUNTIME)  // 运行时可通过反射读取
public @interface Log {
    String action();   // 操作类型:"新增"、"修改"、"删除"
    String target();   // 操作对象:"老人信息"、"健康记录"
}

两个元注解的解释:

元注解 含义
@Target ElementType.METHOD 这个注解只能贴在方法上,贴类上编译报错
@Retention RetentionPolicy.RUNTIME 运行时保留,@Around 通过反射读取。如果写 SOURCE,编译后就丢了,切面读不到

@Retention 的三种策略

  • SOURCE:只保留在源码中,编译时丢弃。例如 @Override@SuppressWarnings,编译器看完就扔。

  • CLASS:保留到字节码,但运行时 JVM 不加载。默认值,用得少。

  • RUNTIME:保留到运行时,可通过反射读取。Spring 的 @Autowired@Transactional、我们的 @Log 都是这个级别。AOP 必须用 RUNTIME。

4.3 注解的使用

java 复制代码
// ElderlyServiceImpl
@Log(action = "添加老人信息", target = "老人信息")
@CacheEvict(value = "elderly:list", allEntries = true)
public boolean addElderly(Elderly elderly) { ... }
​
@Log(action = "更新老人信息", target = "老人信息")
@Caching(evict = {...})
public boolean updateById(Elderly entity) { ... }
​
@Log(action = "删除老人信息", target = "老人信息")
@Caching(evict = {...})
public boolean removeById(Serializable id) { ... }
​
// HealthRecordServiceImpl
@Log(action = "添加或更新健康记录", target = "健康记录")
@Caching(evict = {...})
public boolean saveOrUpdate(HealthRecord entity) { ... }
​
// ThresholdConfigServiceImpl
@Log(action = "更新保存健康阈值", target = "健康阈值")
@CacheEvict(value = "threshold", key = "'config'")
public boolean saveOrUpdateConfig(ThresholdConfig config) { ... }

一个方法可以贴多个注解,各自独立工作。 addElderly 同时有 @Log(记录日志)和 @CacheEvict(清除缓存),AOP 有两个切面各自拦截,互不影响。


五、LogAspect 切面:核心实现逐行解析

java 复制代码
@Component          // 交给 Spring 管理
@Aspect             // 声明这是一个切面
public class LogAspect {
​
    private final OperationLogMapper operationLogMapper;
​
    public LogAspect(OperationLogMapper operationLogMapper) {
        this.operationLogMapper = operationLogMapper;
    }
​
    // ① 定义切点:所有贴了 @Log 注解的方法
    @Pointcut("@annotation(com.liu.healthback.annotation.Log)")
    public void logPointcut() {}
​
    // ② 环绕通知
    @Around("logPointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // ... 下面逐块解析
    }
}

5.1 第一步:从方法上读 @Log 注解

java 复制代码
// 拿到方法签名(包含方法名、返回值类型、参数类型、注解等信息)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
​
// 从签名上取出 @Log 注解实例
Log log = signature.getMethod().getAnnotation(Log.class);
​
// 读注解属性
String action = log.action();   // 例如:"添加老人信息"
String target = log.target();   // 例如:"老人信息"

关键理解getAnnotation() 是 Java 反射 API,运行时动态读取类/方法上贴的注解。@Retention(RUNTIME) 就是为了让这个方法能读到。

5.2 第二步:从 SecurityContext 拿当前用户

java 复制代码
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
​
String username = auth.getName();       // "admin"
Long userId = (Long) auth.getDetails(); // 1
​
// role 取出来带 ROLE_ 前缀:"ROLE_ADMIN",写日志前要去掉
String role = auth.getAuthorities()
    .iterator().next()
    .getAuthority();
role = role.replace("ROLE_", "");       // "ADMIN"

为什么任何地方都能取到 SecurityContext

底层是 ThreadLocal

java 复制代码
ThreadLocal = 当前线程的"私人储物柜"
​
Tomcat 每个请求分配一个独立线程
  → JwtAuthenticationFilter 在处理请求时把身份存进 ThreadLocal
  → 同一条请求链路:Filter → Controller → Service → AOP,全在同一个线程
  → 任何环节都能从 ThreadLocal 取出当前用户 ✅

生活类比 :进了公司大楼,前台发给你一个访客牌挂在胸前。之后你走到任何部门(Controller、Service、AOP),别人看到你的访客牌就知道你是谁。ThreadLocal 就是这个访客牌。

5.3 第三步:拿 IP 地址

java 复制代码
ServletRequestAttributes attributes =
    (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
​
String ip = request.getRemoteAddr();

RequestContextHolderSecurityContextHolder 一样,底层也是 ThreadLocal,但存的是 HTTP 请求对象而非用户信息。

5.4 第四步:组装 + 写入

java 复制代码
OperationLog logEntity = new OperationLog();
logEntity.setUserId(userId);
logEntity.setUsername(username);
logEntity.setRole(role);
logEntity.setAction(action);
logEntity.setTarget(target);
logEntity.setIp(ip);
​
try {
    Object result = joinPoint.proceed();   // ← 真正执行目标方法!
    logEntity.setResult("成功");
    return result;                          // 返回值原样返回给调用方
} catch (Throwable e) {
    logEntity.setResult("失败");
    logEntity.setErrorMsg(e.getMessage());
    throw e;                                // 异常要重新抛,不能吞!
} finally {
    logEntity.setCreateTime(LocalDateTime.now());
    operationLogMapper.insert(logEntity);   // 不管成败都写日志
}

三个关键理解

  1. joinPoint.proceed() 是这行代码真正去调 addElderly() / updateById() 的地方。不写这一行,目标方法永远不会执行。

  2. finally保证不管成功还是抛异常,日志都会写入数据库。

  3. 异常不能吞catch 后必须重新 throw e。如果吞了异常,上层 Controller 看到的是成功返回,事务也不会回滚。

5.5 完整执行流程

java 复制代码
around() 被触发
    │
    ├── ① 读 @Log 注解 → action, target
    ├── ② SecurityContextHolder.getAuthentication() → username, userId, role
    ├── ③ RequestContextHolder.getRequestAttributes() → IP
    ├── ④ new OperationLog() → set 各个字段
    ├── ⑤ joinPoint.proceed() ←────────────┐
    │       │                               │
    │   ┌───┴───┐                           │
    │   ▼       ▼                           │
    │ 正常返回   抛异常                      │
    │   │       │                           │
    │ result=   errorMsg=                   │
    │ "成功"    e.getMessage()              │
    │   │       │                           │
    │   └───┬───┘                           │
    │       ▼                               │
    └── ⑥ finally: insert(operationLog) ────┘

六、顺便修了一个安全漏洞

AuthController 原来的写法:

java 复制代码
// 有漏洞的写法
String accessToken = jwtTokenProvider.generateAccessToken(
    loginDto.getUsername(),
    loginDto.getRole(),      // ← 用户提交的表单数据!
    loginDto.getUserId()     // ← 用户提交的表单数据!
);

漏洞 :FAMILY 用户在登录表单里人工填 "role": "ADMIN" → Token 里存了 ADMIN → 后续请求被识别为管理员 → 获得管理员权限。

就像你去酒店前台,自己和服务员说"给我总统套房的房卡"---如果前台不查系统就照办,那就出事了。

修复后的写法

java 复制代码
// 安全的写法
Authentication authentication = authenticationManager.authenticate(unauthenticated);
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
​
// 用数据库查出的真实数据,不用用户提交的
String accessToken = jwtTokenProvider.generateAccessToken(
    userDetails.getUsername(),   // 数据库的
    userDetails.getRole(),       // 数据库的,不是用户填的
    userDetails.getUserId()      // 数据库的
);

根因分析 :JWT 签名只能防客户端篡改 Token,防不了【服务端自己签发 Token 时写入了不可信的值】。

教训:构建 Token 时,所有数据必须来自可信数据源(数据库),不能来自用户提交的表单。


七、踩坑记录

坑 1:@Aspect 注解无法解析

现象 :IDEA 提示 Cannot resolve symbol 'Aspect'

根因 :pom.xml 缺少 spring-boot-starter-aop 依赖。AOP 不是 Spring Boot 自动引入的(不像 MVC 和 Web),需要手动添加。

XML 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

教训:Spring Boot 的 starter 不是万能的,只带核心功能。AOP、Validation、Actuator 等都需要显式引入依赖。

坑 2:GlobalExceptionHandler 导包错误

现象:认证失败返回 500 而不是 401。

根因AuthenticationException 有多个同名类------javax.security.saslorg.apache.tomcat.websocketorg.springframework.security.core。IDE 自动补全时导错了包,编译不报错,但运行时永远匹配不上。

教训 :遇到 @ExceptionHandler 不生效,第一反应检查导包!


八、Filter vs AOP:两个拦截器的根本区别

Filter 和 AOP是两个类似的家伙。我们来放在一起对比:

Filter(过滤器) AOP(切面)
拦截级别 HTTP 请求级 方法级
所在层次 Spring MVC 外面(Servlet 容器层) Spring 容器里面
核心对象 HttpServletRequest / HttpServletResponse ProceedingJoinPoint(被拦截的方法)
典型用途 认证、鉴权、编解码、CORS 日志、事务、权限校验、性能监控
能否拿到方法参数
能否拿到注解信息
执行顺序 先于 DispatcherServlet 在 Spring Bean 方法调用时
XML 复制代码
请求 → Filter → DispatcherServlet → Interceptor → Controller → AOP(Service方法) → 返回

一句话区分:Filter 管的是【你能不能进大楼】(HTTP 层面),AOP 管的是【你进了大楼后在哪个房间做了什么】(方法层面)。


九、阶段收获

理解层面

  1. AOP 三大核心概念:Aspect(切面 = 在哪 + 何时 + 做什么)、Pointcut(切点 = 拦截哪些方法)、Advice(通知 = 具体做什么)

  2. 五种通知类型的区别@Before / @After / @AfterReturning / @AfterThrowing / @Around,各自适用场景和能获取的信息不同

  3. ThreadLocal 在安全体系中的应用:SecurityContextHolder 和 RequestContextHolder 都是基于 ThreadLocal,实现请求级别的数据隔离

  4. Filter vs AOP 的根本区别:拦截级别不同(HTTP vs 方法)、层次不同(Servlet 容器 vs Spring 容器)、适用场景不同

工程层面

  1. 能从零搭建自定义注解 + AOP 切面:定义注解 → 写切面 → 贴注解 → 验证生效

  2. 理解了 userId 全链路贯通的实现:数据库 → JWT → SecurityContext → AOP,并掌握了依赖关系驱动的开发顺序思维

  3. 掌握了 @Around 的 try-catch-finally 标准写法,理解为什么异常要重新抛、为什么 finally 里写日志

  4. 学会了排查 AOP 相关问题的思路:依赖是不是缺了 → 导包对不对 → 代理有没有生效

  5. 建立了安全意识:Token 中的数据必须来自可信数据源,不能直接用用户提交的内容


十、自检清单

学完这部分,你应该能回答以下问题。建议对着口述一遍------能说出来 = 真懂了:

  • AOP 的 Aspect、Pointcut、Advice 分别是什么?它们三个是什么关系?
  • 五种通知(Before / After / AfterReturning / AfterThrowing / Around)的区别?
  • 为什么操作日志必须用 @Around 而不能用 @Before?
  • @Target@Retention 分别控制什么?RUNTIMESOURCE 的区别?
  • 自定义注解的 action()target() 在切面里怎么读到的?(反射 API)
  • joinPoint.proceed() 是干什么的?不写会怎样?
  • SecurityContextHolder.getContext().getAuthentication() 背后是什么原理?
  • 为什么切面里 catch 了异常要重新 throw?
  • userId 全链路经过哪几个环节?列出数据流路径和开发顺序。
  • Filter 和 AOP 的区别?分别在什么场景使用?
  • AuthController 的角色伪造漏洞是怎么产生的?怎么修复的?
  • spring-boot-starter-aop 依赖是必须手动添加的吗?为什么?

developing----------

相关推荐
lazy H1 小时前
Maven 依赖爆红怎么办?IDEA 中 Maven 项目常见问题和解决方法总结
java·后端·学习·maven·intellij-idea
Flittly1 小时前
【AgentScope Java新手村系列】(8)多Agent协作
java·spring boot·笔记·spring·ai
CodeSheep1 小时前
又是梁文锋,有点猛啊。
前端·后端·程序员
SimonKing1 小时前
低调低调,白嫖文生图,文生视频模型,无Token限制
java·后端·程序员
我命由我123451 小时前
Android 开发问题:EditText 控件的 android:imeOptions=“actionDone“ 属性不生效
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
我登哥MVP1 小时前
SpringCloud Alibaba 核心组件解析:服务熔断和降级
java·spring boot·后端·spring·spring cloud·java-ee·maven
z_鑫1 小时前
深入理解MyBatis:collection集合封装的底层原理与实现细节
java·开发语言·数据库·spring boot·mybatis
aramae1 小时前
《计算机网络(第5版)》第二章 物理层
服务器·网络·后端·计算机网络
我命由我123451 小时前
Android 开发问题:获取到的 Android ID 发生了变化
android·java·开发语言·java-ee·android studio·android jetpack·android runtime