ed76820e97
读完了两份 spec + 分派器,这分支的全貌清晰了。下面是可直接粘进 Gitea PR 的 MR 说明(标题 + 正文): 建议标题 feat(sms): 短信验证码可切换多 provider(极光/阿里云/创蓝),默认极光零改动 MR 正文(复制以下 markdown): ## 概述 把原单文件 `app/integrations/sms.py` 重构为**可切换 provider 包** `app/integrations/sms/`,在保留极光(默认、行为零改动)的基础上,新增两家验证码短信 provider: - **阿里云号码认证 dypns**(Mode A:阿里云生成/存储/校验验证码,核验免费) - **创蓝云智 253**(Mode B:本服务自管码,httpx 直连 + HMAC 签名) Provider 由 `SMS_PROVIDER` 按调用实时选择,默认 `jiguang`。短信=花钱 + 登录关键路径,故新 provider **opt-in、可灰度、秒级回退**,默认路径零变更。 ## 为什么 现有极光路径本地内存存码(多 worker 不共享,已是技术债),且单一供应商无法灰度/切换。引入 provider 抽象后:阿里云托管码可消除存码债,创蓝作为备选降低单点依赖,三家随配置切换与回退。 ## 改动内容 **架构(`app/integrations/sms/`)** | 文件 | 说明 | |---|---| | `__init__.py` | 对外仍暴露 `send_code/verify_code/SmsError`(auth 导入不变);按 `SMS_PROVIDER` **每次调用**分派;未知值回退 `jiguang` | | `base.py` | `SmsError`(status_code→HTTP) + provider 无关的 `mock_verify` | | `jiguang.py` | 原 `sms.py` 逻辑**原样迁入**,行为零改动(git 识别为 rename) | | `aliyun.py` | 新增,Mode A:`SendSmsVerifyCode` + `CheckSmsVerifyCode`,惰性加载 SDK | | `chuanglan.py` | 新增,Mode B:自管码 + `tpl/send` + HMAC 签名 | **两种验证码模式** - Mode A(阿里云):不本地存码,阿里云 `##code##` 托管生成+校验;本地仅留 per-phone 失败计数防爆破。 - Mode B(极光/创蓝):`secrets` 生成 N 位 → 进程内存 → 供应商只下发;本地一次性校验 + 失败 N 次作废。创蓝**复制**极光存码机器(不重构极光,零回归风险)。 **配置(`config.py` + `.env.example`)** - `SMS_PROVIDER = jiguang | aliyun | chuanglan`(默认 jiguang) - `ALIYUN_SMS_*`(AK/签名/模板/方案名/时长…) + `aliyun_sms_configured` 门控 - `CHUANGLAN_SMS_*`(账号/密码/模板/签名/endpoint…) + `chuanglan_sms_configured` 门控 - 复用现有 `SMS_MOCK / SMS_CODE_LENGTH / SMS_CODE_TTL_SEC / SMS_SEND_INTERVAL_SEC / SMS_MAX_VERIFY_ATTEMPTS` - 切到某 provider 却未配齐 → `send_code` 抛 `SmsError(503)`,不静默 **auth.py(最小改动)** - `verify_code` 现在可能抛 `SmsError`(阿里云降级 503)→ `sms_login`、`wechat_bind_phone_sms` 两处各包 `try/except SmsError → HTTPException`,与 `send_code` 现有写法一致。 **依赖** - `pyproject.toml` 增 `alibabacloud_dypnsapi20170525`(仅阿里云 provider 惰性 import;jiguang/chuanglan 不加载)。创蓝零新依赖(httpx + 标准库)。 **测试** - 新增 `test_sms_aliyun.py` / `test_sms_chuanglan.py`(均 monkeypatch 网络接缝,不发真短信) + `test_sms_dispatch.py`(分派/回退)。 - `test_auth.py` 相应更新。 - 现有测试走 `SMS_MOCK=true` 在分派层短路,不受影响。 **文档** - 设计 spec:`docs/superpowers/specs/2026-07-25-aliyun-sms-verify-design.md`、`2026-07-26-chuanglan-sms-verify-design.md` - 接口调研:`docs/integrations/aliyun/*`、`docs/integrations/chuanglan/tpl-send.md`、`docs/integrations/sms.md` ## 兼容性 & 回退 - **默认 `SMS_PROVIDER=jiguang`,线上行为与现状完全一致**;不改极光逻辑、不动 API 层频控与测试账号短路。 - 切阿里云/创蓝仅改环境变量,出问题秒切回极光;未知 `SMS_PROVIDER` 一律回退极光,防误配打挂登录。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #188
186 lines
6.5 KiB
Python
186 lines
6.5 KiB
Python
"""阿里云短信 provider(Mode A)单元测试。
|
|
|
|
SDK 交互隔离在 aliyun._call_send / aliyun._call_check 两个薄封装,本文件全程 monkeypatch
|
|
它们(返回归一化结果 dict 或抛 SmsError)→ 不触真 SDK、不发网络。测的是 provider 的可映射逻辑:
|
|
错误码→HTTP 码、PASS/UNKNOWN 解释、本地失败计数(与极光同语义)、mock 短路。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.integrations.sms import aliyun
|
|
from app.integrations.sms.base import SmsError
|
|
|
|
PHONE = "13800138000"
|
|
|
|
|
|
def _configure(monkeypatch, *, mock: bool = False) -> None:
|
|
"""配齐阿里云凭证 + 设 SMS_MOCK;清本地失败计数隔离用例。"""
|
|
monkeypatch.setattr(settings, "SMS_MOCK", mock)
|
|
monkeypatch.setattr(settings, "ALIYUN_SMS_ACCESS_KEY_ID", "ak")
|
|
monkeypatch.setattr(settings, "ALIYUN_SMS_ACCESS_KEY_SECRET", "sk")
|
|
monkeypatch.setattr(settings, "ALIYUN_SMS_SIGN_NAME", "恒创联众")
|
|
monkeypatch.setattr(settings, "ALIYUN_SMS_TEMPLATE_CODE", "SMS_100001")
|
|
aliyun._verify_attempts.clear()
|
|
|
|
|
|
def _send_ok(phone):
|
|
return {"success": True, "code": "OK", "message": "成功", "verify_code": "1234"}
|
|
|
|
|
|
def _check(result):
|
|
def _f(phone, code):
|
|
return {"success": True, "code": "OK", "message": "成功", "verify_result": result}
|
|
return _f
|
|
|
|
|
|
# ============================ 发码 ============================
|
|
|
|
def test_send_success_returns_interval_and_resets_attempts(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
aliyun._verify_attempts[PHONE] = 3 # 旧失败计数
|
|
monkeypatch.setattr(aliyun, "_call_send", _send_ok)
|
|
|
|
assert aliyun.send_code(PHONE) == settings.ALIYUN_SMS_INTERVAL_SEC
|
|
assert PHONE not in aliyun._verify_attempts # 新码 = 新预算
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"code,expected",
|
|
[
|
|
("MOBILE_NUMBER_ILLEGAL", 400),
|
|
("BUSINESS_LIMIT_CONTROL", 429),
|
|
("FREQUENCY_FAIL", 429),
|
|
("FUNCTION_NOT_OPENED", 503),
|
|
("INVALID_PARAMETERS", 503),
|
|
("SOME_UNEXPECTED_CODE", 503),
|
|
],
|
|
)
|
|
def test_send_maps_error_codes(monkeypatch, code, expected) -> None:
|
|
_configure(monkeypatch)
|
|
monkeypatch.setattr(
|
|
aliyun, "_call_send",
|
|
lambda phone: {"success": False, "code": code, "message": code, "verify_code": None},
|
|
)
|
|
with pytest.raises(SmsError) as ei:
|
|
aliyun.send_code(PHONE)
|
|
assert ei.value.status_code == expected
|
|
|
|
|
|
def test_send_not_configured_raises_503_without_calling_aliyun(monkeypatch) -> None:
|
|
monkeypatch.setattr(settings, "SMS_MOCK", False)
|
|
monkeypatch.setattr(settings, "ALIYUN_SMS_ACCESS_KEY_ID", "") # 凭证缺
|
|
|
|
def _boom(phone):
|
|
raise AssertionError("未配置时不应调用阿里云")
|
|
|
|
monkeypatch.setattr(aliyun, "_call_send", _boom)
|
|
with pytest.raises(SmsError) as ei:
|
|
aliyun.send_code(PHONE)
|
|
assert ei.value.status_code == 503
|
|
|
|
|
|
def test_send_transport_error_propagates_503(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
|
|
def _boom(phone):
|
|
raise SmsError("network down", status_code=503)
|
|
|
|
monkeypatch.setattr(aliyun, "_call_send", _boom)
|
|
with pytest.raises(SmsError) as ei:
|
|
aliyun.send_code(PHONE)
|
|
assert ei.value.status_code == 503
|
|
|
|
|
|
def test_send_mock_returns_interval_no_network(monkeypatch) -> None:
|
|
_configure(monkeypatch, mock=True)
|
|
|
|
def _boom(phone):
|
|
raise AssertionError("mock 不应调用阿里云")
|
|
|
|
monkeypatch.setattr(aliyun, "_call_send", _boom)
|
|
assert aliyun.send_code(PHONE) == settings.ALIYUN_SMS_INTERVAL_SEC
|
|
|
|
|
|
# ============================ 校验 ============================
|
|
|
|
def test_verify_pass_true_and_clears_attempts(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
aliyun._verify_attempts[PHONE] = 2
|
|
monkeypatch.setattr(aliyun, "_call_check", _check("PASS"))
|
|
|
|
assert aliyun.verify_code(PHONE, "1234") is True
|
|
assert PHONE not in aliyun._verify_attempts # 验过即清
|
|
|
|
|
|
def test_verify_unknown_false_and_increments(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
monkeypatch.setattr(aliyun, "_call_check", _check("UNKNOWN"))
|
|
|
|
assert aliyun.verify_code(PHONE, "0000") is False
|
|
assert aliyun._verify_attempts[PHONE] == 1
|
|
assert aliyun.verify_code(PHONE, "0000") is False
|
|
assert aliyun._verify_attempts[PHONE] == 2
|
|
|
|
|
|
def test_verify_attempts_cap_short_circuits(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
aliyun._verify_attempts[PHONE] = settings.SMS_MAX_VERIFY_ATTEMPTS
|
|
|
|
def _boom(phone, code):
|
|
raise AssertionError("达失败上限后不应再调阿里云")
|
|
|
|
monkeypatch.setattr(aliyun, "_call_check", _boom)
|
|
assert aliyun.verify_code(PHONE, "1234") is False # 本地作废
|
|
|
|
|
|
def test_verify_api_error_raises_503(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
monkeypatch.setattr(
|
|
aliyun, "_call_check",
|
|
lambda phone, code: {"success": False, "code": "SYSTEM_ERROR",
|
|
"message": "err", "verify_result": None},
|
|
)
|
|
with pytest.raises(SmsError) as ei:
|
|
aliyun.verify_code(PHONE, "1234")
|
|
assert ei.value.status_code == 503
|
|
|
|
|
|
def test_verify_transport_error_raises_503(monkeypatch) -> None:
|
|
_configure(monkeypatch)
|
|
|
|
def _boom(phone, code):
|
|
raise SmsError("network down", status_code=503)
|
|
|
|
monkeypatch.setattr(aliyun, "_call_check", _boom)
|
|
with pytest.raises(SmsError) as ei:
|
|
aliyun.verify_code(PHONE, "1234")
|
|
assert ei.value.status_code == 503
|
|
|
|
|
|
def test_verify_mock_passes_any_ndigit(monkeypatch) -> None:
|
|
_configure(monkeypatch, mock=True)
|
|
|
|
def _boom(phone, code):
|
|
raise AssertionError("mock 不应调用阿里云")
|
|
|
|
monkeypatch.setattr(aliyun, "_call_check", _boom)
|
|
assert aliyun.verify_code(PHONE, "123456") is True # 6 位数字放行
|
|
assert aliyun.verify_code(PHONE, "12345") is False # 位数不对
|
|
|
|
|
|
# ============================ 端点:阿里云降级 → 503(auth.py 包 try/except)============================
|
|
|
|
def test_sms_login_aliyun_outage_returns_503(client, monkeypatch) -> None:
|
|
"""SMS_PROVIDER=aliyun 且校验时阿里云异常 → /sms/login 返 503(而非 400/500),便于区分排查。"""
|
|
_configure(monkeypatch) # 配齐凭证 + SMS_MOCK=False + 清计数
|
|
monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun")
|
|
|
|
def _boom(phone, code):
|
|
raise SmsError("aliyun down", status_code=503)
|
|
|
|
monkeypatch.setattr(aliyun, "_call_check", _boom)
|
|
r = client.post("/api/v1/auth/sms/login", json={"phone": "13812345678", "code": "1234"})
|
|
assert r.status_code == 503, r.text
|