085d610c99
# Conflicts: # app/api/v1/auth.py # app/integrations/sms/__init__.py
97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)。
|
||
|
||
对外暴露 `send_code`(返回 SendResult) / `verify_code` / `SmsError` / `SendResult`。
|
||
主 provider = settings.SMS_PROVIDER(默认 jiguang);备 = settings.SMS_FALLBACK_PROVIDER
|
||
(默认空=无备)。**每次调用读 settings**,支持运行时切换 / 灰度回退。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from app.core.config import settings
|
||
|
||
from . import aliyun, chuanglan, jiguang
|
||
from .base import SendResult, SmsError
|
||
|
||
__all__ = ["SmsError", "SendResult", "send_code", "verify_code"]
|
||
|
||
logger = logging.getLogger("shagua.sms")
|
||
|
||
# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。
|
||
_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan}
|
||
_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"}
|
||
|
||
|
||
def _primary():
|
||
"""当前主 provider 模块(未知/缺省回退 jiguang)。"""
|
||
return _ALL.get(settings.SMS_PROVIDER, jiguang)
|
||
|
||
|
||
def _fallback():
|
||
"""启用的备 provider 模块;未配置 / 同主 / 未知名 → None(= 不启用 fallback)。"""
|
||
name = (settings.SMS_FALLBACK_PROVIDER or "").strip()
|
||
if not name or name == settings.SMS_PROVIDER:
|
||
return None
|
||
return _ALL.get(name)
|
||
|
||
|
||
def _send_with_cooldown(provider, phone: str, cooldown_sec: int | None) -> int:
|
||
if cooldown_sec is None:
|
||
return provider.send_code(phone)
|
||
return provider.send_code(phone, cooldown_sec=cooldown_sec)
|
||
|
||
|
||
def send_code(
|
||
phone: str,
|
||
*,
|
||
cooldown_sec: int | None = None,
|
||
) -> SendResult:
|
||
"""发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。
|
||
|
||
429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。
|
||
备也失败则抛备的 SmsError。调用方传入的动态冷却值在主、备渠道保持一致。
|
||
"""
|
||
primary = _primary()
|
||
fb = _fallback()
|
||
try:
|
||
cooldown = _send_with_cooldown(primary, phone, cooldown_sec)
|
||
return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False)
|
||
except SmsError as e:
|
||
if fb is not None and e.status_code == 503:
|
||
logger.warning("[SMS] primary=%s 不可用(%s),fallback→%s",
|
||
_NAME[primary], e, _NAME[fb])
|
||
cooldown = _send_with_cooldown(fb, phone, cooldown_sec)
|
||
return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True)
|
||
raise
|
||
|
||
|
||
def verify_code(
|
||
phone: str,
|
||
code: str,
|
||
*,
|
||
max_failed_attempts: int | None = None,
|
||
) -> bool:
|
||
"""校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。
|
||
|
||
码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、
|
||
不误判、不累加其防爆破计数。动态失败次数上限会一致传给主备渠道。
|
||
"""
|
||
chain = [_primary()]
|
||
fb = _fallback()
|
||
if fb is not None:
|
||
chain.append(fb)
|
||
for prov in chain:
|
||
verified = (
|
||
prov.verify_code(phone, code)
|
||
if max_failed_attempts is None
|
||
else prov.verify_code(
|
||
phone,
|
||
code,
|
||
max_failed_attempts=max_failed_attempts,
|
||
)
|
||
)
|
||
if verified:
|
||
logger.info("[SMS] verify hit provider=%s", _NAME[prov])
|
||
return True
|
||
return False
|