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
194 lines
9.4 KiB
Python
194 lines
9.4 KiB
Python
"""阿里云号码认证(dypns)短信 provider —— Mode A(阿里云托管验证码)。
|
|
|
|
与极光(自管码)最大不同:**本服务不生成/不存储验证码**,验证码由阿里云生成+存储+下发+校验。
|
|
- 发码:调 SendSmsVerifyCode,TemplateParam 用 `{"code":"##code##","min":...}` 占位,阿里云生成。
|
|
- 校验:调 CheckSmsVerifyCode,阿里云返回 PASS / UNKNOWN。核验免费。
|
|
→ 天然消除极光路径「内存存码、多 worker 不共享」的技术债(发码/校验可落不同 worker,阿里云统一裁决)。
|
|
|
|
**唯一本地态**:per-phone 连续失败计数(`_verify_attempts`),用于复刻极光「单码失败
|
|
`SMS_MAX_VERIFY_ATTEMPTS` 次即作废」的防爆破语义 —— 刻意与极光一致,避免两 provider 行为不同
|
|
导致排查困惑。其多 worker 降级特性与极光现状同级;另有 API 层登录频控(设备+IP)做硬兜底。
|
|
|
|
单号发送频控(冷却)交给阿里云 `Interval` 参数(命中→FREQUENCY_FAIL→429),本地不再维护冷却。
|
|
|
|
SDK 交互隔离在 `_call_send` / `_call_check` 两个薄封装(惰性 import + 惰性建 client,仿 wxpay
|
|
惰性加载),单测 monkeypatch 这两个即可,不触真 SDK / 网络。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
from threading import Lock
|
|
|
|
from app.core.config import settings
|
|
|
|
from .base import SmsError, mock_verify
|
|
|
|
logger = logging.getLogger("shagua.sms.aliyun")
|
|
|
|
# 阿里云路径唯一本地态:per-phone 连续失败次数(与极光同语义,防爆破)。
|
|
_verify_attempts: dict[str, int] = {} # phone -> 连续失败次数
|
|
_verify_seen: dict[str, float] = {} # phone -> 最近触碰 epoch(仅供 GC 老化)
|
|
_lock = Lock()
|
|
_GC_THRESHOLD = 10000 # 超此阈值,send 时顺手清老于验证码有效期的计数(仿极光 _gc)
|
|
|
|
# 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
|
|
_SEND_ERRORS: dict[str, tuple[int, str]] = {
|
|
"MOBILE_NUMBER_ILLEGAL": (400, "手机号无效"),
|
|
"BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"),
|
|
"FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"),
|
|
}
|
|
# 需运维介入的配置/开通类错误:打 critical 日志(融合认证未开通 / 参数非法)。
|
|
_SEND_CRITICAL_CODES = frozenset({"FUNCTION_NOT_OPENED", "INVALID_PARAMETERS"})
|
|
|
|
_client = None # 惰性构建的 SDK client(模块级缓存)
|
|
|
|
|
|
# ============================ 对外:发码 / 校验 ============================
|
|
|
|
def send_code(phone: str) -> int:
|
|
"""发送验证码(阿里云生成+下发)。
|
|
|
|
Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行)。
|
|
Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503)。
|
|
"""
|
|
if settings.SMS_MOCK:
|
|
logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3])
|
|
return settings.ALIYUN_SMS_INTERVAL_SEC
|
|
if not settings.aliyun_sms_configured:
|
|
raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503)
|
|
|
|
result = _call_send(phone) # 传输/SDK 异常在内部抛 SmsError(503)
|
|
|
|
if result["success"] and result["code"] == "OK":
|
|
now = time.time()
|
|
with _lock:
|
|
_gc(now) # 顺手清老计数(超阈值才扫)
|
|
_verify_attempts.pop(phone, None) # 新码 = 新失败预算
|
|
_verify_seen.pop(phone, None)
|
|
logger.info("[SMS-aliyun] sent to %s****", phone[:3])
|
|
return settings.ALIYUN_SMS_INTERVAL_SEC
|
|
|
|
code = result["code"]
|
|
logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"])
|
|
if code in _SEND_CRITICAL_CODES:
|
|
logger.critical("[SMS-aliyun] %s —— 需运维处理(融合认证未开通 / 参数非法)", code)
|
|
status, msg = _SEND_ERRORS.get(code, (503, "短信服务暂不可用,请稍后重试"))
|
|
raise SmsError(msg, status_code=status)
|
|
|
|
|
|
def verify_code(phone: str, code: str) -> bool:
|
|
"""校验验证码(阿里云裁决)。
|
|
|
|
- **mock**:放行任意 N 位数字(provider 无关,同极光)。
|
|
- **real**:先查本地失败计数(达上限即本地作废,不调阿里云,与极光一致)→ 调 CheckSmsVerifyCode:
|
|
PASS 清计数返 True(一次性);UNKNOWN 计数 +1 返 False;接口异常抛 SmsError(503)。
|
|
"""
|
|
if settings.SMS_MOCK:
|
|
ok = mock_verify(code)
|
|
logger.info("[SMS-aliyun-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
|
return ok
|
|
|
|
# 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。
|
|
# 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。
|
|
with _lock:
|
|
if _verify_attempts.get(phone, 0) >= settings.SMS_MAX_VERIFY_ATTEMPTS:
|
|
return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致
|
|
|
|
result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503)
|
|
|
|
if not (result["success"] and result["code"] == "OK"):
|
|
# 接口层失败(非码错):降级 503,别误报「验证码错误」(400),便于区分排查。
|
|
logger.error("[SMS-aliyun] check failed code=%s msg=%s", result["code"], result["message"])
|
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
|
|
|
|
if result["verify_result"] == "PASS":
|
|
with _lock:
|
|
_verify_attempts.pop(phone, None) # 验过即清(一次性)
|
|
_verify_seen.pop(phone, None)
|
|
return True
|
|
|
|
# UNKNOWN:码错 / 过期 → 失败计数 +1(累计到上限即作废)
|
|
with _lock:
|
|
_verify_attempts[phone] = _verify_attempts.get(phone, 0) + 1
|
|
_verify_seen[phone] = time.time()
|
|
return False
|
|
|
|
|
|
def _gc(now: float) -> None:
|
|
"""超阈值时清理老于验证码有效期的失败计数(码早已在阿里云侧失效,计数无意义)。仅持锁调用。"""
|
|
if len(_verify_attempts) <= _GC_THRESHOLD:
|
|
return
|
|
cutoff = now - settings.ALIYUN_SMS_VALID_TIME_SEC
|
|
for p in [p for p, ts in _verify_seen.items() if ts < cutoff]:
|
|
_verify_attempts.pop(p, None)
|
|
_verify_seen.pop(p, None)
|
|
|
|
|
|
# ============================ SDK 接缝(单测 monkeypatch 这两个)============================
|
|
|
|
def _get_client():
|
|
"""惰性构建 dypns SDK client(仿 wxpay 惰性加载:jiguang-only 部署不加载 alibabacloud)。"""
|
|
global _client
|
|
if _client is None:
|
|
from alibabacloud_dypnsapi20170525.client import Client
|
|
from alibabacloud_tea_openapi import models as open_api_models
|
|
|
|
cfg = open_api_models.Config(
|
|
access_key_id=settings.ALIYUN_SMS_ACCESS_KEY_ID,
|
|
access_key_secret=settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
|
read_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000, # SDK 单位 ms
|
|
connect_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000,
|
|
)
|
|
cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT
|
|
_client = Client(cfg)
|
|
return _client
|
|
|
|
|
|
def _call_send(phone: str) -> dict:
|
|
"""调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。"""
|
|
valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60)
|
|
template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False)
|
|
try:
|
|
# import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式)
|
|
from alibabacloud_dypnsapi20170525 import models as dypns_models
|
|
req = dypns_models.SendSmsVerifyCodeRequest(
|
|
phone_number=phone,
|
|
sign_name=settings.ALIYUN_SMS_SIGN_NAME,
|
|
template_code=settings.ALIYUN_SMS_TEMPLATE_CODE,
|
|
template_param=template_param,
|
|
code_length=settings.ALIYUN_SMS_CODE_LENGTH,
|
|
valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC,
|
|
interval=settings.ALIYUN_SMS_INTERVAL_SEC,
|
|
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
|
|
)
|
|
body = _get_client().send_sms_verify_code(req).body
|
|
except Exception as e:
|
|
logger.exception("[SMS-aliyun] send_sms_verify_code 调用异常 phone=%s****", phone[:3])
|
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
|
return {"success": bool(body.success), "code": body.code, "message": body.message}
|
|
|
|
|
|
def _call_check(phone: str, code: str) -> dict:
|
|
"""调 CheckSmsVerifyCode。返回归一化 {success, code, message, verify_result};import/建 client/调用 任一失败抛 SmsError(503)。"""
|
|
try:
|
|
# import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式)
|
|
from alibabacloud_dypnsapi20170525 import models as dypns_models
|
|
req = dypns_models.CheckSmsVerifyCodeRequest(
|
|
phone_number=phone,
|
|
verify_code=code,
|
|
scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None,
|
|
)
|
|
body = _get_client().check_sms_verify_code(req).body
|
|
except Exception as e:
|
|
logger.exception("[SMS-aliyun] check_sms_verify_code 调用异常 phone=%s****", phone[:3])
|
|
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
|
verify_result = getattr(body.model, "verify_result", None) if body.model else None
|
|
return {
|
|
"success": bool(body.success),
|
|
"code": body.code,
|
|
"message": body.message,
|
|
"verify_result": verify_result,
|
|
}
|