功能:创蓝云智(253)短信 provider(Mode B 自管码,可切换)

新增第三个可切换短信 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>
This commit is contained in:
guke
2026-07-26 22:13:05 +08:00
parent 1e7f6024c7
commit dfa3d4f07e
9 changed files with 834 additions and 22 deletions
+14 -4
View File
@@ -86,10 +86,12 @@ SMS_MOCK=true
SMS_CODE_TTL_SEC=300
SMS_SEND_INTERVAL_SEC=60
# ===== 短信提供商(可切换:jiguang 默认 / aliyun 阿里云号码认证)=====
# jiguang:本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。
# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。
# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。
# ===== 短信提供商(可切换:jiguang 默认 / aliyun 阿里云号码认证 / chuanglan 创蓝云智)=====
# jiguang :本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。
# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。
# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
SMS_PROVIDER=jiguang
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
@@ -102,6 +104,14 @@ ALIYUN_SMS_CODE_LENGTH=6
ALIYUN_SMS_VALID_TIME_SEC=300
ALIYUN_SMS_INTERVAL_SEC=60
ALIYUN_SMS_TIMEOUT_SEC=15
# --- 创蓝云智(253)---
CHUANGLAN_SMS_ACCOUNT=
CHUANGLAN_SMS_PASSWORD=
CHUANGLAN_SMS_TEMPLATE_ID=
# 短信签名文案【品牌】;模板已关联签名则留空。
CHUANGLAN_SMS_SIGNATURE=
CHUANGLAN_SMS_ENDPOINT=https://smssh.253.com/msg/sms/v2/tpl/send
CHUANGLAN_SMS_TIMEOUT_SEC=10
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
+20 -2
View File
@@ -141,10 +141,11 @@ class Settings(BaseSettings):
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
# ===== 短信提供商(可切换:极光 / 阿里云号码认证)=====
# ===== 短信提供商(可切换:极光 / 阿里云号码认证 / 创蓝云智)=====
# jiguang(默认):本服务生成验证码,极光只负责下发,本地内存校验(自管码,现状不变)。
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
SMS_PROVIDER: Literal["jiguang", "aliyun"] = "jiguang"
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
ALIYUN_SMS_ACCESS_KEY_ID: str = ""
ALIYUN_SMS_ACCESS_KEY_SECRET: str = ""
ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败)
@@ -156,6 +157,14 @@ class Settings(BaseSettings):
ALIYUN_SMS_INTERVAL_SEC: int = 60 # 单号发送频控秒(Interval);核验免费
ALIYUN_SMS_TIMEOUT_SEC: int = 15 # 阿里云 API 读/连超时秒
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名(见 docs/integrations/chuanglan/tpl-send.md)---
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅用于本地算 HMAC 签名,不随请求上行)
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 读/连超时秒
@property
def aliyun_sms_configured(self) -> bool:
"""阿里云短信凭证齐全(缺则 SMS_PROVIDER=aliyun 时 /sms/* 返 503,而非启动崩)。"""
@@ -166,6 +175,15 @@ class Settings(BaseSettings):
and self.ALIYUN_SMS_TEMPLATE_CODE
)
@property
def chuanglan_sms_configured(self) -> bool:
"""创蓝短信凭证齐全(缺则 SMS_PROVIDER=chuanglan 时 /sms/send 返 503,而非启动崩)。"""
return bool(
self.CHUANGLAN_SMS_ACCOUNT
and self.CHUANGLAN_SMS_PASSWORD
and self.CHUANGLAN_SMS_TEMPLATE_ID
)
# ===== 测试账号(release 包全流程联调用)=====
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
+6 -2
View File
@@ -4,6 +4,7 @@
provider 由 `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退):
- `jiguang`(默认):自管码(本服务生成、内存存/校验,极光只发)。见 [jiguang.py](jiguang.py)。
- `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A)。见 [aliyun.py](aliyun.py)。
- `chuanglan`:创蓝云智(253)模板短信,自管码 Mode B(本服务生成、内存存/校验,创蓝只发)。见 [chuanglan.py](chuanglan.py)。
mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由。
拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动。
@@ -12,15 +13,18 @@ from __future__ import annotations
from app.core.config import settings
from . import aliyun, jiguang
from . import aliyun, chuanglan, jiguang
from .base import SmsError
__all__ = ["SmsError", "send_code", "verify_code"]
# provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。
_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan}
def _provider():
"""按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)。"""
return aliyun if settings.SMS_PROVIDER == "aliyun" else jiguang
return _PROVIDERS.get(settings.SMS_PROVIDER, jiguang)
def send_code(phone: str) -> int:
+224
View File
@@ -0,0 +1,224 @@
"""创蓝云智(253)短信 provider(自管码 Mode B)。
创蓝 `tpl/send` v2 是**纯发送网关**(本服务生成码 → 放入 templateParamJson → 创蓝只下发,
无校验接口),故与极光同为 **Mode B**:本服务生成/存储/校验验证码,创蓝只负责发。
**本模块的存码/冷却/一次性/防爆破/GC 机器与 [jiguang.py](jiguang.py) 是刻意的隔离复制**
(设计见 docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md):极光文件一行不动、
零回归风险于登录关键路径的默认 provider;代价是两处 Mode B 并发逻辑重复,改动需同步。唯一新逻辑
是 `_send_via_chuanglan`(HMAC-SHA256 签名 + httpx POST + 错误码映射)。
两种运行模式由 `SMS_MOCK` 切换:
- **mock**(开发/测试,默认):不真发,验证码打日志;校验放行任意 N 位数字。
- **real**(`SMS_MOCK=false` 且 `SMS_PROVIDER=chuanglan`):`secrets` 生成码 → 调创蓝 `tpl/send`
下发(HMAC 签名,password 仅本地算签不上行)→ 校验比对本地存码(一次性 / 过期 / 防爆破)。
验证码存储:**进程内存**(单 worker 够用,多 worker 不共享,与极光同级技术债)。防刷同极光:
单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)+ 单设备/IP 频控(api 层)+ 单码失败 `SMS_MAX_VERIFY_ATTEMPTS`
次即作废。运维侧另需在创蓝控制台配 **IP 白名单**(否则 117)。接口调研见 docs/integrations/chuanglan/tpl-send.md。
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass
from threading import Lock
import httpx
from app.core.config import settings
from .base import SmsError, mock_verify
logger = logging.getLogger("shagua.sms.chuanglan")
@dataclass
class _CodeRecord:
code: str
expires_at: float
attempts: int = 0
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)。与极光同结构。
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
_lock = Lock()
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长)
# 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
_SEND_ERRORS: dict[str, tuple[int, str]] = {
"103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快
"107": (400, "手机号无效"), # 手机号码错误
}
# 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。
_SEND_CRITICAL_CODES = frozenset({
"109", # 无发送量/余额不足
"117", # IP 未加白名单
"102", # 密码错误
"116", # 签名不合法
"124", # 模板内容不匹配
"152", # 模板不存在
"101", # 账号不存在
"118", # 无发送权限
})
def _gen_code() -> str:
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
def _gc(now: float) -> None:
"""顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超阈值才扫它。"""
if len(_codes) > _GC_THRESHOLD:
for p in [p for p, r in _codes.items() if now > r.expires_at]:
_codes.pop(p, None)
if len(_last_sent) > _GC_THRESHOLD:
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
_last_sent.pop(p, None)
def send_code(phone: str) -> int:
"""发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
"""
now = time.time()
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
code = _gen_code()
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
_last_sent[phone] = now
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
# --- lock 外:真正发送(网络 IO 不持锁)---
try:
if settings.SMS_MOCK:
logger.info("[SMS-chuanglan-MOCK] to %s**** code=%s (不真发)", phone[:3], code)
else:
_send_via_chuanglan(phone, code)
logger.info("[SMS-chuanglan] sent to %s****", phone[:3])
except Exception as e:
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时前端重试狂打),
# 只清掉没发出去的码(用户收不到,留着无意义且占内存)。
with _lock:
_codes.pop(phone, None)
if isinstance(e, SmsError):
raise
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
return settings.SMS_SEND_INTERVAL_SEC
def verify_code(phone: str, code: str) -> bool:
"""校验验证码。
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)。
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
"""
if settings.SMS_MOCK:
ok = mock_verify(code)
logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
return ok
with _lock:
rec = _codes.get(phone)
if rec is None:
return False
if time.time() > rec.expires_at:
_codes.pop(phone, None)
return False
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
_codes.pop(phone, None) # 试错过多,作废
return False
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
_codes.pop(phone, None) # 验过即作废
return True
rec.attempts += 1
return False
# ============================ 发送接缝(单测 monkeypatch 这两个 / httpx.post)============================
def _sign(password: str, timestamp: str, nonce: str) -> str:
"""创蓝 HMAC-SHA256 签名:key=md5(password),msg=sorted([md5pwd,ts,nonce]) 拼接去空白,输出小写 hex。"""
md5pwd = hashlib.md5(password.encode()).hexdigest() # 32 位小写 hex
raw = "".join(sorted([md5pwd, timestamp, nonce])) # 字典序升序,无分隔符拼接
raw = "".join(raw.split()) # 去所有空白(faithful;三段本无空白)
return hmac.new(md5pwd.encode(), raw.encode(), hashlib.sha256).hexdigest()
def _call_chuanglan(phone: str, code: str) -> dict:
"""组装 + 签名 + POST 创蓝 tpl/send,返回解析后的响应 dict。
传输错误 / HTTP≠200 / 响应非 JSON 一律抛 SmsError(503)(保「provider 出问题→503」不变式);
业务码(含 000000)由调用方 `_send_via_chuanglan` 判读。password 只用于算签,不入 body。
"""
timestamp = str(int(time.time()))
nonce = secrets.token_hex(16) # 32 位 hex
body = {
"account": settings.CHUANGLAN_SMS_ACCOUNT,
"timestamp": timestamp,
"nonce": nonce,
"phoneNumbers": phone,
"templateId": settings.CHUANGLAN_SMS_TEMPLATE_ID,
"templateParamJson": json.dumps([{"param1": code}]),
}
if settings.CHUANGLAN_SMS_SIGNATURE:
body["signature"] = settings.CHUANGLAN_SMS_SIGNATURE
headers = {
"Content-Type": "application/json",
"X-QA-Hmac-Signature": _sign(settings.CHUANGLAN_SMS_PASSWORD, timestamp, nonce),
}
try:
resp = httpx.post(
settings.CHUANGLAN_SMS_ENDPOINT,
json=body,
headers=headers,
timeout=settings.CHUANGLAN_SMS_TIMEOUT_SEC,
)
except httpx.HTTPError as e:
logger.exception("[SMS-chuanglan] 网络错误 phone=%s****", phone[:3])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
if resp.status_code != 200:
logger.error("[SMS-chuanglan] http=%s body=%s", resp.status_code, resp.text[:200])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
try:
return resp.json()
except Exception as e:
logger.error("[SMS-chuanglan] 响应非 JSON: %s", resp.text[:200])
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
def _send_via_chuanglan(phone: str, code: str) -> None:
"""调创蓝 tpl/send 发送。成功静默返回;失败按错误码映射抛 SmsError。"""
if not settings.chuanglan_sms_configured:
raise SmsError("短信服务未配置(缺创蓝 account/password/templateId)", status_code=503)
result = _call_chuanglan(phone, code) # 传输/非200/解析异常在内部抛 SmsError(503)
rcode = str(result.get("code"))
if rcode == "000000":
return
emsg = result.get("errorMsg") or ""
logger.error("[SMS-chuanglan] send failed code=%s msg=%s", rcode, emsg)
if rcode in _SEND_CRITICAL_CODES:
logger.critical("[SMS-chuanglan] %s —— 需运维处理(余额/IP白名单/密码/签名/模板/账号)", rcode)
status, msg = _SEND_ERRORS.get(rcode, (503, "短信服务暂不可用,请稍后重试"))
raise SmsError(msg, status_code=status)
+117
View File
@@ -0,0 +1,117 @@
# 创蓝云智(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/json`UTF-8 |
| 协议 | HTTPS |
| 鉴权 | HMAC-SHA256 签名头 `X-QA-Hmac-Signature`(推荐)**或** body 明文 `password`(二选一) |
## 鉴权:两种方式(二选一,不可并用)
1. **HMAC 签名头(推荐,密码不上行)**:请求头带 `X-QA-Hmac-Signature`body **不放** `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分钟内有效!`
- `templateParamJson`**JSON 数组,元素为对象**,键按 `param1``param2`…递增;第 1 个 `{s}``param1`,第 2 个 ← `param2`
- 单条验证码(一个 `{s}` = 验证码)示例:`"[{\"param1\":\"123456\"}]"`
## 响应格式
```json
{
"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 用签名头)
```json
{
"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**:无发送时段限制;单号发送、不支持批量。
- **时间戳 60s**`timestamp` 与本地时钟偏差过大会 139,注意服务器时间同步。
- **退订文案**:仅支持 `拒收请回复R`,且必须在短信末尾(验证码短信一般无需)。
- **签名 vs 鉴权头**`signature`body= 短信开头的 `【品牌】` 文案;`X-QA-Hmac-Signature`(header)= 请求鉴权。二者含义完全不同,勿混。
+15 -11
View File
@@ -3,20 +3,24 @@
> 文件:`app/integrations/sms/`(分派器 `__init__` + `jiguang` / `aliyun` provider + `base`) | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md)
## 作用
手机号 + 验证码登录的验证码发送 / 校验。支持**可切换 provider**(`SMS_PROVIDER`):`jiguang`(默认,极光自管码)/ `aliyun`(阿里云号码认证托管码)。`SMS_MOCK` 另切 mock / real。
手机号 + 验证码登录的验证码发送 / 校验。支持**可切换 provider**(`SMS_PROVIDER`):`jiguang`(默认,极光自管码)/ `aliyun`(阿里云号码认证托管码)/ `chuanglan`(创蓝云智模板短信,自管码)`SMS_MOCK` 另切 mock / real。
## 短信提供商(`SMS_PROVIDER`,可切换 + 灰度回退)
| | `jiguang`(默认) | `aliyun` |
|---|---|---|
| 验证码 | **本服务生成**、极光只下发、**本地内存校验** | **阿里云生成 + 下发 + 校验**(dypns 号码认证,Mode A,核验免费) |
| 码 | 极光 `/v1/messages` | `SendSmsVerifyCode`(`##code##` 占位) |
| 校验 | 比对本地存码 | `CheckSmsVerifyCode``PASS` / `UNKNOWN` |
| 多 worker | ⚠️ 内存存码不共享(见已知局限) | ✅ 阿里云托管,天然共享 |
| 防爆破 | 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 | **同语义**(本地 per-phone 失败计数,刻意与极光一致) |
| 单号冷却 | 本地 `SMS_SEND_INTERVAL_SEC` | 交给阿里云 `Interval` |
| 校验降级 | 本地无网络调用 | 阿里云接口异常 → `/sms/login`**503**(非 400,便于排查) |
| | `jiguang`(默认) | `aliyun` | `chuanglan` |
|---|---|---|---|
| 验证码模式 | 自管码 Mode B | 托管码 Mode A | 自管码 Mode B(与极光同) |
| 验证码 | **本服务生成**、极光只下发、**本地内存校验** | **阿里云生成 + 下发 + 校验**(dypns 号码认证,核验免费) | **本服务生成**、创蓝只下发、**本地内存校验** |
| 发码 | 极光 `/v1/messages` | `SendSmsVerifyCode`(`##code##` 占位) | 创蓝 `tpl/send` v2(HMAC 签名头,password 不上行) |
| 校验 | 比对本地存码 | `CheckSmsVerifyCode``PASS` / `UNKNOWN` | 比对本地存码 |
| 多 worker | ⚠️ 内存存码不共享(见已知局限) | ✅ 阿里云托管,天然共享 | ⚠️ 内存存码不共享(与极光同级债) |
| 防爆破 | 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 | **同语义**(本地 per-phone 失败计数) | **同语义**(复制自极光) |
| 单号冷却 | 本地 `SMS_SEND_INTERVAL_SEC` | 交给阿里云 `Interval` | 本地 `SMS_SEND_INTERVAL_SEC` |
| 依赖 | `httpx`(复用) | SDK `alibabacloud_dypnsapi20170525` | `httpx` + 标准库 `hashlib`/`hmac`(**无新依赖**) |
`aliyun` 需在**号码认证控制台开通「融合认证」**,用系统赠送签名 + 赠送模板;配置见 `.env.example``ALIYUN_SMS_*`,SDK 为 `alibabacloud_dypnsapi20170525`**以下章节描述 `jiguang` provider(自管码)细节**。
- `aliyun` 需在**号码认证控制台开通「融合认证」**,用系统赠送签名 + 赠送模板;配置见 `.env.example``ALIYUN_SMS_*`,SDK 为 `alibabacloud_dypnsapi20170525`
- `chuanglan`**YZM 前缀验证码账号**,服务器出网 IP 须在创蓝控制台**加白名单**(否则 117);Mode B 存码/冷却/校验逻辑是**从极光隔离复制**(极光文件不动),仅发送走 HMAC 签名。配置见 `.env.example``CHUANGLAN_SMS_*`,接口调研见 [chuanglan/tpl-send.md](chuanglan/tpl-send.md),设计见 [spec](../superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md)。
**以下章节描述 `jiguang` provider(自管码)细节**(`chuanglan` 的存码/冷却/校验语义与之相同)。
| | mock(`SMS_MOCK=true`,默认 / 开发测试) | real(`SMS_MOCK=false`,生产) |
|---|---|---|
@@ -0,0 +1,143 @@
# 创蓝云智(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 providerMode 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` 全绿。
+286
View File
@@ -0,0 +1,286 @@
"""创蓝云智(253)短信 provider(Mode B 自管码)单元测试。
HTTP 交互隔离在 chuanglan._call_chuanglan(薄封装:签名 + httpx POST + 解析),映射逻辑在
chuanglan._send_via_chuanglan本文件 monkeypatch 这两个接缝(或更底层 httpx.post) 不发真网络
测的是:HMAC 签名算法请求体不上行 password错误码HTTP 自管码存/校验(与极光同语义)mock 短路
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
import httpx
import pytest
from app.core.config import settings
from app.integrations.sms import chuanglan
from app.integrations.sms.base import SmsError
PHONE = "13800138000"
class _FakeResp:
"""极简 httpx.Response 替身:只暴露 status_code / json() / text。"""
def __init__(self, status_code: int = 200, payload: dict | None = None, text: str = "") -> None:
self.status_code = status_code
self._payload = payload if payload is not None else {}
self.text = text or json.dumps(self._payload)
def json(self) -> dict:
return self._payload
def _configure(monkeypatch, *, mock: bool = False) -> None:
"""配齐创蓝凭证 + 设 SMS_MOCK;清本地存码/冷却隔离用例。"""
monkeypatch.setattr(settings, "SMS_MOCK", mock)
monkeypatch.setattr(settings, "CHUANGLAN_SMS_ACCOUNT", "YZM0000001")
monkeypatch.setattr(settings, "CHUANGLAN_SMS_PASSWORD", "secret")
monkeypatch.setattr(settings, "CHUANGLAN_SMS_TEMPLATE_ID", "1021143438")
monkeypatch.setattr(settings, "CHUANGLAN_SMS_SIGNATURE", "【创蓝云智】")
chuanglan._codes.clear()
chuanglan._last_sent.clear()
def _ok_payload(**over) -> dict:
p = {
"code": "000000",
"msgId": "25071018345400902898000000000001",
"time": "20250710183454",
"successNum": "1",
"failNum": "0",
"errorMsg": "",
}
p.update(over)
return p
# ============================ 签名算法 ============================
def test_sign_implements_documented_hmac() -> None:
"""_sign = HmacSHA256(key=md5(password), msg=sorted([md5pwd,ts,nonce]) 拼接),小写 hex。"""
password, ts, nonce = "secret", "1752143733", "0123456789abcdef0123456789abcdef"
md5pwd = hashlib.md5(password.encode()).hexdigest()
expected = hmac.new(
md5pwd.encode(),
"".join(sorted([md5pwd, ts, nonce])).encode(),
hashlib.sha256,
).hexdigest()
sig = chuanglan._sign(password, ts, nonce)
assert sig == expected
assert len(sig) == 64 and sig == sig.lower()
def test_sign_changes_with_nonce() -> None:
assert chuanglan._sign("p", "1", "nonceA") != chuanglan._sign("p", "1", "nonceB")
# ============================ _call_chuanglan(HTTP 接缝)============================
def test_call_chuanglan_builds_signed_request_without_password(monkeypatch) -> None:
_configure(monkeypatch)
captured: dict = {}
def fake_post(url, **kw):
captured.update(url=url, body=kw.get("json"), headers=kw.get("headers"), timeout=kw.get("timeout"))
return _FakeResp(200, _ok_payload())
monkeypatch.setattr(httpx, "post", fake_post)
result = chuanglan._call_chuanglan(PHONE, "123456")
assert result["code"] == "000000"
assert captured["url"] == settings.CHUANGLAN_SMS_ENDPOINT
body = captured["body"]
assert body["account"] == "YZM0000001"
assert body["phoneNumbers"] == PHONE
assert body["templateId"] == "1021143438"
assert body["templateParamJson"] == json.dumps([{"param1": "123456"}])
assert body["signature"] == "【创蓝云智】"
assert "password" not in body # HMAC 方式:密码只用于算签,不上行
assert len(body["nonce"]) == 32
assert captured["headers"]["X-QA-Hmac-Signature"] == chuanglan._sign(
"secret", body["timestamp"], body["nonce"]
)
assert captured["timeout"] == settings.CHUANGLAN_SMS_TIMEOUT_SEC
def test_call_chuanglan_omits_signature_when_blank(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(settings, "CHUANGLAN_SMS_SIGNATURE", "")
captured: dict = {}
monkeypatch.setattr(httpx, "post", lambda url, **kw: captured.update(body=kw.get("json")) or _FakeResp(200, _ok_payload()))
chuanglan._call_chuanglan(PHONE, "123456")
assert "signature" not in captured["body"] # 模板自带签名时不传
def test_call_chuanglan_http_non_200_raises_503(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(httpx, "post", lambda url, **kw: _FakeResp(500, {}, "oops"))
with pytest.raises(SmsError) as ei:
chuanglan._call_chuanglan(PHONE, "123456")
assert ei.value.status_code == 503
def test_call_chuanglan_network_error_raises_503(monkeypatch) -> None:
_configure(monkeypatch)
def boom(url, **kw):
raise httpx.ConnectError("down")
monkeypatch.setattr(httpx, "post", boom)
with pytest.raises(SmsError) as ei:
chuanglan._call_chuanglan(PHONE, "123456")
assert ei.value.status_code == 503
# ============================ _send_via_chuanglan(错误码映射)============================
@pytest.mark.parametrize(
"code,expected",
[
("000000", None), # 成功不抛
("103", 429), # 超频
("107", 400), # 手机号错误
("109", 503), # 无发送量/余额
("117", 503), # IP 未白名单
("102", 503), # 密码错误
("116", 503), # 签名不合法
("124", 503), # 模板内容不匹配
("152", 503), # 模板不存在
("999999", 503), # 未知码兜底
],
)
def test_send_via_chuanglan_maps_codes(monkeypatch, code, expected) -> None:
_configure(monkeypatch)
monkeypatch.setattr(chuanglan, "_call_chuanglan", lambda phone, c: _ok_payload(code=code, errorMsg=code))
if expected is None:
assert chuanglan._send_via_chuanglan(PHONE, "123456") is None
else:
with pytest.raises(SmsError) as ei:
chuanglan._send_via_chuanglan(PHONE, "123456")
assert ei.value.status_code == expected
def test_send_via_chuanglan_not_configured_raises_503_without_calling(monkeypatch) -> None:
monkeypatch.setattr(settings, "SMS_MOCK", False)
monkeypatch.setattr(settings, "CHUANGLAN_SMS_ACCOUNT", "") # 凭证缺
def _boom(phone, c):
raise AssertionError("未配置时不应发起请求")
monkeypatch.setattr(chuanglan, "_call_chuanglan", _boom)
with pytest.raises(SmsError) as ei:
chuanglan._send_via_chuanglan(PHONE, "123456")
assert ei.value.status_code == 503
# ============================ send_code(自管码,复制自极光)============================
def test_send_code_success_stores_and_returns_cooldown(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", lambda p, c: None)
assert chuanglan.send_code(PHONE) == settings.SMS_SEND_INTERVAL_SEC
assert PHONE in chuanglan._codes
assert len(chuanglan._codes[PHONE].code) == settings.SMS_CODE_LENGTH
def test_send_code_cooldown_raises_429(monkeypatch) -> None:
_configure(monkeypatch)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", lambda p, c: None)
chuanglan.send_code(PHONE)
with pytest.raises(SmsError) as ei:
chuanglan.send_code(PHONE)
assert ei.value.status_code == 429
def test_send_code_failure_keeps_cooldown_drops_code(monkeypatch) -> None:
_configure(monkeypatch)
def boom(p, c):
raise SmsError("no balance", status_code=503)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
with pytest.raises(SmsError) as ei:
chuanglan.send_code(PHONE)
assert ei.value.status_code == 503
assert PHONE not in chuanglan._codes # 没发出去的码删掉
assert PHONE in chuanglan._last_sent # 冷却保留:失败也限速
def test_send_code_unexpected_error_wrapped_503(monkeypatch) -> None:
_configure(monkeypatch)
def boom(p, c):
raise RuntimeError("boom")
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
with pytest.raises(SmsError) as ei:
chuanglan.send_code(PHONE)
assert ei.value.status_code == 503
def test_send_code_mock_short_circuits_no_network(monkeypatch) -> None:
_configure(monkeypatch, mock=True)
def boom(p, c):
raise AssertionError("mock 不应发网络")
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
assert chuanglan.send_code(PHONE) == settings.SMS_SEND_INTERVAL_SEC
# ============================ verify_code(自管码,复制自极光)============================
def test_verify_code_success_is_one_time(monkeypatch) -> None:
_configure(monkeypatch)
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() + 300)
assert chuanglan.verify_code(PHONE, "123456") is True
assert chuanglan.verify_code(PHONE, "123456") is False # 验过即作废
def test_verify_code_wrong_caps_then_invalidates(monkeypatch) -> None:
_configure(monkeypatch)
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() + 300)
for _ in range(settings.SMS_MAX_VERIFY_ATTEMPTS):
assert chuanglan.verify_code(PHONE, "000000") is False
# 达失败上限即作废:即便随后给对的码也 False
assert chuanglan.verify_code(PHONE, "123456") is False
def test_verify_code_expired_false_and_cleared(monkeypatch) -> None:
_configure(monkeypatch)
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() - 1)
assert chuanglan.verify_code(PHONE, "123456") is False
assert PHONE not in chuanglan._codes
def test_verify_code_no_record_false(monkeypatch) -> None:
_configure(monkeypatch)
assert chuanglan.verify_code(PHONE, "123456") is False
def test_verify_code_mock_passes_any_ndigit(monkeypatch) -> None:
_configure(monkeypatch, mock=True)
assert chuanglan.verify_code(PHONE, "123456") is True # 6 位数字放行
assert chuanglan.verify_code(PHONE, "12345") is False # 位数不对
# ============================ 端点:SMS_PROVIDER=chuanglan 端到端路由 ============================
def test_sms_send_chuanglan_outage_returns_503(client, monkeypatch) -> None:
"""SMS_PROVIDER=chuanglan 且发送时创蓝异常 → /sms/send 返 503(auth.py 现有 try/except 覆盖)。"""
_configure(monkeypatch)
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
def boom(p, c):
raise SmsError("chuanglan down", status_code=503)
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
r = client.post("/api/v1/auth/sms/send", json={"phone": "13812345678"})
assert r.status_code == 503, r.text
+9 -3
View File
@@ -7,33 +7,39 @@ from __future__ import annotations
from app.core.config import settings
from app.integrations import sms
from app.integrations.sms import aliyun, jiguang
from app.integrations.sms import aliyun, chuanglan, jiguang
def test_send_code_routes_by_provider_and_switches_per_call(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr(jiguang, "send_code", lambda phone: (calls.append("jiguang"), 60)[1])
monkeypatch.setattr(aliyun, "send_code", lambda phone: (calls.append("aliyun"), 60)[1])
monkeypatch.setattr(chuanglan, "send_code", lambda phone: (calls.append("chuanglan"), 60)[1])
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
assert sms.send_code("13800138000") == 60
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
assert sms.send_code("13800138000") == 60
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
assert sms.send_code("13800138000") == 60
assert calls == ["jiguang", "aliyun"] # 每次按当前 provider 路由,运行时可切
assert calls == ["jiguang", "aliyun", "chuanglan"] # 每次按当前 provider 路由,运行时可切
def test_verify_code_routes_by_provider(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setattr(jiguang, "verify_code", lambda phone, code: (calls.append("jiguang"), True)[1])
monkeypatch.setattr(aliyun, "verify_code", lambda phone, code: (calls.append("aliyun"), True)[1])
monkeypatch.setattr(chuanglan, "verify_code", lambda phone, code: (calls.append("chuanglan"), True)[1])
monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang")
assert sms.verify_code("13800138000", "123456") is True
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
assert sms.verify_code("13800138000", "123456") is True
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
assert sms.verify_code("13800138000", "123456") is True
assert calls == ["jiguang", "aliyun"]
assert calls == ["jiguang", "aliyun", "chuanglan"]
def test_unknown_provider_falls_back_to_jiguang(monkeypatch) -> None: