《黑马点评》短信登录:从思路到实现

《黑马点评》短信登录:从思路到实现

黑马点评短信登录:从思路到实现

短信登录现在太常见了,点外卖、打车、刷短视频基本都是手机号 + 验证码一套。黑马点评这个项目的登录逻辑也是这套,但和传统 Session 方案不太一样:它把登录状态放到了 Redis,用 token 做身份识别,再用两个拦截器完成权限校验。这篇文章我按自己读代码的顺序,把整条链路串一遍。

为什么不用 Session?

课程一开始其实用的是HttpSession:把验证码存 session,登录成功后再把UserDTO存 session。后面改成 Redis,原因很简单——session 不适合分布式部署

  • 后端只有一台机器时,session 存在 Tomcat 内存里没问题。
  • 一旦多台机器,负载均衡可能把请求打到不同实例,A 机器上的 session 在 B 机器读不到。
  • 解决办法要么 Session 复制、要么粘滞会话、要么 Spring Session,都比较麻烦。

把登录态抽出来放到 Redis,所有机器都读同一个地方,天然解决共享问题。这个思路在后面很多项目里都能看到。

整体方案

整个登录链路分三个阶段:

  1. 用户输入手机号,后端生成 6 位验证码,存 Redis,有效期 2 分钟。
  2. 用户输入手机号 + 验证码,后端校验,通过则查用户,没有就自动注册,生成 token,把用户信息以 Hash 形式存 Redis,有效期 25 天。
  3. 后续请求在 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}

三个关键点:

  1. token 为空或者 Redis 查不到用户,都直接放行,不抛异常。真正的拦截交给LoginInterceptor
  2. 查到用户后,先转成UserDTO,存入UserHolder(ThreadLocal),然后刷新 token 的 TTL。
  3. 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. 踩坑与注意事项

  1. Long 类型 id 必须转 StringStringRedisTemplate的 Hash 要求 key 和 value 都是字符串。如果直接把UserDTObeanToMap塞进去会报错,必须用setFieldValueEditor把所有值转成 String。

  2. token 刷新机制RefreshTokenInterceptor每次请求都刷新 TTL,这是关键设计。否则固定 25 天过期,用户用着用着就会掉线。

  3. ThreadLocal 必须清理afterCompletion里调用removeUser()不是可选操作,是必选项。Tomcat 线程池会复用线程,不清除会有残留。

  4. Authorization 大小写。代码里从请求头取Authorization,前端发送时大小写要一致。

  5. 拦截器 order 顺序order值越小越先执行。RefreshTokenInterceptor是 0,LoginInterceptor是 1,顺序不能反。

  6. 登录即注册。这个项目里没有独立的注册接口,手机号不存在就直接创建用户,昵称默认是user_+ 随机字符串。业务上如果要完善,后续可以引导用户修改资料。

总结

黑马点评的短信登录方案,本质是把「用户登录态」从 Tomcat 的 session 里搬出来,放到 Redis 里用 token 维护。这样做最大的好处是支持水平扩展,不用处理 session 共享问题。

整条链路的核心点:

  • 验证码:Redis 临时存,2 分钟有效期。
  • 登录:校验验证码、自动注册、生成 token、写 Redis Hash。
  • 双拦截器:一个刷新 token 和用户信息,一个负责真正拦截。
  • ThreadLocal:请求内共享用户信息,请求结束清理。

如果你准备面试或者写项目,建议能手画这套流程。理解它之后,后面的签到、秒杀、优惠券等功能基本都是在这个用户体系上做的。


作为还在学习路上的大学生,这些都是我踩坑踩出来的经验,分享出来希望能帮到同样在学 Java 的小伙伴~如果有写得不对的地方,欢迎大佬们指正!
你们在学习的时候有遇到过什么有意思的坑吗?评论区聊聊呀👇
🎓 我是小小放舟,一个正在努力打怪升级的后端学习者
🌊 个人主页:小小放舟的 CSDN
✨ 点赞收藏不迷路,我们一起放舟技术海~