diff --git a/app/integrations/sms/__init__.py b/app/integrations/sms/__init__.py index 7726bbd..a676c08 100644 --- a/app/integrations/sms/__init__.py +++ b/app/integrations/sms/__init__.py @@ -36,10 +36,23 @@ def _fallback(): def send_code(phone: str) -> SendResult: - """发码:主成功即返回;失败抛 SmsError。返回 SendResult(cooldown + 实际渠道)。""" + """发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。 + + 429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。 + 备也失败则抛备的 SmsError。返回 SendResult(cooldown + 实际渠道 + 是否 fallback)。 + """ primary = _primary() - cooldown = primary.send_code(phone) - return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False) + fb = _fallback() + try: + cooldown = primary.send_code(phone) + 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 = fb.send_code(phone) # 备的冷却/错误码原样透出 + return SendResult(cooldown_sec=cooldown, provider=_NAME[fb], fallback=True) + raise def verify_code(phone: str, code: str) -> bool: diff --git a/tests/test_sms_fallback.py b/tests/test_sms_fallback.py index 883f20b..1649400 100644 --- a/tests/test_sms_fallback.py +++ b/tests/test_sms_fallback.py @@ -5,12 +5,12 @@ monkeypatch 各 provider 的 send_code/verify_code 为标记函数,不发真 """ from __future__ import annotations -import pytest # noqa: F401 +import pytest from app.core.config import settings from app.integrations import sms -from app.integrations.sms import aliyun, chuanglan, jiguang # noqa: F401 -from app.integrations.sms.base import SendResult, SmsError # noqa: F401 (SmsError 在 Task 4+ 使用) +from app.integrations.sms import chuanglan, jiguang +from app.integrations.sms.base import SendResult, SmsError PHONE = "13800138000" @@ -36,3 +36,59 @@ def test_send_code_primary_success_returns_sendresult(monkeypatch): assert r.cooldown_sec == 60 assert r.provider == "jiguang" assert r.fallback is False + + +def test_send_code_503_falls_back_to_configured_backup(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "send_code", + _raiser(SmsError("极光不可用", status_code=503))) + monkeypatch.setattr(chuanglan, "send_code", + lambda phone: (calls.append("chuanglan"), 60)[1]) + + r = sms.send_code(PHONE) + assert r.provider == "chuanglan" + assert r.fallback is True + assert r.cooldown_sec == 60 + assert calls == ["chuanglan"] + + +@pytest.mark.parametrize("status", [429, 400]) +def test_send_code_non_503_does_not_fall_back(monkeypatch, status): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "send_code", + _raiser(SmsError("x", status_code=status))) + monkeypatch.setattr(chuanglan, "send_code", + lambda phone: (calls.append("chuanglan"), 60)[1]) + + with pytest.raises(SmsError) as ei: + sms.send_code(PHONE) + assert ei.value.status_code == status + assert calls == [] + + +def test_send_code_503_without_backup_reraises(monkeypatch): + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") + monkeypatch.setattr(jiguang, "send_code", + _raiser(SmsError("极光不可用", status_code=503))) + + with pytest.raises(SmsError) as ei: + sms.send_code(PHONE) + assert ei.value.status_code == 503 + + +def test_send_code_backup_also_fails_raises_backup_error(monkeypatch): + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "send_code", + _raiser(SmsError("极光不可用", status_code=503))) + monkeypatch.setattr(chuanglan, "send_code", + _raiser(SmsError("创蓝也不可用", status_code=503))) + + with pytest.raises(SmsError) as ei: + sms.send_code(PHONE) + assert "创蓝" in str(ei.value)