采云台 · 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());
}这里要记住两个事实,后面每一步都会用到:
- JWT 的 payload 里只有
empId: 5—— 没有 role。这是有意设计(理由见第八节); - 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 == 0 | 1 == 0 → false,不返回 |
| 【9】 | BaseContext.getCurrentRole() | "PURCHASER"(第 4b 层写入的) |
| 【10】 | currentRole == null | false |
| 【11】 | anyMatch("ADMIN".equals("PURCHASER")) | false |
| 【12】 | !pass | true → 抛异常 |
抛出的异常:
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 有两个后果:
- 不需要在方法签名上声明
throws—— 所以RoleAspect.checkRole的方法签名是干净的public void checkRole(JoinPoint),没有throws BaseException。这也是为什么它能在切面里随便抛; - 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 false | 401 | 空(没有经过任何 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 读 threadLocal | 1L.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
这个顺序解释了三件事:
- 切面必须能读到
BaseContext→ 所以认证必须在授权之前。拦截器 → 切面的顺序天然满足这个依赖; currentRole == null那个分支什么时候会走到?只有当某个带@RequireRole的路径不经过拦截器时。当前没有这种情况,所以它实际是个防御性分支——RoleAspect第 54 行的注释也写明了这一点:「没有角色信息,说明请求没有经过登录拦截器,按未登录处理」;- 授权在参数解析之后 → 越权请求的 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 preHandle | request.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:无 token | B:采购专员 | C:管理员 | |
|---|---|---|---|
| 4a 参数解析 | ✅ 执行(结果被丢弃) | ✅ 执行 | ✅ 执行 |
| 4b 认证(拦截器) | ❌ 401 | ✅ 通过 | ✅ 通过 |
BaseContext 写入 | ❌ 从未写入 | ✅ {5, PURCHASER} | ✅ {1, ADMIN} |
| 4c 授权(切面) | ⏭️ 跳过 | ❌ 抛异常 | ✅ 通过 |
| Controller 方法体 | ⏭️ 跳过 | ⏭️ 跳过 | ✅ 执行 |
| 数据库被修改 | ❌ | ❌ | ✅ |
| 缓存被清理 | ❌ | ❌ | ✅ |
afterCompletion | ⚠️ 不执行 | ✅ 执行 | ✅ 执行 |
| HTTP 状态 | 401 | 200 | 200 |
| 响应体 | 空 | 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,是纯身份凭证) |