【Redis | 黑马点评】短信登录功能 源码深度分析

一、分析范围

本次功能:手机号 + 短信验证码登录

git diff --stat HEAD 显示本次变更涉及 6 个文件,这就是本次功能的完整战场:

文件 变更 职责
service/impl/UserServiceImpl.java +81 核心业务:发码、登录
utils/RefreshTokenInterceptor.java +56 新增:从 Redis 恢复用户 + 续期
config/MvcConfig.java +38 注册两个拦截器,指定拦截路径和顺序
utils/LoginInterceptor.java ±19 改造:从"查 Redis"变成"只看 ThreadLocal"
controller/UserController.java ±5 HTTP 入口
entity/User.java ±8 实体

不在本次范围 :密码登录(LoginFormDTO.password 存在但未被使用)、登出(UserController.logout 仍返回"功能未完成")。


二、业务需求分析

2.1 业务背景

黑马点评是一个"本地生活点评"类应用(类似大众点评)。要发布探店笔记、点赞、关注店铺,系统必须知道"你是谁"。但让用户注册时填一堆资料会劝退大量用户。

所以采用手机验证码登录:输手机号 → 收验证码 → 填验证码 → 完成。

2.2 这个功能解决的核心矛盾

HTTP 是无状态的,但业务需要"记住"用户。

浏览器每次请求都是独立的,服务器默认无法区分"这次请求和上次请求是同一个人"。登录功能的本质就是:在无状态协议之上,人为建立一条"会话"的线索。

2.3 触发条件与输入输出

发验证码 /user/code 登录 /user/login
触发 用户在登录页输入手机号后点「发送验证码」 用户填完验证码点「登录」
输入 phone(查询参数) phone + code(JSON body)
输出 无 data,只有成功/失败 token 字符串
副作用 往 Redis 写一个 2 分钟有效期的验证码 往 Redis 写一个 25 天有效期的用户信息

2.4 核心业务规则

【源码】UserServiceImpl.java

  1. 手机号必须符合正则 ^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\d{8}$
  2. 验证码是 6 位纯数字,有效期 2 分钟
  3. 用户不存在时自动注册(这是关键的商业决策:注册和登录合并成一步,降低门槛)
  4. 登录成功后返回 token,用户信息本身不下发给前端,只给 token
  5. token 有效期 25 天,并且每次访问都会被续期(滑动过期)

2.5 本功能在项目中的位置

它是整个项目的地基。后面所有的"探店笔记发布""点赞""关注"功能,都要先经过这套登录链路拿到当前用户。这套链路一旦坏了,整个项目所有需要登录的功能全部瘫痪。


三、调用链路梳理

3.1 全链路流程图

#mermaid-svg-7rZoHkvO5rWyJrOk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7rZoHkvO5rWyJrOk .error-icon{fill:#552222;}#mermaid-svg-7rZoHkvO5rWyJrOk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7rZoHkvO5rWyJrOk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7rZoHkvO5rWyJrOk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7rZoHkvO5rWyJrOk .marker.cross{stroke:#333333;}#mermaid-svg-7rZoHkvO5rWyJrOk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7rZoHkvO5rWyJrOk p{margin:0;}#mermaid-svg-7rZoHkvO5rWyJrOk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk .cluster-label text{fill:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk .cluster-label span{color:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk .cluster-label span p{background-color:transparent;}#mermaid-svg-7rZoHkvO5rWyJrOk .label text,#mermaid-svg-7rZoHkvO5rWyJrOk span{fill:#333;color:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk .node rect,#mermaid-svg-7rZoHkvO5rWyJrOk .node circle,#mermaid-svg-7rZoHkvO5rWyJrOk .node ellipse,#mermaid-svg-7rZoHkvO5rWyJrOk .node polygon,#mermaid-svg-7rZoHkvO5rWyJrOk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7rZoHkvO5rWyJrOk .rough-node .label text,#mermaid-svg-7rZoHkvO5rWyJrOk .node .label text,#mermaid-svg-7rZoHkvO5rWyJrOk .image-shape .label,#mermaid-svg-7rZoHkvO5rWyJrOk .icon-shape .label{text-anchor:middle;}#mermaid-svg-7rZoHkvO5rWyJrOk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7rZoHkvO5rWyJrOk .rough-node .label,#mermaid-svg-7rZoHkvO5rWyJrOk .node .label,#mermaid-svg-7rZoHkvO5rWyJrOk .image-shape .label,#mermaid-svg-7rZoHkvO5rWyJrOk .icon-shape .label{text-align:center;}#mermaid-svg-7rZoHkvO5rWyJrOk .node.clickable{cursor:pointer;}#mermaid-svg-7rZoHkvO5rWyJrOk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7rZoHkvO5rWyJrOk .arrowheadPath{fill:#333333;}#mermaid-svg-7rZoHkvO5rWyJrOk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7rZoHkvO5rWyJrOk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7rZoHkvO5rWyJrOk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7rZoHkvO5rWyJrOk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7rZoHkvO5rWyJrOk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7rZoHkvO5rWyJrOk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7rZoHkvO5rWyJrOk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7rZoHkvO5rWyJrOk .cluster text{fill:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk .cluster span{color:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7rZoHkvO5rWyJrOk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7rZoHkvO5rWyJrOk rect.text{fill:none;stroke-width:0;}#mermaid-svg-7rZoHkvO5rWyJrOk .icon-shape,#mermaid-svg-7rZoHkvO5rWyJrOk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7rZoHkvO5rWyJrOk .icon-shape p,#mermaid-svg-7rZoHkvO5rWyJrOk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7rZoHkvO5rWyJrOk .icon-shape .label rect,#mermaid-svg-7rZoHkvO5rWyJrOk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7rZoHkvO5rWyJrOk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7rZoHkvO5rWyJrOk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7rZoHkvO5rWyJrOk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /api/user/code

phone=138xxxx
否
是
POST /api/user/login

JSON + header authorization
有
无
是 /user/login
否
无
有
否
是
否
是
否
是
浏览器 login.html
nginx 反向代理
UserController.sendCode
UserServiceImpl.sendCode
手机号格式合法?
Result.fail 手机号格式错误
RandomUtil.randomNumbers 6
Redis: login:code:phone

TTL 2min
返回 ok
浏览器填验证码点登录
RefreshTokenInterceptor.preHandle

order=0 拦截 /**
有 authorization 头?
Redis 读 login:token:xxx
UserHolder.saveUser

ThreadLocal
Redis expire 续期 25天
直接放行
请求路径是否被

LoginInterceptor 排除?
UserController.login
ThreadLocal 有用户?
401 未登录
UserServiceImpl.login
手机号合法?
fail
Redis 读 login:code:phone
code 一致?
fail 验证码错误
MySQL: select * from tb_user

where phone=?
用户存在?
createUserWithPhone

insert 新用户
已有用户
UUID 生成 token
User -> UserDTO

脱敏
Redis: login:token:xxx

HSET id/nickName/icon

TTL 25天
返回 token

3.2 请求进入后端时,谁先执行?

UserController LoginInterceptor order=1 · 排除部分路径 RefreshTokenInterceptor order=0 · 拦截 /** 浏览器 UserController LoginInterceptor order=1 · 排除部分路径 RefreshTokenInterceptor order=0 · 拦截 /** 浏览器 #mermaid-svg-HDaZmdqf75RthiKy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HDaZmdqf75RthiKy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HDaZmdqf75RthiKy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HDaZmdqf75RthiKy .error-icon{fill:#552222;}#mermaid-svg-HDaZmdqf75RthiKy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HDaZmdqf75RthiKy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HDaZmdqf75RthiKy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HDaZmdqf75RthiKy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HDaZmdqf75RthiKy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HDaZmdqf75RthiKy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HDaZmdqf75RthiKy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HDaZmdqf75RthiKy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HDaZmdqf75RthiKy .marker.cross{stroke:#333333;}#mermaid-svg-HDaZmdqf75RthiKy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HDaZmdqf75RthiKy p{margin:0;}#mermaid-svg-HDaZmdqf75RthiKy .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HDaZmdqf75RthiKy text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-HDaZmdqf75RthiKy .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HDaZmdqf75RthiKy .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-HDaZmdqf75RthiKy .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-HDaZmdqf75RthiKy .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-HDaZmdqf75RthiKy #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-HDaZmdqf75RthiKy .sequenceNumber{fill:white;}#mermaid-svg-HDaZmdqf75RthiKy #sequencenumber{fill:#333;}#mermaid-svg-HDaZmdqf75RthiKy #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-HDaZmdqf75RthiKy .messageText{fill:#333;stroke:none;}#mermaid-svg-HDaZmdqf75RthiKy .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HDaZmdqf75RthiKy .labelText,#mermaid-svg-HDaZmdqf75RthiKy .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-HDaZmdqf75RthiKy .loopText,#mermaid-svg-HDaZmdqf75RthiKy .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-HDaZmdqf75RthiKy .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-HDaZmdqf75RthiKy .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-HDaZmdqf75RthiKy .noteText,#mermaid-svg-HDaZmdqf75RthiKy .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-HDaZmdqf75RthiKy .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HDaZmdqf75RthiKy .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HDaZmdqf75RthiKy .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-HDaZmdqf75RthiKy .actorPopupMenu{position:absolute;}#mermaid-svg-HDaZmdqf75RthiKy .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-HDaZmdqf75RthiKy .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-HDaZmdqf75RthiKy .actor-man circle,#mermaid-svg-HDaZmdqf75RthiKy line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-HDaZmdqf75RthiKy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 读 header 拿 token 2. 查 Redis 拿用户 3. 存入 ThreadLocal 4. Redis 续期 永远 return true 只看 UserHolder.getUser() 为空 → 401 + return false POST /user/me (header: authorization=abc) 放行 放行 从 ThreadLocal 取当前用户

顺序的意义 :RefreshTokenInterceptor 负责"把用户找出来放进 ThreadLocal",LoginInterceptor 负责"判断 ThreadLocal 里有没有用户"。必须前者先跑,否则后者永远看到空的 ThreadLocal,所有已登录用户都会被判定成未登录。

这个顺序由 MvcConfig 里的 .order(0) 和 .order(1) 强制保证 ------ 不能依赖注册先后顺序,因为两个拦截器的默认 order 都是 0。

3.3 核心类职责表

类 层 职责
UserController 表现层 接收 HTTP 参数,转调 Service。不做业务判断 (除了 info 接口里清了下 createTime)
IUserService / UserServiceImpl 业务层 所有业务规则:格式校验、验证码比对、自动注册、token 生成
UserMapper 持久层 extends BaseMapper<User>,没有任何自定义方法,全靠 MyBatis-Plus 提供的 CRUD
UserHolder 工具 基于 ThreadLocal<UserDTO> 的当前用户容器
RefreshTokenInterceptor 拦截器 从 Redis 恢复用户到 ThreadLocal + 续期
LoginInterceptor 拦截器 判断登录态,未登录返回 401
MvcConfig 配置 注册拦截器、指定路径与顺序

注意 :UserMapper 是空接口【源码 UserMapper.java:14-16】。query().eq("phone", phone).one() 和 save(user) 都是 MyBatis-Plus 的 IService/BaseMapper 提供的通用方法,不是项目自己写的。这是很多人第一次读这类项目时的困惑点。


四、核心代码深度解读

4.1 发验证码:UserServiceImpl.sendCode

java 复制代码
// UserServiceImpl.java:46-62
public Result sendCode(String phone, HttpSession session) {
    //0.归一化:输入法可能会打出全角数字,先转成半角再校验
    phone = toHalfWidth(phone);
    //1.校验手机号
    if(RegexUtils.isPhoneInvalid(phone)){
        return Result.fail("手机号格式错误!");
    }
    //3.如果符合,生成验证码
    String code = RandomUtil.randomNumbers(6);
    //4.将验证码保存到redis
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //5.发送验证码
    log.debug("发送短信验证码成功!验证码:{}",code);
    return Result.ok();
}

逐点解释:

  • 为什么第 0 步要全角转半角? 这是本项目特有的、教程里没有的 防御代码【源码 toHalfWidth 见 UserServiceImpl.java:110-130】。全角数字 1 是 U+FF11,半角 1 是 U+0031,两个不同字符,equals() 不相等。中文输入法在全角模式下打出的数字肉眼几乎一样,会导致校验和比对莫名失败 。这不是业务需求,是血泪教训的防御性编程。

  • LOGIN_CODE_KEY + phone = login:code:13800000000 ------ 用手机号做 key 后缀,天然实现了"同一手机号只保留最新一条验证码",重发会覆盖。

  • LOGIN_CODE_TTL = 2L + TimeUnit.MINUTES 【源码 RedisConstants.java:5】= 120 秒。为什么这么短?验证码是一次性凭证,窗口越短被截获/爆破的概率越低。

  • log.debug 而不是真的发短信 ------ 项目没接短信服务商,验证码只打日志。application.yaml:27 配了 logging.level.com.hmdp: debug 才能看见。这就是你登录时"验证码"的来源:看控制台。

  • HttpSession session 参数完全没用到 ------ 模板遗留。但因为方法签名有它,Spring MVC 会调用 request.getSession(),导致每个浏览器第一次请求时现场创建 JSESSIONID(这也解释了之前那条 SecureRandom ... took 573 milliseconds 的 WARN)。

4.2 登录主流程:UserServiceImpl.login

java 复制代码
// UserServiceImpl.java:65-99
public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = toHalfWidth(loginForm.getPhone());
    //1.校验手机号
    if(RegexUtils.isPhoneInvalid(phone)){
        return Result.fail("手机号格式错误!");
    }
    //3.校验验证码
    String code = toHalfWidth(loginForm.getCode());
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);
    //4.不一致,报错
    if(cacheCode==null || !cacheCode.equals(code)){
        return Result.fail("验证码错误!");
    }
    //5.一致,根据手机号查询用户
    User user = query().eq("phone", phone).one();
    //6.判断用户是否存在
    if(user==null){
        user=createUserWithPhone(phone);   // 自动注册
    }
    //8.1随机生成token作为登录令牌
    String token = UUID.randomUUID().toString();
    //8.2将user对象作为hash存储
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create()
                    .setIgnoreNullValue(true)
                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
    //8.3存储
    String tokenKey=LOGIN_USER_KEY+token;
    stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
    //8.4设置token有效期
    stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);
    return Result.ok(token);
}
关键点 1:cacheCode == null || !cacheCode.equals(code) 的顺序

【源码】这里 cacheCode == null 的判空必须放在前面 。如果写成 !cacheCode.equals(code),当验证码已过期(Redis 返回 null)时会直接 NPE ,被 WebExceptionAdvice 兜底成"服务器异常"------又是一个会让人查半天的坑。

关键点 2:自动注册(user == null 分支)
java 复制代码
// UserServiceImpl.java:101-108
private User createUserWithPhone(String phone) {
    User user=new User();
    user.setPhone(phone);
    user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
    save(user);
    return user;
}

USER_NICK_NAME_PREFIX = "user_"【源码 SystemConstants.java:5】。

运行证据 :Redis 里实际存的昵称是 user_o4e6fyq51v ------ "user_" + "o4e6fyq51v"(10 位随机字符串)【运行:redis-cli hgetall】。源码和运行结果完全对得上。

save(user) 之后 user.getId() 会自动被回填 (因为 @TableId(type = IdType.AUTO) 配合 MyBatis-Plus 的 useGeneratedKeys),所以紧接着 copyProperties 时 id 已经有值了。这是后面 id=1012 能存进 Redis 的前提。

关键点 3:为什么先转 UserDTO 再存?

【源码】UserDTO 只有 3 个字段:

java 复制代码
// UserDTO.java
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

对比 User 实体:多了 password 、phone、createTime、updateTime。

这是一次有意的脱敏。 如果把整个 User 塞进 Redis,密码(即使加密过)就会散落在缓存里,任何能访问 Redis 的人都能批量拖走。用 DTO 白名单式地只放必要字段,是安全设计的标准动作。

运行证据 :Redis 里那个 hash 的 HLEN = 3,字段正好是 id、nickName、icon【运行】。密码和手机号都不在里面。

关键点 4:那个 CopyOptions 到底在防什么?
java 复制代码
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
        CopyOptions.create()
                .setIgnoreNullValue(true)
                .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

这是本次功能中唯一的真实故障点

根因链条:

  1. UserDTO.id 是 Long 类型;
  2. BeanUtil.beanToMap(userDTO) 原样产出一个 Map<String, Object>,里面 id 的值是 Long;
  3. StringRedisTemplate 的 hash value 序列化器是 StringRedisSerializer;
  4. 这个序列化器的方法签名是 serialize(String string),泛型擦除后 JVM 会在方法入口插入一句 checkcast String;
  5. 传进去的是 Long → ClassCastException: class java.lang.Long cannot be cast to class java.lang.String。

为什么发验证码不炸、只有登录炸? 因为 sendCode 只往 Redis 写了一个 String 验证码,全程碰不到这个强转。登录这里是第一次往 Redis 写对象,才暴露出来。

setIgnoreNullValue(true) 的作用:跳过值为 null 的字段。为什么需要它------见下一节,

  • User 实体里 icon 字段有初始值 private String icon = "";【源码 User.java:59】,所以新建对象时它是空字符串,不是 null;

  • UserDTO 只含 id/nickName/icon 三个字段,createTime/updateTime 根本不在里面,所以也不存在"LocalDateTime 转不成 String"的问题;

  • 运行证据:Redis 那个 hash 的实际内容是

    复制代码
    nickName  -> user_o4e6fyq51v
    icon      -> (空字符串)
    id        -> 1012

    icon 确实存在于 hash 中,值是空字符串 。如果它是 null 且被忽略了,这个字段就不会出现在 HLEN=3 里。

在这个功能里,setIgnoreNullValue(true) 实际上没有起到作用 (因为没有 null 值),真正解决故障的只有 setFieldValueEditor 那一句。保留它可以,但它是防御性的、当前不生效的设置,不是修复的一部分。

4.3 Token 刷新:RefreshTokenInterceptor.preHandle

java 复制代码
// RefreshTokenInterceptor.java:25-49
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    //1.获取请求头中的token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)) {
        return true;                                   // ← 注意:放行,不是拦截
    }
    //2.基于token获取redis中的用户
    String key = RedisConstants.LOGIN_USER_KEY + token;
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
            .entries(RedisConstants.LOGIN_USER_KEY + token);
    //3.判断用户是否存在
    if(userMap.isEmpty()){
        return true;                                   // ← 同样放行
    }
    UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
    //5.保存用户信息到ThreadLocal
    UserHolder.saveUser(userDTO);
    //6.刷新token有效期
    stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
    return true;
}

最反直觉的一点:这个方法所有分支都 return true,从不拦截。

这是职责分离的体现:

  • RefreshTokenInterceptor 的职责是"尽力恢复登录态",它不做判断,只做准备工作;
  • LoginInterceptor 的职责才是"判断要不要拦"。

为什么这么拆?因为它们的作用范围不同:

RefreshTokenInterceptor LoginInterceptor
路径 /**(全部) 排除 shop/**、voucher/**、user/code、user/login 等
目的 让所有接口都能拿到当前用户(哪怕是不需要登录的接口) 只保护需要登录的接口

举例子:店铺详情页 /shop/1 不需要登录就能看,但如果用户已经登录了,页面右上角要显示他的头像 ------ 这就需要 RefreshTokenInterceptor 在后台默默把用户放进 ThreadLocal,而 LoginInterceptor 对这条路径不做拦截。

BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false) 的第三个参数 false 表示"不忽略转换错误"。这里能跑通的前提是:存进去的全是 String,fillBeanWithMap 会把字符串 "1012" 转回 Long 1012。读写两端是配套的 ------ 这也是为什么 setFieldValueEditor 那个修复不能只改一半。

afterCompletion 里 UserHolder.removeUser()【源码 `RefreshTokenInterceptor.java:52-55】:

java 复制代码
@Override
public void afterCompletion(..., @Nullable Exception ex) {
    UserHolder.removeUser();
}

为什么必须清理? Tomcat 用线程池处理请求,线程会被复用 。如果不清理,A 用户请求结束后 ThreadLocal 还留着他的数据,下一个请求恰好落到同一个线程上,就会读到别人的用户信息(典型的越权漏洞)。

为什么放在这里而不是 LoginInterceptor 里是安全的? Spring MVC 只在 preHandle 返回 true 的拦截器上调用 afterCompletion【通用技术原理】。RefreshTokenInterceptor 永远返回 true 且 order=0 最先执行,所以它的 afterCompletion 一定会被调用 ------ 即使后面 LoginInterceptor 返回了 401。

4.4 登录判断:LoginInterceptor.preHandle

java 复制代码
// LoginInterceptor.java:11-24
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    //1.判断是否需要拦截(ThreadLocal中是否有用户)
    if(UserHolder.getUser()==null){
        response.setStatus(401);
        return false;
    }
    return true;
}

简洁到极致 ------ 因为它不查 Redis、不管 token ,那些活都被 RefreshTokenInterceptor 干完了。

为什么用 response.setStatus(401) 而不是 Result.fail(...)? 因为前端 common.js:27-33 是靠 HTTP 状态码判断的:

javascript 复制代码
// nginx-1.18.0/html/hmdp/js/common.js
}, function (error) {
  if(error.response.status == 401){
    setTimeout(() => { location.href = "/login.html" }, 200);
    return Promise.reject("请先登录");
  }
  return Promise.reject("服务器异常");
});

注意 :如果这里返回的是 Result.fail("请先登录")(HTTP 200 + body 里 success=false),前端不会跳转登录页,只会弹一个错误提示 ------ 用户会卡在原页面反复点。这是前后端约定,不看前端代码是理解不了的。

4.5 前端如何携带 token:common.js

javascript 复制代码
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
  config => {
    if(token) config.headers['authorization'] = token
    return config
  }, ...)

这里有个值得注意的细节 :let token 是在脚本加载时读一次 sessionStorage。也就是说,用户在登录页面登录成功后写入 token,如果不刷新整页 ,这个闭包变量 token 还是旧的(null)。

这解释了为什么前端登录成功后通常会 location.href 跳转一次 ------ 让 common.js 重新执行、重新读 token。

存储位置 :sessionStorage 而不是 localStorage。sessionStorage 在关闭标签页后清空,安全性略高,代价是关掉浏览器就要重新登录。


五、关键技术原理

5.1 为什么用 Redis 存 token,而不是 HttpSession?

这是本项目最重要的架构决策,也是"实战篇"取代"基础篇"的核心原因。

HttpSession Redis + token
存储位置 单台服务器的 JVM 内存 独立的外部存储
多台服务器 ❌ A 服务器创建的 session,B 服务器不认识 → 用户刷新一下就掉线 ✅ 所有服务器共享同一份
重启服务器 ❌ 用户全部掉线 ✅ 不受影响
容量 受单机内存限制 可独立扩容
移动端 ❌ 依赖 Cookie ✅ 客户端自己保存并放到 header 里就行

HttpSession 的实现机制 :服务器创建 session 后,把 JSESSIONID 通过 Set-Cookie 发给浏览器,浏览器后续请求自动带上 Cookie,服务器用 JSESSIONID 去内存里找对应的 session 对象【通用技术原理】。

要害在于"去内存里找" ------ 内存是本机的。如果你部署了 3 台服务器,用 nginx 做负载均衡,用户的第一次请求落到 1 号机(session 存在 1 号机内存),第二次请求被转发到 2 号机,2 号机在自己内存里找不到 → 认为用户没登录。

这就是为什么项目从一开始就选择了 Redis 方案。本项目的所有代码里,登录相关信息没有一处依赖 HttpSession (HttpSession 参数在 UserController/UserServiceImpl 里纯属模板遗留,未被使用)。

5.2 ThreadLocal 是什么,为什么需要它?

基础概念 :ThreadLocal 是 Java 提供的一个容器,你往里存的值,只有当前线程能读到。

生活类比:想象一个快递分拣中心。每个分拣员(线程)都有一把自己的储物柜(ThreadLocal),钥匙只有他自己有。他把手上的包裹放进柜子,随时能取出来;别的分拣员打开自己的柜子,看到的永远是自己放的东西,不会串。

本项目中的应用 【源码 UserHolder.java】:

java 复制代码
public class UserHolder {
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
    public static void saveUser(UserDTO user){ tl.set(user); }
    public static UserDTO getUser(){ return tl.get(); }
    public static void removeUser(){ tl.remove(); }
}

它解决了什么痛苦? 如果没有 ThreadLocal,从拦截器拿到用户后,要传给 Controller,Controller 要传给 Service,Service 要传给下层......每一层方法签名都得加一个 UserDTO 参数。这是典型的"横切关注点"污染。

有了 ThreadLocal,任何一层的代码都能直接 UserHolder.getUser() 拿到当前用户 ------ UserController.me() 就是这么写的【源码 UserController.java:66-70】。

为什么能这么用? 因为 Tomcat 处理一次请求,从头到尾都在同一个线程上。拦截器存进去的,Controller 读得到。

风险(必须记住) :Tomcat 的线程是线程池复用 的。如果不 remove(),下一个复用到该线程的请求会读到上一次的数据。这就是 4.3 节 afterCompletion 里那句 removeUser() 存在的全部理由。

注意区分 :ThreadLocal 的"当前线程"指的是服务器处理请求的那个线程,不是浏览器。浏览器每发一个请求,服务器就分配一个线程;同一个浏览器连发两个请求,可能落到两个不同线程上。

5.3 Spring MVC 拦截器链的执行顺序

【源码 MvcConfig.java】:

java 复制代码
registry.addInterceptor(new LoginInterceptor())
        .excludePathPatterns(...)
        .order(1);                                          // 后执行
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
        .addPathPatterns("/**")
        .order(0);                                          // 先执行

三个必须分清的概念:

概念 属于谁 作用
addPathPatterns / excludePathPatterns InterceptorRegistration(登记单) 哪些路径需要走这个拦截器
order InterceptorRegistration 多个拦截器之间谁先谁后
preHandle / afterCompletion HandlerInterceptor(拦截器本身) 具体做什么

常见误区 :addInterceptor() 返回的是一个"登记单"(InterceptorRegistration)而不是拦截器本身。所以

java 复制代码
// ❌ 错:括号位置错了,addPathPatterns 加在拦截器上,它没这个方法
registry.addInterceptor(new RefreshTokenInterceptor(rt).addPathPatterns("/**"));

// ✅ 对:加在登记单上
registry.addInterceptor(new RefreshTokenInterceptor(rt)).addPathPatterns("/**");

关于 order 的注意事项 :两个拦截器的默认 order 都是 0 。order 相同时,Spring 退化为按注册顺序排。也就是说,如果你不写 .order(),就变成了"隐式依赖代码的书写顺序"------ 后人调整一下方法里的行序,登录就会莫名其妙全部 401。显式写 order 是在消除这种脆弱性。

5.4 StringRedisSerializer 的类型强转(本次故障的技术原理)

基础概念 :Spring Data Redis 里,任何 Java 对象要写进 Redis,都必须先序列化 成字节数组。不同的 RedisSerializer 实现就是不同的"翻译官"。

  • RedisTemplate 默认用 JdkSerializationRedisSerializer(把对象序列化成 Java 二进制)
  • StringRedisTemplate 用 StringRedisSerializer(只认字符串)

工作原理 :StringRedisSerializer.serialize 的签名是

java 复制代码
public byte[] serialize(String string) {
    return (string == null ? null : string.getBytes(charset));
}

泛型擦除之后,JVM 在方法入口插入 checkcast String。所以只要 value 不是 String,就一定抛 ClassCastException。

项目中的应用 :UserServiceImpl.java 里那个 setFieldValueEditor 把 Long 手动转成 String,就是在迁就这个"只认字符串的翻译官"。

注意事项 / 常见误区:

  • 写单个值时(opsForValue().set)通常传的就是 String,不会暴露问题;
  • 写 hash / list 等复合结构时,每一个元素都会过一次序列化器,此时最容易踩坑。

六、异常与边界条件

按规则区分三类:①已存在且确有影响的问题 · ②满足特定条件才出现的风险 · ③可选优化方向。不把所有理论风险都算作项目缺陷。

① 已存在且确有影响的问题

1. 验证码登录后未删除,可重复使用(重放)

  • 触发条件:用户成功登录后,在验证码 2 分钟有效期内,用同一个验证码 再次调用 /user/login
  • 代码位置:UserServiceImpl.java:74-78,比对完 cacheCode 后没有 delete(LOGIN_CODE_KEY + phone)
  • 原因:验证码被消费后没有立即失效
  • 影响:一次性凭证变成了"2 分钟内可无限次使用的凭证"。如果攻击者通过某种方式拿到了验证码,他有了 2 分钟的重复利用窗口
  • 当前是否处理:未处理
  • 可选方案:比对成功后立即 stringRedisTemplate.delete(LOGIN_CODE_KEY + phone)

2. 登出功能未实现

  • 代码位置:UserController.java:59-63,logout() 直接返回 Result.fail("功能未完成")
  • 影响:token 无法主动失效。用户点了"退出",服务端的 token 依然有效 25 天
  • 当前是否处理:未处理(属于课程后续内容)

3. RefreshTokenInterceptor 里 key 变量算了两遍

  • 代码位置:RefreshTokenInterceptor.java:32-34

    java 复制代码
    String key = RedisConstants.LOGIN_USER_KEY + token;          // 算了一次
    Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
            .entries(RedisConstants.LOGIN_USER_KEY + token);     // 又拼了一次
  • 影响:功能上完全正确 ,key 后面用于 expire。只是多了一次字符串拼接,可读性略差

  • 定性:这个是代码整洁问题,不是 bug。列在这里是提醒你不要误判

② 满足特定条件才出现的风险

4. 发验证码接口无频率限制

  • 触发条件:攻击者写脚本高频调用 /user/code?phone=受害者手机号
  • 代码位置:UserServiceImpl.java:46-62,没有任何次数/间隔限制
  • 影响:真实项目里会造成短信费用损失 和对用户的骚扰(短信轰炸)
  • 当前是否处理:未处理
  • 可选方案:按手机号 + IP 限流(Redis 计数,如 60 秒内最多 1 次)

5. 登录接口无失败次数限制

  • 触发条件:验证码是 RandomUtil.randomNumbers(6) 生成的6 位纯数字,共 100 万种可能,2 分钟内可无限次尝试
  • 影响:理论上可暴力枚举(尽管 2 分钟窗口 + 网络开销让实际可行性很低)
  • 当前是否处理:未处理
  • 可选方案:同手机号/IP 失败 5 次锁定一段时间

6. 同一手机号并发首次登录会撞唯一索引(数据安全,但报错不友好)

  • 触发条件:同一手机号并发 发起两次"首次登录"(此时 tb_user 中还没有该手机号)
  • 代码位置:UserServiceImpl.java:80-85。两个线程都执行 query().eq("phone", phone).one(),都得到 null,于是都去创建新用户
  • 数据库兜底(已实测) :tb_user.phone 上有唯一索引,索引名 uniqe_key_phone,Non_unique = 0【运行:SHOW INDEX FROM tb_user】。所以第二次 insert 会被 MySQL 直接拒绝(DuplicateKeyException),不会产生重复用户 ------ 数据层面是安全的。
  • 但用户看到的是:异常一路冒泡到 WebExceptionAdvice,返回 "服务器异常",而不是"操作太快,请重试"这类可理解的提示
  • 当前是否处理:数据已被数据库约束保护;用户体验层面未处理
  • 说明:这是"先查后插"在并发下的经典竞态。唯一索引是最后一道防线,但替代不了业务层的处理 ------ 更完整的做法是捕获 DuplicateKeyException 后改为查询已存在的用户再放行
  • 附带发现:这个索引名拼错了,是 uniqe_key_phone(少了一个 u),随课程原始建表语句一起带过来的

③ 可选优化方向(不算缺陷)

7. HttpSession 参数与 LoginFormDTO.password 字段闲置

  • HttpSession 参数在 UserController/UserServiceImpl 中均未被使用(模板遗留,副作用是会创建无用的 JSESSIONID)
  • LoginFormDTO.password 定义但未使用(密码登录属于后续内容)

8. toHalfWidth 只处理全角,不处理首尾空格

  • 手机号若带前后空格仍会被正则拒绝。不过结果是"明确报错"而非"静默错误",属于可接受的严格校验

七、知识关联

这个功能把你在基础篇学过的多个知识点串成了一条线:

本次用到的 关联的知识点 关系
UserDTO 脱敏 Java 面向对象 / 分层设计 DTO 是"对外传输的数据形状",和实体解耦
StringRedisSerializer 强转失败 Java 泛型擦除 擦除后 checkcast 是运行时才发生的,编译期无感知
CopyOptions 的 lambda Java 8 函数式接口 BiFunction<String, Object, Object>,和 Comparator 同理
ThreadLocal Java 并发 / 线程池 理解它必须先理解"线程复用"
拦截器 order Spring MVC AOP 思想 拦截器是横切关注点的一种实现(另一种是 @Aspect)
query().eq(...).one() MyBatis-Plus 的 IService 不用写 XML 就能 CRUD,是"约定优于配置"
@TableId(type = IdType.AUTO) MySQL 自增主键 MyBatis-Plus 靠它把自增 id 回填到对象

最值得注意的一条关联 :你在基础篇学的"泛型擦除"通常只出现在选择题里(List<String> 和 List<Integer> 运行时是同一个类)。但这次它导致了一个真实的运行时异常 ,而且 IDE 完全不会给你提示。这就是"理论知识点"变成"工程事故"的典型路径。


八、总结与理解检查

8.1 核心业务流程(一句话版)

用户拿手机号换验证码(存 Redis,2 分钟)→ 拿验证码换 token(用户不存在则自动注册,用户信息脱敏后存 Redis,25 天)→ 之后每个请求带着 token,由两个拦截器接力恢复登录态。

8.2 最关键的类与方法

类 · 方法 一句话职责
UserServiceImpl.sendCode 生成 6 位验证码,存 Redis 2 分钟
UserServiceImpl.login 校验验证码 → 查/建用户 → 生成 token → 脱敏存 Redis
RefreshTokenInterceptor.preHandle 从 token 恢复用户到 ThreadLocal + 续期(永不拦截)
LoginInterceptor.preHandle 只看 ThreadLocal,空则 401(不碰 Redis)
MvcConfig.addInterceptors 用 .order() 保证恢复在前、判断在后
UserHolder ThreadLocal 容器,必须在 afterCompletion 清理

8.3 本次涉及的技术原理

  1. 有状态化无状态协议:token 方案的出发点
  2. 为什么是 Redis 而不是 HttpSession:多机部署下 session 不共享
  3. ThreadLocal 与线程复用 :为什么必须 remove()
  4. Spring MVC 拦截器链 :order、preHandle/afterCompletion 的调用条件
  5. StringRedisSerializer 的强转:泛型擦除导致的真实运行时异常
  6. DTO 脱敏:为什么不让实体直接出境

8.4 容易产生的理解误区

误区 实际情况
"两个拦截器顺序就是注册顺序" 默认 order 都是 0,必须显式指定,否则依赖书写顺序
"RefreshTokenInterceptor 不拦截就是没用" 它的职责是准备数据,判断交给下一个拦截器 ------ 职责分离
"未登录应该返回 Result.fail()" 必须返回 HTTP 401,因为前端靠状态码跳转登录页
"UserMapper 是空接口所以没查数据库" query()/save() 来自 MyBatis-Plus 的 IService,SQL 是框架生成的
"验证码在 Redis 里就是 String,没问题" token 写的是 hash ,每个 value 都要过 StringRedisSerializer
"copyProperties 只是复制字段,无所谓" 它同时是脱敏边界,决定了什么数据会进缓存

8.5 值得进一步研究的问题

  1. tb_user.phone 有没有唯一索引?没有的话第 6 条风险就是真问题。
  2. Spring MVC 的 afterCompletion 在 preHandle 返回 false 时到底调不调用?只有一个拦截器活着时会怎样?
  3. MyBatis-Plus 是怎么把自增主键回填到实体的?(useGeneratedKeys + KeyGenerator)
  4. 如果把 StringRedisTemplate 换成 RedisTemplate<String, Object>,那个 ClassCastException 还会发生吗?
  5. sessionStorage 和 localStorage 在 XSS 攻击下的安全性差异?

8.6 检验理解程度的五个问题

Q1(业务逻辑)

登录功能里"用户不存在就自动注册"这个设计,解决了什么业务问题?它带来了什么代价或风险?

Q2(调用链路)

一个已经登录的用户请求 GET /user/me。请按执行顺序说出经过了哪些类和哪些方法 ,并说明 UserHolder.getUser() 里的值是在哪一步、被哪一行代码放进去的。

Q3(代码设计)

为什么 RefreshTokenInterceptor 拦截 /**,而 LoginInterceptor 要排除 shop/**、user/login 等路径?如果让 LoginInterceptor 也拦截 /shop/** 会发生什么?请结合前端页面的行为说明。

Q4(技术原理)

BeanUtil.beanToMap(userDTO) 产出的 map 里,id 的值是 Long 类型。请解释:为什么把它交给 stringRedisTemplate.opsForHash().putAll() 会抛出 ClassCastException?这个异常在编译期为什么检测不出来?

Q5(边界条件)

UserHolder.removeUser() 只在 RefreshTokenInterceptor.afterCompletion() 里调用。请说明:假设某次请求中 LoginInterceptor 返回了 false(未登录被拦),这次请求结束后 ThreadLocal 里的数据会不会被清理?为什么?

相关推荐
高频因子挖掘机1 小时前
股票分红送转后价格“断层”:前复权、后复权和不复权该怎么选?
后端·github·api
威哥爱编程1 小时前
【AI全栈12-12】Spring Boot 3.x → 4.x 迁移实操:Jakarta 11、Jackson 3 与 AI 2.0
java·spring boot·后端
威哥爱编程1 小时前
【AI全栈12-11】Spring Boot 把 AI 接口真正上线扛量:限流 / 降级 / 可观测
java·spring boot·后端
小蒜学长1 小时前
基于SpringBoot+uni-app的闪洁家政系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·uni-app·家政服务
敲代码的彭于晏2 小时前
2026 K8s 零基础入门:手把手教你一步步搭建 Kubernetes 集群
java·运维·全栈
希洛兔2 小时前
状态模式详解:让对象随内部状态自动改变行为
java·设计模式·状态模式
需要8262 小时前
Spring Boot 启动流程:run() 里到底发生了什么
java·spring boot·后端
云絮.2 小时前
Mybatis(一)
java·数据库·mybatis
星始流年2 小时前
AI 时代,软件程序员未来 10—15 年的职业前景
前端·后端·ai编程