# 创蓝云智(253)短信验证服务 — 设计方案

- 日期：2026-07-26
- 状态：已定稿，待实现
- 范围：新增创蓝云智（253/蓝创云智）模板短信 provider，与现有极光 / 阿里云可切换

## 1. 背景与目标

短信验证码服务已是**可切换 provider** 架构（`app/integrations/sms/`：`__init__` 分派 + `jiguang` / `aliyun` + `base`）。本次接入第三家 **创蓝云智** 作为新 provider。

创蓝 `tpl/send` v2 接口（调研见 `docs/integrations/chuanglan/tpl-send.md`）是**纯发送网关**：本服务生成验证码、放入 `templateParamJson`，创蓝只负责下发，**无校验接口**。故属 **Mode B（自管码）**，与极光同模式（本地生成/存储/校验），仅"发送调用"不同。

目标：接入创蓝作为可切换 provider；默认仍极光，opt-in 切换，灰度可秒回退。

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

1. **Mode B 自管码**：本服务 `secrets` 生成 N 位码 → 存进程内存 → 创蓝 REST 只下发；`verify_code` 比对本地存码（一次性 + 失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废）。与极光同语义。
2. **代码组织 = 隔离复制（不重构极光）**：`chuanglan.py` **自带一份**存码/冷却/校验机器（从 `jiguang.py` 复制适配），**极光文件一行不动**。契合阿里云先例的 provider 隔离哲学，零回归风险于登录关键路径的默认 provider。代价：Mode B 并发逻辑在 jiguang / chuanglan 两处重复，日后改动需同步（YAGNI 权衡，已接受）。
3. **鉴权 = HMAC 签名头**（`X-QA-Hmac-Signature`）：password 仅用于本地算签、**不上行**。不做明文密码 body 模式。
4. **无新依赖**：创蓝是普通 HTTPS POST，复用现有 `httpx` + 标准库 `hashlib`/`hmac`（对比阿里云需 SDK）。
5. **可切换 + 回退**：`SMS_PROVIDER` 增 `chuanglan`；默认仍 `jiguang`；误配/未知值一律回退 `jiguang`（保持 `test_unknown_provider_falls_back_to_jiguang` 语义）。

## 3. 模块结构

```
app/integrations/sms/
  __init__.py    # 分派器: {"aliyun":aliyun,"chuanglan":chuanglan}.get(SMS_PROVIDER, jiguang)
  base.py        # 不动(SmsError / mock_verify 复用)
  jiguang.py     # 不动
  aliyun.py      # 不动
  chuanglan.py   # 新增(本设计)
```

- `__init__.py` 继续 re-export `SmsError / send_code / verify_code`，`app/api/v1/auth.py` 导入不变。
- 纯增量：只新增 `chuanglan.py` + 扩分派 dict + 加配置；不改极光/阿里云行为。

## 4. 数据流 — chuanglan provider（Mode B，复制自 jiguang）

### 4.1 发码 `chuanglan.send_code(phone) -> int`
结构与 `jiguang.send_code` 一致：
1. `_lock` 内：`_gc` → 单号冷却检查（`_last_sent`，`SMS_SEND_INTERVAL_SEC`，命中→`SmsError(429)`）→ `_gen_code()` 生成 N 位 → **预占**（写 `_last_sent` + `_codes[phone]=_CodeRecord(code, expires_at=now+SMS_CODE_TTL_SEC)`）。
2. `_lock` 外：`SMS_MOCK` → 打日志不真发；否则 `_send_via_chuanglan(phone, code)`。
3. 失败：**保留冷却**（失败也限速）、`_codes.pop(phone)`（没发出去的码删掉）；`SmsError` 原样抛，其他异常 → `SmsError(503)`。
4. 返回 `SMS_SEND_INTERVAL_SEC` 作客户端冷却秒数。

### 4.2 校验 `chuanglan.verify_code(phone, code) -> bool`
与 `jiguang.verify_code` 一致：
- `SMS_MOCK` → `mock_verify`（放行任意 N 位数字）。
- real：`_lock` 内查 `_codes[phone]`；不存在/过期→False（并清）；`attempts >= SMS_MAX_VERIFY_ATTEMPTS`→清+False（防爆破）；`secrets.compare_digest` 匹配→清+True（一次性）；否则 `attempts += 1` 返 False。

### 4.3 发送 `_send_via_chuanglan(phone, code)`（唯一新逻辑）
1. 配置校验 `settings.chuanglan_sms_configured`（缺 account/password/templateId → `SmsError(503)`）。
2. 组装：
   - `timestamp = str(int(time.time()))`、`nonce = secrets.token_hex(16)`（32 hex）
   - `body = {account, timestamp, nonce, phoneNumbers=phone, templateId, templateParamJson=json.dumps([{"param1": code}])}`；`CHUANGLAN_SMS_SIGNATURE` 非空则加 `signature` 字段。**HMAC 方式 body 不含 password。**
3. 签名 `_sign(password, timestamp, nonce)`：
   ```
   md5pwd = md5(password).hexdigest()                       # 32 位小写 hex
   s = "".join(sorted([md5pwd, timestamp, nonce]))          # 字典序升序拼接
   s = "".join(s.split())                                   # 去空白(faithful，本例无空白)
   sig = hmac_sha256(key=md5pwd.encode(), msg=s.encode()).hexdigest()   # 小写 hex
   ```
   置请求头 `X-QA-Hmac-Signature: sig`、`Content-Type: application/json`。
4. `httpx.post(CHUANGLAN_SMS_ENDPOINT, json=body, headers=..., timeout=CHUANGLAN_SMS_TIMEOUT_SEC)`；网络异常 → `SmsError(503)`。
5. 解析 `resp.json()`：`code == "000000"` → 成功返回；否则按 §5 映射抛 `SmsError`。HTTP≠200 或 JSON 解析失败 → `SmsError(503)`。

## 5. 错误码映射（创蓝 `code` → SmsError.status_code）

| 创蓝 code | HTTP | 处理 |
|---|---|---|
| `000000` | — | 成功 return |
| `103` | 429 | 超频，"发送过于频繁，请稍后再试" |
| `107` | 400 | 手机号错误，"手机号无效" |
| `109` | 503 | 无发送量/余额 → **critical 日志**（需充值） |
| `117` | 503 | IP 未白名单 → **critical 日志**（需运维加白） |
| `102` / `116` / `124` / `152` / `101` / `118` | 503 | 密码/签名/模板/账号/权限配置错 → **critical 日志** |
| 其他 / `Success` 非 000000 / HTTP≠200 / 网络错 | 503 | "短信服务暂不可用，请稍后重试" |

- 映射用 `_SEND_ERRORS: dict[str,(int,str)]` + `_SEND_CRITICAL_CODES: frozenset`（仿 aliyun 写法）。

## 6. 防爆破 / 频控分工（与极光同级）

| 机制 | 实现 |
|---|---|
| 验证码存储 | 本地进程内存 `_codes`（与极光同，多 worker 不共享的技术债同级） |
| 单号发送冷却 | 本地 `_last_sent`，`SMS_SEND_INTERVAL_SEC` |
| 单设备+IP 频控 | API 层（`app/api/v1/auth.py`），**不变** |
| 防爆破（单码失败 N 次作废）| 本地 `_CodeRecord.attempts`，`SMS_MAX_VERIFY_ATTEMPTS` |

- 创蓝控制台侧另建议叠加：**IP 白名单**（否则 117）+ 发送频控。

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

```python
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名 ---
CHUANGLAN_SMS_ACCOUNT: str = ""       # YZM 前缀验证码账号
CHUANGLAN_SMS_PASSWORD: str = ""      # API 密码(仅本地算签,不上行)
CHUANGLAN_SMS_TEMPLATE_ID: str = ""   # 模板 ID
CHUANGLAN_SMS_SIGNATURE: str = ""     # 短信签名文案【品牌】;模板已带签名则留空
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10   # httpx 读/连超时
```

- 新增属性 `chuanglan_sms_configured`（仿 `aliyun_sms_configured`）：account/password/templateId 齐全才为真。
- 复用 `SMS_MOCK` / `SMS_CODE_LENGTH` / `SMS_CODE_TTL_SEC` / `SMS_SEND_INTERVAL_SEC` / `SMS_MAX_VERIFY_ATTEMPTS`（provider 无关的 Mode B 旋钮）。
- `.env.example` 增 `CHUANGLAN_SMS_*` 块 + 注释。

### 模板变量敏感点
- 默认按**单占位** `templateParamJson=[{"param1": code}]`（模板形如「您的验证码 {s}，5分钟内有效」）。
- 若控制台模板把「有效分钟」也做成第二个 `{s}`，实现时在此加 `param2`（改 `chuanglan.py` 一处）。

## 8. auth.py 改动

无。`send_code` / `verify_code` 签名与返回不变，分派层内部路由；两调用点现有 `try/except SmsError` 已覆盖 chuanglan 的 429/400/503。

## 9. 测试

- 现有测试：`SMS_MOCK=true` → 分派器短路，全绿不变。
- 新增 `tests/test_sms_chuanglan.py`（monkeypatch `_send_via_chuanglan` 内 httpx 接缝，不发真网络）：
  1. 发码成功（`code=000000`）→ 返回 cooldown、码入内存。
  2. 各错误码 → 对应 `SmsError.status_code`（103→429 / 107→400 / 109/117/其他→503）。
  3. HTTP≠200 / 网络异常 → `SmsError(503)`。
  4. 校验：匹配→True 且作废（一次性）；过期→False；失败累计达上限→作废 False；不匹配→attempts+1 False。
  5. 冷却：`SMS_SEND_INTERVAL_SEC` 内二次发 → `SmsError(429)`。
  6. `_sign` 签名算法：对固定 (password, ts, nonce) 断言 HMAC 输出（独立复算比对）。
- 扩 `tests/test_sms_dispatch.py`：`SMS_PROVIDER=chuanglan` 路由命中 chuanglan；未知值回退 jiguang。

## 10. YAGNI（明确不做）

- ❌ 不重构极光 / 不动阿里云。
- ❌ 不做明文密码 body 模式（只 HMAC）。
- ❌ 不做状态回执 `report` / `callbackUrl`。
- ❌ 不做批量发送（验证码单号）。
- ❌ 不做 DB/Redis 存码（与极光同级内存态，多 worker 债维持现状）。

## 11. 验收标准

- `SMS_PROVIDER=chuanglan` 且配置齐全时：`/sms/send` 走创蓝 `tpl/send`、`/sms/login` 本地校验，真机可收码并登录。
- `SMS_PROVIDER=jiguang`（默认）/ `aliyun`：行为与当前完全一致。
- `SMS_MOCK=true`：任意 N 位数字通过，不真发。
- 创蓝接口异常时：`/sms/send` 返回对应码（429/400/503），日志可区分（余额/白名单打 critical）。
- `ruff check .` 通过；新增/现有 `pytest` 全绿。
