黑马点评01
黑马点评01:短信登陆
第一版用Session实现
发送验证码流程

接收手机号,进行基本校验(非空、格式)。
调用短信服务商API发送验证码
关键步骤:将验证码存入 HttpSession。
Key:一个固定字符串或与手机号关联的Key,例如 “code” 或 “login:code:”+phone。
Value:验证码本身。
登录注册流程

校验登陆状态

为了保护需要登录才能访问的接口,项目通过 Spring MVC 的拦截器(Interceptor)来实现状态校验和信息共享:
- 拦截请求: 拦截器会拦截客户端的请求,并尝试从
HttpSession中获取"user"属性。 - 状态判断:
- 如果没有获取到用户信息,说明未登录,直接拦截请求并返回 401 状态码。
- 如果获取到了用户信息,说明已登录。
- 保存至 ThreadLocal: 拦截器将获取到的
UserDTO保存到ThreadLocal(项目中封装为UserHolder工具类)中。这一步非常关键,它实现了线程隔离,使得在后续的 Controller、Service 层中,可以随时随地通过UserHolder.getUser()方便地获取当前登录的用户信息,而不需要把 request 对象传遍整个调用链路。 - 放行与清理: 拦截器放行请求。在整个请求处理完毕后,必须将
ThreadLocal中的数据清理掉,以防止内存泄漏和线程池复用导致的数据错乱。
第二版用redis实现
Session默认存在各自Tomcat的内存里,不互通,所以如果集群的话改用redis
发送验证码

登录校验

双拦截器
拦截器一:刷新 Token 拦截器 (RefreshTokenInterceptor)
- 拦截范围: 拦截所有路径 (
/**)。 - 核心逻辑:
- 尝试从请求头(Header)的
authorization字段获取 Token。 - 如果没有 Token,直接放行(不拦截,交由下一个拦截器处理)。
- 如果有 Token,拼接 Key 去 Redis 里查询对应的 Hash 数据。
- 如果 Redis 里有数据,将其转回
UserDTO,存入ThreadLocal(UserHolder)。 - 最重要的一步:刷新 Redis 中这个 Token 的有效期(重置为 30 分钟)。
- 放行。
- 尝试从请求头(Header)的
拦截器二:登录拦截器 (LoginInterceptor)
- 拦截范围: 只拦截需要登录的路径(比如下单、查看个人中心等)。
- 核心逻辑:
- 因为它排在“拦截器一”后面,所以它不需要再去查 Redis 了。
- 它只做一件事:判断当前线程的
ThreadLocal(UserHolder) 中有没有UserDTO。 - 如果没有,说明用户没登录(或者 Token 已经过期),拦截请求,返回 401 状态码。
- 如果有,说明已登录,直接放行。
验证码防刷机制
本项目针对的是手机验证码发送接口,主要使用 Redis 和 Lua 脚本实现限流,防止脚本批量调用接口刷验证码。
一、手机号 60 秒冷却
每个手机号对应一个 Redis 冷却 key:
1 | login:code:limit:phone:{phone} |
发送验证码前,Lua 脚本先判断该 key 是否存在:
1 | if redis.call('exists', KEYS[1]) == 1 then |
如果存在,说明该手机号还在冷却期,直接拒绝请求。
如果不存在,则创建一个 60 秒后自动过期的 key:
1 | redis.call('set', KEYS[1], '1', 'EX', ARGV[4], 'NX') |
其中:
1 | ARGV[4] = 60 |
因此流程是:
1 | 发送成功后创建冷却 key |
对应常量:
1 | LOGIN_CODE_PHONE_COOLDOWN_SECONDS = 60L; |
二、手机号每日发送次数限制
手机号每日计数 key 会包含手机号和日期:
1 | login:code:daily:phone:{phone}:{date} |
例如:
1 | login:code:daily:phone:13800138000:2026-09-22 |
发送前先判断当天是否达到上限:
1 | if tonumber(redis.call('get', KEYS[2]) or '0') >= tonumber(ARGV[1]) then |
当前限制为:
1 | 同一手机号每天最多发送 10 次 |
如果没有达到上限,就使用 Redis 的 INCR 将次数加一:
1 | local phoneCount = redis.call('incr', KEYS[2]) |
第一次创建计数 key 时设置 24 小时过期时间:
1 | if phoneCount == 1 then |
超过 10 次后,Java 返回:
1 | 该手机号今日验证码发送次数已达上限 |
三、IP 限流
控制器先获取客户端 IP:
1 | String forwarded = request.getHeader("X-Forwarded-For"); |
获取顺序是:
1 | 1. 优先读取 X-Forwarded-For 的第一个 IP |
IP 计数使用两个 Redis key:
1 | login:code:limit:ip:minute:{ip} |
设计上的限制是:
1 | 同一 IP 短时间最多 10 次 |
Lua 检查逻辑:
1 | if tonumber(redis.call('get', KEYS[3]) or '0') >= tonumber(ARGV[2]) then |
通过检查后,IP 计数加一:
1 | local ipMinuteCount = redis.call('incr', KEYS[3]) |
Java 根据返回值提示:
1 | 返回 3:请求过于频繁,请稍后再试 |
四、验证码有效期
验证码保存到 Redis 时设置 2 分钟 TTL:
1 | stringRedisTemplate.opsForValue().set( |
验证码 key:
1 | login:code:{phone} |
有效期:
1 | 2 分钟 |
2 分钟后 Redis 自动删除验证码,即使用户没有登录,也不能继续使用。
五、验证码一次性使用
登录时,通过 Lua 脚本原子完成验证码读取、比对和删除:
1 | local code = redis.call('get', KEYS[1]) |
Java 调用脚本并处理结果:
1 | Long verifyResult = stringRedisTemplate.execute( |
执行流程:
1 | 验证码错误 → 校验失败,保留验证码 |
读取、比对和删除期间不会插入其他请求的 Redis 命令,因此并发提交同一个正确验证码,也只有一个请求能通过校验。
注意:删除发生在后续登录业务之前;如果后续数据库操作失败,验证码也不会恢复。
六、为什么使用 Lua 脚本
手机号冷却、手机号计数和 IP 计数都放在同一个 Redis Lua 脚本中执行。
这样可以保证:
1 | 检查限制 |
这些操作作为一个原子操作执行,多个并发请求不会在检查和计数之间互相穿插,从而减少绕过限流的可能。
七、完整流程
1 | 客户端请求发送验证码 |