链路 1:管理端登陆

POST /admin/employee/login
Body: {"username":"purchaser","password":"123456"}

┌─【第 0 层】★ 拦截器【不生效】
│    WebMvcConfiguration.addInterceptors:
│      admin 拦截器 addPathPatterns("/admin/**")
│                   .excludePathPatterns("/admin/employee/login")   ← ★ 唯一的豁免路径
│    ※ 如果不豁免,就会变成"要先登录才能调用登录接口"的死循环
│
├─【第 1 层】EmployeeController.login(EmployeeLoginDTO) 
│    ├─ log.info("员工登录:{}", employeeLoginDTO)              ★★ 见 M1-1:整个 DTO 被打印
│    │     → 明文密码进入日志:"员工登录:EmployeeLoginDTO(username=purchaser, password=123456)"
│    └─ employeeService.login(dto)
│
├─【第 2 层】EmployeeServiceImpl.login(dto)           
│    ├─ checkLogin(dto)                                        ← 共用校验(见下面展开)
│    └─ if (RoleConstant.EMPLOYEE.equals(employee.getRole()))
│           throw new AccountLockedException(NO_ADMIN_PERMISSION)
│              ★ 端隔离:普通员工不允许登录管理端
│              ⚠️ 复用了 AccountLockedException,异常类语义与消息不匹配(M4-8)
│
│    ┌── checkLogin(dto) 展开(第 77-111 行)───────────────────┐
│    │ ① failKey = "login:fail:purchaser"                       │
│    │ ② Redis GET failKey                                       │
│    │      计数 >= 5 → throw AccountLockedException(            │
│    │                     "登录失败次数过多,请15分钟后再试")     │
│    │ ③ employeeMapper.getByUsername("purchaser")     ← MySQL  │
│    │      null → Redis INCR failKey → throw AccountNotFound    │
│    │ ④ password = DigestUtils.md5DigestAsHex(password)        │
│    │      != employee.getPassword()                           │
│    │        → Redis INCR failKey → throw PasswordErrorException│
│    │ ⑤ status == DISABLE → throw AccountLockedException(锁定) │
│    │ ⑥ Redis DEL failKey            ← 成功清空计数             │
│    │ ⑦ return employee                                       │
│    └──────────────────────────────────────────────────────────┘
│
├─【第 3 层】★ 签发 JWT                              EmployeeController 第 62-67 行
│    Map<String,Object> claims = new HashMap<>();
│    claims.put(JwtClaimsConstant.EMP_ID, employee.getId());   ← ★ 只放 empId
│    String token = JwtUtil.createJWT(
│            jwtProperties.getAdminSecretKey(),   // "itcast"
│            jwtProperties.getAdminTtl(),         // 7200000
│            claims);
│    ★ JWT 内容是 {"empId":5,"exp":...} —— 【不含任何权限信息】
│
├─【第 4 层】★ 写 Redis 白名单                    
│    stringRedisTemplate.opsForValue().set(
│        "login:token:" + token,                 // 键 = 完整 JWT(约 150-200 字符)
│        employee.getId().toString(),            // 值 = 员工 id(后续不校验)
│        7200000L, TimeUnit.MILLISECONDS);       // ★ 与 JWT 的 exp 严格对齐
│
├─【第 5 层】组装 EmployeeLoginVO                  
│    .id / .userName / .name / .role / .deptId
│    .deptName(departmentService.getNameById(deptId))   ← 额外一次 MySQL 查询(M6)
│    .token(token)
│
└─【第 6 层】Result.success(EmployeeLoginVO)
     ⚠️ 响应体里有 token,前端存 localStorage(不是 Cookie → 所以不需要 CSRF 防护)

链路 2:员工端登陆

POST /user/user/login
Body: {"username":"zhangsan","password":"123456"}

   ↓ 拦截器豁免:user 拦截器 excludePathPatterns("/user/user/login")
     (同时还豁免了 "/user/shop/status"——那是采购开关查询,未登录也能看)
   ↓ UserController.login                               
   ├─ log.info("员工采购端登录:{}", employeeLoginDTO.getUsername())   ★ 只打用户名(比管理端正确)
   ├─ employeeService.staffLogin(dto)    
   │     └─ return checkLogin(dto)        ← 与登录共用的校验,【不做角色检查】
   │           ★ 任意在职员工都能登录员工端
   ├─ claims.put(JwtClaimsConstant.EMP_ID, employee.getId())   ← 统一用 empId(不是 userId)
   ├─ JwtUtil.createJWT(userSecretKey="itheima", userTtl=7200000, claims)
   ├─ Redis SET login:token:{token} → empId,TTL 7200000 ms
   └─ Result.success(EmployeeLoginVO)     ← 结构与管理端完全一样,靠 role 区分前端可用功能

链路 3:每个已认证请求的校验

GET /admin/goods/page
Header: token = {JWT}

   ↓ JwtTokenAdminInterceptor.preHandle()                    第 49 行
   ├─【1】handler instanceof HandlerMethod ?
   │      不是(静态资源等)→ return true 直接放行
   │      ※ 注意:静态资源虽然放行,但它【不会清除 ThreadLocal】——
   │        所以 afterCompletion 仍然会被调用(见第 7 步)
   ├─【2】token = request.getHeader("token")
   ├─【3】Claims claims = JwtUtil.parseJWT("itcast", token)
   │      ├─ 验签(HS256)失败 → SignatureException → catch → 401
   │      └─ exp 已过期 → ExpiredJwtException → catch → 401
   ├─【4】empId = Long.valueOf(claims.get("empId").toString())
   ├─【5】★ Redis GET login:token:{token}          ← 第 67-68 行
   │      返回 null(已登出/被踢/TTL 到期)→ response.setStatus(401); return false
   │      ⚠️ 只判空,【不比对 loginValue 与 empId 是否一致】(M1-5)
   ├─【6】★ employee = employeeMapper.getById(empId)    ← MySQL,每次请求都查(M4-6)
   │      ├─ employee == null                      → 401
   │      ├─ role == "EMPLOYEE"                    → 401   ← 端隔离
   │      └─ status == DISABLE                     → 401   ← 账号停用即时失效
   ├─【7】BaseContext.setCurrentId(empId)
   │      BaseContext.setCurrentRole(employee.getRole())
   └─ return true
   
   ↓ ... 控制器执行 ...
   
   ↓ afterCompletion()                                        第 98-100 行
      BaseContext.clear()      ← ★ 无论成功/失败/异常都执行

链路 4:退出即失效

【写】登录 → Redis SET login:token:{JWT} = empId, TTL 7200000ms
   ↓
【读】每个请求 → Redis GET login:token:{JWT}
      不存在 → 401
   ↓
【删】登出 → Redis DEL login:token:{JWT}