功能:创蓝云智(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:
+14
-4
@@ -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
@@ -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 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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 字段,必须与签名里用的一致。
|
||||
|
||||
## 请求参数(body,JSON)
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `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` | 消息 ID(32 位) |
|
||||
| `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
@@ -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 provider(Mode 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` 全绿。
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user