"""短信验证码服务 —— 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