链路 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 == 01 == 0 → false,不返回
BaseContext.getCurrentRole()"PURCHASER"(第 4b 层写入的)
currentRole == nullfalse
anyMatch("ADMIN".equals("PURCHASER"))false
!passtrue → 抛异常

抛出异常后,不会执行 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 次),下次直接进入判断,抛出账号锁定异常,拒绝登录,防止暴力撞库。