# 阿里云短信验证服务 — 设计方案

- 日期：2026-07-25
- 状态：已定稿，待实现
- 范围：新增阿里云 dypns（号码认证服务）短信验证码 provider，与现有极光短信可切换

## 1. 背景与目标

现有短信验证码服务 `app/integrations/sms.py`：本服务**本地生成**验证码、存**进程内存**、极光 REST 仅负责下发；`verify_code()` 比对本地内存（一次性 + 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废）。docstring 已标注"内存存码、多 worker 不共享"为技术债。

阿里云文档（`docs/integrations/aliyun/`）为 **号码认证服务 dypns** 的 `SendSmsVerifyCode` + `CheckSmsVerifyCode`：该产品由**阿里云生成并校验**验证码（`{"code":"##code##"}` 模式），核验免费。

目标：接入阿里云该套接口作为一个新的短信 provider，可与极光切换。

## 2. 关键决策（已确认）

1. **验证码模式 = Mode A（阿里云托管码）**：发码用 `SendSmsVerifyCode` + `##code##` 占位符，阿里云生成/存储/下发；校验用 `CheckSmsVerifyCode`，阿里云返回 `PASS/UNKNOWN`。本服务不再本地生成/存储验证码。
2. **可切换 Provider**：新增 `SMS_PROVIDER=jiguang|aliyun` 开关，`send_code/verify_code` 按 provider 分派；保留极光作回退（短信=花钱+登录关键路径，灰度上线/融合认证未开通时可秒切回极光）。
3. **官方 SDK**：调阿里云 dypns 用 `alibabacloud_dypnsapi20170525`，签名/加签由 SDK 处理。
4. **防爆破与极光一致**（排查一致性）：阿里云路径**保留**与极光相同的"单码失败 N 次即作废"本地计数，而非改用 API 层频控，避免两 provider 行为不一致导致排查困惑。

## 3. 模块结构（`sms.py` 单文件升级为 provider 包）

```
app/integrations/sms/
  __init__.py     # 公开 API + 分派器:send_code / verify_code / SmsError
                  #   - SMS_MOCK=true 短路(不碰任何 provider)
                  #   - 按 settings.SMS_PROVIDER 选 jiguang / aliyun
  base.py         # SmsError(沿用现定义)+ Provider 协议(send_code/verify_code 签名约定)
  jiguang.py      # 现有自管码逻辑原样迁入(内存存码/冷却/一次性/防爆破 全保留,行为零改动)
  aliyun.py       # 新增:SendSmsVerifyCode 发码 + CheckSmsVerifyCode 校验 + 本地失败计数
```

- `__init__.py` 继续 re-export `SmsError / send_code / verify_code`，故 `app/api/v1/auth.py:37` 的
  `from app.integrations.sms import SmsError, send_code, verify_code` **导入不变**。
- 纯增量重构：极光逻辑整体迁入 `jiguang.py`，对外行为零变化。

## 4. 数据流 — 阿里云 provider（Mode A）

### 4.1 发码 `aliyun.send_code(phone) -> int`
1. 校验 `settings.aliyun_sms_configured`（缺 AK/SignName/TemplateCode → `SmsError(503)`）。
2. 调 `SendSmsVerifyCode`：
   - `PhoneNumber=phone`
   - `SignName=ALIYUN_SMS_SIGN_NAME`、`TemplateCode=ALIYUN_SMS_TEMPLATE_CODE`
   - `TemplateParam = json({"code":"##code##","min": str(ALIYUN_SMS_VALID_TIME_SEC//60)})`
   - `CodeLength=ALIYUN_SMS_CODE_LENGTH`、`ValidTime=ALIYUN_SMS_VALID_TIME_SEC`、`Interval=ALIYUN_SMS_INTERVAL_SEC`
   - `SchemeName=ALIYUN_SMS_SCHEME_NAME`(可空)
3. 成功（`body.Success and body.Code=="OK"`）→ **清本地失败计数**（新码=新预算）→ 返回 `ALIYUN_SMS_INTERVAL_SEC` 作客户端冷却秒数。
4. 失败 → 按 §6 错误码映射抛 `SmsError`。

### 4.2 校验 `aliyun.verify_code(phone, code) -> bool`
1. **本地失败计数**：`attempts >= SMS_MAX_VERIFY_ATTEMPTS` → 直接 `False`（码已作废，不调阿里云）。
2. 调 `CheckSmsVerifyCode(PhoneNumber, VerifyCode=code, SchemeName)`。
3. `body.Model.VerifyResult`：
   - `"PASS"` → 清计数，返回 `True`（一次性）。
   - `"UNKNOWN"` → `attempts += 1`，返回 `False`（码错/过期）。
4. 网络错误 / 接口非 `OK` → 抛 `SmsError(503)`（**不静默返回 False**，区分"阿里云挂了"与"码错了"；网络错误不计入 attempts）。

本服务**不存验证码**，仅存一个 per-phone 失败计数（见 §7）。

## 5. 分派器 & mock（`__init__.py`）

```
send_code(phone):
    if settings.SMS_MOCK:          # 短路:不碰 provider(测试/开发)
        log placeholder code; return cooldown
    return _provider().send_code(phone)

verify_code(phone, code):
    if settings.SMS_MOCK:          # 放行任意 N 位数字(沿用现 mock 语义)
        return len(code)==SMS_CODE_LENGTH and code.isdigit()
    return _provider().verify_code(phone, code)

_provider():  jiguang if settings.SMS_PROVIDER=="jiguang" else aliyun
```

- mock 语义提到分派层、provider 无关 → 现有 28 个测试文件（conftest 设 `SMS_MOCK=true`）全部零改动通过。

## 6. 错误映射

### 发码（阿里云错误码 → SmsError.status_code）
| 阿里云码 | HTTP | 说明 |
|---|---|---|
| `MOBILE_NUMBER_ILLEGAL` | 400 | 手机号格式错误 |
| `BUSINESS_LIMIT_CONTROL` | 429 | 号码天级流控 |
| `FREQUENCY_FAIL` | 429 | 频控（`Interval` 命中） |
| `FUNCTION_NOT_OPENED` | 503 | 融合认证未开通（**critical 日志**，需运维开通） |
| `INVALID_PARAMETERS` | 503 | 参数错误（配置/模板问题，**critical 日志**） |
| 其他非 OK / `Success=false` / 网络错误 | 503 | 供应商不可用 |

### 校验
- `PASS` → True；`UNKNOWN` → False；接口异常/网络错误 → `SmsError(503)`。

## 7. 防爆破 / 频控分工

| 机制 | 极光（Mode B） | 阿里云（Mode A） |
|---|---|---|
| 验证码存储 | 本地内存 | **阿里云托管**（消除多 worker 存码债） |
| 单号发送冷却 | 本地 `_last_sent` 60s | **交给阿里云 `Interval`**（无本地状态），命中→429 |
| 单设备+IP 频控 | API 层 5/时、20/天 | 同左，**不变** |
| **防爆破（单码失败 N 次即作废）** | 本地 `_CodeRecord.attempts` | **本地 per-phone 计数**，与极光同语义（§4.2） |

- 阿里云路径的**唯一本地状态** = per-phone 失败计数 `dict[phone,int]` + `Lock` + GC（仿极光 `_gc`）。
- 多 worker 降级：失败计数按 worker 各计，effective 上限 = N×workers；与极光现状**同级**（属刻意保留的一致性），且 API 层登录频控（`sms-login-device` 设备+IP 5/时）提供硬兜底。
- 计数复位：`send_code` 成功清计数、`verify` PASS 清计数（新码/验过即新预算）。
- API 层设备频控与测试账号短路（`app/core/test_account.py`）**完全不动**。

## 8. 配置项（`app/core/config.py` 新增）

```python
SMS_PROVIDER: str = "jiguang"          # jiguang | aliyun;默认极光(保持现状,上线后切 aliyun)
# --- 阿里云 dypns 号码认证 ---
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
ALIYUN_SMS_SIGN_NAME: str = ""         # 系统赠送签名(自定义签名下发易失败)
ALIYUN_SMS_TEMPLATE_CODE: str = ""     # 赠送模板 CODE(须与赠送签名搭配)
ALIYUN_SMS_SCHEME_NAME: str = ""       # 方案名(可空=默认方案);send/check 必须一致 → 单一来源
ALIYUN_SMS_ENDPOINT: str = "dypnsapi.aliyuncs.com"
ALIYUN_SMS_CODE_LENGTH: int = 6        # CodeLength 4~8
ALIYUN_SMS_VALID_TIME_SEC: int = 300   # ValidTime;短信内 min 文案 = //60
ALIYUN_SMS_INTERVAL_SEC: int = 60      # Interval 单号发送频控
```

- 新增属性 `aliyun_sms_configured`（仿 `mt_cps_configured`）：AK_ID/AK_SECRET/SignName/TemplateCode 齐全才为真；`SMS_PROVIDER=aliyun` 但未配 → `send_code` 抛 `SmsError(503)`。
- 复用现有 `SMS_MOCK`、`SMS_CODE_LENGTH`（mock 校验位数）、`SMS_MAX_VERIFY_ATTEMPTS`（防爆破上限，两 provider 共用）。

### 配置敏感点
- `TemplateParam` 变量名（`code`/`min`）须与控制台所选**赠送模板**一致。融合认证验证码模板通常即 `code`+`min`，按此硬编码并加注释；若模板变量名不同，改 `aliyun.py` 该处即可。
- `SchemeName` 在 send 与 check 必须一致，故用**单一** `ALIYUN_SMS_SCHEME_NAME` 供两处，避免不匹配（CheckSmsVerifyCode 文档明确警告）。

## 9. auth.py 改动（最小）

`verify_code` 现在可能抛 `SmsError`（阿里云降级 503）。两处调用点各包一层 `try/except SmsError → HTTPException(e.status_code)`，与 `send_code` 现有写法一致：
- `app/api/v1/auth.py` `sms_login`（约 L185）
- `app/api/v1/auth.py` `wechat_bind_phone_sms`（约 L325）

`send_code` 调用点已 try/except `SmsError`，无需改。

## 10. 依赖 & SDK

- `pyproject.toml` 增 `alibabacloud_dypnsapi20170525`（连带 `alibabacloud-tea-openapi` 等）。
- SDK 同步阻塞调用 → 与现有 sync 端点 + sync httpx 风格一致（FastAPI 跑 threadpool，无碍）。
- `aliyun.py` 内**惰性 import SDK + 惰性建 client**（仿 `wxpay` 惰性加载证书）：`SMS_PROVIDER=jiguang` 时不加载 alibabacloud，启动保持精简。
- SDK 调用形态（实现时按实际包名/字段核对）：
  ```python
  from alibabacloud_dypnsapi20170525.client import Client
  from alibabacloud_dypnsapi20170525 import models as dypns_models
  from alibabacloud_tea_openapi import models as open_api_models
  cfg = open_api_models.Config(access_key_id=..., access_key_secret=...)
  cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT
  client = Client(cfg)
  resp = client.send_sms_verify_code(dypns_models.SendSmsVerifyCodeRequest(...))
  # resp.body.code / resp.body.success / resp.body.model.verify_code
  resp = client.check_sms_verify_code(dypns_models.CheckSmsVerifyCodeRequest(...))
  # resp.body.model.verify_result == "PASS"
  ```

## 11. 测试

- 现有测试：`SMS_MOCK=true` → 分派器短路，全绿不变。
- 新增 `tests/test_sms_aliyun.py`（monkeypatch SDK client，不发真网络）：
  1. 发码成功 → 返回 cooldown、清计数。
  2. 各错误码 → 对应 `SmsError.status_code`（400/429/503）。
  3. 校验 `PASS`→True（清计数）/ `UNKNOWN`→False（计数 +1）/ 接口异常→`SmsError(503)`。
  4. 失败计数达 `SMS_MAX_VERIFY_ATTEMPTS` → 直接 False，不再调阿里云。
  5. `send_code` 成功复位计数。
- 新增分派测试：`SMS_PROVIDER` 切换选中正确 provider；`SMS_MOCK` 优先于 provider。

## 12. YAGNI（明确不做）

- ❌ 不做 Redis/DB 存码（Mode A 无需；极光路径内存债维持现状，非本次范围）。
- ❌ 不改极光任何行为、不动 API 层频控/测试账号逻辑。
- ❌ 不做多签名/多模板轮换（单签名单模板足够）。
- ❌ 不把失败计数持久化/跨进程（刻意保留与极光同级的本地态）。

## 13. 验收标准

- `SMS_PROVIDER=aliyun` 且配置齐全时：`/sms/send` 走 `SendSmsVerifyCode`、`/sms/login` 走 `CheckSmsVerifyCode`，真机可收码并登录。
- `SMS_PROVIDER=jiguang`（默认）：行为与当前完全一致。
- `SMS_MOCK=true`：任意 N 位数字通过，不发真短信。
- 阿里云接口异常时：`/sms/login` 返回 503（非 400），日志可区分。
- `ruff check .` 通过；新增/现有 `pytest` 全绿。
