链路 1:认证与授权的完整顺序
以“采购专员尝试新增物资(越权)”为例子
┌─────────────────────────────────────────────────────────────────┐
│ 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 异常 → 统一响应体(如果抛了异常) │
└─────────────────────────────────────────────────────────────────┘
请求的完整内容:
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)登陆时签发的,token 是在用户登陆时候时候生成的:
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.EMP_ID, employee.getId());
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 的负载这里只有 empId=5
redis 中只有login:token:{这一长串JWT}
登陆之后所有管理端请求:/admin/** (login 除外)
比如:/admin/emp/list
前端请求(请求头带token) → JwtTokenAdminInterceptor(拦截器,先执行!) → Controller → AOP 授权切面 RoleAspect → 业务代码
@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 状态:
线程 T-x 的 ThreadLocalMap
├── BaseContext.threadLocal → 5L
└── BaseContext.roleThreadLocal → "PURCHASER"
RoleAspect.checkRole() AOP 代理判断角色是否有权限调用该方法

针对采购专员:
| 代码 | 实际值 |
|---|---|
joinPoint.getSignature() | Result com.caiyuntai.controller.admin.GoodsController.update(GoodsDTO) |
signature.getMethod() | GoodsController.update(GoodsDTO) 的 Method 对象 |
method.getAnnotation(RequireRole.class) | @RequireRole(value={"ADMIN"}) → 不为 null |
| (不执行) | — |
| (不执行) | — |
requireRole.value() | String[]{"ADMIN"} |
allowed.length == 0 | 1 == 0 → false,不返回 |
BaseContext.getCurrentRole() | "PURCHASER"(第 4b 层写入的) |
currentRole == null | false |
anyMatch("ADMIN".equals("PURCHASER")) | false |
!pass | true → 抛异常 |
抛出异常后,不会执行 super.update()
// 代理对象里的等价逻辑
public Result update(GoodsDTO goodsDTO) {
aspect.checkRole(joinPoint); // ← 这里抛了异常
return super.update(goodsDTO); // ← ★★★ 这一行【永远不会执行】
}
链路 2:普通员工尝试登陆管理端
POST /admin/employee/login
Body: {"username":"zhangsan", "password":"123456"} ← zhangsan 是 EMPLOYEE
↓ 【拦截器不生效】
WebMvcConfiguration 里:admin 拦截器 excludePathPatterns("/admin/employee/login")
★ 这是唯一被豁免的管理端路径
↓ EmployeeController.login(EmployeeLoginDTO)
↓ EmployeeServiceImpl.login(dto)
├─ checkLogin(dto) ← 账号密码 + 状态校验
│ ├─ Redis GET login:fail:zhangsan ← 失败次数 ≥ 5 → 抛 AccountLockedException
│ ├─ employeeMapper.getByUsername("zhangsan")
│ ├─ password = DigestUtils.md5DigestAsHex(...) ← 无盐 MD5
│ ├─ 不匹配 → incrLoginFail → 抛 PasswordErrorException
│ ├─ status == DISABLE → 抛 AccountLockedException
│ └─ 成功 → Redis DEL login:fail:zhangsan
└─ if (RoleConstant.EMPLOYEE.equals(employee.getRole()))
throw new AccountLockedException(MessageConstant.NO_ADMIN_PERMISSION)
← ★ "当前账号无权登录管理端"
↓ GlobalExceptionHandler → Result.error("当前账号无权登录管理端")
对照:同一套账号打 POST /user/user/login → staffLogin() → 不做角色检查 → 登录成功
管理员有关的 controller 是 EmployeeController,而员工有关的 controller是UserController

在 checkLogin 里面:
- 密码不对 →
incrLoginFail():Redis 的失败次数 + 1;第一次失败时给 key 设置 15 分钟过期 - 抛出
PasswordErrorException,登录失败,不会下发 token。
连续失败达到 MAX_LOGIN_FAIL 阈值(比如 5 次),下次直接进入判断,抛出账号锁定异常,拒绝登录,防止暴力撞库。