Appearance
验证码状态机(captcha + sms-code)
图形验证码(Redis: oauth:captcha:{id}, TTL 120s)
mermaid
stateDiagram-v2
[*] --> Fresh: captcha() 返回
Fresh --> Consumed: sms-send 到达(无论图形码对错)
Fresh --> Expired: 120s 无消费 / 生成 IP 的 /24 段变更后校验
Consumed --> [*]
Expired --> [*]前端对照:
- captcha 调用后立即渲染
<image :src="png"> - 任何失败分支(40101003 / 40101004)后必须重新
captcha - 换网(WiFi↔4G)IP /24 段变更 → 已生成但未消费的 captcha 会失效
短信验证码(Redis: oauth:sms:code:{phone}:{clientId}, TTL 600s)
mermaid
stateDiagram-v2
[*] --> Fresh: sms-send 成功
Fresh --> Fresh: sms-verify/sms-login 码正确(verify 不消费;login 消费)
Fresh --> Wrong: 码错误(attempts+1)
Wrong --> Fresh: attempts < 3(保留剩余 TTL)
Wrong --> [*]: 第 3 次错误 → 立即删除
Fresh --> Consumed: sms-login 成功 / bind-phone 成功
Fresh --> Expired: 600s 过期
Consumed --> [*]
Expired --> [*]前端对照(三个独立约束):
| 独立约束 | UI 对应 |
|---|---|
| 60s 发送倒计时(限流) | 按钮倒计时禁用 |
| 10min 有效期(TTL) | "验证码已过期" |
| 3 错锁定(attempts) | "剩余 X 次"提示 |
三层独立约束的 UI 实现
约束 1:60s 发送倒计时(限流)
- 服务端硬限:手机号 60s 内只能发送 1 条
- 前端可先 disable 按钮 60s(体验优化),但最终以服务端为准
- 超限返回
40101005(msg 含「60s」),前端展示服务端 msg
约束 2:10min 有效期(TTL)
- 服务端 Redis TTL 600s
- 用户 10 分钟内未使用验证码 → 过期
- 任意时刻 sms-verify / sms-login 拿到
40101001(码不存在) → 引导重发
约束 3:3 错锁定(attempts)
- 错误计数器跨 sms-verify 与 sms-login 共享(同一个 Redis key)
- 第 1、2 次错:
40101003(msg=「短信验证码错误」),可继续试 - 第 3 次错:码被立即删除,下次请求返回
40101001(码不存在),必须重发 - 分步表单特别提示:如果预验证已经输错 2 次,最终提交只剩 1 次机会
验证码生命周期与并发安全
服务端用 Lua 脚本保证 GET + 校验 + 标记 consumed 的原子性:
lua
-- captcha consume 原子脚本(节选)
local data = redis.call('GET', KEYS[1])
if not data then return {0, 'not_found'} end
local obj = cjson.decode(data)
if obj.state ~= 'fresh' then return {0, 'consumed'} end
if obj.ip_subnet ~= ARGV[2] then return {0, 'ip_mismatch'} end
if obj.code_hash ~= ARGV[1] then return {0, 'wrong'} end
obj.state = 'consumed'
redis.call('SET', KEYS[1], cjson.encode(obj), 'EX', tonumber(ARGV[3]))
return {1, 'ok'}前端不要本地缓存 captcha —— 并发安全由服务端 Lua 保证,前端重复调用会浪费服务端计算资源。
跨设备 captcha 失效场景
场景 1:换网(WiFi ↔ 4G)
- 用户的 IP /24 段变化(如从
192.168.1.0/24到10.0.0.0/24) - 已生成但未消费的 captcha 校验时 ip_mismatch → 静默失败
- 用户无感知(前端用前端提示),需主动重新拉 captcha
场景 2:captcha 跨页面共享
- 同一
(IP /24, captchaId)一次性消费 - 跨页面复用会触发 consumed 状态 →
captcha wrong - 每个新页面/失败分支都必须重新
captcha
真机调试要点
| 现象 | 排查 |
|---|---|
| 一直提示图形码错(实际码正确) | 检查是否换网 / 多次点击 / captcha 已被前一次消费 |
| 短信码一直显示「不存在」 | 检查 clientId 是否与发码时一致 / 600s TTL 是否已过 / 错码累计 3 次 |
| 60s 后还能立即发送 | 服务端 Redis 重启或限流配置丢失;检查 oauth:rate:sms:phone:{phone} |
| 提示「图形验证码已过期」 | 120s 内未消费 / 用户停留页面过久 |
mock vs 生产差异
| 项 | mock(开发) | 生产 |
|---|---|---|
| 短信码获取 | 固定 1234(启动日志可见) | 真短信 |
| sms-send 图形码 | 校验+一次性消费(内存 Map) | ✅ Redis + IP /24 绑定 |
| 限流 | 仅手机号 60s/1 | 全 5 层(IP 30/min / 日 10 条 仅生产) |
| 短信码 TTL | 600s | 600s |
| captcha IP /24 绑定 | ❌ 不绑定 | ✅ 绑定 |
联调提示:mock 端拿到的是模拟 IP,生产必须真机(不同网络)验证 IP /24 段变更。