# 短信验证码（sms）

> 文件:`app/integrations/sms/`(分派器 `__init__` + `jiguang` / `aliyun` provider + `base`) | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)

## 作用
手机号 + 验证码登录的验证码发送 / 校验。支持**可切换 provider**(`SMS_PROVIDER`):`jiguang`(默认,极光自管码)/ `aliyun`(阿里云号码认证托管码)/ `chuanglan`(创蓝云智模板短信,自管码)。`SMS_MOCK` 另切 mock / real。

## 短信提供商(`SMS_PROVIDER`,可切换 + 灰度回退)
| | `jiguang`(默认) | `aliyun` | `chuanglan` |
|---|---|---|---|
| 验证码模式 | 自管码 Mode B | 托管码 Mode A | 自管码 Mode B(与极光同) |
| 验证码 | **本服务生成**、极光只下发、**本地内存校验** | **阿里云生成 + 下发 + 校验**(dypns 号码认证,核验免费) | **本服务生成**、创蓝只下发、**本地内存校验** |
| 发码 | 极光 `/v1/messages` | `SendSmsVerifyCode`(`##code##` 占位) | 创蓝 `tpl/send` v2(HMAC 签名头,password 不上行) |
| 校验 | 比对本地存码 | `CheckSmsVerifyCode` → `PASS` / `UNKNOWN` | 比对本地存码 |
| 多 worker | ⚠️ 内存存码不共享(见已知局限) | ✅ 阿里云托管,天然共享 | ⚠️ 内存存码不共享(与极光同级债) |
| 防爆破 | 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 | **同语义**(本地 per-phone 失败计数) | **同语义**(复制自极光) |
| 单号冷却 | 本地 `SMS_SEND_INTERVAL_SEC` | 交给阿里云 `Interval` | 本地 `SMS_SEND_INTERVAL_SEC` |
| 依赖 | `httpx`(复用) | SDK `alibabacloud_dypnsapi20170525` | `httpx` + 标准库 `hashlib`/`hmac`(**无新依赖**) |

- `aliyun` 需在**号码认证控制台开通「融合认证」**,用系统赠送签名 + 赠送模板;配置见 `.env.example` 的 `ALIYUN_SMS_*`,SDK 为 `alibabacloud_dypnsapi20170525`。
- `chuanglan` 用 **YZM 前缀验证码账号**,服务器出网 IP 须在创蓝控制台**加白名单**(否则 117);Mode B 存码/冷却/校验逻辑是**从极光隔离复制**(极光文件不动),仅发送走 HMAC 签名。配置见 `.env.example` 的 `CHUANGLAN_SMS_*`,接口调研见 [chuanglan/tpl-send.md](chuanglan/tpl-send.md),设计见 [spec](../superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md)。

**以下章节描述 `jiguang` provider(自管码)细节**(`chuanglan` 的存码/冷却/校验语义与之相同)。

| | mock(`SMS_MOCK=true`,默认 / 开发测试) | real(`SMS_MOCK=false`,生产) |
|---|---|---|
| 发送 | 不真发,验证码打日志 | 调极光 `/v1/messages` 真发 |
| 校验 | 放行任意 6 位数字(测试便利) | 比对本服务存的码(一次性 / 过期 / 防爆破) |

## 为什么是"自己生成码"模式
极光有两种验证码玩法:① `/v1/codes` 极光生成 + `/codes/{msg_id}/valid` 极光校验;② `/v1/messages` 本服务生成码、极光只负责发。**选 ②**:校验本地完成(不依赖极光二次往返)、有效期/错误次数/作废完全可控、跟原 mock 口子结构一致。

## 函数 / 异常
| 函数 | 行为 |
|---|---|
| `send_code(phone) -> int` | 防刷检查(冷却)→ `secrets` 生成 N 位码 → **预占**(冷却/存码)→ mock 打日志 / real 调极光 → 失败**回滚预占**。返回距下次可发秒数 |
| `verify_code(phone, code) -> bool` | mock 放行任意 6 位;real 比对存码,匹配即作废,失败累计到上限作废 |
| `SmsError(msg, status_code)` | `status_code` 决定 HTTP 码:过频 **429**、供应商失败 **503**、号码无效 **400** |

## 配置
| 配置项 | 默认 | 说明 |
|---|---|---|
| `SMS_MOCK` | `true` | mock / real 切换。**生产置 false** |
| `SMS_CODE_TTL_SEC` | 300 | 验证码有效期(秒),与极光模板文案"5分钟"一致 |
| `SMS_SEND_INTERVAL_SEC` | 60 | 单号两次发送最小间隔(冷却) |
| `SMS_SEND_ENDPOINT` | 极光 `/v1/messages` | 短信发送地址 |
| `SMS_SIGN_ID` | 31729 | 极光签名 ID |
| `SMS_TEMPLATE_ID` | 1 | 极光模板 ID(变量名 `code`) |
| `SMS_CODE_LENGTH` | 6 | 验证码位数(本服务生成;前端 code 4-8 位兼容) |
| `SMS_MAX_VERIFY_ATTEMPTS` | 5 | 单码最多校验失败次数,超过作废(防爆破) |

> **鉴权复用极光一键登录**:`/v1/messages` 用 `base64(JG_APP_KEY:JG_MASTER_SECRET)` 做 HTTP Basic Auth——短信与一键登录是**同一个极光应用**(同 AppKey)。**上线不需要额外凭证,只需 `SMS_MOCK=false`**(`JG_*` 一键登录已配)。

## 防刷(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权)
> 2026-07-03 精简:登录风控只留「单号冷却 + 单设备频控」两道主策略(删单号每日上限、删登录纯 IP `rate_limit`);单码失败上限属验证码安全底线,保留。
1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(单号维度)
2. 单设备(`device_id` + IP)每小时频控:`/sms/send` ≤ `SMS_SEND_MAX_PER_HOUR_PER_DEVICE`(5)、`/sms/login` ≤ `SMS_LOGIN_MAX_PER_HOUR`(5)——堵「换号绕开单号冷却」+ 挡登录撞库,在 `app/api/v1/auth.py` 内 `enforce_rate_limit`
3. 单码校验失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 + 验过即作废(一次性)
4. 运维侧建议在极光控制台叠加:**IP 白名单**(只许服务器 IP)+ **防轰炸设置**

## 极光错误码(节选,映射在 `_send_via_jiguang`)
| code | 含义 | 处理 |
|---|---|---|
| 50014 | 余额不足 | `logger.critical` 告警 + 503 |
| 50009 | 极光侧超频 | 429 |
| 50006 | 手机号无效 | 400 |
| 其他 | — | 503 |

## 上线步骤
1. 极光控制台:企业实名 + 短信充值 + 签名审核(`sign_id=31729`)+ 模板审核(`temp_id=1`)——**均已就绪**
2. `.env` 设 `SMS_MOCK=false`(`JG_APP_KEY`/`JG_MASTER_SECRET` 一键登录已配)
3. 真机发一条验证:收到短信 + 能登录

## 已知局限
**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。
