采云台 · M4 链路 1 逐步拆解(认证 → 授权 的完整执行过程)

用途:把 M4 的”链路 1”从一张流程图,拆解成每一步的真实代码 + 数据流转 + 谁调用了谁。
场景:采购专员(PURCHASER)尝试新增物资 —— 这条链路的特殊之处是它能通过认证、但在授权层被拒绝,所以能把”认证”和”授权”两层都走完。
所有代码均取自 backend/cai-yun-tai 实际源码,行号已核验。


零、先建立整体心智模型

0.1 一条请求会被 7 个组件依次处理

┌─────────────────────────────────────────────────────────────────┐
│ 1. Tomcat 线程池          把 HTTP 字节流变成 HttpServletRequest    │
├─────────────────────────────────────────────────────────────────┤
│ 2. DispatcherServlet      Spring MVC 的总调度器                   │
├─────────────────────────────────────────────────────────────────┤
│ 3. HandlerMapping         找出"这个 URL 该由哪个方法处理"           │
├─────────────────────────────────────────────────────────────────┤
│ 4. HandlerAdapter         调用方法:先解析参数 → 再执行             │
│      ├─ 4a. 参数解析       @RequestBody 反序列化 JSON → GoodsDTO   │
│      ├─ 4b. Interceptor    ★ JwtTokenAdminInterceptor.preHandle    │
│      ├─ 4c. 方法调用        ★ RoleAspect(AOP 代理里插进来的)       │
│      └─ 4d. 返回值处理      Result → JSON                        │
├─────────────────────────────────────────────────────────────────┤
│ 5. Interceptor.afterCompletion  ★ BaseContext.clear()            │
├─────────────────────────────────────────────────────────────────┤
│ 6. @RestControllerAdvice   异常 → 统一响应体(如果抛了异常)        │
└─────────────────────────────────────────────────────────────────┘

0.2 三个关键顺序(这是最容易搞错的地方,必须先记住)

顺序事实在哪能看到
①参数解析在 preHandle 之前DispatcherServlet.doDispatch() 是先 ha.handle(...)(内部先解析参数),而 mappedHandler.applyPreHandle() 在 ha.handle() 之前;preHandle 的注释写着 “after HandlerMapping determined an appropriate handler object”——它保证的是”handler 已确定”,不保证”参数未解析”
②preHandle 在 AOP 切面之前preHandle 属于 Spring MVC 的拦截器链;RoleAspect 属于 Spring AOP,插在目标方法调用那一刻。所以拦截器一定先执行
③afterCompletion 在最后,且一定会执行无论成功、抛异常、还是 preHandle 返回 false,只要 preHandle 返回过 true 就会被调用

这三条顺序决定了两件事:

  • 认证在授权之前(拦截器 → 切面)→ 所以 BaseContext 里的角色在切面执行时一定已经写好了;
  • 参数解析在认证之前 → 所以未登录请求的 body 也会被反序列化(这是一个性能细节,不是安全问题)。

一、请求的起点:前端发了什么

前端 admin-ui/console.js 的请求封装(第 30 行、第 44-45 行、第 53-65 行):

// ① 组装请求头
const headers = { 'Content-Type': 'application/json' };
const token = this.getToken();                   // 从 localStorage 取
if (token) headers['token'] = token;             // ★ 管理端请求头名是 token
 
// ② 发请求
fetch(url, { method, headers, body: data ? JSON.stringify(data) : undefined })
 
// ③ 处理响应
if (res.status === 401) { ... }                  // ★ 认证失败走这里
if (json.code !== 1) {                           // ★ 业务失败走这里
    ElementPlus.ElMessage.error(json.msg || '操作失败');
    throw new Error(json.msg || 'error');
}

注意前端有两条错误分支——这正好对应后端的两层:

前端判断对应后端表现
res.status === 401认证失败(拦截器 setStatus(401))前端跳登录
json.code !== 1业务失败 / 授权失败(抛 BaseException)前端弹 msg

所以”授权失败”在前端表现为一个弹窗”无操作权限”,而不是跳登录页。 这个区别很重要,说明两层在协议层就是分开的。

本次请求的完整内容

PUT /admin/goods HTTP/1.1
Host: 127.0.0.1:8084
Content-Type: application/json
token: eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6NX0.xxxxx      ← ★ 管理端请求头名是 token

{
  "id": 70,
  "name": "A4 复印纸(改)",
  "categoryId": 23,
  "price": 28.00,
  "status": 1,
  "flavors": [...]
}

这个 token 是采购专员(empId=5)登录时签发的。 回到 EmployeeController.login(第 54-87 行)看它是怎么来的:

@PostMapping("/login")                        // 第 54 行
public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
 
    Employee employee = employeeService.login(employeeLoginDTO);   // 账号密码 + 角色校验
 
    // ① 生成 JWT —— 注意 claims 里【只放了 empId】,没有任何角色信息
    Map<String, Object> claims = new HashMap<>();
    claims.put(JwtClaimsConstant.EMP_ID, employee.getId());        // 第 63 行
    String token = JwtUtil.createJWT(
            jwtProperties.getAdminSecretKey(),                     // "itcast"
            jwtProperties.getAdminTtl(),                           // 7200000 ms = 2 小时
            claims);
 
    // ② 写入 Redis 白名单(TTL 与 JWT 一致)
    stringRedisTemplate.opsForValue().set(
            RedisKeyConstant.LOGIN_TOKEN_PREFIX + token,           // "login:token:{完整JWT}"
            employee.getId().toString(),
            jwtProperties.getAdminTtl(),
            TimeUnit.MILLISECONDS);
 
    // ③ 返回(token 给前端存 localStorage)
    return Result.success(EmployeeLoginVO.builder()....token(token).build());
}

这里要记住两个事实,后面每一步都会用到:

  1. JWT 的 payload 里只有 empId: 5 —— 没有 role。这是有意设计(理由见第八节);
  2. Redis 里有一条 login:token:{这一长串JWT} —— 这是”可注销”的实现方式。

二、第 1-3 层:Tomcat → DispatcherServlet → HandlerMapping

这三层没有项目代码,但要知道发生了什么:

// Tomcat 的工作线程(线程池里的某个 T-x)
//     把 socket 字节流解析成 HttpServletRequest
 
// DispatcherServlet.doDispatch()(Spring MVC 源码,简化)
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) {
 
    // 【第 3 层】HandlerMapping:找出该由谁处理
    HandlerExecutionChain mappedHandler =
            getHandler(request);       // ← 匹配到 GoodsController.update(),
                                       //    包装成 HandlerMethod
    HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
 
    // 【第 4 层】★ 关键:这一行内部会依次做
    //   ① 参数解析(@RequestBody 反序列化)
    //   ② applyPreHandle()  → 拦截器
    //   ③ invokeHandlerMethod() → 目标方法(AOP 切面在这里面)
    //   ④ 返回值处理
    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
 
    // 【第 5 层】afterCompletion
    // 注意:它在 finally 块里,所以一定会执行
    if (mappedHandler != null) {
        mappedHandler.applyAfterCompletion(request, response, null);
    }
}

getHandler() 匹配到的 HandlerMethod 里封装了三样东西(第 4 层要用):

HandlerMethod {
    Object bean;              // → GoodsController 的【AOP 代理对象】
    Method method;            // → GoodsController.update(GoodsDTO) 【真实类的方法对象】
    Class<?> beanType;        // → GoodsController.class
}

⚠️ 这里有一个非常重要的细节:bean 是代理对象,但 method 是真实类 GoodsController 上的方法对象。

  • 为什么重要?因为 RoleAspect 里要 method.getAnnotation(RequireRole.class)——如果 method 是代理类的,就读不到注解了;
  • 而 Spring 存的就是目标类的方法,所以能正确读到注解。这也解释了为什么 RoleAspect 里 method.getDeclaringClass() 能正确拿到 GoodsController。

三、第 4a 层:参数解析(在认证之前!)

HandlerAdapter 先做参数绑定:

// RequestResponseBodyMethodProcessor.resolveArgument()
// 处理 @RequestBody 标注的参数
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
 
// 走的是项目在 WebMvcConfiguration 里注册的转换器
// WebMvcConfiguration.extendMessageConverters() 第 108-116 行:
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(new JacksonObjectMapper());   // ← 自定义日期格式
converters.add(0, converter);                           // ← 插到第 0 位,优先使用

结果:JSON 字符串 → GoodsDTO 对象。

GoodsDTO goodsDTO = {
    id = 70,
    name = "A4 复印纸(改)",
    categoryId = 23,
    price = 28.00,
    status = 1,
    flavors = [...]
}

⚠️ 这一步值得单独强调:

  • 即使这个请求没有 token(未登录),JSON body 也会被完整反序列化!因为参数解析在 preHandle 之前;
  • 未登录请求的 body 解析结果不会被使用(拦截器返回 false 就中断了),但 CPU 已经花了;
  • 这就是我在 M4 分析里说的”授权不够早”的根本原因——RoleAspect 更晚,它在参数解析之后。

四、第 4b 层:★ 认证 —— JwtTokenAdminInterceptor.preHandle()

这是第一层真正的业务逻辑。 完整代码(JwtTokenAdminInterceptor 第 49-91 行):

@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
 
    @Autowired private JwtProperties jwtProperties;
    @Autowired private EmployeeMapper employeeMapper;             // ★ 注意:拦截器里注入了 Mapper
    @Autowired private StringRedisTemplate stringRedisTemplate;
 
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
 
        // 【1】判断拦截到的是不是 Controller 方法
        if (!(handler instanceof HandlerMethod)) {
            return true;              // 不是动态方法(静态资源等)→ 直接放行
        }
 
        // 【2】从请求头取令牌
        String token = request.getHeader(jwtProperties.getAdminTokenName());  // "token"
 
        try {
            log.info("jwt校验:{}", token);
 
            // 【3】校验 JWT:验签 + 过期
            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
            Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
            log.info("当前员工id:{}", empId);
 
            // 【4】★ 校验 Redis 登录状态(白名单)
            String loginKey = RedisKeyConstant.LOGIN_TOKEN_PREFIX + token;
            String loginValue = stringRedisTemplate.opsForValue().get(loginKey);
            if (loginValue == null) {
                response.setStatus(401);
                return false;                 // ← 登出后的旧 token 在这里被拦
            }
 
            // 【5】★ 查库拿角色,同时校验角色与账号状态
            Employee employee = employeeMapper.getById(empId);
            if (employee == null
                || RoleConstant.EMPLOYEE.equals(employee.getRole())        // 普通员工禁入管理端
                || StatusConstant.DISABLE.equals(employee.getStatus())) {  // 账号被停用
                response.setStatus(401);
                return false;
            }
 
            // 【6】★ 写入 ThreadLocal,供下游(切面、Service、@AutoFill)读取
            BaseContext.setCurrentId(empId);
            BaseContext.setCurrentRole(employee.getRole());
 
            // 【7】放行
            return true;
 
        } catch (Exception ex) {
            response.setStatus(401);
            return false;
        }
    }
 
    // 【8】请求结束后清理 ThreadLocal
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        BaseContext.clear();
    }
}

采购专员的这次请求,这一步发生了什么

【2】token = "eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6NX0.xxxxx"

【3】JwtUtil.parseJWT("itcast", token)
     → 验签通过(HS256,密钥 "itcast")
     → 未过期(签发时间 + 7200000ms)
     → claims.get("empId") = 5                    ← empId = 5

【4】Redis GET "login:token:eyJhbGciOiJIUzI1NiJ9.eyJlbXBJZCI6NX0.xxxxx"
     → 返回 "5"(非 null)  ✓ 通过

【5】employeeMapper.getById(5L)
     → SQL: SELECT * FROM employee WHERE id = 5
     → Employee{ id=5, username="purchaser", role="PURCHASER", status=1, deptId=null, ... }
     
     判断:
       employee == null                          → false
       "EMPLOYEE".equals("PURCHASER")            → false   ✓ 不是普通员工,允许进管理端
       Integer(0).equals(Integer(1))             → false   ✓ 账号是启用的
     → 三个条件都不满足 → 不返回 401

【6】BaseContext.setCurrentId(5L)
     BaseContext.setCurrentRole("PURCHASER")
     
     此刻线程 T-x 的两个 ThreadLocal 里:
       threadLocal     = 5L
       roleThreadLocal = "PURCHASER"

【7】return true  → 放行,继续往下走

关键点:BaseContext 此刻的状态

public class BaseContext {
    public static ThreadLocal<Long>   threadLocal     = new ThreadLocal<>();
    public static ThreadLocal<String> roleThreadLocal = new ThreadLocal<>();
 
    public static void setCurrentId(Long id) { threadLocal.set(id); }
    public static void setCurrentRole(String role) { roleThreadLocal.set(role); }
    public static void clear() {                  // ← afterCompletion 调用
        threadLocal.remove();
        roleThreadLocal.remove();
    }
}

此刻线程 T-x 的状态(这是后面切面能读到角色的原因):

线程 T-x 的 ThreadLocalMap
├── BaseContext.threadLocal       →  5L
└── BaseContext.roleThreadLocal   →  "PURCHASER"

注意 @AutoFillAspect 也用同一个 threadLocal:

// AutoFillAspect 第 53 行
Long currentId = BaseContext.getCurrentId();       // → 5L
// 然后反射填 create_user / update_user

所以 BaseContext 是被三处共享的:拦截器(写)、RoleAspect(读 role)、AutoFillAspect + Service(读 id)。


五、第 4c 层:★★ 授权 —— RoleAspect.checkRole()

5.1 先理解 AOP 代理是怎么插进来的

HandlerMethod.bean 是 GoodsController 的 CGLIB 代理对象,不是原始对象:

HandlerAdapter 调用:
    mappedHandler.getHandler().getMethod().invoke(handlerMethod.getBean(), args)
                                                    ↑
                                        这是【代理对象】

GoodsController 的 CGLIB 代理(简化后的等价 Java)
└── class GoodsController$$EnhancerBySpringCGLIB
    └── public Result update(GoodsDTO goodsDTO) {
            // ↓↓↓ Spring AOP 自动织入的通知链
            //  ① 找到匹配 GoodsController.update 的所有 Advisor
            //     → RoleAspect 的 @Before 命中
            //  ② 执行通知
            RoleAspect aspect = (RoleAspect) BeanFactory.getBean("roleAspect");
            aspect.checkRole(joinPoint);          // ← ★ 权限校验在这里
            //  ③ 如果没有抛异常,调用真实方法
            return super.update(goodsDTO);
        }

所以 RoleAspect.checkRole() 是”被代理对象自动调用的”,你的代码里找不到任何显式的调用点。 这是 AOP 的特点,也是它难排查的原因。

5.2 切点表达式:为什么方法会被切到

@Before("@annotation(com.caiyuntai.annotation.RequireRole) || @within(com.caiyuntai.annotation.RequireRole)")
public void checkRole(JoinPoint joinPoint) { ... }

看 GoodsController.update() 上的注解(admin/GoodsController.java 第 106-119 行,注解标注在第 108-110 行):

@PutMapping                                              // ← 第 108 行
@ApiOperation("修改物资")
@RequireRole(RoleConstant.ADMIN)                          // ← 第 110 行 ★ 命中 @annotation
public Result update(@RequestBody GoodsDTO goodsDTO) {    // ← 第 111 行
    log.info("修改物资:{}", goodsDTO);
    goodsService.updateWithSpecs(goodsDTO);
    cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*");
    return Result.success();
}

匹配过程:

表达式对 GoodsController.update 的判断
@annotation(RequireRole)方法上有 @RequireRole → ✅ 命中
@within(RequireRole)类 GoodsController 上没有 @RequireRole → 不命中
A || B命中 → 执行通知

验证方法级 vs 类级的效果:项目里 31 处 @RequireRole 全部是方法级、没有一处类级。如果哪天有人把注解放到类上,@within 就会生效。

5.3 checkRole 逐句执行

@Before("...") 
public void checkRole(JoinPoint joinPoint) {                    // 【1】
 
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();  // 【2】
    Method method = signature.getMethod();                      // 【3】
 
    // 【4】方法上的注解优先
    RequireRole requireRole = method.getAnnotation(RequireRole.class);
    if (requireRole == null) {
        // 【5】方法上没有 → 看类上
        requireRole = method.getDeclaringClass().getAnnotation(RequireRole.class);
    }
    if (requireRole == null) {
        return;                                                 // 【6】★ 放行分支①
    }
 
    String[] allowed = requireRole.value();                     // 【7】
    if (allowed.length == 0) {
        return;                                                 // 【8】★ 放行分支②
    }
 
    String currentRole = BaseContext.getCurrentRole();          // 【9】
    if (currentRole == null) {
        log.warn("角色校验失败:未获取到登录角色,方法={}", method.getName());
        throw new BaseException(MessageConstant.USER_NOT_LOGIN); // 【10】
    }
 
    boolean pass = Arrays.stream(allowed).anyMatch(role -> role.equals(currentRole));  // 【11】
    if (!pass) {
        log.warn("角色校验失败:当前角色={},允许角色={},方法={}",
                currentRole, Arrays.toString(allowed), method.getName());
        throw new BaseException(MessageConstant.NO_PERMISSION); // 【12】★ 本次走这里
    }
}

采购专员的这次请求,逐句的实际值

步骤代码实际值
【2】joinPoint.getSignature()Result com.caiyuntai.controller.admin.GoodsController.update(GoodsDTO)
【3】signature.getMethod()GoodsController.update(GoodsDTO) 的 Method 对象
【4】method.getAnnotation(RequireRole.class)@RequireRole(value={"ADMIN"}) → 不为 null
【5】(不执行)—
【6】(不执行)—
【7】requireRole.value()String[]{"ADMIN"}
【8】allowed.length == 01 == 0 → false,不返回
【9】BaseContext.getCurrentRole()"PURCHASER"(第 4b 层写入的)
【10】currentRole == nullfalse
【11】anyMatch("ADMIN".equals("PURCHASER"))false
【12】!passtrue → 抛异常

抛出的异常:

throw new BaseException(MessageConstant.NO_PERMISSION);
// MessageConstant.NO_PERMISSION = "无操作权限"

同时打了一条日志:

WARN  c.c.aspect.RoleAspect - 角色校验失败:当前角色=PURCHASER,允许角色=[ADMIN],方法=update

★★★ 关键:异常抛出后,super.update() 永远不会执行

// 代理对象里的等价逻辑
public Result update(GoodsDTO goodsDTO) {
    aspect.checkRole(joinPoint);        // ← 这里抛了异常
    return super.update(goodsDTO);      // ← ★★★ 这一行【永远不会执行】
}

所以这三件事都没有发生:

  • ❌ goodsService.updateWithSpecs(goodsDTO) —— 数据库没有被修改
  • ❌ cleanCache("goods:list:*") —— 缓存没有被清
  • ❌ return Result.success() —— 没有正常返回值

这就是”授权”的意义:它保护的是”方法执行”,而不是”方法返回”。


六、第 6 层:★ 异常如何变成一个 HTTP 200 响应

6.1 BaseException 为什么能被捕获(继承链)

// caiyuntai-common/.../exception/BaseException.java
public class BaseException extends RuntimeException {       // ★ 运行时异常!
    public BaseException() {}
    public BaseException(String msg) { super(msg); }
}

extends RuntimeException 有两个后果:

  1. 不需要在方法签名上声明 throws —— 所以 RoleAspect.checkRole 的方法签名是干净的 public void checkRole(JoinPoint),没有 throws BaseException。这也是为什么它能在切面里随便抛;
  2. Spring 的 @ExceptionHandler 能捕获它 —— Spring MVC 的异常解析会按类型匹配 handler。

6.2 全局异常处理器

// caiyuntai-server/.../handler/GlobalExceptionHandler.java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
 
    @ExceptionHandler                                        // ← 按参数类型匹配
    public Result exceptionHandler(BaseException ex){         // ★ 匹配上了
        log.error("异常信息:{}", ex.getMessage());
        return Result.error(ex.getMessage());                 // → Result{code=0, msg="无操作权限"}
    }
 
    @ExceptionHandler
    public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){ ... }
}

匹配逻辑:Spring MVC 遍历所有 @ExceptionHandler 方法,找参数类型能接收抛出异常的那个。抛的是 BaseException,第一个 handler 的参数正是 BaseException → 命中。

6.3 最终响应

// Result 类
public class Result<T> implements Serializable {
    private Integer code;     // 1 成功,0 失败
    private String  msg;
    private T       data;
 
    public static <T> Result<T> error(String msg) {
        Result<T> result = new Result<>();
        result.code = 0;
        result.msg = msg;
        return result;
    }
}
HTTP/1.1 200 OK                                    ← ★ 注意是 200,不是 403
Content-Type: application/json

{
  "code": 0,
  "msg": "无操作权限",
  "data": null
}

前端 console.js 的处理:

if (res.status === 401) { ... }        // ← 不成立(是 200)
if (json.code !== 1) {                 // ← 0 !== 1 → 成立
    ElementPlus.ElMessage.error(json.msg || '操作失败');   // 弹窗:"无操作权限"
    throw new Error(json.msg || 'error');
}

⚠️ 三层对比(这是面试可以深挖的点):

层触发方式HTTP 状态响应体
认证失败拦截器 response.setStatus(401); return false401空(没有经过任何 Controller/异常处理器)
授权失败切面 throw BaseException → 全局处理器200{"code":0,"msg":"无操作权限"}
业务失败Service throw OrderBusinessException → 全局处理器200{"code":0,"msg":"申领单状态错误"}

认证失败为什么响应体是空的? 因为 preHandle 返回 false 时,DispatcherServlet 不会去调用 handler、也不会走异常处理链——它直接返回,所以 body 是空的。这是一个容易忽略的细节。


七、第 5 层:afterCompletion 清理

// DispatcherServlet.doDispatch() 的 finally 块(简化)
} finally {
    ...
    if (mappedHandler != null) {
        mappedHandler.applyAfterCompletion(request, response, null);   // ★ 一定执行
    }
}
 
// 触发 JwtTokenAdminInterceptor.afterCompletion
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                            Object handler, Exception ex) {
    BaseContext.clear();          // threadLocal.remove() + roleThreadLocal.remove()
}

清理前后的线程状态:

清理前(线程 T-x):
  BaseContext.threadLocal       →  5L
  BaseContext.roleThreadLocal   →  "PURCHASER"

afterCompletion 执行 BaseContext.clear()
  ↓
清理后(线程 T-x):
  BaseContext.threadLocal       →  null (Entry 被 remove)
  BaseContext.roleThreadLocal   →  null

⚠️ 注意:afterCompletion 是在异常发生的情况下也会执行的。 因为它在 finally 块里。

如果不清理会怎样(用具体例子说明):

请求 A:管理员(empId=1, role=ADMIN)打 PUT /admin/goods
    → 线程 T-7 处理,BaseContext = {1L, "ADMIN"}
    → 假设【没有】clear()

请求 B:某个不带 token 的请求打到 /doc.html(静态资源)
    → Tomcat 恰好又分配给线程 T-7
    → 拦截器不生效(不是 HandlerMethod,或者路径不匹配)
    → BaseContext 里的值【仍然是 {1L, "ADMIN"}】

     如果这个请求链路里有人调 BaseContext.getCurrentId()
     → 读到的是【管理员 id=1】                     ★★ 身份串号

后果按严重度:

读取点后果方向
RoleAspect 读 roleThreadLocal拿到 "ADMIN" → 越权通过❌❌ 最严重
@AutoFillAspect 读 threadLocal把 create_user 写成 id=1❌ 审计污染
assertOwner 读 threadLocal1L.equals(真实userId) → 不匹配 → 抛异常✅ 误拒(安全方向)

八、两个”为什么”(回到设计层面)

8.1 为什么 JWT 里只放 empId,不放 role

从上面的链路能看到:BaseContext.setCurrentRole(employee.getRole()) 里的角色是从数据库查出来的,不是从 JWT 里解析的。

// JwtTokenAdminInterceptor 第 62-63 行
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());   // ★ 只取 empId
 
// 第 76 行:角色来自数据库
Employee employee = employeeMapper.getById(empId);                             // ★ 查库
...
BaseContext.setCurrentRole(employee.getRole());                                // ★ 用库里的值

如果 role 放在 JWT 里会怎样:

① 员工 A 当前是 PURCHASER
   → 登录,签发 JWT{empId:5, role:"PURCHASER"}
② 管理员把 A 改成 EMPLOYEE(或停用账号)
③ A 手里那张旧 JWT【仍然自称 PURCHASER】,在 2 小时内有效
   → 降权不生效!A 还能继续审批申领单

所以每次查库是刻意的:用一次主键查询换”权限实时生效”。JWT 在本项目里是纯身份凭证——只回答”你是谁”(empId),不回答”你能做什么”。

8.2 为什么认证在拦截器、授权在切面 —— 从执行顺序看

回到第 0.2 节的三条顺序,把它们和代码对应起来:

【时序】
4a. 参数解析        GoodsDTO 已构造
      ↓
4b. preHandle       验证 JWT → 查 Redis → 查库 → ★ 写 BaseContext
      ↓             (如果这里 return false,后面全部不执行)
4c. checkRole       ★ 读 BaseContext.getCurrentRole()
      ↓             (依赖 4b 写入的值 —— 所以 4b 必须在 4c 之前)
4c'. super.update() 真正执行 Controller 方法
      ↓
4d. 返回值处理      Result → JSON
      ↓
5.  afterCompletion ★ 清 BaseContext

这个顺序解释了三件事:

  1. 切面必须能读到 BaseContext → 所以认证必须在授权之前。拦截器 → 切面的顺序天然满足这个依赖;
  2. currentRole == null 那个分支什么时候会走到?只有当某个带 @RequireRole 的路径不经过拦截器时。当前没有这种情况,所以它实际是个防御性分支——RoleAspect 第 54 行的注释也写明了这一点:「没有角色信息,说明请求没有经过登录拦截器,按未登录处理」;
  3. 授权在参数解析之后 → 越权请求的 body 已经解析了。要更早拦住,得把授权搬进 preHandle(这就是 M4-6 讨论的取舍)。

九、完整时序图(一页速览)

前端                      Tomcat         DispatcherServlet        HandlerAdapter      拦截器            RoleAspect         Controller        DB/Redis
 │                          │                  │                      │                │                  │                  │                │
 │ PUT /admin/goods         │                  │                      │                │                  │                  │                │
 │ header: token=eyJ...     │                  │                      │                │                  │                  │                │
 │ body: {id:70,...}        │                  │                      │                │                  │                  │                │
 ├─────────────────────────>│                  │                      │                │                  │                  │                │
 │                          │ HttpServletRequest                      │                │                  │                  │                │
 │                          ├─────────────────>│                      │                │                  │                  │                │
 │                          │                  │ getHandler()         │                │                  │                  │                │
 │                          │                  │ → HandlerMethod(GoodsController.update) │                  │                  │                │
 │                          │                  ├─────────────────────>│                │                  │                  │                │
 │                          │                  │                      │                │                  │                  │                │
 │                          │                  │                      │ ① 参数解析      │                  │                  │                │
 │                          │                  │                      │ JSON → GoodsDTO │                  │                  │                │
 │                          │                  │                      │                │                  │                  │                │
 │                          │                  │                      │ ② preHandle    │                  │                  │                │
 │                          │                  │                      ├───────────────>│                  │                  │                │
 │                          │                  │                      │                │ parseJWT("itcast")│                 │                │
 │                          │                  │                      │                ├─────────────────────────────────────────────────────>│
 │                          │                  │                      │                │ ← claims{empId:5}│                  │                │
 │                          │                  │                      │                │ GET login:token:eyJ...                │                │
 │                          │                  │                      │                ├─────────────────────────────────────────────────────>│
 │                          │                  │                      │                │ ← "5"             │                  │                │
 │                          │                  │                      │                │ getById(5)        │                  │                │
 │                          │                  │                      │                ├─────────────────────────────────────────────────────>│
 │                          │                  │                      │                │ ← {role:PURCHASER,status:1}          │                │
 │                          │                  │                      │                │ setCurrentId(5)   │                  │                │
 │                          │                  │                      │                │ setCurrentRole("PURCHASER")          │                │
 │                          │                  │                      │                │ return true       │                  │                │
 │                          │                  │                      │<───────────────┤                  │                  │                │
 │                          │                  │                      │                │                  │                  │                │
 │                          │                  │                      │ ③ 调用方法(AOP 代理)             │                  │                │
 │                          │                  │                      ├────────────────────────────────────>│                  │                │
 │                          │                  │                      │                │                  │ 读 @RequireRole  │                │
 │                          │                  │                      │                │                  │ → {ADMIN}        │                │
 │                          │                  │                      │                │                  │ getCurrentRole() │                │
 │                          │                  │                      │                │                  │ → "PURCHASER"    │                │
 │                          │                  │                      │                │                  │ anyMatch → false │                │
 │                          │                  │                      │                │                  │ ✗ throw BaseException             │
 │                          │                  │                      │<────────────────────────────────────┤ ("无操作权限")   │                │
 │                          │                  │                      │                │                  │                  │ ❌ super.update() 未执行
 │                          │                  │                      │                │                  │                  │                │
 │                          │                  │ ④ @RestControllerAdvice 捕获 BaseException             │                  │                │
 │                          │                  │ → Result.error("无操作权限")          │                  │                  │                │
 │                          │                  │                      │                │                  │                  │                │
 │                          │                  │ ⑤ applyAfterCompletion                  │                  │                  │                │
 │                          │                  │    → BaseContext.clear()                │                  │                  │                │
 │                          │<─────────────────┤                      │                │                  │                  │                │
 │ HTTP 200                 │                  │                      │                │                  │                  │                │
 │ {"code":0,               │                  │                      │                │                  │                  │                │
 │  "msg":"无操作权限",       │                  │                      │                │                  │                  │                │
 │  "data":null}            │                  │                      │                │                  │                  │                │
 │<─────────────────────────┤                  │                      │                │                  │                  │                │
 │                          │                  │                      │                │                  │                  │                │
 │ ElementPlus 弹窗"无操作权限"│                 │                      │                │                  │                  │                │

十、对照实验:三个”同类请求”的差异

把三个请求放在一起对比,能立刻看清每一层的作用。建议你自己在本机跑一遍验证。

实验 A:不带 token(认证层拒绝)

PUT /admin/goods
Content-Type: application/json
(没有 token 请求头)

body: {"id":70,"name":"x","categoryId":23,"price":28.00}
层发生了什么
4a 参数解析✅ 仍然执行,GoodsDTO 被构造出来了(但没人用)
4b preHandlerequest.getHeader("token") → null
JwtUtil.parseJWT("itcast", null) → 抛异常(JWT 为 null)
→ catch (Exception ex) { response.setStatus(401); return false; }
4c 切面❌ 不执行
Controller❌ 不执行
5 afterCompletion⚠️ 不执行!因为 preHandle 返回了 false——Spring MVC 只对成功通过的拦截器调用 afterCompletion
响应HTTP 401,响应体为空

⚠️ 第 5 条是个容易忽略的细节:preHandle 返回 false 时,该拦截器自己的 afterCompletion 不会被调用。

那 BaseContext 会不会残留?不会——因为认证在 [6] 步之前就失败了,setCurrentId/setCurrentRole 从来没被调用,所以没有东西需要清理。这个顺序是安全的,但依赖”写入在最后”这个写法。 如果哪天有人在 preHandle 开头(校验之前)就写 BaseContext,那么 return false 时就会留下未清理的残留——这是一个需要留意的脆弱点。

实验 B:采购专员的 token(授权层拒绝,本文档主场景)

层结果
4a 参数解析✅ GoodsDTO 构造成功
4b preHandle✅ 通过,BaseContext = {5L, "PURCHASER"}
4c 切面❌ anyMatch(ADMIN, PURCHASER) = false → 抛 BaseException
Controller❌ super.update() 未执行,数据库没改、缓存没清
5 afterCompletion✅ 执行(因为 preHandle 返回了 true),BaseContext.clear()
响应HTTP 200 + {"code":0,"msg":"无操作权限"}

实验 C:管理员的 token(全链路通过)

PUT /admin/goods
token: {管理员 admin 的 JWT}      ← empId=1, role=ADMIN
层结果
4b preHandle✅ 通过,BaseContext = {1L, "ADMIN"}
4c 切面✅ anyMatch("ADMIN".equals("ADMIN")) = true → 不抛异常
Controller✅ 执行:goodsService.updateWithSpecs(dto) → 事务内改 dish + dish_flavor
✅ cleanCache("goods:list:*") → KEYS + DEL
✅ return Result.success()
5 afterCompletion✅ BaseContext.clear()
响应HTTP 200 + {"code":1,"msg":null,"data":null}

顺带能看到 @AutoFill 也在这一层生效:

// GoodsServiceImpl.updateWithSpecs 第 139-159 行
@Transactional
public void updateWithSpecs(GoodsDTO goodsDTO) {
    Goods goods = new Goods();
    BeanUtils.copyProperties(goodsDTO, goods);
    goodsMapper.update(goods);          // ← 这里触发 @AutoFillAspect
    ...
}

GoodsMapper.update 上有 @AutoFill(OperationType.UPDATE),所以切面会反射填 updateTime 和 updateUser:

// AutoFillAspect 第 72-84 行(`invoke` 在第 79-80 行)
}else if(operationType == OperationType.UPDATE){
    Method setUpdateTime = entity.getClass().getDeclaredMethod("setUpdateTime", LocalDateTime.class);
    Method setUpdateUser = entity.getClass().getDeclaredMethod("setUpdateUser", Long.class);
    setUpdateTime.invoke(entity, now);
    setUpdateUser.invoke(entity, BaseContext.getCurrentId());   // ★ 拿到 1L(管理员)
}

所以 update_user 字段记录的是 1(管理员)——这就是”认证在拦截器写 BaseContext、下游组件共享”这个设计的实际价值。

三个实验的对照表

A:无 tokenB:采购专员C:管理员
4a 参数解析✅ 执行(结果被丢弃)✅ 执行✅ 执行
4b 认证(拦截器)❌ 401✅ 通过✅ 通过
BaseContext 写入❌ 从未写入✅ {5, PURCHASER}✅ {1, ADMIN}
4c 授权(切面)⏭️ 跳过❌ 抛异常✅ 通过
Controller 方法体⏭️ 跳过⏭️ 跳过✅ 执行
数据库被修改❌❌✅
缓存被清理❌❌✅
afterCompletion⚠️ 不执行✅ 执行✅ 执行
HTTP 状态401200200
响应体空code:0 无操作权限code:1

十一、如果要自己验证(可执行的命令)

BASE=http://127.0.0.1:8084
 
# ① 采购专员登录,拿 token
PT=$(curl -s -X POST $BASE/admin/employee/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"purchaser","password":"123456"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
echo "采购专员 token: ${PT:0:40}..."
 
# ② 管理员登录,拿 token
AT=$(curl -s -X POST $BASE/admin/employee/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"123456"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
echo "管理员 token: ${AT:0:40}..."
 
echo "--- 实验 A:不带 token(期望 HTTP 401 + 空 body)"
curl -s -o /tmp/a.txt -w "HTTP=%{http_code}\n" -X PUT $BASE/admin/goods \
  -H 'Content-Type: application/json' \
  -d '{"id":70,"name":"A4 复印纸","categoryId":23,"price":25.00}'
echo "body: [$(cat /tmp/a.txt)]"          # 期望 body 为空
 
echo "--- 实验 B:采购专员 token(期望 HTTP 200 + code:0 无操作权限)"
curl -s -w "\nHTTP=%{http_code}\n" -X PUT $BASE/admin/goods \
  -H "token: $PT" -H 'Content-Type: application/json' \
  -d '{"id":70,"name":"A4 复印纸","categoryId":23,"price":25.00}'
# 期望:{"code":0,"msg":"无操作权限","data":null}  HTTP=200
 
echo "--- 实验 C:管理员 token(期望 HTTP 200 + code:1)"
curl -s -w "\nHTTP=%{http_code}\n" -X PUT $BASE/admin/goods \
  -H "token: $AT" -H 'Content-Type: application/json' \
  -d '{"id":70,"name":"A4 复印纸","categoryId":23,"price":25.00}'
# 期望:{"code":1,"msg":null,"data":null}  HTTP=200
 
echo "--- 观察后端日志,应该能看到:"
#   jwt校验:eyJ...
#   当前员工id:5
#   角色校验失败:当前角色=PURCHASER,允许角色=[ADMIN],方法=update     ← ★ 这条是关键证据
#   异常信息:无操作权限
 
echo "--- 验证 afterCompletion 生效:查 Redis 登录态仍在(说明 token 没被误删)"
redis-cli -a 123456 -n 10 --no-auth-warning --scan --pattern 'login:token:*' | wc -l

最有价值的一步是看日志:角色校验失败:当前角色=PURCHASER,允许角色=[ADMIN],方法=update 这行 log.warn(RoleAspect 第 62-63 行)就是权限拒绝的直接证据,它同时告诉了你三件事:谁被拒了、要求什么角色、哪个方法。


十二、一页总结:这条链路的 6 个必答点

#问题答案
1认证在哪一层?JwtTokenAdminInterceptor.preHandle()——参数解析之后、方法调用之前
2授权在哪一层?RoleAspect 的 @Before——拦截器之后、目标方法之前,由 CGLIB 代理自动织入
3两层靠什么串联?BaseContext(线程 T-x 的两个 ThreadLocal):认证层写、授权层读
4授权失败为什么不改数据?切面在代理对象里、super.xxx() 之前抛异常 → 目标方法体从未执行
5授权失败返回什么?BaseException(extends RuntimeException)→ @RestControllerAdvice → HTTP 200 + code:0;对比认证失败是 401 + 空 body
6为什么 JWT 里不放 role?JWT 不可撤销。放了 role 就会出现”降权在 2 小时 TTL 内不生效”;所以每次查库取实时角色(JWT 只携带 empId,是纯身份凭证)