feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动 #188

Merged
guke merged 7 commits from feat/aliyun-sms-verify into main 2026-07-28 09:20:58 +08:00
Member

读完了两份 spec + 分派器,这分支的全貌清晰了。下面是可直接粘进 Gitea PR 的 MR 说明(标题 + 正文):

建议标题

feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动
MR 正文(复制以下 markdown):

概述

把原单文件 app/integrations/sms.py 重构为可切换 provider 包 app/integrations/sms/,在保留极光(默认、行为零改动)的基础上,新增两家验证码短信 provider:

  • 阿里云号码认证 dypns(Mode A:阿里云生成/存储/校验验证码,核验免费)
  • 创蓝云智 253(Mode B:本服务自管码,httpx 直连 + HMAC 签名)

Provider 由 SMS_PROVIDER 按调用实时选择,默认 jiguang。短信=花钱 + 登录关键路径,故新 provider opt-in、可灰度、秒级回退,默认路径零变更。

为什么

现有极光路径本地内存存码(多 worker 不共享,已是技术债),且单一供应商无法灰度/切换。引入 provider 抽象后:阿里云托管码可消除存码债,创蓝作为备选降低单点依赖,三家随配置切换与回退。

改动内容

架构(app/integrations/sms/)

文件 说明
__init__.py 对外仍暴露 send_code/verify_code/SmsError(auth 导入不变);按 SMS_PROVIDER 每次调用分派;未知值回退 jiguang
base.py SmsError(status_code→HTTP) + provider 无关的 mock_verify
jiguang.py sms.py 逻辑原样迁入,行为零改动(git 识别为 rename)
aliyun.py 新增,Mode A:SendSmsVerifyCode + CheckSmsVerifyCode,惰性加载 SDK
chuanglan.py 新增,Mode B:自管码 + tpl/send + HMAC 签名

两种验证码模式

  • Mode A(阿里云):不本地存码,阿里云 ##code## 托管生成+校验;本地仅留 per-phone 失败计数防爆破。
  • Mode B(极光/创蓝):secrets 生成 N 位 → 进程内存 → 供应商只下发;本地一次性校验 + 失败 N 次作废。创蓝复制极光存码机器(不重构极光,零回归风险)。

配置(config.py + .env.example)

  • SMS_PROVIDER = jiguang | aliyun | chuanglan(默认 jiguang)
  • ALIYUN_SMS_*(AK/签名/模板/方案名/时长…) + aliyun_sms_configured 门控
  • CHUANGLAN_SMS_*(账号/密码/模板/签名/endpoint…) + chuanglan_sms_configured 门控
  • 复用现有 SMS_MOCK / SMS_CODE_LENGTH / SMS_CODE_TTL_SEC / SMS_SEND_INTERVAL_SEC / SMS_MAX_VERIFY_ATTEMPTS
  • 切到某 provider 却未配齐 → send_codeSmsError(503),不静默

auth.py(最小改动)

  • verify_code 现在可能抛 SmsError(阿里云降级 503)→ sms_loginwechat_bind_phone_sms 两处各包 try/except SmsError → HTTPException,与 send_code 现有写法一致。

依赖

  • pyproject.tomlalibabacloud_dypnsapi20170525(仅阿里云 provider 惰性 import;jiguang/chuanglan 不加载)。创蓝零新依赖(httpx + 标准库)。

测试

  • 新增 test_sms_aliyun.py / test_sms_chuanglan.py(均 monkeypatch 网络接缝,不发真短信) + test_sms_dispatch.py(分派/回退)。
  • test_auth.py 相应更新。
  • 现有测试走 SMS_MOCK=true 在分派层短路,不受影响。

文档

  • 设计 spec:docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md2026-07-26-chuanglan-sms-verify-design.md
  • 接口调研:docs/integrations/aliyun/*docs/integrations/chuanglan/tpl-send.mddocs/integrations/sms.md

兼容性 & 回退

  • 默认 SMS_PROVIDER=jiguang,线上行为与现状完全一致;不改极光逻辑、不动 API 层频控与测试账号短路。
  • 切阿里云/创蓝仅改环境变量,出问题秒切回极光;未知 SMS_PROVIDER 一律回退极光,防误配打挂登录。
读完了两份 spec + 分派器,这分支的全貌清晰了。下面是可直接粘进 Gitea PR 的 MR 说明(标题 + 正文): 建议标题 feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动 MR 正文(复制以下 markdown): ## 概述 把原单文件 `app/integrations/sms.py` 重构为**可切换 provider 包** `app/integrations/sms/`,在保留极光(默认、行为零改动)的基础上,新增两家验证码短信 provider: - **阿里云号码认证 dypns**(Mode A:阿里云生成/存储/校验验证码,核验免费) - **创蓝云智 253**(Mode B:本服务自管码,httpx 直连 + HMAC 签名) Provider 由 `SMS_PROVIDER` 按调用实时选择,默认 `jiguang`。短信=花钱 + 登录关键路径,故新 provider **opt-in、可灰度、秒级回退**,默认路径零变更。 ## 为什么 现有极光路径本地内存存码(多 worker 不共享,已是技术债),且单一供应商无法灰度/切换。引入 provider 抽象后:阿里云托管码可消除存码债,创蓝作为备选降低单点依赖,三家随配置切换与回退。 ## 改动内容 **架构(`app/integrations/sms/`)** | 文件 | 说明 | |---|---| | `__init__.py` | 对外仍暴露 `send_code/verify_code/SmsError`(auth 导入不变);按 `SMS_PROVIDER` **每次调用**分派;未知值回退 `jiguang` | | `base.py` | `SmsError`(status_code→HTTP) + provider 无关的 `mock_verify` | | `jiguang.py` | 原 `sms.py` 逻辑**原样迁入**,行为零改动(git 识别为 rename) | | `aliyun.py` | 新增,Mode A:`SendSmsVerifyCode` + `CheckSmsVerifyCode`,惰性加载 SDK | | `chuanglan.py` | 新增,Mode B:自管码 + `tpl/send` + HMAC 签名 | **两种验证码模式** - Mode A(阿里云):不本地存码,阿里云 `##code##` 托管生成+校验;本地仅留 per-phone 失败计数防爆破。 - Mode B(极光/创蓝):`secrets` 生成 N 位 → 进程内存 → 供应商只下发;本地一次性校验 + 失败 N 次作废。创蓝**复制**极光存码机器(不重构极光,零回归风险)。 **配置(`config.py` + `.env.example`)** - `SMS_PROVIDER = jiguang | aliyun | chuanglan`(默认 jiguang) - `ALIYUN_SMS_*`(AK/签名/模板/方案名/时长…) + `aliyun_sms_configured` 门控 - `CHUANGLAN_SMS_*`(账号/密码/模板/签名/endpoint…) + `chuanglan_sms_configured` 门控 - 复用现有 `SMS_MOCK / SMS_CODE_LENGTH / SMS_CODE_TTL_SEC / SMS_SEND_INTERVAL_SEC / SMS_MAX_VERIFY_ATTEMPTS` - 切到某 provider 却未配齐 → `send_code` 抛 `SmsError(503)`,不静默 **auth.py(最小改动)** - `verify_code` 现在可能抛 `SmsError`(阿里云降级 503)→ `sms_login`、`wechat_bind_phone_sms` 两处各包 `try/except SmsError → HTTPException`,与 `send_code` 现有写法一致。 **依赖** - `pyproject.toml` 增 `alibabacloud_dypnsapi20170525`(仅阿里云 provider 惰性 import;jiguang/chuanglan 不加载)。创蓝零新依赖(httpx + 标准库)。 **测试** - 新增 `test_sms_aliyun.py` / `test_sms_chuanglan.py`(均 monkeypatch 网络接缝,不发真短信) + `test_sms_dispatch.py`(分派/回退)。 - `test_auth.py` 相应更新。 - 现有测试走 `SMS_MOCK=true` 在分派层短路,不受影响。 **文档** - 设计 spec:`docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md`、`2026-07-26-chuanglan-sms-verify-design.md` - 接口调研:`docs/integrations/aliyun/*`、`docs/integrations/chuanglan/tpl-send.md`、`docs/integrations/sms.md` ## 兼容性 & 回退 - **默认 `SMS_PROVIDER=jiguang`,线上行为与现状完全一致**;不改极光逻辑、不动 API 层频控与测试账号短路。 - 切阿里云/创蓝仅改环境变量,出问题秒切回极光;未知 `SMS_PROVIDER` 一律回退极光,防误配打挂登录。
guke added 6 commits 2026-07-27 19:32:07 +08:00
Mode A(阿里云托管码) + 可切换 provider(SMS_PROVIDER) + 官方 SDK;
防爆破保留与极光一致的单码失败计数,校验降级返回 503。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
现有 sms.py(极光自管码)升级为 app/integrations/sms/ 包:
  - base   : SmsError + provider 无关的 mock_verify
  - jiguang: 原极光自管码逻辑逐字迁入,行为零改动(git 识别为 sms.py 的 rename)
  - aliyun : 新增阿里云 dypns 号码认证(Mode A:阿里云生成+下发+校验,核验免费)
  - __init__: 按 SMS_PROVIDER 每次调用路由的分派器(默认 jiguang,可秒切回退)

关键决策:
  - Mode A:发码 SendSmsVerifyCode(##code## 占位)、校验 CheckSmsVerifyCode(PASS/UNKNOWN);
    本服务不再存码 → 消除极光路径「内存存码、多 worker 不共享」技术债。
  - 防爆破与极光一致:aliyun 保留 per-phone 失败计数(SMS_MAX_VERIFY_ATTEMPTS),达上限本地作废,
    避免两 provider 行为不同致排查困惑(此为 aliyun 路径唯一本地态)。
  - 校验降级:阿里云接口异常 → verify_code 抛 SmsError(503),auth 两处 try/except 透出 503(非误报 400)。
  - 官方 SDK alibabacloud_dypnsapi20170525;SDK 交互隔离在 _call_send/_call_check(惰性 import + 惰性建
    client),单测 monkeypatch 不触真网络。

测试:test_sms_aliyun(17)+ test_sms_dispatch(3)全绿;test_auth 内部访问 retarget 到 jiguang.*。
配置:SMS_PROVIDER + ALIYUN_SMS_*(见 .env.example);文档 docs/integrations/sms.md + aliyun 接口参考。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
冲突仅 app/api/v1/auth.py::sms_login,合并两侧改动:
  - 保留(本分支)阿里云 provider 的 verify_code try/except —— 校验降级抛 SmsError 时透出其状态码(503),不误报 400。
  - 保留(main)风控失败事件 record_behavior_event —— 但仅在「验证码错误」(ok 为 False)时记;
    provider 降级 503 已在 try/except 提前 raise,不计入「验证失败」风控。
wechat_bind_phone_sms 的 try/except 自动合并、无冲突(main 未在该处加风控)。

验证:全量 pytest 552 passed / 8 failed(8 个均为合并前已存在、与本次无关)。
新增第三个可切换短信 provider chuanglan,与极光/阿里云并列(SMS_PROVIDER 切换,默认仍
jiguang,未知值回退 jiguang)。创蓝 tpl/send v2 是纯发送网关:本服务生成码、创蓝只下发、
本地内存校验(与极光同 Mode B)。存码/冷却/一次性/防爆破/GC 从极光隔离复制(极光文件不动,
零回归风险),唯一新逻辑是 HMAC-SHA256 签名(password 不上行)+ httpx POST + 错误码映射。
复用现有 httpx + 标准库 hashlib/hmac,无新依赖。

- app/integrations/sms/chuanglan.py: 新 provider
- app/integrations/sms/__init__.py: 分派器改 dict + chuanglan 路由
- app/core/config.py: SMS_PROVIDER 加 chuanglan + CHUANGLAN_SMS_* + chuanglan_sms_configured
- .env.example / docs/integrations/sms.md / docs/integrations/chuanglan/tpl-send.md: 配置与接口调研文档
- docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md: 设计 spec
- tests/test_sms_chuanglan.py(28 例)+ test_sms_dispatch.py 扩 chuanglan 路由

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
账号/密码仍留空,真实凭证只放本地 .env(不入库)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
个人本地调试用(admin server 端口 8771,run.sh 的 Windows 变体),不入库;
精确锚定 /run8771.bat,不影响 run.bat 及 scripts 下的 .bat。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

🤖 review-pr 深审结论

#188「feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝)」 🟢 可合并(置信度 0.9)

隔离 worktree 完整仓深审 + 实跑验证:63 条 SMS/auth 测试全过,且合并最新 main(领先 12 提交,含刚合入的 applog)后仍全过、无冲突;创蓝 HMAC 签名与接口文档逐条一致;provider 未配置时优雅降级 503(非启动崩)。未发现 high/med 级问题。

实跑证据

  • pytest test_sms_aliyun/chuanglan/dispatch/auth63 passed(PR head)
  • 试合并 origin/main(领先 12 提交)→ 无冲突,合并树上 63 passed(挡住「各分支自洽、合并后炸」)
  • 创蓝 _sign:md5(pwd) 作 key、sorted([md5pwd,ts,nonce]) 无分隔拼接去空白、HmacSHA256 小写 hex、头 X-QA-Hmac-Signature —— 与 docs/integrations/chuanglan/tpl-send.md 逐条一致
  • 阿里云失败路径 fail-closed(UNKNOWN/接口异常 → 不误判 PASS);aliyun_sms_configured/chuanglan_sms_configured 均为 @property(未配置 → 503 生效)
  • 新增 app/integrations/sms/*.py + auth.py ruff 全清

写得好

  • 干净的 provider 抽象:sms.pysms/jiguang.py 行为保持迁移(91% 相似),SmsError 上提 base.py,包入口只做路由并默认兜底 jiguang(防误配打挂登录)
  • 安全基元到位:secrets.choice 生成、secrets.compare_digest 常量时比对、一次性作废、过期、失败上限、发送失败保留冷却(挡重试风暴)
  • auth 层 SmsError→原状态码映射:provider 降级返 503 而非误报「验证码错误」400,且不记风控失败事件(供应商故障不算用户失败)—— 关键正确点

观察点(均非阻塞)

  • [info] Mode B 多 worker 技术债:极光 + 新增创蓝的「内存存码/冷却」不跨 worker 共享(生产 --workers 1 + API 层设备/IP 频控兜底;aliyun 托管码天然规避)。两处 Mode B 逻辑刻意隔离复制,后续改动需同步 jiguang 与 chuanglan
  • [info] 部署:切 SMS_PROVIDER=aliyun 前需在服务器装新依赖 alibabacloud_dypnsapi20170525(uv sync);未装则该 provider import 失败 → 503(优雅但功能不可用)。默认 jiguang 不受影响
  • [info] 既存 lint 债(非本 PR 引入):app/core/config.py 有 2 处 ruff 告警(Field 未用 F401、"Settings" 注解带引号 UP037),在 main 上已存在,可顺手 ruff --fix

🤖 由 review-pr 技能生成(只读隔离深审,worktree 已清理);结论供参考,合并前请人工复核。

## 🤖 review-pr 深审结论 **#188「feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝)」** 🟢 **可合并**(置信度 0.9) 隔离 worktree 完整仓深审 + 实跑验证:**63 条 SMS/auth 测试全过**,且**合并最新 main(领先 12 提交,含刚合入的 applog)后仍全过、无冲突**;创蓝 HMAC 签名与接口文档逐条一致;provider 未配置时优雅降级 503(非启动崩)。未发现 high/med 级问题。 ### 实跑证据 - `pytest test_sms_aliyun/chuanglan/dispatch/auth` → **63 passed**(PR head) - 试合并 `origin/main`(领先 **12 提交**)→ **无冲突**,合并树上 **63 passed**(挡住「各分支自洽、合并后炸」) - 创蓝 `_sign`:`md5(pwd)` 作 key、`sorted([md5pwd,ts,nonce])` 无分隔拼接去空白、HmacSHA256 小写 hex、头 `X-QA-Hmac-Signature` —— 与 `docs/integrations/chuanglan/tpl-send.md` 逐条一致 - 阿里云失败路径 fail-closed(UNKNOWN/接口异常 → 不误判 PASS);`aliyun_sms_configured`/`chuanglan_sms_configured` 均为 `@property`(未配置 → 503 生效) - 新增 `app/integrations/sms/*.py` + `auth.py` **ruff 全清** ### 写得好 - 干净的 provider 抽象:`sms.py`→`sms/jiguang.py` 行为保持迁移(91% 相似),`SmsError` 上提 `base.py`,包入口只做路由并默认兜底 jiguang(防误配打挂登录) - 安全基元到位:`secrets.choice` 生成、`secrets.compare_digest` 常量时比对、一次性作废、过期、失败上限、发送失败保留冷却(挡重试风暴) - auth 层 `SmsError`→原状态码映射:provider 降级返 **503** 而非误报「验证码错误」400,且**不记风控失败事件**(供应商故障不算用户失败)—— 关键正确点 ### 观察点(均非阻塞) - **[info] Mode B 多 worker 技术债**:极光 + 新增创蓝的「内存存码/冷却」不跨 worker 共享(生产 `--workers 1` + API 层设备/IP 频控兜底;aliyun 托管码天然规避)。两处 Mode B 逻辑**刻意隔离复制**,后续改动需**同步 jiguang 与 chuanglan** - **[info] 部署**:切 `SMS_PROVIDER=aliyun` 前需在服务器装新依赖 `alibabacloud_dypnsapi20170525`(`uv sync`);未装则该 provider import 失败 → 503(优雅但功能不可用)。默认 jiguang 不受影响 - **[info] 既存 lint 债(非本 PR 引入)**:`app/core/config.py` 有 2 处 ruff 告警(`Field` 未用 F401、`"Settings"` 注解带引号 UP037),在 main 上已存在,可顺手 `ruff --fix` <sub>🤖 由 review-pr 技能生成(只读隔离深审,worktree 已清理);结论供参考,合并前请人工复核。</sub>
guke added 1 commit 2026-07-27 21:30:54 +08:00
guke merged commit ed76820e97 into main 2026-07-28 09:20:58 +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#188