diff --git a/docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md b/docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md new file mode 100644 index 0000000..3c49208 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md @@ -0,0 +1,182 @@ +# 阿里云短信验证服务 — 设计方案 + +- 日期: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` 全绿。