Files
guke ed76820e97 feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动 (#188)
读完了两份 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` 一律回退极光,防误配打挂登录。

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #188
2026-07-28 09:20:57 +08:00

5.3 KiB
Raw Permalink Blame History

创蓝云智(253/蓝创云智)模板短信 v2 发送接口

官方文档:https://doc.chuanglan.com/document/HAQYSZKH9HT5Z50L 用途:手机号 + 验证码登录的验证码短信下发(本服务生成码 → 创蓝只负责发送,属自管码 Mode B,与极光同模式)。 本文件为接口调研摘要,供 app/integrations/sms/chuanglan.py 实现对照。以线上文档为准。

接口概览

请求地址 POST https://smssh.253.com/msg/sms/v2/tpl/send
Content-Type application/jsonUTF-8
协议 HTTPS
鉴权 HMAC-SHA256 签名头 X-QA-Hmac-Signature(推荐) body 明文 password(二选一)

鉴权:两种方式(二选一,不可并用)

  1. HMAC 签名头(推荐,密码不上行):请求头带 X-QA-Hmac-Signaturebody 不放 password
  2. 明文密码body 放 password,不带签名头。

HMAC-SHA256 签名算法

  1. md5Password = MD5(password) —— 32 位小写十六进制
  2. 取三个值 [md5Password, timestamp, nonce]按字典序升序排序无分隔符拼接,再 replaceAll("\\s+", "") 去除所有空白。
  3. signature = HmacSHA256(key = md5Password, message = 上一步拼接串) —— 输出小写十六进制
  4. 放入请求头:X-QA-Hmac-Signature: <signature>

注意 key 就是 md5Password 本身(32 位 hex 字符串),不是原始 password。timestamp / nonce 同时也是 body 字段,必须与签名里用的一致。

请求参数(bodyJSON

参数 类型 必填 说明
account String API 账号;验证码短信用 YZM 前缀账号(如 YZM0000001
timestamp String Unix 秒级时间戳;60 秒内有效,过期报 139
nonce String 32 位随机串(防重放)
phoneNumbers String 手机号,逗号分隔最多 1000 个;YZM 验证码账号不支持批量,只能单号
templateId String 模板 ID(控制台创建 / 模板接口查询)
templateParamJson String 条件 模板变量,JSON 字符串;模板有 {s} 占位符时必填(见下)
password String 条件 仅在不使用签名头时放 body
signature String 条件 短信签名文案(如 【创蓝云智】);模板未关联签名时必填。注意与鉴权头 X-QA-Hmac-Signature 是两回事
report String "true" 时接收状态回执
callbackUrl String 回执回调完整 URL
uid String 自定义标识(≤256 字符),回执原样返回
extend String 数字扩展码(≤5 位),用于上行匹配

templateParamJson 格式与 {s} 占位

  • 模板内容用 {s} 作占位符,例:您正在申请手机注册,验证码为:{s},5分钟内有效!
  • templateParamJsonJSON 数组,元素为对象,键按 param1param2…递增;第 1 个 {s}param1,第 2 个 ← param2
  • 单条验证码(一个 {s} = 验证码)示例:"[{\"param1\":\"123456\"}]"

响应格式

{
  "code": "000000",
  "msgId": "25071018345400902898000000000001",
  "time": "20250710183454",
  "successNum": "1",
  "failNum": "0",
  "errorMsg": ""
}
字段 说明
code 状态码,"000000" = 成功
msgId 消息 ID32 位)
time 响应时间戳
successNum / failNum 提交成功 / 失败条数
errorMsg 错误描述(成功为空)

响应 / 错误码(节选)

code 含义 归属
000000 成功
101 账号不存在 客服
102 密码错误 客服
103 提交速度过快(超频) 客服
107 手机号码错误 客服
109 无发送量(余额/套餐不足) 销售
110 不在发送时段 销售
116 签名不合法 / 未带签名 服务
117 IP 未加白名单 服务
118 账号无发送权限 服务
124 模板内容不匹配 服务
129 JSON 格式错误 客服
135 相同手机号内容重复 客服
139 时间戳过期 客服
152 模板不存在 服务
158 退订文案不合规 客服

完整请求示例(单条验证码,明文密码方式省略 password 用签名头)

{
  "account": "YZM0000001",
  "timestamp": "1752143733",
  "nonce": "x4zfk0y5foqwx6cbnw3bfmimy98abqs1",
  "phoneNumbers": "17601337176",
  "templateId": "1021143438",
  "templateParamJson": "[{\"param1\":\"123456\"}]",
  "report": "true"
}

HMAC 方式:另加请求头 X-QA-Hmac-Signature: <算法输出>body 不含 password。)

接入要点

  • IP 白名单:服务器出网 IP 必须在控制台加白,否则 117。
  • 验证码账号(YZM:无发送时段限制;单号发送、不支持批量。
  • 时间戳 60stimestamp 与本地时钟偏差过大会 139,注意服务器时间同步。
  • 退订文案:仅支持 拒收请回复R,且必须在短信末尾(验证码短信一般无需)。
  • 签名 vs 鉴权头signaturebody= 短信开头的 【品牌】 文案;X-QA-Hmac-Signature(header)= 请求鉴权。二者含义完全不同,勿混。