黑马点评项目学习(一):短信登录模块 + 扩展(双token无感刷新)
2026/9/25 1:29:59 网站建设 项目流程

黑马点评项目学习(一):短信登录模块 + 扩展(双token无感刷新)

最近开始跟练黑马点评项目,把短信登录这一块的一些思考整理成笔记,分享给正在学习这个项目的朋友。


一、项目背景与数据库设计

这是一个基于 Spring Boot + Redis + MyBatis-Plus 的模拟点评项目。先来看一下整体的数据库表设计,以及我学到的一些“为什么这么设计”。

1.1 表结构概览

从整体来看,这个项目涵盖了用户、商户、优惠券、社交互动等常见业务场景,一共设计了7张核心表。之所以这样拆分,也是遵循了数据库设计的一些基本原则:

表名用途
用户表存储用户账号、手机号、密码等信息
用户详情表存储用户昵称、头像、简介等扩展信息
商户信息表存储入驻商家的详细信息
商户类型表商户分类(如美食、酒店、景点等)
用户日记表用户发布的探店笔记/动态
用户关注表用户之间的关注关系
优惠券表商户发放的优惠券信息
优惠券的订单表用户领取/购买优惠券的订单记录

1.2 扩展思考:MySQL 到底能不能“水平扩展”?

在学习表设计时,我想到一个问题:如果一个表数据量太大了怎么办?MySQL能不能像Redis那样无限加机器扩展?

答案是:MySQL原生不支持无缝水平扩展,但可以通过一些手段实现“伪水平扩展”

方案一:读写分离(主从复制)

增加多台从库(Slave)负责读,主库(Master)负责写。

  • 优点:扩展了读能力,配置相对简单
  • 缺点:写能力依然卡在单台主库上,且存在主从延迟问题

方案二:分库分表

通过 ShardingSphere / MyCat 等中间件,将一张大表拆成多张小表分散到不同服务器。

  • 优点:理论上可以无限扩展
  • 缺点:代码复杂度暴增,跨库事务、复杂JOIN、分页排序等能力受限

结论:MySQL 做水平扩展是有代价的,不像 NoSQL 那么“天然”。所以在项目初期,优先考虑索引优化、缓存、冷热分离,等数据量真正达到瓶颈再考虑分库分表。对于大多数学习项目来说,单库+Redis缓存已经够用了。

二、核心知识点梳理

在开始写登录功能之前,有几个前置概念需要先搞懂。

2.1 Nginx 反向代理

Nginx 不就是请求转发吗?为什么叫“反向代理”?

正向代理 vs 反向代理的本质区别:

维度正向代理反向代理
谁知道自己要访问谁?客户端知道目标服务器客户端不知道,只知道代理地址
代理服务谁?代理客户端代理服务器
生活中类比跑腿小哥帮你买东西打 400 客服电话,接线员转接具体专员

形象比喻:

  • 正向代理像“跑腿小哥”:你(客户端)想买东西但出不去,找了小哥帮你买。商家只知道小哥来买,不知道是你买的,但你心里清楚买的是谁家的。
  • 反向代理像“公司前台/400客服”:你拨通 400 电话,接线员(Nginx)帮你转接给具体售后专员(Tomcat)。你自始至终只拨了 400 这个号码,根本不知道背后是谁在处理。

在我们这个项目里,前端请求 → Nginx(反向代理)→ Tomcat(后端服务),用户只跟 Nginx 打交道,不需要知道背后有几台服务器。这就是反向代理的核心价值:负载均衡 + 隐藏后端细节

2.2 ThreadLocal 线程域对象

为什么需要 ThreadLocal?直接用局部变量不行吗?

在 Web 开发中,每个请求是一个独立的线程。如果没有 ThreadLocal,在多线程并发场景下,共享变量会出现线程安全问题。

ThreadLocal 的本质:

ThreadLocal 是一个 Map Key:当前线程 Value:线程独有的数据副本

每个线程都有自己的 ThreadLocalMap,数据被隔离在各自线程中,互不干扰。下图展示了 ThreadLocal 的数据隔离机制:

┌─────────────────────────────────────────────────────────────────┐ │ JVM 进程空间 │ │ │ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ 用户线程 A │ │ 用户线程 B │ │ │ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │ │ │ │ ThreadLocalMap │ │ │ │ ThreadLocalMap │ │ │ │ │ │ (key: ThreadLocal)│ │ │ (key: ThreadLocal)│ │ │ │ │ │ (value: 用户A) │ │ │ │ (value: 用户B) │ │ │ │ │ └─────────────────┘ │ │ └─────────────────┘ │ │ │ └─────────────────────┘ └─────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘

典型应用场景:在登录拦截器中,把当前登录用户信息存入 ThreadLocal,在 Controller、Service 层随时可以获取,无需层层传参。

三、短信验证码登录流程

这个项目的登录/注册功能做得很巧妙:登录和注册合二为一

3.1 发送验证码

开始 → 提交手机号 → 校验手机号格式 → 生成6位验证码 → 保存验证码到Session → 发送验证码 → 结束

3.2 登录/注册(合二为一)

开始 → 提交手机号 + 验证码 → 校验验证码是否正确 → 根据手机号查询用户 → 用户是否存在? → 不存在:创建新用户并保存到数据库 → 存在:直接取出用户信息 → 保存用户信息到 Session → 登录成功

为什么说是“合二为一”?因为对于前端来说,只需要一个登录接口,后端自动判断是登录还是注册,这种设计在移动端很常见,减少了前端判断逻辑。

四、登录状态校验与拦截器

4.1 核心流程图

┌─────────────────────────────────────────────────────────────────────────────────────┐ │ 登录状态校验流程 │ ├─────────────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────┐ │ │ │ 浏览器请求 │───▶│ RefreshToken │───▶│ Login │───▶│ Controller │ │ │ │ (携带token)│ │ Interceptor │ │ Interceptor │ │ 业务处理 │ │ │ └──────────┘ │ (优先级0) │ │ (优先级1) │ └─────────────┘ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ 1.从Header取token │ │ 1.从ThreadLocal │ │ │ │ 2.Redis查用户 │ │ 取用户信息 │ │ │ │ 3.存在则刷新TTL │ │ 2.不存在则拦截 │ │ │ │ 4.存入ThreadLocal│ │ 返回401 │ │ │ └──────────────────┘ └──────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────────────────────────────┐ │ │ │ afterCompletion │ │ │ │ 无论成功/失败,最终移除ThreadLocal │ │ │ │ 防止内存泄漏和数据污染 │ │ │ └──────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────────────────┘

4.2 为什么需要两层拦截器?

单体 Session 模式的问题:

每次请求通过 SessionId 判断是否有登录用户,但项目部署在多台 Tomcat 上时,Session 不共享,请求切换到不同服务器会导致用户数据丢失,用户被迫重新登录。

解决方案:用 Redis 代替 Tomcat Session 存储用户信息。

我设计了两层拦截器,职责分工明确:

  • RefreshTokenInterceptor(刷新拦截器)

    • 从请求头获取 token
    • 基于 token 从 Redis 中查询用户信息
    • 如果存在:刷新 token 有效期,并将用户信息存入 ThreadLocal
    • 无论是否找到用户,都放行(真正的登录校验交给第二层)
    • 存在目的:保证用户访问任何请求时,只要携带有效 token,就能刷新有效期,避免用户正在操作时突然过期。
  • LoginInterceptor(登录校验拦截器)

    • 从 ThreadLocal 中获取用户
    • 如果不存在:返回 401 状态码,拦截请求
    • 如果存在:放行

拦截器配置:

@ConfigurationpublicclassMvcConfigimplementsWebMvcConfigurer{@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){// 第一层:刷新拦截器 — 拦截所有请求,刷新 token 有效期registry.addInterceptor(refreshTokenInterceptor).addPathPatterns("/**").order(0);// 优先级最高// 第二层:登录校验拦截器 — 排除不需要登录的接口registry.addInterceptor(loginInterceptor).excludePathPatterns("/shop/**","/voucher/**","/shop-type/**","/upload/**","/blog/hot","/user/code","/user/login").order(1);}}

关键点:

  • RefreshTokenInterceptor 的order(0)先执行
  • LoginInterceptor 的order(1)后执行
  • 登录校验拦截器需要排除掉登录、注册、商铺查询等不需要登录就能访问的接口

4.3 登录拦截器代码实现

LoginInterceptor(登录校验拦截器):

publicclassLoginInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// 1. 从 ThreadLocal 中获取用户UserDTOuser=UserHolder.getUser();// 2. 没有用户,拦截并返回 401if(user==null){response.setStatus(HttpStatus.UNAUTHORIZED.value());returnfalse;}// 3. 有用户,放行returntrue;}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{// 防止内存泄漏和线程复用时的数据污染UserHolder.removeUser();}}

RefreshTokenInterceptor(刷新拦截器):

@Slf4j@ComponentpublicclassRefreshTokenInterceptorimplementsHandlerInterceptor{@AutowiredprivateStringRedisTemplatestringRedisTemplate;@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{// 1. 从请求头获取 tokenStringtoken=request.getHeader("authorization");if(StrUtil.isBlank(token)){returntrue;// 没有 token,放行(让 LoginInterceptor 去拦截)}// 2. 基于 token 从 Redis 获取用户信息Stringkey=RedisConstants.LOGIN_USER_KEY+token;Map<Object,Object>userMap=stringRedisTemplate.opsForHash().entries(key);// 3. 用户不存在,放行(让 LoginInterceptor 去拦截)if(userMap.isEmpty()){returntrue;}// 4. 将 Hash 数据转为 UserDTOUserDTOuserDTO=BeanUtil.fillBeanWithMap(userMap,newUserDTO(),false);// 5. 存入 ThreadLocalUserHolder.saveUser(userDTO);// 6. 刷新 token 有效期(30分钟)stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);returntrue;// 放行}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{UserHolder.removeUser();}}

五、关于无感刷新的思考

5.1 这个方案算“无感刷新”吗?

算!而且已经实现了无感刷新的核心逻辑。

所谓的“无感刷新”是指:用户在请求过程中,Token 过期了,但系统在不打扰用户的情况下自动续期

这个方案中:

  1. 用户每次请求都携带 token
  2. RefreshTokenInterceptor 从 Redis 查到用户后,自动续期 token 有效期
  3. 用户无感知,也不会被踢下线

这就是一个完整的“无感刷新”机制,只不过实现方式比较轻量。

5.2 为什么不做双 Token(Access Token + Refresh Token)?

你提到的双 Token 方案是标准的 JWT 无感刷新方案:

  • Access Token:短期有效(如 15 分钟),用于日常请求
  • Refresh Token:长期有效(如 7 天),只在 Access Token 过期时用来换取新的 Access Token

双 Token 方案的流程:

┌─────────────────────────────────────────────────────────────────────┐ │ 双Token无感刷新流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ 1.登录请求 ┌──────────────┐ │ │ │ 前端 │ ───────────▶ │ 后端 │ │ │ │ │ ◀─────────── │ 返回 access+ │ │ │ │ │ 2.返回双Token│ refresh_token │ │ │ └─────────┘ └──────────────┘ │ │ │ │ │ │ │ 3.携带 access_token 请求 │ │ │ │ ──────────────────────▶ │ │ │ │ │ 4.校验 access_token │ │ │ │ 5.过期?使用 refresh_token 续期 │ │ │ ◀────────────────────── │ 6.返回新的 access_token │ │ │ 7.返回新access_token │ │ │ │ │ │ └─────────────────────────────────────────────────────────────────────┘

这个项目的 Token 其实是“Token + Redis”模式,而不是纯 JWT 方案。

方案实现方式优点缺点
黑马点评方案(低配版)Token 作为 Redis 的 Key,用户信息存 Redis实现简单,可随时踢人下线每次请求都要查 Redis,Token 本身不携带信息
双 Token 方案(高配版)Access Token + Refresh Token 都是 JWT不依赖 Redis 存储,性能更好实现复杂,无法主动踢人下线

为什么黑马点评这个项目没有用双 Token 方案?

  1. 课时限制:这个项目是教学项目,重点在于讲清楚 Redis 在登录中的应用场景
  2. 复杂度控制:双 Token 涉及 JWT 解析、过期逻辑判断、前端配合刷新等,对于新手来说理解成本较高
  3. 其实已经够用:对于绝大多数业务系统,Redis 存储 Token + 自动续期完全够用了,只是双 Token 方案更“专业”

但在真正的生产环境,尤其是追求极致性能的大规模分布式系统中,往往倾向于用双 Token 方案,将登录状态校验从 Redis 中解放出来,同时兼顾性能与安全性。

5.3 如何将这个项目升级为双 Token 方案?

如果你学有余力,可以尝试把这个项目升级为双 Token 方案,主要改动点:

登录接口改造: ├── 生成 Access Token(有效期 15-30 分钟) ├── 生成 Refresh Token(有效期 7 天) └── 两个 Token 都返回给前端 刷新接口新增: ├── /user/refresh 接口 ├── 接收 Refresh Token ├── 校验 Refresh Token 是否有效 ├── 生成新的 Access Token 返回 前端改造: ├── 请求拦截器:每次请求携带 Access Token ├── 响应拦截器:收到 401 时自动调用刷新接口 ├── 刷新成功后重试原请求 └── 刷新失败则跳转登录页

六、总结

这一节学了短信登录模块的完整实现,核心收获:

知识点要点
MySQL水平扩展读写分离扩展读能力,分库分表扩展写能力,但有代价
正向/反向代理正向代理代理客户端,反向代理代理服务器,Nginx是反向代理
ThreadLocal每个线程独立的数据副本,用于请求链路中传递用户信息
短信登录登录注册合二为一,通过手机号+验证码完成认证
集群Session问题多台Tomcat不共享Session,用Redis统一存储解决
两层拦截器第一层刷新Token有效期,第二层校验登录状态
无感刷新通过拦截器自动续期Token,用户无感知

一个小建议:这个项目的登录方案其实已经实现了无感刷新的核心逻辑。如果你是初学者,先把这套方案吃透,再去研究双 Token 方案,会轻松很多。毕竟先搞懂“为什么这么做”,再研究“怎么做得更好”,学习效率最高。


下一篇预告:黑马点评(二):商户缓存与Redis实战 —— 为什么说缓存是性能优化的第一把刀?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询