Files
shaguabijia-app-server/app/integrations/sms/__init__.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

97 lines
3.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""短信验证码服务 —— 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