---
url: /docs/api-docs/v1/oauth/state-machine.md
---
# 验证码状态机（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 段变更。
