一、分析范围
本次功能:手机号 + 短信验证码登录
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([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\d{8}$ - 验证码是 6 位纯数字,有效期 2 分钟
- 用户不存在时自动注册(这是关键的商业决策:注册和登录合并成一步,降低门槛)
- 登录成功后返回 token,用户信息本身不下发给前端,只给 token
- 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()));
这是本次功能中唯一的真实故障点
根因链条:
UserDTO.id是Long类型;BeanUtil.beanToMap(userDTO)原样产出一个Map<String, Object>,里面id的值是Long;StringRedisTemplate的 hash value 序列化器是StringRedisSerializer;- 这个序列化器的方法签名是
serialize(String string),泛型擦除后 JVM 会在方法入口插入一句checkcast String; - 传进去的是
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 -> 1012icon确实存在于 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-34javaString 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 本次涉及的技术原理
- 有状态化无状态协议:token 方案的出发点
- 为什么是 Redis 而不是 HttpSession:多机部署下 session 不共享
- ThreadLocal 与线程复用 :为什么必须
remove() - Spring MVC 拦截器链 :
order、preHandle/afterCompletion的调用条件 StringRedisSerializer的强转:泛型擦除导致的真实运行时异常- 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 值得进一步研究的问题
tb_user.phone有没有唯一索引?没有的话第 6 条风险就是真问题。- Spring MVC 的
afterCompletion在preHandle返回 false 时到底调不调用?只有一个拦截器活着时会怎样? - MyBatis-Plus 是怎么把自增主键回填到实体的?(
useGeneratedKeys+KeyGenerator) - 如果把
StringRedisTemplate换成RedisTemplate<String, Object>,那个ClassCastException还会发生吗? 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 里的数据会不会被清理?为什么?