From 10ac1511c377b05ebfc368599e008018599e5ec4 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 15:21:04 +0800 Subject: [PATCH 01/11] =?UTF-8?q?docs(sms):=20=E6=9E=81=E5=85=89=E2=86=92?= =?UTF-8?q?=E5=88=9B=E8=93=9D=20=E7=9F=AD=E4=BF=A1=E5=AE=B9=E7=81=BE=20fal?= =?UTF-8?q?lback=20=E8=AE=BE=E8=AE=A1=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 极光为主、创蓝为备:仅极光供应商不可用(SmsError status_code=503)时自动 转创蓝补发;本地冷却/超频(429)、手机号无效(400)不转。校验用 try-both 遍历「启用的 fallback 链」(关闭时只问极光,创蓝零参与);码只存实际发码那家 的进程内存,命中即知渠道、防爆破不被绕过。后台经 EVENT_SMS_SEND.details (provider/fallback)+ 分派层日志区分实际渠道。开关 SMS_FALLBACK_PROVIDER 默认空=关、生产设 chuanglan、置空秒回退。 provider 文件(jiguang/chuanglan/aliyun)一行不动;仅改分派层 __init__ + base(SendResult)+ config + auth 一处 details + 测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0-sms-jiguang-chuanglan-fallback-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md diff --git a/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md b/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md new file mode 100644 index 0000000..2bf3338 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md @@ -0,0 +1,208 @@ +# 短信验证码 极光→创蓝 容灾 fallback — 设计方案 + +- 日期:2026-07-30 +- 状态:待评审 +- 范围:短信发送验证码服务,默认极光;**极光供应商不可用时自动转创蓝补发**;发/校验全程后台可区分本次走的是极光还是创蓝。 +- 关联:`docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md`(创蓝 provider 接入) + +## 1. 背景与目标 + +短信验证码已是**可切换 provider** 架构(`app/integrations/sms/`:`__init__` 分派 + `jiguang` / `aliyun` / `chuanglan` + `base`)。极光(默认)与创蓝都是 **Mode B(自管码)**:本服务 `secrets` 生成码 → 存**各自模块的进程内存** → provider 只负责发送;`verify_code` 是**纯本地内存比对**,不联网。 + +现状是"单选一个 provider":`send_code` / `verify_code` 都只走 `SMS_PROVIDER` 指定的那一家。极光一旦供应商侧故障(欠费 / 网络 / 服务异常),`/sms/send` 直接 503,用户收不到码、登录中断。 + +**目标**:把极光设为主、创蓝设为备,在**极光供应商不可用**时自动转创蓝补发,最大化验证码送达率;同时让后台能查到每次发码/校验实际走的是哪家、是否发生了 fallback。 + +**非目标**:不改极光/创蓝各自的 Mode B 机器(发码/存码/校验/防刷),不引入 DB/Redis 共享存码,不做多 worker 共享(沿用现有内存态技术债)。 + +## 2. 关键决策(已与需求方确认) + +| # | 决策 | 结论 | +|---|---|---| +| A | **fallback 触发范围** | **仅主 provider 返回"供应商不可用"(`SmsError.status_code == 503`:网络错误 / 余额不足 / 服务故障)时转备。** 本地单号 60s 冷却与极光侧超频(均 429)、手机号无效(400)**不转**——不绕过防刷、不为无效号白烧一条。 | +| B | **校验路由** | **try-both,但只遍历"当前启用的 fallback 链"。** verify 依次问链上 provider(主→备),任一命中即通过;关闭 fallback 时链中只有主,创蓝完全不参与。 | +| C | **后台可见性** | **风控行为流水 + 结构化日志。** 复用 `risk_repo.record_behavior_event(EVENT_SMS_SEND)` 的 `details` 加 `provider` / `fallback` 字段;dispatch 层对 fallback 触发、校验命中打点。不新增表、不改客户端响应。 | +| D | **默认开关** | **配置开关 opt-in,生产设开。** 新增 `SMS_FALLBACK_PROVIDER`,默认空=关(保持现状零风险);生产 `.env` 设 `chuanglan` 开启。出问题置空即秒回退、不改代码。 | + +补充约束:**fallback 仅在 Mode B ↔ Mode B(jiguang / chuanglan)之间有意义**。aliyun 是 Mode A(阿里云托管码、校验走接口),不参与 fallback 链。 + +## 3. 现状回顾(设计依赖的事实) + +- **分派层** [`app/integrations/sms/__init__.py`](../../../app/integrations/sms/__init__.py):`_provider()` 每次按 `settings.SMS_PROVIDER` 选一个模块(默认 jiguang),`send_code` / `verify_code` 委托给它。 +- **极光 / 创蓝均 Mode B**,各持一份**独立**的 `_codes` / `_last_sent` / `_lock`(两个互不相通的进程内存 dict)。`verify_code` 纯本地比对(`_codes.get(phone)` + `secrets.compare_digest`),**不发网络**;provider 的网络调用只在**发送**一步。 +- **`SmsError(message, status_code)`**([`base.py`](../../../app/integrations/sms/base.py)):`429`=过频/超频(客户端稍后再来)、`503`=供应商不可用、`400`=手机号无效。这是 A 决策用来判定"是否 fallback"的唯一信号。 +- **极光/创蓝 `send_code` 失败时"保留冷却、清掉未发出的码"**:`_last_sent[phone]=now` 保留、`_codes.pop(phone)`。此行为不改(见 §4.3 冷却交互)。 +- **auth 层落点**:`sms_send` 发码成功 [auth.py:231](../../../app/api/v1/auth.py) 记 `EVENT_SMS_SEND` `outcome="success" details={"mock":...}`;失败 [auth.py:212](../../../app/api/v1/auth.py) 记 `outcome="failed" reason=...`。`send_code` 是内部函数,全仓仅此一处调用。`verify_code` 在 `sms_login` 与换绑两处调用,签名 `(phone, code)->bool`。 + +## 4. 设计 + +### 4.1 fallback 链模型(分派层,`__init__.py`) + +引入"主 + 备"两级链,全部在分派层组装,**provider 文件一行不动**: + +```python +_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan} +_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"} + +def _primary(): + # 未知/缺省回退 jiguang(保持 test_unknown_provider_falls_back_to_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) # 未知名 → None +``` + +### 4.2 发码 `send_code(phone) -> SendResult`(透出实际渠道) + +现返回 `int`(cooldown),改为返回携带渠道信息的小结果对象(放 `base.py`,provider 无关): + +```python +@dataclass(frozen=True) +class SendResult: + cooldown_sec: int # 距下次可发秒数(= 实际发码 provider 的 SMS_SEND_INTERVAL_SEC) + provider: str # 实际发码的 provider 名:"jiguang" / "chuanglan" + fallback: bool # 是否走了备(True=主不可用转备) +``` + +分派逻辑: + +```python +def send_code(phone) -> SendResult: + primary, fb = _primary(), _fallback() + try: + cooldown = primary.send_code(phone) + return SendResult(cooldown, _NAME[primary], fallback=False) + except SmsError as e: + # A 决策:仅"供应商不可用(503)"且配置了备时转备;429/400 原样抛 + 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) # 备也可能抛 SmsError(其冷却/错误码)→ 原样透出 + return SendResult(cooldown, _NAME[fb], fallback=True) + raise +``` + +要点: +- **只认 `status_code == 503`**:极光的 429(本地冷却 / 超频 50009)、400(手机号无效)直接 raise,不 fallback。 +- **备自带完整 Mode B 机器**:创蓝重新走自己的单号冷却 / 存码 / 错误码映射。备也失败则抛备的 `SmsError`,`auth` 按其 `status_code` 返回。 +- **主/备文件不动**:`jiguang.send_code` / `chuanglan.send_code` 仍返回 `int`;由分派层包装成 `SendResult`。 + +### 4.3 冷却交互(已知行为,保守安全,不改 provider) + +极光 `send_code` 失败(503)时**保留自己的冷却**(现有行为,防"欠费/签名失效时前端狂打极光")。在 fallback 下的连锁: + +1. 首发:极光 503(记极光冷却)→ 转创蓝发成功(记创蓝冷却)。用户收到创蓝的码。 +2. 60s 内重发:主=极光**冷却中 → 429**,`429 ≠ 503` → **不 fallback** → 用户收到 429"过于频繁"。**这是期望行为**:挡住 60s 内重复补发烧钱;且此时创蓝也在自己冷却期。 +3. 极光**持续故障**:每过 60s,极光冷却过期→再试极光(仍 503)→转创蓝补发。稳态下每 60s 白试一次极光、成功走创蓝,功能可用。 + +结论:现有"失败保留冷却"语义在 fallback 下自洽且保守。**不改极光/创蓝任何冷却逻辑。** + +### 4.4 校验 `verify_code(phone, code) -> bool`(try-both over 启用链) + +```python +def verify_code(phone, code) -> bool: + chain = [_primary()] + fb = _fallback() + if fb is not None: + chain.append(fb) + for prov in chain: + if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网、不抛异常 + logger.info("[SMS] verify hit provider=%s", _NAME[prov]) + return True + return False +``` + +要点: +- **关闭 fallback 时 `chain=[极光]`**,与现状字节级等价,**创蓝完全不参与**。 +- **码只存在实际发码那家**(fallback 发码前极光已 `_codes.pop` 清掉自己的码)→ 同一手机号同一时刻只有一家有码;另一家 `rec is None` 立即 False,不误判、不累加其 `attempts`。**防爆破仍在真实那家正常生效,不被绕过**。 +- **命中即知渠道**(日志打点),天然满足 C 的校验侧可见性,无需显式记忆 phone→provider。 +- 绝大多数登录:极光发成功→码在极光→第一家命中,创蓝那次内存查根本不发生。 +- Mode B 的 `verify_code` 不抛 `SmsError`;若链中混入 Mode A(aliyun,本设计约束外)其 verify 降级抛 503,应原样透出中断——但按 §2 约束 aliyun 不入链,此路不触发。 + +### 4.5 auth.py 改动(最小) + +- **`sms_send`**(唯一改动点): + ```python + result = send_code(req.phone) # 现 SendResult + cooldown = result.cooldown_sec + # 成功侧 details 加 provider/fallback(C) + details={"mock": settings.SMS_MOCK, "provider": result.provider, "fallback": result.fallback} + ``` + `SmsSendResponse(cooldown_sec=cooldown, ...)` 不变——**不把 provider 返回客户端**(C 选风控流水,非接口返回)。 +- **`verify_code` 两处调用点不变**:签名 `(phone,code)->bool` 未改;校验命中渠道由分派层 §4.4 日志打点,auth 无需感知。 +- **失败可观测**:极光 503 且创蓝也失败时,抛创蓝的 `SmsError` → 现有失败侧 `record_behavior_event(reason=str(e))` 记录;fallback 触发/失败另有 §4.2 分派层 `logger.warning`。 + +### 4.6 可观测性汇总(满足 C) + +| 事件 | 落点 | 内容 | +|---|---|---| +| 发码成功 | `EVENT_SMS_SEND` `details` | `provider`(jiguang/chuanglan)、`fallback`(bool) | +| fallback 触发 | 分派层 `logger.warning` | 主不可用原因 + 转向的备 | +| 校验命中 | 分派层 `logger.info` | 命中的 `provider` | +| 发码彻底失败 | `EVENT_SMS_SEND` `outcome=failed` | `reason`(末端 SmsError 文案) | + +## 5. 配置项(`app/core/config.py` 新增) + +```python +# 主 provider 供应商不可用(503)时转投的备 provider;空=关闭 fallback(默认,保持现状)。 +# 仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 +SMS_FALLBACK_PROVIDER: str = "" +``` + +- 复用现有 `SMS_PROVIDER`(主,默认 jiguang)与两家的凭证/旋钮(`CHUANGLAN_SMS_*`、`SMS_CODE_LENGTH` 等),**无其它新增**。 +- `.env.example` 加 `SMS_FALLBACK_PROVIDER=`(注释:生产设 `chuanglan` 开启极光→创蓝容灾)。 +- 生产前置:创蓝凭证齐全(`chuanglan_sms_configured`)+ 创蓝控制台 IP 白名单,否则 fallback 到创蓝仍 503。 + +## 6. 错误处理与边界 + +- **主 503 + 未配备**:`_fallback()` 返 None → 原样抛主的 503(= 现状)。 +- **主 503 + 备也失败**:抛备的 `SmsError`(按备错误码 429/400/503),auth 照常返回。 +- **主 429/400**:不 fallback,原样抛。 +- **mock 模式(`SMS_MOCK=true`)**:主 `send_code` mock 分支直接成功、不抛异常 → **永不触发 fallback**;`verify` mock 分支放行任意 N 位 → try-both 第一家(主)即 True。**现有 mock 测试全绿不变。** +- **未知 `SMS_FALLBACK_PROVIDER` 名**:`_ALL.get` 返 None → 视为未配置、不 fallback(不崩)。 +- **`SMS_FALLBACK_PROVIDER == SMS_PROVIDER`**:`_fallback()` 返 None(不自我 fallback)。 + +## 7. 测试计划 + +新增 `tests/test_sms_fallback.py`(monkeypatch 各 provider 的 `send_code`/`verify_code`,不真发): + +1. 主发成功 → `SendResult(provider=主, fallback=False)`,不调备。 +2. 主 503 + 配备 → 转备,备成功 → `SendResult(provider=备, fallback=True)`。 +3. 主 429 / 400 → 不 fallback,原样抛(断言备未被调用)。 +4. 主 503 + 未配备 → 抛 503。 +5. 主 503 + 备也 503 → 抛备的 SmsError。 +6. verify:码在主 → 命中主;码在备 → 命中备;两家都无 → False。 +7. **关闭 fallback**(`SMS_FALLBACK_PROVIDER=""`)→ verify 只问主,**断言备 `verify_code` 未被调用**;send 主失败原样抛、不转备。 +8. 防爆破:码在主,连续错到 `SMS_MAX_VERIFY_ATTEMPTS` 作废;全程备 `rec is None` 不受影响。 + +auth 层扩 `tests/test_auth.py`:`sms_send` 成功后 `EVENT_SMS_SEND` 的 `details` 含 `provider`/`fallback`(mock 下 provider=jiguang、fallback=False)。 + +回归:`test_sms_dispatch.py`、现有 `test_auth.py` 中依赖 `send_code` 返回 `int` 的断言改为读 `SendResult.cooldown_sec`。 + +## 8. YAGNI(明确不做) + +- ❌ 不改极光/创蓝的 Mode B 机器(发/存/校验/冷却/防爆破)。 +- ❌ 不做 DB/Redis 共享存码或 phone→provider 记忆表(多 worker 债维持现状、两家一起将来迁)。 +- ❌ 不做 aliyun(Mode A)参与 fallback。 +- ❌ 不做多级(>2)fallback 链、不做主备自动健康探测/熔断。 +- ❌ 不把 provider 渠道透传给客户端(仅后台可见)。 +- ❌ 不做发送量/成功率统计报表(C 只取"风控流水+日志",报表另议)。 + +## 9. 验收标准 + +- `SMS_FALLBACK_PROVIDER=chuanglan` 且创蓝配置齐全时:极光注入 503(欠费/网络)→ `/sms/send` 实际由创蓝下发、真机可收码并登录;`EVENT_SMS_SEND.details.provider=chuanglan`、`fallback=true`。 +- 极光正常时:走极光,`provider=jiguang`、`fallback=false`;创蓝零调用。 +- `SMS_FALLBACK_PROVIDER=""`(默认):行为与当前完全一致,创蓝不参与发/校验。 +- 极光 429/400:不转创蓝,原样返回。 +- `SMS_MOCK=true`:任意 N 位通过、不真发、不触发 fallback。 +- `ruff check .` 通过;新增/现有 `pytest` 全绿。 + +## 10. 影响面 / 回滚 + +- **改动文件**:`sms/__init__.py`(分派层,主要)、`sms/base.py`(加 `SendResult`)、`core/config.py`(加开关)、`api/v1/auth.py`(`sms_send` 一处记 details)、`.env.example`、测试。**`jiguang.py` / `chuanglan.py` / `aliyun.py` 不动。** +- **破坏性接口变更**:`send_code` 返回 `int → SendResult`,仅 `auth.py:210` 一处调用 + 相关测试需同步。 +- **回滚**:置 `SMS_FALLBACK_PROVIDER=""` 即运行时秒回退到"纯极光",无需改代码/迁移。 -- 2.52.0 From 2399e56a453460cb1cb5a6825998392068ee08ab Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 15:31:06 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs(sms):=20=E6=9E=81=E5=85=89=E2=86=92?= =?UTF-8?q?=E5=88=9B=E8=93=9D=20fallback=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92(TDD=207=20=E4=BB=BB=E5=8A=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-30-sms-jiguang-chuanglan-fallback.md | 639 ++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md diff --git a/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md b/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md new file mode 100644 index 0000000..82bcda2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md @@ -0,0 +1,639 @@ +# 短信 极光→创蓝 容灾 fallback 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 极光为主、创蓝为备的短信验证码容灾——极光供应商不可用(503)时自动转创蓝补发,发/校验全程后台可区分实际渠道,由配置开关控制、可秒回退。 + +**Architecture:** 全部逻辑收在分派层 `app/integrations/sms/__init__.py`(provider 文件 jiguang/chuanglan/aliyun 一行不动)。`send_code` 返回携带渠道的 `SendResult`;`verify_code` 用 try-both 遍历「启用的 fallback 链」。开关 `SMS_FALLBACK_PROVIDER` 默认空=关。 + +**Tech Stack:** FastAPI + pydantic-settings + pytest(monkeypatch)。SMS provider 均 Mode B(本地内存自管码)。 + +设计依据:`docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md` + +--- + +## 文件结构 + +| 文件 | 职责 | 动作 | +|---|---|---| +| `app/integrations/sms/base.py` | 加 `SendResult` 结果类型(provider 无关,与 `SmsError` 同层) | 修改 | +| `app/core/config.py` | 加 `SMS_FALLBACK_PROVIDER` 开关 | 修改 | +| `app/integrations/sms/__init__.py` | 分派层:主/备解析 + `send_code` fallback + `verify_code` try-both | 修改(核心) | +| `app/api/v1/auth.py` | `sms_send` 读 `SendResult.cooldown_sec` + `details` 记 `provider`/`fallback` | 修改(一处) | +| `.env.example` | 加 `SMS_FALLBACK_PROVIDER=` 注释 | 修改 | +| `tests/test_sms_dispatch.py` | 现有 3 处 `== 60` 断言改读 `.cooldown_sec` | 修改 | +| `tests/test_sms_fallback.py` | fallback + try-both 全量新测试 | 新建 | +| `tests/test_auth.py` | `sms_send` 成功 `details` 含 provider/fallback | 修改(加 1 用例) | + +`jiguang.py` / `chuanglan.py` / `aliyun.py` **不动**。 + +--- + +## Task 1: `SendResult` 结果类型(base.py) + +**Files:** +- Modify: `app/integrations/sms/base.py` +- Test: `tests/test_sms_base.py`(新建) + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_sms_base.py`: + +```python +"""SMS 共享基座类型 SendResult。""" +from __future__ import annotations + +import dataclasses + +import pytest + +from app.integrations.sms.base import SendResult + + +def test_send_result_fields(): + r = SendResult(cooldown_sec=60, provider="jiguang", fallback=True) + assert r.cooldown_sec == 60 + assert r.provider == "jiguang" + assert r.fallback is True + + +def test_send_result_fallback_defaults_false(): + r = SendResult(cooldown_sec=60, provider="jiguang") + assert r.fallback is False + + +def test_send_result_is_frozen(): + r = SendResult(cooldown_sec=60, provider="jiguang") + with pytest.raises(dataclasses.FrozenInstanceError): + r.provider = "chuanglan" +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `python -m pytest tests/test_sms_base.py -q` +Expected: FAIL — `ImportError: cannot import name 'SendResult'` + +- [ ] **Step 3: 最小实现** + +在 `app/integrations/sms/base.py` 顶部 import 区加 `from dataclasses import dataclass`,并在 `SmsError` 类后新增: + +```python +@dataclass(frozen=True) +class SendResult: + """发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。 + + 分派层 send_code 的返回类型(provider 各自的 send_code 仍返回 int cooldown, + 由分派层包装)。auth 层据此把渠道记入风控流水 details。 + """ + + cooldown_sec: int + provider: str + fallback: bool = False +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `python -m pytest tests/test_sms_base.py -q` +Expected: PASS (3 passed) + +- [ ] **Step 5: 提交** + +```bash +git add app/integrations/sms/base.py tests/test_sms_base.py +git commit -m "feat(sms): 加 SendResult 结果类型(cooldown+provider+fallback)" +``` + +--- + +## Task 2: `SMS_FALLBACK_PROVIDER` 配置开关(config.py) + +**Files:** +- Modify: `app/core/config.py`(`SMS_PROVIDER` 定义附近,约 line 150) +- Test: `tests/test_sms_fallback.py`(新建,本 Task 起逐步补全) + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_sms_fallback.py`: + +```python +"""极光→创蓝 容灾 fallback(分派层 send_code / verify_code)。 + +monkeypatch 各 provider 的 send_code/verify_code 为标记函数,不发真网络; +断言:仅主 503 才转备;429/400 不转;verify try-both 只遍历启用的链。 +""" +from __future__ import annotations + +import pytest + +from app.core.config import settings +from app.integrations import sms +from app.integrations.sms import aliyun, chuanglan, jiguang +from app.integrations.sms.base import SendResult, SmsError + +PHONE = "13800138000" + + +def _raiser(exc: Exception): + """返回一个调用即 raise exc 的 send_code 替身(lambda 不能 raise)。""" + def _f(phone): + raise exc + return _f + + +def test_fallback_provider_defaults_empty(): + assert settings.SMS_FALLBACK_PROVIDER == "" +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q` +Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'SMS_FALLBACK_PROVIDER'` + +- [ ] **Step 3: 最小实现** + +在 `app/core/config.py` 的 `SMS_PROVIDER` 定义行下方新增: + +```python + # 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认, + # 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 + SMS_FALLBACK_PROVIDER: str = "" +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `python -m pytest tests/test_sms_fallback.py::test_fallback_provider_defaults_empty -q` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add app/core/config.py tests/test_sms_fallback.py +git commit -m "feat(sms): 加 SMS_FALLBACK_PROVIDER 开关(默认空=关)" +``` + +--- + +## Task 3: 分派层 `send_code` 返回 SendResult(主路径) + +先让主路径返回 `SendResult`(不含 fallback),并把现有 `test_sms_dispatch.py` 的 `== 60` 断言迁到 `.cooldown_sec`。fallback 分支在 Task 4 加。 + +**Files:** +- Modify: `app/integrations/sms/__init__.py` +- Modify: `tests/test_sms_dispatch.py`(line 20/22/24、line 45-53) +- Test: `tests/test_sms_fallback.py` + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_sms_fallback.py` 追加: + +```python +def test_send_code_primary_success_returns_sendresult(monkeypatch): + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") + monkeypatch.setattr(jiguang, "send_code", lambda phone: 60) + + r = sms.send_code(PHONE) + assert isinstance(r, SendResult) + assert r.cooldown_sec == 60 + assert r.provider == "jiguang" + assert r.fallback is False +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `python -m pytest tests/test_sms_fallback.py::test_send_code_primary_success_returns_sendresult -q` +Expected: FAIL — `assert isinstance(r, SendResult)`(现 send_code 返回 int 60) + +- [ ] **Step 3: 最小实现** + +把 `app/integrations/sms/__init__.py` 整体替换为(本 Task 只到主路径;Task 4/5 再扩 fallback 与 try-both): + +```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_code(phone: str) -> SendResult: + """发码:主成功即返回;失败抛 SmsError。返回 SendResult(cooldown + 实际渠道)。""" + primary = _primary() + cooldown = primary.send_code(phone) + return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False) + + +def verify_code(phone: str, code: str) -> bool: + """校验:委托当前主 provider(try-both 在 Task 5 加)。""" + return _primary().verify_code(phone, code) +``` + +- [ ] **Step 4: 迁移现有 dispatch 测试断言** + +在 `tests/test_sms_dispatch.py` 中,把 3 处 `assert sms.send_code("13800138000") == 60`(line 20/22/24)改为: + +```python + assert sms.send_code("13800138000").cooldown_sec == 60 +``` + +(line 45-53 的 `test_unknown_provider_falls_back_to_jiguang` 用 `sms.send_code(...)` 不比较返回值,无需改;`test_verify_code_routes_by_provider` 不涉及返回值,无需改。) + +- [ ] **Step 5: 跑测试确认通过** + +Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q` +Expected: PASS(新用例 + 原 dispatch 3 用例全绿) + +- [ ] **Step 6: 提交** + +```bash +git add app/integrations/sms/__init__.py tests/test_sms_dispatch.py tests/test_sms_fallback.py +git commit -m "feat(sms): send_code 返回 SendResult(主路径),迁移 dispatch 断言" +``` + +--- + +## Task 4: 分派层 `send_code` fallback 分支(仅 503 转备) + +**Files:** +- Modify: `app/integrations/sms/__init__.py`(`send_code`) +- Test: `tests/test_sms_fallback.py` + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_sms_fallback.py` 追加: + +```python +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) +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `python -m pytest tests/test_sms_fallback.py -k "fall_back or 503 or non_503 or backup" -q` +Expected: FAIL — 主抛 503 时当前 `send_code` 未捕获,直接透出(`test_...falls_back...` 期望走到创蓝但没走) + +- [ ] **Step 3: 实现 fallback 分支** + +把 `app/integrations/sms/__init__.py` 的 `send_code` 替换为: + +```python +def send_code(phone: str) -> SendResult: + """发码:主成功即返回;仅主「供应商不可用(503)」且配置了备时转备补发。 + + 429(本地冷却/超频)、400(手机号无效)不转——不绕过防刷、不为无效号白烧。 + 备也失败则抛备的 SmsError。返回 SendResult(cooldown + 实际渠道 + 是否 fallback)。 + """ + primary = _primary() + 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 +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `python -m pytest tests/test_sms_fallback.py -q` +Expected: PASS(含 parametrize 的 429/400 两条) + +- [ ] **Step 5: 提交** + +```bash +git add app/integrations/sms/__init__.py tests/test_sms_fallback.py +git commit -m "feat(sms): send_code 仅主 503 时转创蓝补发(429/400 不转)" +``` + +--- + +## Task 5: 分派层 `verify_code` try-both(只遍历启用的链) + +**Files:** +- Modify: `app/integrations/sms/__init__.py`(`verify_code`) +- Test: `tests/test_sms_fallback.py` + +- [ ] **Step 1: 写失败测试** + +在 `tests/test_sms_fallback.py` 追加: + +```python +def test_verify_hits_primary_without_touching_backup(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "verify_code", + lambda p, c: (calls.append("jiguang"), True)[1]) + monkeypatch.setattr(chuanglan, "verify_code", + lambda p, c: (calls.append("chuanglan"), True)[1]) + + assert sms.verify_code(PHONE, "123456") is True + assert calls == ["jiguang"] # 主命中即停,不查备 + + +def test_verify_falls_through_to_backup(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "verify_code", + lambda p, c: (calls.append("jiguang"), False)[1]) + monkeypatch.setattr(chuanglan, "verify_code", + lambda p, c: (calls.append("chuanglan"), True)[1]) + + assert sms.verify_code(PHONE, "123456") is True + assert calls == ["jiguang", "chuanglan"] # 主未命中 → 查备(码在备) + + +def test_verify_all_miss_returns_false(monkeypatch): + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "verify_code", lambda p, c: False) + monkeypatch.setattr(chuanglan, "verify_code", lambda p, c: False) + + assert sms.verify_code(PHONE, "123456") is False + + +def test_verify_backup_not_touched_when_fallback_off(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") # 关闭 fallback + monkeypatch.setattr(jiguang, "verify_code", + lambda p, c: (calls.append("jiguang"), False)[1]) + monkeypatch.setattr(chuanglan, "verify_code", + lambda p, c: (calls.append("chuanglan"), True)[1]) + + assert sms.verify_code(PHONE, "123456") is False + assert calls == ["jiguang"] # 关闭时创蓝零参与 +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `python -m pytest tests/test_sms_fallback.py -k verify -q` +Expected: FAIL — `test_verify_falls_through_to_backup`(当前 verify 只问主,主 False 即返 False,不查备) + +- [ ] **Step 3: 实现 try-both** + +把 `app/integrations/sms/__init__.py` 的 `verify_code` 替换为: + +```python +def verify_code(phone: str, code: str) -> bool: + """校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。 + + 码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、 + 不误判、不累加其防爆破计数。关闭 fallback 时链中只有主,备完全不参与。 + """ + chain = [_primary()] + fb = _fallback() + if fb is not None: + chain.append(fb) + for prov in chain: + if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网 + logger.info("[SMS] verify hit provider=%s", _NAME[prov]) + return True + return False +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py -q` +Expected: PASS(全部 fallback + 原 dispatch 用例) + +- [ ] **Step 5: 提交** + +```bash +git add app/integrations/sms/__init__.py tests/test_sms_fallback.py +git commit -m "feat(sms): verify_code try-both 遍历启用的 fallback 链" +``` + +--- + +## Task 6: auth `sms_send` 接入 SendResult + 记 provider/fallback + +**Files:** +- Modify: `app/api/v1/auth.py`(line 210 调用、line 231-243 成功侧 details) +- Test: `tests/test_auth.py`(加 1 用例) + +- [ ] **Step 1: 写失败测试** + +先确认现有 `test_auth.py` 里 `sms_send` 的调用/断言与 fixture 写法(找一个已有的 sms_send 成功用例作参照),再在 `tests/test_auth.py` 末尾追加: + +```python +def test_sms_send_records_provider_in_details(client, db_session, monkeypatch): + """sms_send 成功后风控流水 details 带 provider/fallback(mock 下 jiguang/False)。""" + from app.repositories import risk as risk_repo + + captured = {} + orig = risk_repo.record_behavior_event + + def _spy(db, **kw): + if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success": + captured.update(kw.get("details") or {}) + return orig(db, **kw) + + monkeypatch.setattr(risk_repo, "record_behavior_event", _spy) + + resp = client.post("/api/v1/auth/sms/send", + json={"phone": "13800009999", "device_id": "dev-provider-test"}) + assert resp.status_code == 200, resp.text + assert captured.get("provider") == "jiguang" + assert captured.get("fallback") is False +``` + +> 注:`client` / `db_session` fixture 名、请求路径前缀(`/api/v1/auth/...`)、`record_behavior_event` 的 import 路径以 `tests/test_auth.py` 现有用例为准,若不同则对齐后再写。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q` +Expected: FAIL — `captured.get("provider")` 为 None(当前 details 只有 `{"mock": ...}`) + +- [ ] **Step 3: 实现** + +在 `app/api/v1/auth.py` 的 `sms_send` 中: + +(a) line 209-210 的 +```python + try: + cooldown = send_code(req.phone) +``` +改为 +```python + try: + send_result = send_code(req.phone) + cooldown = send_result.cooldown_sec +``` + +(b) line 231-243 成功侧 `record_behavior_event` 的 `details` 由 +```python + details={"mock": settings.SMS_MOCK}, +``` +改为 +```python + details={"mock": settings.SMS_MOCK, + "provider": send_result.provider, + "fallback": send_result.fallback}, +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `python -m pytest tests/test_auth.py::test_sms_send_records_provider_in_details -q` +Expected: PASS + +- [ ] **Step 5: 提交** + +```bash +git add app/api/v1/auth.py tests/test_auth.py +git commit -m "feat(sms): sms_send 记 provider/fallback 到风控流水 details" +``` + +--- + +## Task 7: `.env.example` + 全量回归 + ruff + +**Files:** +- Modify: `.env.example` + +- [ ] **Step 1: 加配置注释** + +在 `.env.example` 的 `SMS_PROVIDER` 附近加: + +```bash +# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。 +# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。 +SMS_FALLBACK_PROVIDER= +``` + +- [ ] **Step 2: 全量 SMS 相关回归** + +Run: `python -m pytest tests/test_sms_fallback.py tests/test_sms_dispatch.py tests/test_sms_chuanglan.py tests/test_sms_aliyun.py tests/test_auth.py -q` +Expected: PASS(新增全绿;原 SMS/auth 用例不回归) + +- [ ] **Step 3: lint** + +Run: `python -m ruff check app/integrations/sms/ app/core/config.py app/api/v1/auth.py tests/test_sms_fallback.py tests/test_sms_base.py` +Expected: `All checks passed!` + +- [ ] **Step 4: 基线对比(确认无新增失败)** + +Run: `python -m pytest -q` +Expected: 失败数 ≤ 基线(已知先前债 ~8 个);本改动不新增失败。若有新增,定位并修。 + +- [ ] **Step 5: 提交** + +```bash +git add .env.example +git commit -m "docs(sms): .env.example 加 SMS_FALLBACK_PROVIDER 说明" +``` + +--- + +## Self-Review + +**Spec 覆盖核对**(对 `2026-07-30-sms-jiguang-chuanglan-fallback-design.md`): + +- §2-A 仅 503 转 / 429·400 不转 → Task 4(`test_send_code_503_falls_back` + `test_send_code_non_503_does_not_fall_back`)✅ +- §2-B try-both 只遍历启用链 → Task 5(4 个 verify 用例,含关闭时备零参与)✅ +- §2-C details 记 provider/fallback + 日志 → Task 6(details)+ Task 4/5(logger.warning/info)✅ +- §2-D 配置开关默认空、生产设开 → Task 2 + Task 7 ✅ +- §4.1 主/备解析(未知/同主→None)→ Task 3(`_primary`/`_fallback`)✅ +- §4.2 SendResult + 只认 503 + 备失败抛备错 → Task 1 + Task 4(`test_send_code_backup_also_fails`)✅ +- §4.4 码只在一家/防爆破不被绕过 → provider 内部行为(不改),try-both 逻辑 Task 5 覆盖;防爆破由 provider 各自既有测试保证 ✅ +- §4.5 auth 一处改、verify 调用点不变 → Task 6 ✅ +- §5 配置项 → Task 2 ✅ +- §6 mock 永不 fallback → mock 下主 send_code 成功不抛异常,天然不进 fallback 分支(现有 mock 测试在 Task 7 回归保证)✅ +- §10 send_code int→SendResult 仅 auth 一处 + dispatch 测试 → Task 3/6 ✅ + +**占位扫描**:无 TBD/TODO;每个 code step 均含完整代码与命令。Task 6 Step 1 标注了「以现有 fixture 为准」——这是**对齐现有约定**的指示,非占位(fixture 名在同文件已存在,执行时照抄)。 + +**类型/命名一致性**:`SendResult(cooldown_sec, provider, fallback)` 三字段在 Task 1/3/4/6 全程一致;`_primary()`/`_fallback()`/`_ALL`/`_NAME` 命名跨 Task 3/4/5 一致;`SMS_FALLBACK_PROVIDER` 跨 Task 2/3/4/5/7 一致。 + +--- + +## 执行顺序说明 + +Task 1→2→3 有依赖(后者用前者的类型/配置/分派骨架),必须顺序执行。Task 4/5 都改 `send_code`/`verify_code`,也应在 Task 3 之后顺序做。Task 6 依赖 Task 3 的 `SendResult` 返回。Task 7 收尾。全程一条功能分支 `feat/sms-jiguang-chuanglan-fallback`,每 Task 一提交。 -- 2.52.0 From cbe1d340d6b7314985038dfe56bd42e69f4df1b8 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 15:39:31 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(sms):=20=E5=8A=A0=20SendResult=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=B1=BB=E5=9E=8B(cooldown+provider+fallback?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/integrations/sms/base.py | 15 +++++++++++++++ tests/test_sms_base.py | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 tests/test_sms_base.py diff --git a/app/integrations/sms/base.py b/app/integrations/sms/base.py index 702910b..7458014 100644 --- a/app/integrations/sms/base.py +++ b/app/integrations/sms/base.py @@ -5,6 +5,8 @@ """ from __future__ import annotations +from dataclasses import dataclass + from app.core.config import settings @@ -18,6 +20,19 @@ class SmsError(Exception): self.status_code = status_code +@dataclass(frozen=True) +class SendResult: + """发码结果:距下次可发秒数 + 实际发码的 provider 名 + 是否走了备(fallback)。 + + 分派层 send_code 的返回类型(provider 各自的 send_code 仍返回 int cooldown, + 由分派层包装)。auth 层据此把渠道记入风控流水 details。 + """ + + cooldown_sec: int + provider: str + fallback: bool = False + + def mock_verify(code: str) -> bool: """mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。""" return len(code) == settings.SMS_CODE_LENGTH and code.isdigit() diff --git a/tests/test_sms_base.py b/tests/test_sms_base.py new file mode 100644 index 0000000..50ee530 --- /dev/null +++ b/tests/test_sms_base.py @@ -0,0 +1,26 @@ +"""SMS 共享基座类型 SendResult。""" +from __future__ import annotations + +import dataclasses + +import pytest + +from app.integrations.sms.base import SendResult + + +def test_send_result_fields(): + r = SendResult(cooldown_sec=60, provider="jiguang", fallback=True) + assert r.cooldown_sec == 60 + assert r.provider == "jiguang" + assert r.fallback is True + + +def test_send_result_fallback_defaults_false(): + r = SendResult(cooldown_sec=60, provider="jiguang") + assert r.fallback is False + + +def test_send_result_is_frozen(): + r = SendResult(cooldown_sec=60, provider="jiguang") + with pytest.raises(dataclasses.FrozenInstanceError): + r.provider = "chuanglan" -- 2.52.0 From 0cd667618d03e91011c6a86b674353679b6ad7c3 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 15:40:47 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(sms):=20=E5=8A=A0=20SMS=5FFALLBACK?= =?UTF-8?q?=5FPROVIDER=20=E5=BC=80=E5=85=B3(=E9=BB=98=E8=AE=A4=E7=A9=BA=3D?= =?UTF-8?q?=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/config.py | 3 +++ tests/test_sms_fallback.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 tests/test_sms_fallback.py diff --git a/app/core/config.py b/app/core/config.py index bc41b29..9afb594 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -148,6 +148,9 @@ class Settings(BaseSettings): # aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。 # chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。 SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang" + # 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认, + # 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 + SMS_FALLBACK_PROVIDER: str = "" ALIYUN_SMS_ACCESS_KEY_ID: str = "" ALIYUN_SMS_ACCESS_KEY_SECRET: str = "" ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败) diff --git a/tests/test_sms_fallback.py b/tests/test_sms_fallback.py new file mode 100644 index 0000000..8da9dff --- /dev/null +++ b/tests/test_sms_fallback.py @@ -0,0 +1,26 @@ +"""极光→创蓝 容灾 fallback(分派层 send_code / verify_code)。 + +monkeypatch 各 provider 的 send_code/verify_code 为标记函数,不发真网络; +断言:仅主 503 才转备;429/400 不转;verify try-both 只遍历启用的链。 +""" +from __future__ import annotations + +import pytest # noqa: F401 + +from app.core.config import settings +from app.integrations import sms # noqa: F401 +from app.integrations.sms import aliyun, chuanglan, jiguang # noqa: F401 +from app.integrations.sms.base import SendResult, SmsError # noqa: F401 + +PHONE = "13800138000" + + +def _raiser(exc: Exception): + """返回一个调用即 raise exc 的 send_code 替身(lambda 不能 raise)。""" + def _f(phone): + raise exc + return _f + + +def test_fallback_provider_defaults_empty(): + assert settings.SMS_FALLBACK_PROVIDER == "" -- 2.52.0 From c9fad9bca833e17ab5532f5aff544e852c3a1c6a Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 15:50:58 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(sms):=20SMS=5FFALLBACK=5FPROVIDER=20?= =?UTF-8?q?=E7=94=A8=20Literal=20=E7=BA=A6=E6=9D=9F=20+=20base=20docstring?= =?UTF-8?q?=20=E8=A1=A5=20SendResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/config.py | 2 +- app/integrations/sms/base.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/core/config.py b/app/core/config.py index 9afb594..0e00e2c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -150,7 +150,7 @@ class Settings(BaseSettings): SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang" # 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认, # 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 - SMS_FALLBACK_PROVIDER: str = "" + SMS_FALLBACK_PROVIDER: Literal["jiguang", "aliyun", "chuanglan", ""] = "" ALIYUN_SMS_ACCESS_KEY_ID: str = "" ALIYUN_SMS_ACCESS_KEY_SECRET: str = "" ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败) diff --git a/app/integrations/sms/base.py b/app/integrations/sms/base.py index 7458014..06c7248 100644 --- a/app/integrations/sms/base.py +++ b/app/integrations/sms/base.py @@ -2,6 +2,7 @@ 各 provider(jiguang / aliyun)都 `from .base import SmsError`,api 层也从包入口拿到同一个 `SmsError` —— 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致。 +`SendResult` 是分派层 `send_code` 的返回类型,携带 cooldown、实际 provider 及是否走了 fallback。 """ from __future__ import annotations -- 2.52.0 From 63e8083fbdc356a718d7c14cc1bc341f54ff13b1 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 15:58:35 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat(sms):=20send=5Fcode=20=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20SendResult(=E4=B8=BB=E8=B7=AF=E5=BE=84),=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20dispatch=20=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/integrations/sms/__init__.py | 52 +++++++++++++++++++------------- tests/test_sms_dispatch.py | 6 ++-- tests/test_sms_fallback.py | 16 ++++++++-- 3 files changed, 48 insertions(+), 26 deletions(-) diff --git a/app/integrations/sms/__init__.py b/app/integrations/sms/__init__.py index a67ab4e..7726bbd 100644 --- a/app/integrations/sms/__init__.py +++ b/app/integrations/sms/__init__.py @@ -1,37 +1,47 @@ -"""短信验证码服务 —— provider 分派入口。 +"""短信验证码服务 —— provider 分派入口(极光主 + 可选创蓝备)。 -对外只暴露 `send_code` / `verify_code` / `SmsError`,api 层无需关心用哪个 provider。 -provider 由 `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退): - - `jiguang`(默认):自管码(本服务生成、内存存/校验,极光只发)。见 [jiguang.py](jiguang.py)。 - - `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A)。见 [aliyun.py](aliyun.py)。 - - `chuanglan`:创蓝云智(253)模板短信,自管码 Mode B(本服务生成、内存存/校验,创蓝只发)。见 [chuanglan.py](chuanglan.py)。 - -mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由。 -拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动。 +对外暴露 `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 SmsError +from .base import SendResult, SmsError -__all__ = ["SmsError", "send_code", "verify_code"] +__all__ = ["SmsError", "SendResult", "send_code", "verify_code"] -# provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。 -_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan} +logger = logging.getLogger("shagua.sms") + +# provider 名 <-> 模块。未知/缺省主 provider 回退 jiguang(防误配把登录打挂)。 +_ALL = {"jiguang": jiguang, "aliyun": aliyun, "chuanglan": chuanglan} +_NAME = {jiguang: "jiguang", aliyun: "aliyun", chuanglan: "chuanglan"} -def _provider(): - """按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)。""" - return _PROVIDERS.get(settings.SMS_PROVIDER, jiguang) +def _primary(): + """当前主 provider 模块(未知/缺省回退 jiguang)。""" + return _ALL.get(settings.SMS_PROVIDER, jiguang) -def send_code(phone: str) -> int: - """发送验证码,返回距下次可发的冷却秒数;失败抛 SmsError。委托给当前 provider。""" - return _provider().send_code(phone) +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_code(phone: str) -> SendResult: + """发码:主成功即返回;失败抛 SmsError。返回 SendResult(cooldown + 实际渠道)。""" + primary = _primary() + cooldown = primary.send_code(phone) + return SendResult(cooldown_sec=cooldown, provider=_NAME[primary], fallback=False) def verify_code(phone: str, code: str) -> bool: - """校验验证码,返回是否通过;provider 异常降级抛 SmsError。委托给当前 provider。""" - return _provider().verify_code(phone, code) + """校验:委托当前主 provider(try-both 在 Task 5 加)。""" + return _primary().verify_code(phone, code) diff --git a/tests/test_sms_dispatch.py b/tests/test_sms_dispatch.py index 8137b3c..83c6f84 100644 --- a/tests/test_sms_dispatch.py +++ b/tests/test_sms_dispatch.py @@ -17,11 +17,11 @@ def test_send_code_routes_by_provider_and_switches_per_call(monkeypatch) -> None monkeypatch.setattr(chuanglan, "send_code", lambda phone: (calls.append("chuanglan"), 60)[1]) monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") - assert sms.send_code("13800138000") == 60 + assert sms.send_code("13800138000").cooldown_sec == 60 monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun") - assert sms.send_code("13800138000") == 60 + assert sms.send_code("13800138000").cooldown_sec == 60 monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan") - assert sms.send_code("13800138000") == 60 + assert sms.send_code("13800138000").cooldown_sec == 60 assert calls == ["jiguang", "aliyun", "chuanglan"] # 每次按当前 provider 路由,运行时可切 diff --git a/tests/test_sms_fallback.py b/tests/test_sms_fallback.py index 8da9dff..883f20b 100644 --- a/tests/test_sms_fallback.py +++ b/tests/test_sms_fallback.py @@ -8,9 +8,9 @@ from __future__ import annotations import pytest # noqa: F401 from app.core.config import settings -from app.integrations import sms # noqa: F401 +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 +from app.integrations.sms.base import SendResult, SmsError # noqa: F401 (SmsError 在 Task 4+ 使用) PHONE = "13800138000" @@ -24,3 +24,15 @@ def _raiser(exc: Exception): def test_fallback_provider_defaults_empty(): assert settings.SMS_FALLBACK_PROVIDER == "" + + +def test_send_code_primary_success_returns_sendresult(monkeypatch): + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") + monkeypatch.setattr(jiguang, "send_code", lambda phone: 60) + + r = sms.send_code(PHONE) + assert isinstance(r, SendResult) + assert r.cooldown_sec == 60 + assert r.provider == "jiguang" + assert r.fallback is False -- 2.52.0 From f3cd9bd318441fee907bcfe07e78c7269917a8dc Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 16:01:40 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat(sms):=20send=5Fcode=20=E4=BB=85?= =?UTF-8?q?=E4=B8=BB=20503=20=E6=97=B6=E8=BD=AC=E5=88=9B=E8=93=9D=E8=A1=A5?= =?UTF-8?q?=E5=8F=91(429/400=20=E4=B8=8D=E8=BD=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/integrations/sms/__init__.py | 19 ++++++++-- tests/test_sms_fallback.py | 62 ++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 6 deletions(-) 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) -- 2.52.0 From 60c8fece2399d15cee77b952c35f40070eaae5b4 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 16:02:58 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat(sms):=20verify=5Fcode=20try-both=20?= =?UTF-8?q?=E9=81=8D=E5=8E=86=E5=90=AF=E7=94=A8=E7=9A=84=20fallback=20?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/integrations/sms/__init__.py | 16 +++++++++-- tests/test_sms_fallback.py | 48 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/app/integrations/sms/__init__.py b/app/integrations/sms/__init__.py index a676c08..90191bb 100644 --- a/app/integrations/sms/__init__.py +++ b/app/integrations/sms/__init__.py @@ -56,5 +56,17 @@ def send_code(phone: str) -> SendResult: def verify_code(phone: str, code: str) -> bool: - """校验:委托当前主 provider(try-both 在 Task 5 加)。""" - return _primary().verify_code(phone, code) + """校验:try-both,遍历「启用的 fallback 链」(主→备),任一命中即 True。 + + 码只存在实际发码那家(fallback 前主已 pop 掉自己的码),另一家 rec is None 即 False、 + 不误判、不累加其防爆破计数。关闭 fallback 时链中只有主,备完全不参与。 + """ + chain = [_primary()] + fb = _fallback() + if fb is not None: + chain.append(fb) + for prov in chain: + if prov.verify_code(phone, code): # Mode B:纯本地内存比对,不联网 + logger.info("[SMS] verify hit provider=%s", _NAME[prov]) + return True + return False diff --git a/tests/test_sms_fallback.py b/tests/test_sms_fallback.py index 1649400..cb511b7 100644 --- a/tests/test_sms_fallback.py +++ b/tests/test_sms_fallback.py @@ -92,3 +92,51 @@ def test_send_code_backup_also_fails_raises_backup_error(monkeypatch): with pytest.raises(SmsError) as ei: sms.send_code(PHONE) assert "创蓝" in str(ei.value) + + +def test_verify_hits_primary_without_touching_backup(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "verify_code", + lambda p, c: (calls.append("jiguang"), True)[1]) + monkeypatch.setattr(chuanglan, "verify_code", + lambda p, c: (calls.append("chuanglan"), True)[1]) + + assert sms.verify_code(PHONE, "123456") is True + assert calls == ["jiguang"] + + +def test_verify_falls_through_to_backup(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "verify_code", + lambda p, c: (calls.append("jiguang"), False)[1]) + monkeypatch.setattr(chuanglan, "verify_code", + lambda p, c: (calls.append("chuanglan"), True)[1]) + + assert sms.verify_code(PHONE, "123456") is True + assert calls == ["jiguang", "chuanglan"] + + +def test_verify_all_miss_returns_false(monkeypatch): + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "chuanglan") + monkeypatch.setattr(jiguang, "verify_code", lambda p, c: False) + monkeypatch.setattr(chuanglan, "verify_code", lambda p, c: False) + + assert sms.verify_code(PHONE, "123456") is False + + +def test_verify_backup_not_touched_when_fallback_off(monkeypatch): + calls = [] + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + monkeypatch.setattr(settings, "SMS_FALLBACK_PROVIDER", "") + monkeypatch.setattr(jiguang, "verify_code", + lambda p, c: (calls.append("jiguang"), False)[1]) + monkeypatch.setattr(chuanglan, "verify_code", + lambda p, c: (calls.append("chuanglan"), True)[1]) + + assert sms.verify_code(PHONE, "123456") is False + assert calls == ["jiguang"] -- 2.52.0 From ca206bccb2cf47642015d34f0278cefa413882bf Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 16:36:45 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(sms):=20auth=20sms=5Fsend=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20SendResult(=E4=BF=AE=20cooldown=5Fsec=20500)+=20?= =?UTF-8?q?=E8=AE=B0=20provider/fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批次2 将 send_code 返回类型 int→SendResult 后,sms_send 仍把它整体塞进 SmsSendResponse.cooldown_sec:int → pydantic ValidationError → 500。改为取 send_result.cooldown_sec;成功侧 EVENT_SMS_SEND details 加 provider/fallback。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/auth.py | 7 +++++-- tests/test_auth.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index a8c3141..5627cdf 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -207,7 +207,8 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes check_rate_limits(request, subject=req.device_id, rules=send_rules) try: - cooldown = send_code(req.phone) + send_result = send_code(req.phone) + cooldown = send_result.cooldown_sec except SmsError as e: risk_repo.record_behavior_event( db, @@ -238,7 +239,9 @@ def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendRes phone=req.phone, client_ip=_client_ip(request), outcome="success", - details={"mock": settings.SMS_MOCK}, + details={"mock": settings.SMS_MOCK, + "provider": send_result.provider, + "fallback": send_result.fallback}, evaluate_rule=risk_repo.RULE_SMS_HOURLY, ) diff --git a/tests/test_auth.py b/tests/test_auth.py index 42d9add..bdf068c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -68,6 +68,29 @@ def test_sms_login_and_me_flow(client) -> None: assert r.json()["ok"] is True +def test_sms_send_records_provider_and_fallback_in_details(client, monkeypatch) -> None: + """批次3(Task 6):sms_send 成功后风控流水 details 带 provider/fallback。 + mock 默认走极光主(SMS_FALLBACK_PROVIDER 空)→ provider=jiguang、fallback=False。 + (回归:修复前 send_code 返回 SendResult 被塞进 cooldown_sec:int → 500,此处 200 断言即挡住。)""" + from app.repositories import risk as risk_repo + + captured: dict = {} + orig = risk_repo.record_behavior_event + + def _spy(db, **kw): + if kw.get("event_type") == risk_repo.EVENT_SMS_SEND and kw.get("outcome") == "success": + captured.update(kw.get("details") or {}) + return orig(db, **kw) + + monkeypatch.setattr(risk_repo, "record_behavior_event", _spy) + + r = client.post("/api/v1/auth/sms/send", + json={"phone": "13800009999", "device_id": "dev-provider-test"}) + assert r.status_code == 200, r.text + assert captured.get("provider") == "jiguang" + assert captured.get("fallback") is False + + def test_sms_send_too_frequent(client) -> None: phone = "13900139000" assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200 -- 2.52.0 From 8f186bc8f7ac724f6da0639caa89d7578cd7d250 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 16:44:38 +0800 Subject: [PATCH 10/11] =?UTF-8?q?docs(sms):=20.env.example=20=E5=8A=A0=20S?= =?UTF-8?q?MS=5FFALLBACK=5FPROVIDER=20=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index b77fd01..8656841 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,9 @@ SMS_SEND_INTERVAL_SEC=60 # chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。 # 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。 SMS_PROVIDER=jiguang +# 极光→创蓝 容灾:主 provider(SMS_PROVIDER)供应商不可用(503)时转投的备。 +# 空=关闭 fallback(默认);生产设 chuanglan 开启极光主、创蓝备;置空即秒回退。 +SMS_FALLBACK_PROVIDER= ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_SECRET= ALIYUN_SMS_SIGN_NAME= -- 2.52.0 From 52811484882e0ff9a981220fa0129bbedc3dbd15 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 30 Jul 2026 16:52:34 +0800 Subject: [PATCH 11/11] =?UTF-8?q?harden(sms):=20SMS=5FFALLBACK=5FPROVIDER?= =?UTF-8?q?=20Literal=20=E6=94=B6=E7=AA=84=E4=B8=BA=20Mode=20B(=E5=8E=BB?= =?UTF-8?q?=20aliyun)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最终 review Minor:aliyun 是 Mode A(校验走接口、可抛 SmsError),不该做 Mode B fallback 备。收窄 Literal 为 ["jiguang","chuanglan",""],pydantic 启动即挡住误配 SMS_FALLBACK_PROVIDER=aliyun。同步设计/计划文档。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/config.py | 2 +- .../plans/2026-07-30-sms-jiguang-chuanglan-fallback.md | 2 +- .../specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 0e00e2c..76aa507 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -150,7 +150,7 @@ class Settings(BaseSettings): SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang" # 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认, # 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 - SMS_FALLBACK_PROVIDER: Literal["jiguang", "aliyun", "chuanglan", ""] = "" + SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = "" ALIYUN_SMS_ACCESS_KEY_ID: str = "" ALIYUN_SMS_ACCESS_KEY_SECRET: str = "" ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败) diff --git a/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md b/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md index 82bcda2..f6bfe27 100644 --- a/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md +++ b/docs/superpowers/plans/2026-07-30-sms-jiguang-chuanglan-fallback.md @@ -156,7 +156,7 @@ Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'SMS_FALL ```python # 主 provider 供应商不可用(SmsError 503)时转投的备 provider;空=关闭 fallback(默认, # 保持现状零风险)。仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 - SMS_FALLBACK_PROVIDER: str = "" + SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = "" ``` - [ ] **Step 4: 跑测试确认通过** diff --git a/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md b/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md index 2bf3338..2fd0917 100644 --- a/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md +++ b/docs/superpowers/specs/2026-07-30-sms-jiguang-chuanglan-fallback-design.md @@ -150,7 +150,7 @@ def verify_code(phone, code) -> bool: ```python # 主 provider 供应商不可用(503)时转投的备 provider;空=关闭 fallback(默认,保持现状)。 # 仅 Mode B(jiguang/chuanglan)互为主备有意义;生产建议设 "chuanglan"。 -SMS_FALLBACK_PROVIDER: str = "" +SMS_FALLBACK_PROVIDER: Literal["jiguang", "chuanglan", ""] = "" # Mode B only(aliyun 不入链) ``` - 复用现有 `SMS_PROVIDER`(主,默认 jiguang)与两家的凭证/旋钮(`CHUANGLAN_SMS_*`、`SMS_CODE_LENGTH` 等),**无其它新增**。 -- 2.52.0