《黑马点评》短信登录:从思路到实现
黑马点评短信登录:从思路到实现
短信登录现在太常见了,点外卖、打车、刷短视频基本都是手机号 + 验证码一套。黑马点评这个项目的登录逻辑也是这套,但和传统 Session 方案不太一样:它把登录状态放到了 Redis,用 token 做身份识别,再用两个拦截器完成权限校验。这篇文章我按自己读代码的顺序,把整条链路串一遍。
为什么不用 Session?
课程一开始其实用的是HttpSession:把验证码存 session,登录成功后再把UserDTO存 session。后面改成 Redis,原因很简单——session 不适合分布式部署。
- 后端只有一台机器时,session 存在 Tomcat 内存里没问题。
- 一旦多台机器,负载均衡可能把请求打到不同实例,A 机器上的 session 在 B 机器读不到。
- 解决办法要么 Session 复制、要么粘滞会话、要么 Spring Session,都比较麻烦。
把登录态抽出来放到 Redis,所有机器都读同一个地方,天然解决共享问题。这个思路在后面很多项目里都能看到。
整体方案
整个登录链路分三个阶段:
- 用户输入手机号,后端生成 6 位验证码,存 Redis,有效期 2 分钟。
- 用户输入手机号 + 验证码,后端校验,通过则查用户,没有就自动注册,生成 token,把用户信息以 Hash 形式存 Redis,有效期 25 天。
- 后续请求在 Header 里带上 token,后端拦截器校验 token 有效性,并刷新 token 的有效期。
下面逐段看代码。
1. 发送验证码
UserController只负责接收和转发:
@PostMapping("code")publicResultsendCode(@RequestParam("phone")Stringphone,HttpSessionsession){returnuserService.setCode(phone,session);}实际逻辑在UserServiceImpl.setCode:
@OverridepublicResultsetCode(Stringphone,HttpSessionsession){// 1. 校验手机号格式if(RegexUtils.isPhoneInvalid(phone)){returnResult.fail("手机号格式错误!!!");}// 2. 生成 6 位验证码Stringcode=RandomUtil.randomNumbers(6);// 3. 写入 Redis,key = login:code:手机号,有效期 2 分钟stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL,TimeUnit.MINUTES);// 4. 发送验证码(这里用日志模拟,未接真实短信网关)log.info("发送验证码成功,验证码:{}",code);returnResult.ok();}注意项目没有接真实短信平台,只是用log.info模拟。本地学习这样够用了;真正上线时把这里换成第三方短信服务即可。
Redis 里验证码对应的 key:
publicstaticfinalStringLOGIN_CODE_KEY="login:code:";publicstaticfinalLongLOGIN_CODE_TTL=2L;TTL 是 2 分钟,验证码必须在有效期内使用,否则登录时 Redis 查不到,会返回「验证码错误」。
2. 登录 / 自动注册
UserController.login接收手机号和验证码:
@PostMapping("/login")publicResultlogin(@RequestBodyLoginFormDTOloginForm,HttpSessionsession){returnuserService.login(loginForm,session);}LoginFormDTO长这样:
@DatapublicclassLoginFormDTO{privateStringphone;privateStringcode;privateStringpassword;}密码字段在短信登录模式下没有用到,只校验 phone 和 code。
UserServiceImpl.login是整条链路的核心,我把它拆成七步来看:
@OverridepublicResultlogin(LoginFormDTOloginForm,HttpSessionsession){// 1. 校验手机号格式Stringphone=loginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)){returnResult.fail("手机号格式错误!!!");}// 2. 校验验证码StringcacheCode=stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY+phone);Stringcode=loginForm.getCode();if(cacheCode==null||!cacheCode.toString().equals(code)){returnResult.fail("验证码错误");}// 3. 按手机号查用户,不存在则自动注册Useruser=query().eq("phone",phone).one();if(user==null){user=createUserWithPhone(phone);}// 4. 生成 token,作为登录令牌Stringtoken=UUID.randomUUID().toString(true);// 5. 用户信息脱敏为 UserDTO,再转为 MapUserDTOuserDTO=BeanUtil.copyProperties(user,UserDTO.class);Map<String,Object>userMap=BeanUtil.beanToMap(userDTO,newHashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));// 6. 写入 Redis HashStringtokenKey=LOGIN_USER_KEY+token;stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);// 7. 设置 token 有效期stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);returnResult.ok(token);}createUserWithPhone负责新用户初始化:
privateUsercreateUserWithPhone(Stringphone){Useruser=newUser();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));save(user);returnuser;}这里有个细节值得注意:BeanUtil.beanToMap时,id 是 Long 类型,但StringRedisTemplate的 Hash 要求 key 和 value 都是字符串。代码通过setFieldValueEditor把所有字段值转成 String,否则写入 Redis 会报错。我第一次看的时候也被这个toString()绕了一下,因为直觉上觉得 Hutool 会自动处理,但实际上对于 StringRedisTemplate 不行。
返回的 token 交给前端保存,后续请求带上它即可。
3. 双拦截器与 token 刷新
这是整段逻辑里我最喜欢的部分。MvcConfig里注册了两个拦截器:
@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){// 先执行:解析 token、刷新有效期、注入用户信息registry.addInterceptor(newRefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);// 后执行:真正判断是否需要登录registry.addInterceptor(newLoginInterceptor()).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login").order(1);}拆成两个拦截器,职责很清晰:
RefreshTokenInterceptor拦截所有请求,只负责从 token 解析用户并刷新 token 有效期。没有 token 的请求它也不拦,放行。LoginInterceptor负责真正的权限判断。哪些接口需要登录、哪些接口游客可以访问,在这里配置。
像/shop/**、/blog/hot、/user/code、/user/login这些接口不需要登录,所以用excludePathPatterns排除掉。但即使排除的接口,请求仍然会经过RefreshTokenInterceptor,因为它配置了/**。如果用户已经登录,访问这些页面时也能拿到用户信息,只是不会被强制要求登录。
RefreshTokenInterceptor的关键代码:
@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{Stringtoken=request.getHeader("Authorization");if(StrUtil.isBlank(token)){returntrue;// 没有 token,放行,交给 LoginInterceptor 判断}Stringkey=LOGIN_USER_KEY+token;Map<Object,Object>userMap=stringRedisTemplate.opsForHash().entries(key);if(userMap.isEmpty()){returntrue;// token 无效或过期,同样放行,交给 LoginInterceptor}// 转成 UserDTO 并存入 ThreadLocalUserDTOuserDTO=BeanUtil.fillBeanWithMap(userMap,newUserDTO(),false);UserHolder.saveUser(userDTO);// 刷新 token 有效期stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{UserHolder.removeUser();// 请求结束,清理 ThreadLocal}三个关键点:
- token 为空或者 Redis 查不到用户,都直接放行,不抛异常。真正的拦截交给
LoginInterceptor。 - 查到用户后,先转成
UserDTO,存入UserHolder(ThreadLocal),然后刷新 token 的 TTL。 afterCompletion里必须清理 ThreadLocal,否则线程池复用线程时可能出现用户信息残留。
UserHolder的代码非常简单:
publicclassUserHolder{privatestaticfinalThreadLocal<UserDTO>tl=newThreadLocal<>();publicstaticvoidsaveUser(UserDTOuser){tl.set(user);}publicstaticUserDTOgetUser(){returntl.get();}publicstaticvoidremoveUser(){tl.remove();}}ThreadLocal 让每个请求有独立的用户副本,不会被其他线程覆盖。removeUser放在afterCompletion清理,防止内存泄漏。
LoginInterceptor只干一件事:
@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{if(UserHolder.getUser()==null){response.setStatus(401);returnfalse;}returntrue;}如果RefreshTokenInterceptor在前面没有成功注入用户,说明没登录或者 token 无效,这里直接返回 401。
4. Redis 数据结构设计
Redis 里用了两种 key,分别对应验证码和登录态:
验证码相关:
publicstaticfinalStringLOGIN_CODE_KEY="login:code:";publicstaticfinalLongLOGIN_CODE_TTL=2L;登录态相关:
publicstaticfinalStringLOGIN_USER_KEY="login:token:";publicstaticfinalLongLOGIN_USER_TTL=36000L;LOGIN_USER_TTL = 36000分钟,也就是 25 天。这个值看起来很长,但没关系,因为每次请求都会刷新 TTL。只要用户持续活跃,登录状态就不会过期。
登录态用 Hash 而不是 String,是因为用户信息包含多个字段(id、nickName、icon)。Hash 查一次能拿到全部字段,也方便后续扩展。
5. 踩坑与注意事项
Long 类型 id 必须转 String。
StringRedisTemplate的 Hash 要求 key 和 value 都是字符串。如果直接把UserDTO的beanToMap塞进去会报错,必须用setFieldValueEditor把所有值转成 String。token 刷新机制。
RefreshTokenInterceptor每次请求都刷新 TTL,这是关键设计。否则固定 25 天过期,用户用着用着就会掉线。ThreadLocal 必须清理。
afterCompletion里调用removeUser()不是可选操作,是必选项。Tomcat 线程池会复用线程,不清除会有残留。Authorization 大小写。代码里从请求头取
Authorization,前端发送时大小写要一致。拦截器 order 顺序。
order值越小越先执行。RefreshTokenInterceptor是 0,LoginInterceptor是 1,顺序不能反。登录即注册。这个项目里没有独立的注册接口,手机号不存在就直接创建用户,昵称默认是
user_+ 随机字符串。业务上如果要完善,后续可以引导用户修改资料。
总结
黑马点评的短信登录方案,本质是把「用户登录态」从 Tomcat 的 session 里搬出来,放到 Redis 里用 token 维护。这样做最大的好处是支持水平扩展,不用处理 session 共享问题。
整条链路的核心点:
- 验证码:Redis 临时存,2 分钟有效期。
- 登录:校验验证码、自动注册、生成 token、写 Redis Hash。
- 双拦截器:一个刷新 token 和用户信息,一个负责真正拦截。
- ThreadLocal:请求内共享用户信息,请求结束清理。
如果你准备面试或者写项目,建议能手画这套流程。理解它之后,后面的签到、秒杀、优惠券等功能基本都是在这个用户体系上做的。
作为还在学习路上的大学生,这些都是我踩坑踩出来的经验,分享出来希望能帮到同样在学 Java 的小伙伴~如果有写得不对的地方,欢迎大佬们指正!
你们在学习的时候有遇到过什么有意思的坑吗?评论区聊聊呀👇
🎓 我是小小放舟,一个正在努力打怪升级的后端学习者
🌊 个人主页:小小放舟的 CSDN
✨ 点赞收藏不迷路,我们一起放舟技术海~
