外观
06 - 认证鉴权
知识图谱
HTTP Request
│
│ Header: authorization: {token}
▼
┌────────────────────────────────────────────────────┐
│ RefreshTokenInterceptor (order=0, 匹配 /**) │
│ │
│ 1. 从请求头获取 token │
│ 2. 无 token → 放行(不设 UserHolder) │
│ 3. 有 token → Redis HGETALL login:token:{token} │
│ 4. 无数据 → 放行 │
│ 5. 有数据 → Map → UserDTO → UserHolder.saveUser() │
│ 6. 刷新 token TTL (36000 min) │
│ 7. 放行 │
│ │
│ afterCompletion: UserHolder.removeUser() │
└────────────────────┬───────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ LoginInterceptor (order=1, 排除公开路径) │
│ │
│ 排除: /shop/** /voucher/** /shop-type/** │
│ /upload/** /blog/hot /user/code /user/login │
│ │
│ 1. 检查 UserHolder.getUser() │
│ 2. null → 返回 401 │
│ 3. 非 null → 放行 │
└────────────────────────────────────────────────────┘核心代码走读
双拦截器配置
文件: src/main/java/com/hmdp/config/MvcConfig.java
java
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 第一层: Token 刷新 (所有路径)
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0); // 最先执行
// 第二层: 登录校验 (排除公开路径)
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**", "/voucher/**", "/shop-type/**",
"/upload/**", "/blog/hot", "/user/code", "/user/login"
).order(1); // 后执行
}RefreshTokenInterceptor
文件: src/main/java/com/hmdp/utils/RefreshTokenInterceptor.java
java
@Override
public boolean preHandle(HttpServletRequest request, ...) {
// 1. 获取 token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true; // 无 token → 放行, 让 LoginInterceptor 决定
}
// 2. 从 Redis Hash 获取用户数据
String key = LOGIN_USER_KEY + token; // login:token:{token}
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) {
return true; // token 过期或无效
}
// 3. Map → UserDTO → ThreadLocal
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
// 4. 刷新 token TTL
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// LOGIN_USER_TTL = 36000L, 单位 MINUTES → 36000 分钟 = 600 小时 = 25 天
return true;
}
@Override
public void afterCompletion(...) {
UserHolder.removeUser(); // 清理 ThreadLocal, 防止内存泄漏
}登录注册流程
文件: src/main/java/com/hmdp/service/impl/UserServiceImpl.java
java
// ========== 发送验证码 ==========
public Result sendCode(String phone, HttpSession session, HttpServletRequest request) {
// 1. 校验手机号格式
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2. 三层限流防刷
// 2.1 锁: 1分钟内不能重复发送
String phoneGetCodeLock = stringRedisTemplate.opsForValue()
.get(GET_CODE_LOCK + phone);
if (phoneGetCodeLock != null) {
return Result.fail("获取验证码过快,请稍后重试!");
}
// 2.2 手机号黑名单: 400次/24h
String blacklistPhoneCount = stringRedisTemplate.opsForValue()
.get(GET_CODE_BLACKLIST_PHONE + phone);
int getCodePhoneCount = (blacklistPhoneCount != null) ?
Integer.parseInt(blacklistPhoneCount) : 0;
if (getCodePhoneCount >= 400) {
return Result.fail("该手机号已被限制!");
}
// 2.3 IP黑名单: 300次/24h
String blacklistIpAddrCount = stringRedisTemplate.opsForValue()
.get(GET_CODE_BLACKLIST_IP_ADDR + clientIpAddr);
int getCodeIpAddrCount = (blacklistIpAddrCount != null) ?
Integer.parseInt(blacklistIpAddrCount) : 0;
if (getCodeIpAddrCount >= 300) {
return Result.fail("您的IP已被限制!");
}
// 3. 更新限流计数器
stringRedisTemplate.opsForValue().set(
GET_CODE_BLACKLIST_PHONE + phone,
String.valueOf(getCodePhoneCount + 1),
REFRESH_BLACKLIST_TTL, TimeUnit.HOURS); // 24小时
stringRedisTemplate.opsForValue().set(
GET_CODE_LOCK + phone, "1",
GET_CODE_LOCK_TTL, TimeUnit.MINUTES); // 1分钟
// 4. 生成验证码
String code = "123123"; // ⚠️ 硬编码! 开发遗留
// 5. 存入 Redis (2分钟过期)
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone, code,
LOGIN_CODE_TTL, TimeUnit.MINUTES);
return Result.ok();
}
// ========== 登录 ==========
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
// 2. 校验验证码 (Redis 中存的 vs 用户输入的)
String cacheCode = stringRedisTemplate.opsForValue()
.get(LOGIN_CODE_KEY + phone);
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 3. 查询或创建用户
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
// 4. 生成 Token → 存入 Redis Hash
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, ...);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.HOURS);
// ⚠️ 注意: 这里用 TimeUnit.HOURS, 而 RefreshTokenInterceptor 用 MINUTES
return Result.ok(token);
}Bitmap 签到
java
// 签到
public Result sign() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String key = USER_SIGN_KEY + userId +
now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
int dayOfMonth = now.getDayOfMonth();
// SETBIT sign:{userId}:202403 14 1 (第15天签到)
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
// 统计连续签到天数
public Result signCount() {
// BITFIELD sign:{userId}:202403 GET u15 0
// 获取从第0位开始的15个位(本月前15天的签到记录)
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0)
);
Long num = result.get(0);
// 从最低位开始数连续的1
int count = 0;
while (true) {
if ((num & 1) == 0) break; // 遇到0停止
count++;
num >>>= 1; // 无符号右移
}
return Result.ok(count);
}面试 Q&A
Q1: 为什么用双拦截器?一个不行吗?
单拦截器的问题:如果 LoginInterceptor 排除了
/shop/**,那么用户访问商铺页面时不会执行拦截器,Token TTL 不会刷新。用户可能在浏览商铺时 Token 过期。双拦截器设计:
RefreshTokenInterceptor(order=0):匹配所有路径,负责 Token 刷新和 UserHolder 填充LoginInterceptor(order=1):排除公开路径,只负责鉴权这样用户访问任何页面(包括公开页面)都会刷新 Token,不会因为"只看不操作"而被登出。
追问:如果把刷新逻辑放在 LoginInterceptor 里,排除路径设为空呢?
不行。排除路径为空意味着所有请求都需要登录,未登录用户连商铺列表都看不了。
双拦截器的精妙在于:Token 刷新和权限校验解耦。第一层"尽力刷新"(有 Token 就刷新,没有也放行),第二层"严格校验"(保护路径必须登录)。
Q2: Token TTL 有个不一致的地方,你注意到了吗?
是的,这是一个隐蔽的 Bug:
位置 常量 单位 实际时长 login()创建 TokenLOGIN_USER_TTL = 36000TimeUnit.HOURS36000小时 ≈ 4年 RefreshTokenInterceptor刷新LOGIN_USER_TTL = 36000TimeUnit.MINUTES36000分钟 ≈ 25天 同一个常量在两处用了不同的 TimeUnit!创建时 Token 能活 4 年,但每次刷新后 TTL 被缩短为 25 天。
更合理的做法:统一为
TimeUnit.MINUTES配合一个合理的值(如 30 分钟或 7 天)。
Q3: 为什么用 Redis Hash 存 Token 而不是 String?
Hash 优势:
- 部分字段读取:可以
HGET单个字段,不需要反序列化整个对象- 部分字段更新:可以
HSET更新单个字段- 内存效率:小 Hash(< 512 字段)使用 ziplist 编码,比 JSON String 更省内存
String 优势:
- 整体读写更简单(序列化/反序列化一步到位)
- 不需要处理类型转换(Hash 的 value 都是 String)
项目选 Hash 是合理的——UserDTO 只有 3 个字段(id, phone, nickName),很适合 Hash。
追问:BeanUtil.beanToMap 那段有什么作用?
javaMap<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));关键是
setFieldValueEditor:把所有字段值转为 String。因为StringRedisTemplate的 Hash 只接受<String, String>,UserDTO 的id是 Long 类型,不转会报 ClassCastException。
Q4: ThreadLocal 有什么需要注意的?
内存泄漏风险:Tomcat 使用线程池,线程被复用。如果不在请求结束时清理 ThreadLocal,前一个用户的数据可能"泄漏"给下一个请求。
项目中通过
afterCompletion调用UserHolder.removeUser()来清理。但有个风险:如果preHandle中抛异常,afterCompletion仍然会执行(Spring MVC 保证),所以不会泄漏。
追问:如果用 @Async 异步方法,能拿到 UserHolder 的数据吗?
不能。
@Async会在新线程中执行,而 ThreadLocal 是线程隔离的。解决方案:
- 在调用异步方法前取出 userId 作为参数传入
- 使用
InheritableThreadLocal(但线程池场景下仍有问题)- 使用
TransmittableThreadLocal(阿里开源,专门解决线程池传递问题)
Q5: Bitmap 签到的空间效率怎么样?
极致高效:
- 每天 1 bit,一个月 31 bits ≈ 4 bytes
- 一个用户一年:12 keys × 4 bytes = 48 bytes
- 100 万用户一年:48 MB
对比数据库方案:每天一行记录,一个用户一年 365 行,100 万用户 3.65 亿行。
追问:连续签到天数怎么算的?
用
BITFIELD GET u{n} 0取出本月前 n 天的签到位图(作为十进制数),然后从最低位(今天)开始逐位与1做"与运算":假设签到位图: 1110110 (第1,2,3,5,6天签到, 第4天未签到) 反转后从低位算: 0110111 0110111 & 1 = 1 → count=1, 右移 011011 & 1 = 1 → count=2, 右移 01101 & 1 = 1 → count=3, 右移 0110 & 1 = 0 → 中断! 返回 count=3
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
| Token TTL 单位不一致 | login() 用 HOURS, interceptor 用 MINUTES | 细节观察力 |
| 验证码硬编码 | code = "123123" 开发遗留 | 安全意识 |
| 限流计数器非原子 | GET + 判断 + SET 不是原子操作 | 并发安全 |
| ThreadLocal 清理 | 必须在 afterCompletion 中 removeUser | 内存泄漏 |
| IP 获取方式 | request.getRemoteAddr() 在反向代理后获取的是代理IP | 运维知识 |
加分回答
- 提到
request.getRemoteAddr()应改为request.getHeader("X-Forwarded-For")(Nginx 场景) - 分析限流计数器的并发问题:两个请求同时 GET 到 count=399,都判断 < 400,都 SET 为 400 → 实际发了 401 次。可用 Redis INCR 原子操作替代
- 提到可以用 Redis + Lua 实现滑动窗口限流(比固定窗口更精确)
- 讨论 Token 方案 vs JWT:Token 需要存储但可以服务端主动失效,JWT 无状态但无法主动踢人
关联文档
- 00-项目总览与架构 — 请求生命周期
- 01-Redis缓存设计 — Redis 数据结构
- 11-已知问题与优化方向 — Token TTL Bug、验证码硬编码