ed76820e97
读完了两份 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
225 lines
9.6 KiB
Python
225 lines
9.6 KiB
Python
"""创蓝云智(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)
|