15fb73791f
## 需求背景 将比价、短信与登录、广告、引导与账号、风控免告警等限制统一配置,并支持按手机号或设备设置有有效期的临时白名单。 ## 主要改动 - 新增统一限制策略注册表、全局 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>
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
|