Files
shaguabijia-app-server/app/integrations/sms/chuanglan.py
T
linkeyu 15fb73791f 功能:统一限制策略与白名单管理 (#207)
## 需求背景
将比价、短信与登录、广告、引导与账号、风控免告警等限制统一配置,并支持按手机号或设备设置有有效期的临时白名单。

## 主要改动
- 新增统一限制策略注册表、全局 JSON 配置与白名单覆盖表
- 新增白名单管理、设备检索、批量追加与主体统一编辑接口
- 接入比价、短信登录、广告奖励、引导视频、账号换绑及风险告警调用链
- 保留旧配置接口兼容,并同步统一策略全局值
- 增加单主体唯一有效期、恢复全局、审计日志和风险事件自动处理
- 增加数据库迁移及完整回归测试

## 验证
- 白名单、权限、配置及风控测试 50 项通过
- 短信、登录、比价、广告关联测试 98 项通过
- Ruff 与 Python 编译检查通过
- Alembic 保持单一 head
- 已同步最新 main

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #207
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-31 17:08:06 +08:00

238 lines
9.9 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, *, cooldown_sec: int | None = None) -> int:
"""发送验证码。
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
"""
now = time.time()
effective_cooldown = (
settings.SMS_SEND_INTERVAL_SEC if cooldown_sec is None else cooldown_sec
)
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
with _lock:
_gc(now) # 顺手清过期内存(超阈值才扫)
elapsed = now - _last_sent.get(phone, 0.0)
if elapsed < effective_cooldown:
remain = int(effective_cooldown - 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 effective_cooldown
def verify_code(
phone: str,
code: str,
*,
max_failed_attempts: int | None = None,
) -> 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
effective_max_attempts = (
settings.SMS_MAX_VERIFY_ATTEMPTS
if max_failed_attempts is None
else max_failed_attempts
)
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 >= effective_max_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)