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

📅 2026/7/10 1:18:58
《黑马点评》短信登录:从思路到实现
黑马点评短信登录从思路到实现短信登录现在太常见了点外卖、打车、刷短视频基本都是手机号 验证码一套。黑马点评这个项目的登录逻辑也是这套但和传统 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.setCodeOverridepublicResultsetCode(Stringphone,HttpSessionsession){// 1. 校验手机号格式if(RegexUtils.isPhoneInvalid(phone)){returnResult.fail(手机号格式错误);}// 2. 生成 6 位验证码StringcodeRandomUtil.randomNumbers(6);// 3. 写入 Rediskey login:code:手机号有效期 2 分钟stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEYphone,code,LOGIN_CODE_TTL,TimeUnit.MINUTES);// 4. 发送验证码这里用日志模拟未接真实短信网关log.info(发送验证码成功验证码:{},code);returnResult.ok();}注意项目没有接真实短信平台只是用log.info模拟。本地学习这样够用了真正上线时把这里换成第三方短信服务即可。Redis 里验证码对应的 keypublicstaticfinalStringLOGIN_CODE_KEYlogin:code:;publicstaticfinalLongLOGIN_CODE_TTL2L;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. 校验手机号格式StringphoneloginForm.getPhone();if(RegexUtils.isPhoneInvalid(phone)){returnResult.fail(手机号格式错误);}// 2. 校验验证码StringcacheCodestringRedisTemplate.opsForValue().get(LOGIN_CODE_KEYphone);StringcodeloginForm.getCode();if(cacheCodenull||!cacheCode.toString().equals(code)){returnResult.fail(验证码错误);}// 3. 按手机号查用户不存在则自动注册Useruserquery().eq(phone,phone).one();if(usernull){usercreateUserWithPhone(phone);}// 4. 生成 token作为登录令牌StringtokenUUID.randomUUID().toString(true);// 5. 用户信息脱敏为 UserDTO再转为 MapUserDTOuserDTOBeanUtil.copyProperties(user,UserDTO.class);MapString,ObjectuserMapBeanUtil.beanToMap(userDTO,newHashMap(),CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue)-fieldValue.toString()));// 6. 写入 Redis HashStringtokenKeyLOGIN_USER_KEYtoken;stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);// 7. 设置 token 有效期stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES);returnResult.ok(token);}createUserWithPhone负责新用户初始化privateUsercreateUserWithPhone(Stringphone){UserusernewUser();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIXRandomUtil.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{Stringtokenrequest.getHeader(Authorization);if(StrUtil.isBlank(token)){returntrue;// 没有 token放行交给 LoginInterceptor 判断}StringkeyLOGIN_USER_KEYtoken;MapObject,ObjectuserMapstringRedisTemplate.opsForHash().entries(key);if(userMap.isEmpty()){returntrue;// token 无效或过期同样放行交给 LoginInterceptor}// 转成 UserDTO 并存入 ThreadLocalUserDTOuserDTOBeanUtil.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存入UserHolderThreadLocal然后刷新 token 的 TTL。afterCompletion里必须清理 ThreadLocal否则线程池复用线程时可能出现用户信息残留。UserHolder的代码非常简单publicclassUserHolder{privatestaticfinalThreadLocalUserDTOtlnewThreadLocal();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_KEYlogin:code:;publicstaticfinalLongLOGIN_CODE_TTL2L;登录态相关publicstaticfinalStringLOGIN_USER_KEYlogin:token:;publicstaticfinalLongLOGIN_USER_TTL36000L;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是 0LoginInterceptor是 1顺序不能反。登录即注册。这个项目里没有独立的注册接口手机号不存在就直接创建用户昵称默认是user_ 随机字符串。业务上如果要完善后续可以引导用户修改资料。总结黑马点评的短信登录方案本质是把「用户登录态」从 Tomcat 的 session 里搬出来放到 Redis 里用 token 维护。这样做最大的好处是支持水平扩展不用处理 session 共享问题。整条链路的核心点验证码Redis 临时存2 分钟有效期。登录校验验证码、自动注册、生成 token、写 Redis Hash。双拦截器一个刷新 token 和用户信息一个负责真正拦截。ThreadLocal请求内共享用户信息请求结束清理。如果你准备面试或者写项目建议能手画这套流程。理解它之后后面的签到、秒杀、优惠券等功能基本都是在这个用户体系上做的。作为还在学习路上的大学生这些都是我踩坑踩出来的经验分享出来希望能帮到同样在学 Java 的小伙伴如果有写得不对的地方欢迎大佬们指正你们在学习的时候有遇到过什么有意思的坑吗评论区聊聊呀 我是小小放舟一个正在努力打怪升级的后端学习者 个人主页小小放舟的 CSDN✨ 点赞收藏不迷路我们一起放舟技术海