feat(sms): 短信验证码 极光→创蓝 容灾 fallback #204

Merged
guke merged 13 commits from feat/sms-jiguang-chuanglan-fallback into main 2026-07-31 11:17:32 +08:00
Member

背景
短信验证码已是可切换 provider 架构(极光 / 创蓝)。极光(默认)一旦供应商侧故障(欠费 / 网络 / 服务异常),/sms/send 直接 503 → 用户收不到码、登录中断。本 PR 把极光设为主、创蓝设为备,在极光供应商不可用时自动转创蓝补发,并让后台可区分每次实际走的渠道。

方案(4 个关键决策)

| 决策 | 结论

-- | -- | --
A | fallback 触发范围 | 仅主返回「供应商不可用」(SmsError.status_code == 503:网络 / 余额 / 服务故障)才转备。本地冷却 & 超频(429)、手机号无效(400)不转——不绕过防刷、不为无效号白烧
B | 校验路由 | try-both:极光转创蓝后码在创蓝内存,校验遍历「启用的 fallback 链」(主→备),任一命中即通过;关闭 fallback 时链中只有极光、创蓝零参与
C | 后台可见性 | 成功侧 EVENT_SMS_SEND.details 记 provider / fallback + 分派层日志,风控后台可按号/设备查本次走哪家、是否 fallback
D | 默认开关 | SMS_FALLBACK_PROVIDER 默认空=关(保持现状零风险),生产设 chuanglan 开启,置空即秒回退。仅 Mode B(jiguang/chuanglan)互为主备

背景 短信验证码已是可切换 provider 架构(极光 / 创蓝)。极光(默认)一旦供应商侧故障(欠费 / 网络 / 服务异常),/sms/send 直接 503 → 用户收不到码、登录中断。本 PR 把极光设为主、创蓝设为备,在极光供应商不可用时自动转创蓝补发,并让后台可区分每次实际走的渠道。 方案(4 个关键决策) # | 决策 | 结论 -- | -- | -- A | fallback 触发范围 | 仅主返回「供应商不可用」(SmsError.status_code == 503:网络 / 余额 / 服务故障)才转备。本地冷却 & 超频(429)、手机号无效(400)不转——不绕过防刷、不为无效号白烧 B | 校验路由 | try-both:极光转创蓝后码在创蓝内存,校验遍历「启用的 fallback 链」(主→备),任一命中即通过;关闭 fallback 时链中只有极光、创蓝零参与 C | 后台可见性 | 成功侧 EVENT_SMS_SEND.details 记 provider / fallback + 分派层日志,风控后台可按号/设备查本次走哪家、是否 fallback D | 默认开关 | SMS_FALLBACK_PROVIDER 默认空=关(保持现状零风险),生产设 chuanglan 开启,置空即秒回退。仅 Mode B(jiguang/chuanglan)互为主备
guke added 11 commits 2026-07-30 17:12:04 +08:00
极光为主、创蓝为备:仅极光供应商不可用(SmsError status_code=503)时自动
转创蓝补发;本地冷却/超频(429)、手机号无效(400)不转。校验用 try-both
遍历「启用的 fallback 链」(关闭时只问极光,创蓝零参与);码只存实际发码那家
的进程内存,命中即知渠道、防爆破不被绕过。后台经 EVENT_SMS_SEND.details
(provider/fallback)+ 分派层日志区分实际渠道。开关 SMS_FALLBACK_PROVIDER
默认空=关、生产设 chuanglan、置空秒回退。

provider 文件(jiguang/chuanglan/aliyun)一行不动;仅改分派层 __init__ +
base(SendResult)+ config + auth 一处 details + 测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
批次2 将 send_code 返回类型 int→SendResult 后,sms_send 仍把它整体塞进
SmsSendResponse.cooldown_sec:int → pydantic ValidationError → 500。改为取
send_result.cooldown_sec;成功侧 EVENT_SMS_SEND details 加 provider/fallback。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
最终 review Minor:aliyun 是 Mode A(校验走接口、可抛 SmsError),不该做 Mode B
fallback 备。收窄 Literal 为 ["jiguang","chuanglan",""],pydantic 启动即挡住误配
SMS_FALLBACK_PROVIDER=aliyun。同步设计/计划文档。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
guke added 1 commit 2026-07-30 17:13:32 +08:00
Author
Member

🤖 review-pr 深审结论

🟢 可合并 · 置信度 0.9 —— 短信容灾 fallback 设计严谨、实现正确、测试充分(33 用例全过)。

改动:极光(主)→创蓝(备)供应商容灾。分派层 sms/__init__.py 重构 + SendResult + config + auth 适配 + 完整设计/计划文档。

正确性核对(合并后,4 决策落地)

  • A 触发范围send_codee.status_code == 503(供应商不可用)转备;429(冷却/超频)、400(无效号)走 raise 不转 —— 不绕过防刷、不为无效号白烧。
  • B 校验 try-bothverify_code 遍历「主→备」链任一命中即通过;码只在实际发码那家,另一家 rec is None→False,不误判、不累加其防爆破计数。
  • C 后台可见auth.py 成功事件 detailsprovider + fallback
  • D 默认关SMS_FALLBACK_PROVIDER 默认空 → _fallback() None → 现状零风险;Literal 限定 jiguang/chuanglan(Mode B),杜绝误配 aliyun(Mode A 自校验)做备。
  • 适配send_code 返回 intSendResultauth.pycooldown_sec 正确;_primary() 未知回退 jiguang 防误配。
  • 铁律1:main 未改本 PR 文件。

构建/测试(实跑 .venv / py3.12 合并后)

  • test_sms_base + test_sms_fallback + test_sms_dispatch + test_auth = 33 passed(含 fallback 专项)

风险 🟢。设计/计划文档齐备(docs/superpowers/)。

🤖 review-pr 自动深审 @guke

## 🤖 review-pr 深审结论 🟢 **可合并** · 置信度 0.9 —— 短信容灾 fallback 设计严谨、实现正确、测试充分(33 用例全过)。 **改动**:极光(主)→创蓝(备)供应商容灾。分派层 `sms/__init__.py` 重构 + `SendResult` + `config` + `auth` 适配 + 完整设计/计划文档。 **正确性核对(合并后,4 决策落地)** - **A 触发范围**:`send_code` 仅 `e.status_code == 503`(供应商不可用)转备;429(冷却/超频)、400(无效号)走 `raise` 不转 —— 不绕过防刷、不为无效号白烧。 - **B 校验 try-both**:`verify_code` 遍历「主→备」链任一命中即通过;码只在实际发码那家,另一家 `rec is None`→False,不误判、不累加其防爆破计数。 - **C 后台可见**:`auth.py` 成功事件 `details` 记 `provider` + `fallback`。 - **D 默认关**:`SMS_FALLBACK_PROVIDER` 默认空 → `_fallback()` None → 现状零风险;`Literal` 限定 `jiguang`/`chuanglan`(Mode B),杜绝误配 `aliyun`(Mode A 自校验)做备。 - **适配**:`send_code` 返回 `int`→`SendResult`,`auth.py` 取 `cooldown_sec` 正确;`_primary()` 未知回退 jiguang 防误配。 - 铁律1:main 未改本 PR 文件。 **构建/测试(实跑 .venv / py3.12 合并后)** - ✅ `test_sms_base` + `test_sms_fallback` + `test_sms_dispatch` + `test_auth` = **33 passed**(含 fallback 专项) 风险 🟢。设计/计划文档齐备(`docs/superpowers/`)。 — 🤖 review-pr 自动深审 @guke
guke added 1 commit 2026-07-31 11:17:24 +08:00
guke merged commit 31bff63ed4 into main 2026-07-31 11:17:32 +08:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: WonderableAI/shaguabijia-app-server#204