链路 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}