
1. 项目概述与登录模块的业务定位1.1 黑马点评项目的整体背景黑马点评是一个仿大众点评的实战教学项目把 Redis 的核心知识点全部塞进了一个完整的业务闭环里短信登录、商户查询缓存、优惠券秒杀、达人探店、好友关注、附近商铺、UV统计每一个模块都对应 Redis 的一种典型用法。很多人把它当作面试项目写进简历面试官也愿意拿里面的细节追问因为它不是 CRUD 演示每个模块都有真实的业务场景和可深挖的技术点。这个项目里最容易被拿出来讲的就是短信登录模块。原因很简单它是整个项目的第一个实战模块也是用户接触系统的第一道门。一个连登录都做不好的系统后面的一切业务都无从谈起。而且短信登录这个模块麻雀虽小五脏俱全涉及验证码发送、Redis 存储设计、Token 会话管理、拦截器、ThreadLocal 用户信息传递、登录状态刷新几乎囊括了后端开发日常最常用的一套技术组合吃透它等于把企业级登录方案的基础打牢了。1.2 为什么第一课选短信登录而不是账号密码很多人会疑惑明明账号密码登录更常见为什么不先从那个入手我的理解是短信验证码登录在移动端场景下其实是更主流的选择。对于大众点评这类本地生活应用用户的使用场景往往是临时性、碎片化的——拿起手机看附近的店、领优惠券、看评价让用户记住一套账号密码再输进去这个门槛太高了。短信验证码天然解决了注册和登录的一体化问题手机号就是用户名验证码就是凭证没注册过的用户第一次登录时直接自动注册省去了繁琐的注册流程。从技术角度看短信登录模块比账号密码登录多了一层验证码的生成、存储、校验、防刷这层逻辑大量依赖 Redis 的过期机制和原子操作正好引出项目后续要用 Redis 解决的各种问题。所以黑马点评把它放在第一个模块是有教学节奏上的考量的。1.3 登录模块的三大核心子功能整个短信登录模块可以拆成三个子功能来理解验证码发送用户输入手机号点击获取验证码后端生成随机数字验证码存到 Redis 并设置过期时间同时把验证码下发到用户手机本项目为了教学方便开发环境下直接把验证码打印在日志或返回给前端真实生产环境需要接短信服务商。验证码校验与自动注册用户提交手机号和验证码后端从 Redis 取出验证码比对通过后查询用户表如果用户不存在则用手机号自动创建一个新用户。登录状态的管理登录成功后生成一个 Token 返回给前端前端后续请求带上这个 Token后端通过拦截器校验 Token 并维护用户的登录状态Redis 负责存储 Token 对应的用户信息并管理过期时间。这三个子功能是层层递进的。验证码发送是整个流程的入口校验与注册是核心业务逻辑登录状态管理则是从登录延伸到后续所有需要登录的接口的关键桥梁。项目后续模块的鉴权能力比如发布笔记、点赞、关注等操作全部建立在这套登录状态管理之上。2. 整体架构与登录流程设计2.1 一图理清完整请求链路我在最初学习这个模块的时候第一步就是把业务流程在纸上画清楚否则后面看代码很容易陷入局部细节里出不来。完整的请求链路是这样的用户在登录页面输入手机号点击获取验证码前端发起POST /api/user/code?phonexxx请求。后端校验手机号格式生成 6 位随机数字验证码以login:code:{phone}为 key 存入 Redis设置 5 分钟过期时间并将验证码通过短信发送给用户。开发环境没有短信服务直接打印到日志前端拿不到真实验证码只能在日志里看。用户输入收到的验证码点击登录前端发起POST /api/user/login请求body 里携带phone和code。后端从 Redis 取出该手机号对应的验证码校验是否一致。校验通过后删除 Redis 中的验证码确保一次性使用。根据手机号查询数据库用户表如果不存在则自动注册新用户分配一个雪花 ID并设置默认昵称。生成一个 UUID 字符串作为 Token以login:token:{token}为 key用 Redis 的 Hash 结构存储用户脱敏信息id、昵称、头像并设置 30 分钟过期时间。把 Token 返回给前端前端保存起来后续每个请求在请求头中携带authorization: {token}。后端注册了两个拦截器第一个拦截器对所有请求生效解析请求头中的 Token查 Redis有值则说明用户已登录把用户信息存入 ThreadLocal并刷新 Token 的过期时间第二个拦截器只对需要登录才能访问的路径生效检查 ThreadLocal 中是否有用户没有则直接返回 401。这个链路里Redis 承担了两个完全不同的角色login:code:*是验证码的临时存储区login:token:*是用户会话的存储区。两者虽然都是字符串和 Hash 结构但用途、过期策略、操作方式完全不同理解这两个角色的区别是看懂整个模块的关键。2.2 为什么选 Redis 做会话存储而不是 Session这是面试必问的问题也是这个模块设计的核心理由。先说说 Session 方案的痛点Session 数据默认保存在 Tomcat 的内存里单机部署没问题一旦做多实例部署用户的请求打到不同的机器上就找不到 Session 了必须引入 Session 共享机制比如 Spring Session Redis等于是绕了一圈最后还是用 Redis。Session 的过期时间完全由容器管理想精确控制某个 Session 的存活时长很别扭而且 Session 不释放会一直占着 JVM 内存在高并发下容易拖垮应用。Session 依赖 Cookie 传递JSESSIONID在移动端 App 或小程序场景下Cookie 机制并不总是好用而自定义 Token 放在请求头里是纯前后端分离的标准玩法。黑马点评做的是前后端分离项目前端是移动端页面后端只提供接口天然不适合 Session Cookie 这套服务端渲染时代的方案。改用 Redis Token 之后会话数据不占应用内存、过期时间精确到秒、天然支持分布式部署——同一个 Token 在 Redis 里无论请求打到哪个后端实例都能查到。可以说Redis 就是这个项目的外部会话仓库应用服务器变成无状态的了。2.3 为什么用 UUID 生成 Token 而不是 JWTJWT 是现在流行的无状态登录方案但在这个项目里它的劣势非常明显。JWT 的过期时间是在签发时写死在 Token 里的比如签发时设定 30 分钟过期那 30 分钟一到无论用户怎么活跃Token 都失效了用户必须重新登录。想实现活跃用户自动续期用 JWT 要不就得做双 Token 刷新机制Access Token Refresh Token要不就得引入黑名单机制管理已签发的 Token复杂度直接上升一个台阶。而 Redis 的 TTL 天然支持续期操作——每次用户访问接口只要携带的 Token 在 Redis 中能查到就重新设置一次 30 分钟过期时间。这意味着只要用户一直在操作登录状态就永远不会断一旦用户超过 30 分钟没有操作Redis 自动删除这个 key登录状态自然过期。这个最后一次访问时间 滑动过期的效果用 Redis 一行代码就实现了JWT 却要设计一整套机制才能勉强做到。还有一个安全层面的考虑JWT 是无状态的服务端签发了就没法主动让它失效用户改密码、被封禁、退出登录之后旧的 JWT 在到期之前依然有效。而 Redis 存 Token 的方案退出登录时直接把login:token:{token}删掉就立刻生效控制权完全在服务端手里。3. 验证码的生成、存储、校验与防刷3.1 验证码生成与手机号校验验证码生成在代码层面并不复杂核心是生成 6 位随机数字。实际开发中要注意的不是怎么生成而是用什么生成。有的教程直接用Math.random()拼字符串这在面试时会被追问并发安全问题的。Math.random()在多线程环境下有一定性能瓶颈更规范的做法是用ThreadLocalRandom.current().nextInt(1000000)或者RandomUtil.randomNumbers(6)Hutool 工具类生成的数再用String.format(%06d, ...)补足 6 位避免出现012345这种前导零被截断的情况。手机号校验也很关键这个接口对外暴露如果不校验格式恶意请求随便传一串字符就能触发短信发送白白浪费短信费用。推荐直接用正则^1[3-9]\\d{9}$去匹配这个正则覆盖了目前国内主流的手机号段基本上够用了。校验不通过直接返回手机号格式错误不往下走。3.2 Redis 存储结构设计与过期时间确定验证码存储的 key 我建议按这个规范设计业务模块:业务类型:唯一标识。在本项目中就是login:code:13800138000这种形式。为什么要加login:前缀因为同一个 Redis 实例上后面还有商户缓存、优惠券库存、签到记录等各种业务数据key 不区分前缀的话万一不同业务的数据撞了 key 名会发生数据覆盖的严重事故。这个命名规范也是面试中的加分点。过期时间的设定要权衡安全和体验。太短了用户来不及输入验证码体验很差太长了验证码有效期过长被截获后攻击窗口变大。项目里选 5 分钟是比较合理的中间值生产环境中不少公司也是这个值。还有一种常见做法是让验证码 5 分钟内有效但 1 分钟内不能重复发送——前一个限制保证验证码本身不过期后一个限制是防刷策略两者互补。Redis 存储验证码用的是opsForValue().set(key, code, Duration.ofMinutes(5))注意这个Duration参数它会让 Redis 在 5 分钟之后自动删除这个 key。这个过期机制是 Redis 的杀手锏也是这个模块选择 Redis 而非普通 Map 存储验证码的最重要原因——如果存在应用内存的 Map 里不仅要自己写定时任务清理过期验证码多实例部署下还可能出现用户验证码落在 A 机器、请求却打到 B 机器的问题。3.3 防刷策略不能让短信接口变成提款机短信验证码接口是最容易被恶意刷的接口之一。一条短信几分钱如果接口不加任何限制攻击者用脚本批量调这个接口一个晚上就能刷出几万条短信直接让公司的短信账单爆炸。这个项目里至少要实现以下这层防刷同一个手机号 60 秒内不能重复发送验证码。具体实现思路是利用 Redis 的setIfAbsent方法这个方法只有在 key 不存在时才会写入成功正好天然适合做分布式锁和接口防刷// 伪代码核心逻辑演示 String lockKey login:code:lock: phone; Boolean canSend stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(60)); if (Boolean.FALSE.equals(canSend)) { return Result.fail(发送过于频繁请稍后再试); }第一次请求时login:code:lock:{phone}这个 key 不存在写入成功可以发送验证码60 秒内这个 key 还存在再次请求会写入失败直接被拒绝。60 秒后 key 自动过期用户才能再次发送。这个方案不需要额外维护状态一个 key 就搞定了而且借助 Redis 的网络原子性多实例部署下也不会出现两个实例同时判断key不存在都放行的问题。生产环境还会叠加更多层防潮策略比如同一个手机号每天发送上限10 次左右、同一个 IP 的发送频率限制、图形验证码或滑块验证人机校验、设备指纹识别等。这些策略可以按成本从低到高逐步叠加从项目的教学角度掌握60 秒频控这一层已经足够应付面试的追问了。3.4 发送短信服务如何接真实生产环境的短信发送要接入云服务商的 API比如阿里云短信、腾讯云短信流程无非是在服务商控制台申请签名和模板后端调用服务商提供的 SDK 发送短信。一个实际的发送代码示例大概是这样的// 伪代码示意短信服务商 SDK 的调用方式 SmsClient client SmsClient.create() .setAccessKeyId(your_access_key) .setAccessKeySecret(your_secret) .build(); SendSmsRequest request new SendSmsRequest() .setPhoneNumbers(phone) .setSignName(你的应用签名) .setTemplateCode(SMS_123456) .setTemplateParam({\code\:\ code \}); client.sendSms(request);但在开发调试阶段每次都真的发短信既费钱又费时间所以项目里的做法是把验证码存进 Redis 之后用日志打印出来开发时打开日志就能看到验证码。很多团队还会配合一个万能验证码比如 123456在测试环境写死一个固定验证码方便联调上线时移除。这种环境区分的思维方式在后端开发中很常用——同一个代码逻辑在不同的环境下有不同的行为表现。4. 登录校验与自动注册的核心实现4.1 验证码校验一次性使用原则登录接口接收手机号和验证码后第一件事就是从 Redis 中取出已存储的验证码进行比对。这里有一个很容易踩坑的细节校验通过后一定要顺手把 Redis 里的验证码删掉确保验证码是一次性的。否则用户首次登录成功后验证码还在 Redis 里攻击者拿到这个验证码在 5 分钟内还能再登录一次等于接口变成了可重放攻击的入口。删除操作直接用stringRedisTemplate.delete(login:code: phone)即可。但要注意一个并发问题如果用户同时提交了两个登录请求两个请求都查到了同一个验证码都校验通过如果不在校验通过后立刻删除就会出现验证码被重复使用的风险。Redis 是单线程处理命令的get和delete是两条独立命令中间可能有其他请求插入要绝对杜绝验证码被使用两次可以考虑用 Lua 脚本把比对删除打包成一个原子操作当然这是进阶优化基础版先掌握通过即删除这个原则就合格了。校验失败的情况也要区分处理验证码不存在已过期或被使用过要提示验证码已过期请重新获取验证码不匹配要提示验证码错误。这些提示信息虽然简单但在真实体验中直接决定用户能不能顺畅地完成登录流程不要偷懒统一返回验证码错误。4.2 自动注册逻辑的实现校验通过后要根据手机号查用户表。用户表的主键用 MyBatis-Plus 的IdType.ASSIGN_ID策略生成雪花 IDTableId(value id, type IdType.ASSIGN_ID)这个注解要记住19 位 Long 类型 ID既能保证全局唯一又不会暴露自增 ID 带来的数据量泄露风险。查询逻辑很简单User user userService.query() .eq(phone, phone) .one();如果user null说明这是一个新用户走自动注册流程创建User对象phone设为当前手机号。昵称用固定前缀加随机字符串比如用户_abc123。头像可以给一个默认头像 URL。调用userService.save(user)插入数据库。之所以能做到第一次登录即注册是因为手机号天然具备唯一性验证码又证明了用户对手机号的所有权所以密码这一步完全可以省掉。用户无感知注册就完成了。在真实程序中这一步还可以追加一些运营逻辑——新用户注册送优惠券、记录注册渠道、初始化用户偏好设置等。4.3 用户信息脱敏与 UserDTO登录成功后需要把用户信息存进 Redis。但要注意User实体里有password这类绝对不能暴露的字段如果把整个实体直接存进去Redis 里就会出现明文密码虽然本项目没有密码字段但账号密码登录扩展时这是个大坑即使存进去了后续接口返回给前端也会泄露敏感字段。所以我一直建议所有涉及用户信息返回的场景都统一使用 DTO。这里的关键步骤是// 把 User 转换成 UserDTO只保留 id、nickname、icon 等必要字段 UserDTO userDTO BeanUtil.copyProperties(user, UserDTO.class); // 将 DTO 转成 Map存入 Redis Hash MapString, Object userMap BeanUtil.beanToMap(userDTO, new HashMap(), CopyOptions.create() .setIgnoreNullValue(true) .setFieldValueEditor((fieldName, fieldValue) - fieldValue.toString())); stringRedisTemplate.opsForHash().putAll(login:token: token, userMap);这里有两个值得注意的细节。第一Hutool 的beanToMap在转字段值时会自动调用toString()所以需要自定义FieldValueEditor防止空值处理出错或者没有覆盖 toString 的位置报错。第二Hash 的 field 是字段名value 是字符串值后面从 Redis 读出来再组装成 UserDTO 时要注意类型转换——BeanUtil.fillBeanWithMap可以把 Map 填回 Bean。这套互相转换的 API 熟练了以后写业务代码会顺手很多。5. 拦截器设计与登录状态管理5.1 双拦截器架构职责分离是关键这个项目在设计拦截器时用了两个而不是一个这个设计很值得说道说道。如果只用一个拦截器并且这个拦截器对所有请求生效那它既要负责刷新 Token 过期时间对所有请求都要做又要在请求不需要登录时直接放行逻辑会变得很绕。两个拦截器的好处是职责完全分开RefreshTokenInterceptor拦截所有请求/**。只做一件事检查请求头有没有 Token有 Token 就去 Redis 查查到就说明用户已登录把用户信息放进 ThreadLocal并刷新 Token 的过期时间。查不到或者没带 Token 就放行不做拦截判断。LoginInterceptor只拦截需要登录才能访问的路径。从 ThreadLocal 里取用户信息取不到说明没登录直接返回 401拦截这个请求。从执行顺序上看RefreshTokenInterceptor的order值要小于LoginInterceptororder 值越小优先级越高也就是说先执行刷新逻辑再执行登录校验逻辑。这个顺序保证了一个场景的正确处理用户带着有效 Token 访问需要登录的接口时Refresh 拦截器已经把用户装载进 ThreadLocal 了Login 拦截器才能取到用户如果两个拦截器顺序反了Login 拦截器先执行ThreadLocal 里还没数据明明登录了的用户也会被判为未登录。那能不能把两个拦截器合并成一个直接在这个拦截器里完成刷新 Token 判断登录 拦截未登录请求功能上当然可以但代码的耦合度会变高。如果以后有某些接口允许未登录访问但也要刷新 Token的需求比如游客随便逛首页但首页接口也要顺便刷新登录状态那这个合并后的拦截器就要加一堆if分支可读性很差。职责分离是软件设计里的基本原则这份代码是个很好的教学案例。5.2 ThreadLocal 传递用户信息为什么用它拿到用户信息后有一个问题拦截器里取到的用户怎么传给后续的 Controller 和 Service 呢最粗暴的方案是每个方法都加一个UserDTO user参数从拦截器一路传下去。但这样所有接口的签名都被迫加参数代码侵入性太强而且很多工具类、拦截器里的公共逻辑根本拿不到这个参数。ThreadLocal 是解决这个问题的利器。它的核心特性是同一个线程内在任何地方都可以存取同一个变量。一次 HTTP 请求在 Tomcat 中由一个线程从头处理到尾所以拦截器里放进去的用户信息Controller 和 Service 都能从 ThreadLocal 里拿到——这实际上是一种线程级上下文的传递方式业务代码无需显式传参。工具类UserHolder的实现也很简单public class UserHolder { private static final ThreadLocalUserDTO 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 就得负责清理。因为 Tomcat 的工作线程是池化复用的一次请求结束后线程并不会销毁而是回到线程池等待下一次任务。如果afterCompletion里没有调用UserHolder.removeUser()下一次请求复用这个线程时ThreadLocal 里还残留着上一次请求的用户信息轻则用户信息串号A 用户看到了 B 用户的数据重则引发内存泄漏。所以规范写法是在拦截器的afterCompletion方法里务必调用UserHolder.removeUser()。5.3 登录状态刷新滑动过期机制的实现这个模块最有意思的一个点就是登录状态刷新。Redis 给login:token:{token}设置了 30 分钟过期然后在 RefreshTokenInterceptor 里每次用户请求只要带上了有效 Token就重新设置一次过期时间stringRedisTemplate.expire(login:token: token, Duration.ofMinutes(30));效果就是用户只要保持在 30 分钟内有任意操作登录态就永远有效超过 30 分钟没操作Redis 自动把 key 清掉用户下次请求时拦截器查不到用户ThreadLocal 里没数据Login 拦截器就会拦截并返回 401前端收到 401 后引导用户重新登录。这个机制我在多个项目里都在用体验上最接近移动端 App 的习惯一直用就不会掉线闲置太久就让你重新登录一次。那次重新登录的成本对用户来说几乎无感但安全性好很多——长期有效的登录态等于给攻击者留着敞开的门。前端配合的存储方式一般是 localStorage 或小程序的 storage在 axios 请求拦截器里统一加上authorization请求头// 前端 axios 请求拦截器示意 service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[authorization] token; } return config; });后端通过request.getHeader(authorization)拿到这个 Token。注意前后端约定的请求头名称要完全一致大小写无所谓HTTP Header 大小写不敏感但名称本身别写错。5.4 拦截器配置路径与注册顺序的细节拦截器在MvcConfig中注册核心配置如下Configuration public class MvcConfig implements WebMvcConfigurer { Resource private StringRedisTemplate stringRedisTemplate; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)) .addPathPatterns(/**) .order(0); registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/shop/**, /voucher/**, /blog/**, /upload/**) .excludePathPatterns(/api/user/login, /api/user/code, /blog/hot, /shop-type/**, /shop/**) .order(1); } }几个容易出问题的地方addPathPatterns(/**)表示拦截所有路径包括静态资源。Refresh 拦截器对所有请求都执行没有性能问题因为它只做 Redis 查询比静态资源本身的磁盘 IO 还轻。但 Login 拦截器如果也拦截静态资源未登录用户连页面都加载不出来所以它只拦截需要登录才能访问的业务路径。excludePathPatterns放行的路径要仔细核对比如查询热门博客/blog/hot游客也能看不能拦截。放行登录注册接口/api/user/login和/api/user/code是必须的——用户都没登录你拦着不让他走登录流程这属于逻辑自杀。拦截器过滤的是 Spring MVC 的路径/**和/*的区别要注意/*只匹配一层路径/**匹配任意层。项目里接口路径可能是shop/xxx/yyy的多层结构用/**才不会漏。6. 常见问题排查与面试高频追问6.1 实操中踩过的坑把我在写这个模块时实际遇到的问题和排查方法整理成了一个速查表这些都是文档里查不到的经验问题现象排查思路解决方案验证码一直提示错误看 Redis 里 key 是否存在、值是否正确检查存储 key 是否与查询 key 一致排查是否有删 key 逻辑被提前执行用户明明登录了访问需要登录的接口还是 401检查请求头是否携带了正确的 authorization检查两个拦截器顺序是否错了给请求头加 token把 Refresh 拦截器的 order 调小ThreadLocal 出现用户信息串号检查拦截器afterCompletion是否调用了removeUser()在afterCompletion中清理 ThreadLocal前端跨域请求被拦截浏览器发的是 OPTIONS 预检请求拦截器直接返回 401拦截器放行 OPTIONS 请求部署多实例后登录态偶尔失效确认 Redis 是公共实例应用各实例都能访问同一个 Redis检查服务器 Redis 地址配置是否一致验证码可以重复使用登录校验成功后没有删除 Redis 中验证码校验通过立刻delete对应 key这里面跨域预检请求的问题最隐蔽。前后端分离项目前端页面在 8080 端口后端接口在 8081 端口浏览器发跨域请求时会先发一个 OPTIONS 预检请求。如果拦截器把这个 OPTIONS 请求拦了前端连真实请求都发不出去表现就是接口一直报 401。解决方案是在拦截器里加上下面的判断if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; }这种问题很难从后端日志看出端倪一般要打开浏览器 F12看到请求列表里状态为 failed 或者被 cancel 的 OPTIONS 请求才会意识到是拦截器的问题。6.2 面试官最爱追问的七个问题把自己代入面试官的角度围绕这个模块最常被问到的问题基本是这几个第一个问题为什么不用 Session 而是用 Redis回答要点Session 默认存 JVM 内存分布式部署时会话不同步必须引入外部存储Session 过期时间不精确控制Cookie 机制不适合前后端分离和移动端场景。第二个问题为什么 Token 用 UUID 而不是 JWT回答要点Redis 天然支持续期可以实现滑动过期服务端可以主动删 key 让登录态立刻失效JWT 过期时间固定且无法主动失效需要额外设计刷新机制。第三个问题ThreadLocal 的原理是什么为什么用它回答要点每个线程有自己的线程局部变量副本同一次请求的代码链路共享可以避免方法参数层层传递注意要remove()防止线程池复用带来的数据串号和内存泄漏。第四个问题怎么防止验证码被刷回答要点60 秒频控RedissetIfAbsent实现单日发送上限IP 级限流图形验证码生产环境短信平台的风控机制。第五个问题两个拦截器能不能合成一个回答要点理论上可以但职责分离一个负责刷新会话、一个负责登录校验合并后会导致拦截器承担多个职责后续扩展不灵活。第六个问题为什么用户信息存 Hash 而不是 String回答要点Hash 结构方便单独更新某个字段比如修改昵称时只更新 Hash 里的 name 字段不用整体覆写字符串减少网络传输数据量String JSON 序列化需要读写整个字符串。第七个问题UserHolder 是单例的吗线程安全吗回答要点UserHolder 本身是静态方法工具类不存在实例状态ThreadLocal 让每个线程读写自己的副本天然线程安全。6.3 这个模块可以做哪些优化学完基础版本后如果想让项目在简历上更有竞争力可以考虑以下几个方向用 Lua 脚本保证校验验证码 删除验证码的原子性防止并发下验证码被重复使用。增加连续错误次数限制验证码输错 5 次直接作废加大暴力破解难度。接入真正的短信服务商并且在配置类中区分本地环境直接返回验证码和生产环境发送短信。把 Token 的格式从纯 UUID 扩展成前缀 UUID 签名方便在日志中快速识别业务来源和做多端登录隔离。增加多端登录支持手机端 Token 和 Web 端 Token 使用不同的业务前缀互不干扰踢人下线也更灵活。这些优化虽然不复杂但在面试中的示范效应很好至少说明你没有停留在跟着教程敲代码的层面而是在真正思考工程化的问题。7. 个人实操心得与后续扩展建议7.1 从零跑通这个模块的三个经验第一不要一上来就对着代码敲。我自己学这个模块的时候先花了一个小时把整个请求链路画出来前端发什么请求、后端怎么处理、Redis 存什么、返回什么。画完图之后再去看代码就非常顺畅。后面做任何新模块我都保持这个习惯先画链路再动手效率翻倍。第二Debug 的抓手是 Redis。这个模块几乎所有状态都在 Redis 里遇到问题先redis-cli连上服务器用keys login:*查看所有登录相关的 key用TTL key看过期时间用HGETALL key看用户信息是否完整。配合后端的日志输出绝大多数问题十分钟内能定位。熟练掌握 Redis 命令行对这个模块的调试帮助极大。第三前端调试要看 Network 面板。我看到很多人后端接口明明正常前端就是登录不进去最后发现是 axios 请求没带authorization请求头或者请求头发送了但名字写错了。前后端联调时先确认请求有没有发出、请求头有没有携带、响应状态码是什么再去查后端代码能省掉大量互相甩锅的时间。7.2 在真实项目中如何扩展这套登录体系黑马点评的短信登录是个非常标准的教学案例放到真实项目里可以直接作为会话管理的基础框架。但生产环境通常还会在此基础上叠加更多能力。真实项目的登录远比教程复杂常见的扩展点包括密码登录和验证码登录并存、第三方平台登录微信、QQ、扫码登录、多端设备管理强制下线其他设备、登录风控异常 IP 检测、常用设备校验、操作日志审计等。这个模块的 Redis Token ThreadLocal 框架就是地基所有扩展功能都在这套框架上生长地基没打好上层功能做得再多也会出乱子。面试的时候把这套框架讲清楚再结合一两个扩展点举例比如我在这个基础上增加了微信登录流程是微信授权码换 openid再通过 openid 查用户表没有则自动注册面试官基本就能认可你的设计能力了。7.3 最后一个小技巧如果你想把登录模块做成一键登录的体验可以在自动注册那一环做点文章新用户注册时直接给他生成默认昵称和头像再顺带初始化一些个性化数据。这个细节在面试中提到比空谈我熟悉登录流程要具体得多——说明你真的思考过用户从进入系统到产生价值这条路上每一步该怎么铺垫。短信登录模块是整个黑马点评项目的第一块砖也是把前后端分离、Redis、拦截器这些知识点串联起来的第一条线索。把它的每一行代码都吃透后面学商户查询缓存、优惠券秒杀你会明显感觉到自己在用同一套思维方式去理解新的业务场景这就是打通任督二脉的感觉。