黑马点评01:短信登陆

第一版用Session实现

发送验证码流程

img

接收手机号,进行基本校验(非空、格式)。

调用短信服务商API发送验证码

关键步骤:将验证码存入 HttpSession。

Key:一个固定字符串或与手机号关联的Key,例如 “code” 或 “login:code:”+phone。

Value:验证码本身。

登录注册流程

img

校验登陆状态

img

为了保护需要登录才能访问的接口,项目通过 Spring MVC 的拦截器(Interceptor)来实现状态校验和信息共享:

  • 拦截请求: 拦截器会拦截客户端的请求,并尝试从 HttpSession 中获取 "user" 属性。
  • 状态判断:
    • 如果没有获取到用户信息,说明未登录,直接拦截请求并返回 401 状态码。
    • 如果获取到了用户信息,说明已登录。
  • 保存至 ThreadLocal: 拦截器将获取到的 UserDTO 保存到 ThreadLocal(项目中封装为 UserHolder 工具类)中。这一步非常关键,它实现了线程隔离,使得在后续的 Controller、Service 层中,可以随时随地通过 UserHolder.getUser() 方便地获取当前登录的用户信息,而不需要把 request 对象传遍整个调用链路。
  • 放行与清理: 拦截器放行请求。在整个请求处理完毕后,必须将 ThreadLocal 中的数据清理掉,以防止内存泄漏和线程池复用导致的数据错乱。

第二版用redis实现

Session默认存在各自Tomcat的内存里,不互通,所以如果集群的话改用redis

发送验证码

image-20260922154646162

登录校验

image-20260922154722205

双拦截器

拦截器一:刷新 Token 拦截器 (RefreshTokenInterceptor)

  • 拦截范围: 拦截所有路径 (/**)。
  • 核心逻辑:
    1. 尝试从请求头(Header)的 authorization 字段获取 Token。
    2. 如果没有 Token,直接放行(不拦截,交由下一个拦截器处理)。
    3. 如果有 Token,拼接 Key 去 Redis 里查询对应的 Hash 数据。
    4. 如果 Redis 里有数据,将其转回 UserDTO,存入 ThreadLocal (UserHolder)。
    5. 最重要的一步:刷新 Redis 中这个 Token 的有效期(重置为 30 分钟)。
    6. 放行。

拦截器二:登录拦截器 (LoginInterceptor)

  • 拦截范围: 只拦截需要登录的路径(比如下单、查看个人中心等)。
  • 核心逻辑:
    1. 因为它排在“拦截器一”后面,所以它不需要再去查 Redis 了。
    2. 它只做一件事:判断当前线程的 ThreadLocal (UserHolder) 中有没有 UserDTO。
    3. 如果没有,说明用户没登录(或者 Token 已经过期),拦截请求,返回 401 状态码。
    4. 如果有,说明已登录,直接放行。

验证码防刷机制

本项目针对的是手机验证码发送接口,主要使用 Redis 和 Lua 脚本实现限流,防止脚本批量调用接口刷验证码。

一、手机号 60 秒冷却

每个手机号对应一个 Redis 冷却 key:

1
login:code:limit:phone:{phone}

发送验证码前,Lua 脚本先判断该 key 是否存在:

1
2
3
if redis.call('exists', KEYS[1]) == 1 then
return 1
end

如果存在,说明该手机号还在冷却期,直接拒绝请求。

如果不存在,则创建一个 60 秒后自动过期的 key:

1
redis.call('set', KEYS[1], '1', 'EX', ARGV[4], 'NX')

其中:

1
ARGV[4] = 60

因此流程是:

1
2
3
发送成功后创建冷却 key
60 秒内再次请求 -> 拒绝
60 秒后 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
2
3
if tonumber(redis.call('get', KEYS[2]) or '0') >= tonumber(ARGV[1]) then
return 2
end

当前限制为:

1
同一手机号每天最多发送 10 次

如果没有达到上限,就使用 Redis 的 INCR 将次数加一:

1
local phoneCount = redis.call('incr', KEYS[2])

第一次创建计数 key 时设置 24 小时过期时间:

1
2
3
if phoneCount == 1 then
redis.call('expire', KEYS[2], ARGV[5])
end

超过 10 次后,Java 返回:

1
该手机号今日验证码发送次数已达上限

三、IP 限流

控制器先获取客户端 IP:

1
2
3
4
5
6
7
String forwarded = request.getHeader("X-Forwarded-For");

if (forwarded != null && !forwarded.trim().isEmpty()) {
return forwarded.split(",")[0].trim();
}

return request.getRemoteAddr();

获取顺序是:

1
2
1. 优先读取 X-Forwarded-For 的第一个 IP
2. 没有该请求头时,使用 request.getRemoteAddr()

IP 计数使用两个 Redis key:

1
2
login:code:limit:ip:minute:{ip}
login:code:daily:ip:{ip}:{date}

设计上的限制是:

1
2
同一 IP 短时间最多 10 次
同一 IP 每天最多 100 次

Lua 检查逻辑:

1
2
3
4
5
6
7
if tonumber(redis.call('get', KEYS[3]) or '0') >= tonumber(ARGV[2]) then
return 3
end

if tonumber(redis.call('get', KEYS[4]) or '0') >= tonumber(ARGV[3]) then
return 4
end

通过检查后,IP 计数加一:

1
2
local ipMinuteCount = redis.call('incr', KEYS[3])
local ipDayCount = redis.call('incr', KEYS[4])

Java 根据返回值提示:

1
2
返回 3:请求过于频繁,请稍后再试
返回 4:当前网络今日请求次数已达上限

四、验证码有效期

验证码保存到 Redis 时设置 2 分钟 TTL:

1
2
3
4
5
6
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone,
code,
LOGIN_CODE_TTL,
TimeUnit.MINUTES
);

验证码 key:

1
login:code:{phone}

有效期:

1
2 分钟

2 分钟后 Redis 自动删除验证码,即使用户没有登录,也不能继续使用。

五、验证码一次性使用

登录时,通过 Lua 脚本原子完成验证码读取、比对和删除:

1
2
3
4
5
6
7
8
9
10
11
12
local code = redis.call('get', KEYS[1])

if not code then
return 1 -- 不存在或已过期
end

if code ~= ARGV[1] then
return 2 -- 验证码错误,保留验证码
end

redis.call('del', KEYS[1])
return 0 -- 校验成功并删除

Java 调用脚本并处理结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Long verifyResult = stringRedisTemplate.execute(
VERIFY_CODE_SCRIPT,
Collections.singletonList(LOGIN_CODE_KEY + phone),
code
);

if (verifyResult == null) {
return Result.fail("请稍后再试");
}
if (verifyResult == 1L) {
return Result.fail("验证码已失效,请重新获取");
}
if (verifyResult != 0L) {
return Result.fail("验证码错误");
}

// 校验并消费成功,继续查询或创建用户、生成 Token

执行流程:

1
2
3
验证码错误 → 校验失败,保留验证码
验证码正确 → 原子删除验证码 → 继续登录
再次使用 → 验证码不存在 → 校验失败

读取、比对和删除期间不会插入其他请求的 Redis 命令,因此并发提交同一个正确验证码,也只有一个请求能通过校验。

注意:删除发生在后续登录业务之前;如果后续数据库操作失败,验证码也不会恢复。

六、为什么使用 Lua 脚本

手机号冷却、手机号计数和 IP 计数都放在同一个 Redis Lua 脚本中执行。

这样可以保证:

1
2
3
4
检查限制
创建冷却 key
手机号计数加一
IP 计数加一

这些操作作为一个原子操作执行,多个并发请求不会在检查和计数之间互相穿插,从而减少绕过限流的可能。

七、完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
客户端请求发送验证码
↓
校验手机号格式
↓
获取客户端 IP
↓
Lua 原子检查手机号冷却
↓
检查手机号每日次数
↓
检查 IP 短窗口次数
↓
检查 IP 每日次数
↓
全部通过
↓
创建手机号 60 秒冷却 key
↓
手机号和 IP 计数加一
↓
生成 6 位验证码
↓
验证码写入 Redis,2 分钟过期
↓
登录时校验验证码
↓
校验成功后删除验证码