
1. 先说清楚短信登录在整个黑马点评里的位置1.1 点评类业务为什么绕不开手机号验证黑马点评这套项目我前后刷了两遍第一遍跟着视频敲了个大概第二遍才真正把这个短信登录模块吃透。很多新手一上来就急着写代码结果要么卡在拦截器死活拿不到用户要么就是Redis里的验证码过期时间设计得一塌糊涂。我觉得学任何项目先搞清楚这个模块在整个业务里到底承担什么角色比什么都重要。黑马点评这个项目模仿的是点评类应用的核心玩法用户可以浏览商家、查看评价、下单消费那商家和用户之间靠什么产生真正的数据关联答案就是登录。没有登录态你没法知道是谁收藏了店铺、是谁写了点评、是谁下的单。而点评类产品的用户有一个非常显著的特征——绝大多数用户都习惯用手机号快速登录不愿意去记一套额外的用户名密码。短信登录就是不设密码、不填表单、拿到验证码就能进系统的那套方案它直接决定了后续所有用户行为能不能落到具体人头上。你去看后台的Redis数据就会发现短信登录本质上干了两件事第一件事是验证你确实是这个手机号的持有者第二件事是给这个用户创建一个可用的登录凭证让他后续访问接口时不用反复验证。这两件事看着简单但设计得好不好直接影响项目后面接JWT、接网关、接单点登录时的改造成本。1.2 菜鸟拿到需求后的常规误区和正确思路我第一次拿到这个模块的需求时心里想的方案特别简单粗暴用户输入手机号和验证码后端校验通过就把用户ID和手机号塞进Session里之后每个请求都能从Session里拿user。这个思路在单体小项目里确实能跑通但我很快就发现两个问题。第一个问题是Session依赖服务端内存。黑马点评的视频里会特意提到一个概念叫多台服务器部署时Session共享困难而我们在学习阶段跑单机还感受不到可一旦以后做集群Session就会成为分布式环境里的第一块绊脚石。第二个问题是验证码的存储。很多新手觉得验证码就是存数据库或者存内存Map结果要么是重启失效要么是没法设置合理的过期时间要么是多个服务实例之间验证码不互通。正确的思路其实就一句话把会话数据从服务端内存里挪出来放到Redis这种共享存储中间件里面。这样验证码有统一的存储位置、有过期时间控制、有原子性校验登录Token也独立于服务端进程随时可以横向扩容机器。黑马点评这个模块的核心就是用Redis替代Session来管理登录态这个思路在真实企业项目里非常常见所以这个项目才会被拿出来当教学案例。2. 设计阶段先想清楚Redis的存储结构2.1 验证码的Key、过期时间和取值短信验证码的逻辑不复杂客户端把手机号传给后端后端生成一个随机的6位数字验证码调用短信服务商的接口把验证码发到用户手机上然后在Redis里记录这个手机号和验证码的对应关系。这里值得琢磨的是Key怎么设计。我见过有同学直接用手机号做Key比如stringRedisTemplate.opsForValue().set(phone, code);这样写有什么问题首先Key太短没有任何业务前缀以后Redis里存的业务多了光看Key根本分不清这是登录验证码还是别的功能验证码。其次验证码和登录Token如果都依赖手机号做Key那两种不同类型的数据就会相互覆盖。黑马点评的惯例是给Redis Key加上业务前缀用冒号分隔命名空间比如login:code:13800138000这种设计跟MySQL里的表名前缀是一个道理线上Redis实例里往往同时存着各种业务的数据规范的命名能让你一眼看出这条数据是谁的、什么时候存的、什么时候该清理。过期时间我建议设置为5分钟。太短用户来不及收短信太长又容易被人刷验证码、增加被爆破的风险。设置过期时间要用到Redis的expire机制Spring Data Redis里可以用stringRedisTemplate.opsForValue().set(key, code, 5, TimeUnit.MINUTES);注意这里传入的TimeUnit我后面会在问题排查部分专门讲新手在这里容易踩的坑。生成验证码的方式新手最常犯的错误是真去写什么随机数算法其实JDK自带的Random或者SecureRandom就够用了更稳妥的是用工具类生成6位数字String code String.format(%06d, new Random().nextInt(1000000));这个写法看起来没问题但有个小隐患如果哪天验证码位数从6位调整成4位你用%06d写死的地方就得跟着改。建议把位数定义成常量或者用String.format拼接占位符别到处硬编码。2.2 一个手机号可以被多个设备同时登录吗这个问题是我在看面试题的时候注意到的很多公司在考察Redis使用经验时喜欢问一个账号多个设备同时登录该怎么设计。黑马点评的教学版本给的方案是不管有几个设备后端只管生成一个Token新登录的设备会让旧Token失效。对应的实现方式就是在用户登录成功后生成一个随机Token作为Redis的Key值为用户信息的JSON字符串同时设置过期时间。如果用户在多台设备上重复登录后端并不会去记录设备和Token的对应关系而是直接用新的Token覆盖掉旧的这样旧Token对应的Redis数据过期或不存在了前端拿着旧Token来访问自然会被拦截下来。不过说实话真实生产环境里多端同时在线是很常见的需求比如同一账号在手机和电脑同时登录。黑马点评这个简化设计更适合用来练手因为逻辑简单、边界少你只需要关心一条链条验证码校验成功、生成Token、存用户信息、返回给前端。但你在做扩展的时候要清楚如果要支持多端共存Redis的Key就不能只存一个Token通常会用用户ID设备标识来区分多条会话记录这是一个很好的加分点。3. 从0到1实现验证码发送和登录校验3.1 发送验证码接口的完整代码与避坑先看后端接口。正常的Spring Boot Controller会这么写PostMapping(/code) public Result sendCode(RequestParam(phone) String phone) { // 校验手机号格式 if (RegexUtils.isPhoneInvalid(phone)) { return Result.fail(手机号格式错误); } // 生成6位验证码 String code String.format(%06d, new Random().nextInt(1000000)); // 保存到Redis有效期5分钟 stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY phone, code, 5, TimeUnit.MINUTES); // 发送验证码教学环境通常打印到日志 log.debug(验证码: {}, code); return Result.ok(); }这段代码看着简单但我要专门提醒几个点。手机号格式校验这块很多新手图省事直接写正则判断11位数字。真实项目里手机号校验需要按运营商号段更新黑马点评项目里提供了一个RegexUtils工具类里面有现成的isPhoneInvalid方法直接用那个就好别自己造轮子。验证码发送这一步真实企业会对接阿里云短信或者腾讯云短信的SDK而在教学项目里通常不会真的花钱发短信很多老师会让你直接把验证码打到日志里本地开发时去看控制台。我在本地跑的时候就是这样操作的每点一次发送按钮控制台就会打印出验证码。如果你跟着网上的教程跑完发现收不到短信先别怀疑接口逻辑去IDEA控制台看日志。还有一点项目里如果是用Logback或者Slf4j强烈建议用log.debug输出验证码而不是System.out.println。因为System.out没有级别控制以后上线万一忘删验证码就直接裸奔在日志文件里了。3.2 校验验证码并完成登录的细节用户把手机号和收到的验证码一起提交到后端后端要做三个判断手机号不能为空且格式正确、验证码不能为空、Redis里存的验证码要和用户提交的验证码一致。伪代码逻辑是这样的PostMapping(/login) public Result login(RequestParam(phone) String phone, RequestParam(code) String code) { // 1. 校验手机号和验证码格式 // 2. 从Redis取验证码 String redisCode stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY phone); // 3. 判断验证码是否存在 if (redisCode null) { return Result.fail(验证码已过期或未发送); } // 4. 判断验证码是否匹配 if (!redisCode.equals(code)) { return Result.fail(验证码错误); } // 5. 根据手机号查询用户 User user userService.query().eq(phone, phone).one(); // 6. 用户不存在则创建新用户 if (user null) { user createUserWithPhone(phone); } // 7. 生成Token保存用户信息到Redis String token UUID.randomUUID().toString(true); UserDTO userDTO BeanUtil.copyProperties(user, UserDTO.class); stringRedisTemplate.opsForValue().set(LOGIN_TOKEN_KEY token, JSONUtil.toJsonStr(userDTO), 30, TimeUnit.MINUTES); // 8. 返回Token给前端 return Result.ok(token); }这里的校验顺序其实是有一套逻辑的先查Redis里有没有验证码再比对验证码是否一致。这个顺序不能反因为如果Redis里没有验证码你根本不需要去比对直接告诉用户过期即可。而把验证码错误和验证码过期分开提示对用户来说体验会好很多对排查问题也好定位。第5步和第6步是我见过很多新手容易出bug的地方。用手机号去查用户表如果查不到就自动注册。这个无感注册是这类项目的默认逻辑很多点评类产品都是这样你第一次用手机号登录时系统自动给你建了一个账号你根本感知不到。实现上注意用户可能存在唯一索引冲突这时候要做好异常处理否则并发请求下同样的手机号可能被插入两条记录。第7步是整套登录逻辑的精髓。Token我建议用UUID并且去掉中间的横杠。如果你用hutool的UUID工具类可以生成32位不带横杠的随机串作为Redis的Key。这里有个细节保存的用户信息不要是整个User对象而是UserDTO这种轻量对象。原因很简单真实生产环境里User对象往往包含密码、盐值、手机号等强敏感信息一旦Redis里的数据被脱库后果很严重。黑马点评项目里也是这么处理的只存ID、昵称、头像这几个非敏感字段。3.3 前端拿到Token之后怎么用后端返回Token后前端要做的就是在本地保存并在后续请求的请求头里带着它。黑马点评项目的前端Vue逻辑里登录成功后会调用setToken把Token写入localStorage之后Axios请求拦截器会在每个请求的Header中加Authorization字段。我之前看很多人卡在这一步明明后端登录返回了Token但是发后续请求时后端还是拿不到用户。排查来排查去发现前端根本没在Header里带Token。这个倒不是后端代码的问题而是前端联调环节的疏漏。你在自己用Postman测试后端接口时记得也要手动加上Authorization头否则等价于没登录。这里再补一个后端细节项目里定义了常量LOGIN_TOKEN_KEY值是login:token:而真正的Redis Key是login:token:后面拼UUID字符串。也就是说你直接访问Redis里的Key能看到类似这样的内容login:token:5f4b8c2a1e9d4f3a9b7c8d2e6f1a0b3c值是用户的JSON字符串比如{id:1,nickName:张三,icon:/img/avatar.png}这个Key和值的对应关系建议你用redis-cli手动set一条数据测试一下能非常直观地理解后面拦截器为什么能通过Token找到用户。4. 登录状态保持的关键拦截器与ThreadLocal4.1 一个拦截器的正确写法登录完成后用户后续带着Token来访问后端需要能识别出这个Token对应的是谁。常规做法是写一个拦截器在请求到达Controller之前从Header里取出Token去Redis查用户信息。查到了就放行查不到就拦截。黑马点评的教学设计中拦截器是分层的第一层拦截所有请求只做刷新Token有效期和把用户保存到ThreadLocal这两件事不强制登录第二层拦截业务接口真正做登录校验。这种分层设计的理念是有些接口允许匿名访问比如看商家详情有些接口必须登录比如下单如果用一个拦截器统一要求登录那匿名接口就全废了。核心拦截器代码大概是这样的public class RefreshTokenInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(authorization); if (StrUtil.isBlank(token)) { return true; } String key LOGIN_TOKEN_KEY token; String userJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(userJson)) { return true; } // 刷新有效期 stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES); // 存入ThreadLocal UserHolder.saveUser(JSONUtil.toBean(userJson, UserDTO.class)); return true; } }注意这个拦截器里没有返回false的逻辑它把所有请求都放行了只是做了有Token就刷新、有用户就塞进ThreadLocal的工作。真正拦截的是LoginInterceptorpublic class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { UserDTO user UserHolder.getUser(); if (user null) { response.setStatus(401); return false; } return true; } }怎么理解这两层呢我把RefreshTokenInterceptor当成一个顺路干活的它不拦任何人只负责更新Redis里的过期时间、准备用户数据LoginInterceptor才是看门大爷它只关心ThreadLocal里有没有用户没有就拒之门外。这种各管一摊的分工让我在排查问题时轻松了很多。4.2 每次请求都刷新有效期有什么意义黑马点评把Token的有效期直接设成了30分钟但这里的30分钟不是从登录那一刻开始算的固定30分钟而是只要用户持续在操作就不断往后顺延30分钟。这个滑动过期机制靠Redis的expire命令实现每次请求进来都会调一次expire刷新过期时间效果就是用户只要一直在用就永远不会掉线离开了30分钟才自动失效。这个设计我认为非常贴近真实场景。你可以对比一下固定过期时间如果Token设成固定的30分钟过期那么用户在第29分钟发起一个请求之后再过一分钟也就是登录后第31分钟就突然被踢下线了尽管他才刚刚操作完体验极差。滑动过期则是把每个请求都当成一次用户还活着的信号更像正常产品该有的行为。要注意的细节是刷新过期时间的动作必须写在RefreshTokenInterceptor里而不是LoginInterceptor里。如果写在LoginInterceptor里那匿名请求就不会触发续期用户在看商家详情的过程中Token也可能悄悄过期等他想下单时突然让你重新登录体验就很割裂。4.3 ThreadLocal为什么是必选项拦截器拿到了UserDTO之后要把它传递给Controller层最简单的方案是在request域里setAttribute然后在Controller里getAttribute。但这样写很啰嗦每个接口都要做一次类型强转。黑马点评项目里用的是ThreadLocal这是Java中比较经典的线程本地变量容器。ThreadLocal的原理简单说就是每个线程都有一份独立的存储空间同一个线程在任意位置都可以从ThreadLocal里取到同一个对象。因为一个HTTP请求从进入到返回中间经过拦截器、Controller、Service都是同一个线程在执行所以拦截器里存进去的用户数据Service层随时能拿出来。实际使用中必须解决的一个问题是内存泄漏也就是请求处理结束后要及时remove。黑马点评里的UserHolder类封装了保存、获取、移除三个静态方法而移除操作放在拦截器的afterCompletion里执行Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserHolder.removeUser(); }新手极容易漏掉的正是这步remove。如果是Tomcat这样用线程池的地方线程处理完一个请求后会被归还到池子里下次处理别的请求时还是同一个线程。如果不removeThreadLocal里的用户数据会被下一个请求读到造成严重的用户串号问题。我当年第一次写到这里就漏了测试的时候发现A用户登录后能看到B用户的数据查了好几个小时才找到这个根因。5. 新手最容易踩的5个坑与排查方法5.1 Redis里的验证码一直查不到症状是前端点击发送验证码后后端也打印了验证码但登录时始终提示验证码已过期或未发送。排查步骤先打开redis-cli执行keys login:code:*如果压根没有这个key说明验证码在发送时就没存进去。这时候回头看两件事第一件RedisTemplate有没有正确注入第二件你是不是用了opsForValue()却忘了指定过期时间导致rediTemplate里存的是永久key看着存在但key拼错了。如果是key存在但登录时查不到问题大概率出在手机号不一致上。前端传过来的手机号可能带空格或者后端在处理时对手机号做了格式化导致存储和查询用的key对不上。这种问题建议在set和get时都打印一下key肉眼对一遍就清楚了。5.2 验证码总是提示错误这个坑我见过不止一个人踩。Redis里存的值和用户提交的值都是字符串但是有些人会把验证码当数字存比如Redis里的值是123456用户在表单里填的也是123456但前端JSON解析时把123456转成了Integer类型String类型和Integer类型用equals比较时返回false。解决办法有两个一个是生成验证码时用String.format强转成字符串另一个是校验时先把用户提交的值统一转成String再equals。React/Vue项目里尤其要注意input标签如果绑定了v-model.number数字字符串会被转成数字类型这个细节很容易忽略。另外还有一种情况是验证码被存了两次第二次覆盖了第一次。比如用户手滑点了两次发送每次请求都生成新验证码覆盖了旧的而用户收到的是第一条短信提交的却是旧验证码自然就校验失败了。生产环境通常会限制发送频率比如同一个手机号一分钟只能发一次黑马点评的教学里没有这个限制你自己实现时可以加一个防刷逻辑比如用短信发送冷却时间的Redis key。5.3 拦截器取不到Header里的Token我在联调时遇到过前端明明把Token放进了Header但后端拦截器用request.getHeader(authorization)拿到的却是null。原因是前端Axios的自定义Header名跟后端要求的不一致比如前端叫token后端却读authorization。这种问题最直接的排查方式是在浏览器F12的Network面板看请求头确认Header名和值都传上来了再对照后端代码里读的Header名是否完全一致。还有一类情况是网关、Nginx这类代理层对自定义Header做了过滤或改名不过黑马点评项目一般不涉及网关遇到这种情况的概率不大但排查思路要有。5.4 Token过期时间到了用户还一直在线如果你把Token的过期时间设置成了30天那用户当然一直在线这不是bug是你配置的问题。但如果你设置的是30分钟用户却明显超过了30分钟还在正常操作就要看是不是刷新Token的拦截器漏配了路径。检查一下WebMvcConfigurer里的addInterceptors配置RefreshTokenInterceptor注册的addPathPatterns是不是写的/**只要写成了/*就只拦一级路径多级api路径根本不会走到拦截器里也就没有续期动作。这种路径匹配的问题特别隐蔽不打印日志根本发现不了。5.5 LocalDateTime序列化错误用户信息存进Redis用的是JSON字符串如果你直接序列化了完整的User对象而User类里有LocalDateTime字段那JSON序列化时如果没配置JavaTimeModule会直接抛类型转换异常或者输出一串奇怪的时间数组。黑马点评项目里的解法是复制一个UserDTO只保留ID、昵称、头像三个字段彻底绕开了LocalDateTime序列化的问题。这个处理方式很值得借鉴它不只是为了安全还顺便解决了序列化兼容性问题。如果你确实需要序列化LocalDateTime就需要对ObjectMapper做全局配置注册JavaTimeModule并设置时间格式。6.1 把短信登录改成多端共存的小扩展如果你学完这套短信登录觉得不过瘾我建议你自己动手扩展一个功能让同一个账号支持多端同时在线。思路很简单把Redis的Key从login:token:随机UUID改成login:token:用户ID:设备标识比如手机端和PC端各存一份Token互不覆盖。登录时先看这个设备标识有没有已存在的Token有就删除旧的没有就直接创建。这个扩展做完之后你会发现原本以为设计得很完善的登录模块其实还有不少能优化的地方比如怎么记录登录日志、怎么推送异地登录提醒、怎么在退出登录时只踢特定设备。这些思考能让你把黑马点评这个项目真正吃透。6. 我个人在这个模块上的体会刷完这个模块最大的感受是黑马点评的短信登录设计得很克制它没有引入复杂的Spring Security或者Shiro只用Spring Boot加Redis就完成了整套登录态管理这让新手能把注意力全部集中在登录到底做了什么这件事上。我现在回头看验证码的存储、Token的生成、拦截器的分层、ThreadLocal的使用这四个知识点完全可以平移到任何企业项目里。遇到面试官问你项目里的登录怎么实现你把Redis里的key设计、滑动过期逻辑、双拦截器分工说清楚基本就能看出你对这块是真的动手做过而不是照着视频敲了一遍就完事。最后分享一个我实测有效的学习建议不要只跟着视频敲代码你把短信登录跑通之后故意改坏几个地方比如把拦截器注册路径改成/*或者把UserHolder的remove去掉然后用Postman反复测你写的接口观察Redis里key的变化和返回的状态码。这种主动制造故障的学习方式比顺着Demo跑一遍的记忆效果要强得多。