From 7a2b7cb8edcb72124d7c14f9b831865a7d21f723 Mon Sep 17 00:00:00 2001 From: guke Date: Sat, 25 Jul 2026 23:51:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=E5=8F=B7=E7=A0=81=E8=AE=A4=E8=AF=81=E7=9F=AD=E4=BF=A1=20provid?= =?UTF-8?q?er=20+=20sms.py=20=E6=8B=86=E4=B8=BA=E5=8F=AF=E5=88=87=E6=8D=A2?= =?UTF-8?q?=20provider=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现有 sms.py(极光自管码)升级为 app/integrations/sms/ 包: - base : SmsError + provider 无关的 mock_verify - jiguang: 原极光自管码逻辑逐字迁入,行为零改动(git 识别为 sms.py 的 rename) - aliyun : 新增阿里云 dypns 号码认证(Mode A:阿里云生成+下发+校验,核验免费) - __init__: 按 SMS_PROVIDER 每次调用路由的分派器(默认 jiguang,可秒切回退) 关键决策: - Mode A:发码 SendSmsVerifyCode(##code## 占位)、校验 CheckSmsVerifyCode(PASS/UNKNOWN); 本服务不再存码 → 消除极光路径「内存存码、多 worker 不共享」技术债。 - 防爆破与极光一致:aliyun 保留 per-phone 失败计数(SMS_MAX_VERIFY_ATTEMPTS),达上限本地作废, 避免两 provider 行为不同致排查困惑(此为 aliyun 路径唯一本地态)。 - 校验降级:阿里云接口异常 → verify_code 抛 SmsError(503),auth 两处 try/except 透出 503(非误报 400)。 - 官方 SDK alibabacloud_dypnsapi20170525;SDK 交互隔离在 _call_send/_call_check(惰性 import + 惰性建 client),单测 monkeypatch 不触真网络。 测试:test_sms_aliyun(17)+ test_sms_dispatch(3)全绿;test_auth 内部访问 retarget 到 jiguang.*。 配置:SMS_PROVIDER + ALIYUN_SMS_*(见 .env.example);文档 docs/integrations/sms.md + aliyun 接口参考。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 20 +- app/api/v1/auth.py | 12 +- app/core/config.py | 25 ++ app/integrations/sms/__init__.py | 33 ++ app/integrations/sms/aliyun.py | 193 +++++++++ app/integrations/sms/base.py | 23 + app/integrations/{sms.py => sms/jiguang.py} | 24 +- .../integrations/aliyun/CheckSmsVerifyCode.md | 224 ++++++++++ docs/integrations/aliyun/SendSmsVerifyCode.md | 396 ++++++++++++++++++ docs/integrations/sms.md | 17 +- pyproject.toml | 3 + tests/test_auth.py | 79 ++-- tests/test_sms_aliyun.py | 185 ++++++++ tests/test_sms_dispatch.py | 47 +++ 14 files changed, 1220 insertions(+), 61 deletions(-) create mode 100644 app/integrations/sms/__init__.py create mode 100644 app/integrations/sms/aliyun.py create mode 100644 app/integrations/sms/base.py rename app/integrations/{sms.py => sms/jiguang.py} (91%) create mode 100644 docs/integrations/aliyun/CheckSmsVerifyCode.md create mode 100644 docs/integrations/aliyun/SendSmsVerifyCode.md create mode 100644 tests/test_sms_aliyun.py create mode 100644 tests/test_sms_dispatch.py diff --git a/.env.example b/.env.example index a7f92f8..99f6d96 100644 --- a/.env.example +++ b/.env.example @@ -81,12 +81,28 @@ HEARTBEAT_TIMEOUT_MINUTES=60 HEARTBEAT_SCAN_INTERVAL_SEC=60 # ===== 短信 (mock 模式) ===== -# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。 -# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。 +# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。生产改 false。 SMS_MOCK=true SMS_CODE_TTL_SEC=300 SMS_SEND_INTERVAL_SEC=60 +# ===== 短信提供商(可切换:jiguang 默认 / aliyun 阿里云号码认证)===== +# jiguang:本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。 +# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。 +# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。 +SMS_PROVIDER=jiguang +ALIYUN_SMS_ACCESS_KEY_ID= +ALIYUN_SMS_ACCESS_KEY_SECRET= +ALIYUN_SMS_SIGN_NAME= +ALIYUN_SMS_TEMPLATE_CODE= +# 方案名:留空=默认方案;若填,发码与校验须一致(本服务已共用同一配置项,不会不匹配)。 +ALIYUN_SMS_SCHEME_NAME= +ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com +ALIYUN_SMS_CODE_LENGTH=6 +ALIYUN_SMS_VALID_TIME_SEC=300 +ALIYUN_SMS_INTERVAL_SEC=60 +ALIYUN_SMS_TIMEOUT_SEC=15 + # ===== 测试账号(release 包全流程联调用)===== # 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】 # (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。 diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index e3728be..13d7eb6 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -182,7 +182,11 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit detail="登录尝试过于频繁,请稍后再试", ) - if not verify_code(req.phone, req.code): + try: + ok = verify_code(req.phone, req.code) + except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」 + raise HTTPException(status_code=e.status_code, detail=str(e)) from e + if not ok: raise HTTPException(status_code=400, detail="invalid sms code") user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms") @@ -322,7 +326,11 @@ def wechat_bind_phone_sms( detail="登录尝试过于频繁,请稍后再试", ) - if not verify_code(req.phone, req.code): + try: + ok = verify_code(req.phone, req.code) + except SmsError as e: # provider 校验降级(如阿里云接口异常)→ 原样透出其状态码(503),别误报「验证码错误」 + raise HTTPException(status_code=e.status_code, detail=str(e)) from e + if not ok: raise HTTPException(status_code=400, detail="invalid sms code") return _finish_wechat_bind( diff --git a/app/core/config.py b/app/core/config.py index 81149db..de6901d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -141,6 +141,31 @@ class Settings(BaseSettings): SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费) SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破) + # ===== 短信提供商(可切换:极光 / 阿里云号码认证)===== + # jiguang(默认):本服务生成验证码,极光只负责下发,本地内存校验(自管码,现状不变)。 + # aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。 + SMS_PROVIDER: Literal["jiguang", "aliyun"] = "jiguang" + ALIYUN_SMS_ACCESS_KEY_ID: str = "" + ALIYUN_SMS_ACCESS_KEY_SECRET: str = "" + ALIYUN_SMS_SIGN_NAME: str = "" # 系统赠送签名(自定义签名下发易失败) + ALIYUN_SMS_TEMPLATE_CODE: str = "" # 赠送模板 CODE(须与赠送签名搭配) + ALIYUN_SMS_SCHEME_NAME: str = "" # 方案名(可空=默认方案);send/check 共用避免不匹配 + ALIYUN_SMS_ENDPOINT: str = "dypnsapi.aliyuncs.com" + ALIYUN_SMS_CODE_LENGTH: int = 6 # 验证码位数(CodeLength 4~8) + ALIYUN_SMS_VALID_TIME_SEC: int = 300 # 验证码有效期秒(ValidTime);短信内 min 文案 = //60 + ALIYUN_SMS_INTERVAL_SEC: int = 60 # 单号发送频控秒(Interval);核验免费 + ALIYUN_SMS_TIMEOUT_SEC: int = 15 # 阿里云 API 读/连超时秒 + + @property + def aliyun_sms_configured(self) -> bool: + """阿里云短信凭证齐全(缺则 SMS_PROVIDER=aliyun 时 /sms/* 返 503,而非启动崩)。""" + return bool( + self.ALIYUN_SMS_ACCESS_KEY_ID + and self.ALIYUN_SMS_ACCESS_KEY_SECRET + and self.ALIYUN_SMS_SIGN_NAME + and self.ALIYUN_SMS_TEMPLATE_CODE + ) + # ===== 测试账号(release 包全流程联调用)===== # 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】 # (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人 diff --git a/app/integrations/sms/__init__.py b/app/integrations/sms/__init__.py new file mode 100644 index 0000000..66853de --- /dev/null +++ b/app/integrations/sms/__init__.py @@ -0,0 +1,33 @@ +"""短信验证码服务 —— provider 分派入口。 + +对外只暴露 `send_code` / `verify_code` / `SmsError`,api 层无需关心用哪个 provider。 +provider 由 `settings.SMS_PROVIDER` 选择(**每次调用读取**,支持运行时切换 + 灰度回退): + - `jiguang`(默认):自管码(本服务生成、内存存/校验,极光只发)。见 [jiguang.py](jiguang.py)。 + - `aliyun`:阿里云号码认证(阿里云生成+下发+校验,Mode A)。见 [aliyun.py](aliyun.py)。 + +mock(`SMS_MOCK=true`)与各 provider 的行为差异都封在 provider 内部;本层只做路由。 +拆包前本模块是单文件 `sms.py`;拆包后极光逻辑迁入 `jiguang` 子模块,行为零改动。 +""" +from __future__ import annotations + +from app.core.config import settings + +from . import aliyun, jiguang +from .base import SmsError + +__all__ = ["SmsError", "send_code", "verify_code"] + + +def _provider(): + """按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)。""" + return aliyun if settings.SMS_PROVIDER == "aliyun" else jiguang + + +def send_code(phone: str) -> int: + """发送验证码,返回距下次可发的冷却秒数;失败抛 SmsError。委托给当前 provider。""" + return _provider().send_code(phone) + + +def verify_code(phone: str, code: str) -> bool: + """校验验证码,返回是否通过;provider 异常降级抛 SmsError。委托给当前 provider。""" + return _provider().verify_code(phone, code) diff --git a/app/integrations/sms/aliyun.py b/app/integrations/sms/aliyun.py new file mode 100644 index 0000000..1b49842 --- /dev/null +++ b/app/integrations/sms/aliyun.py @@ -0,0 +1,193 @@ +"""阿里云号码认证(dypns)短信 provider —— Mode A(阿里云托管验证码)。 + +与极光(自管码)最大不同:**本服务不生成/不存储验证码**,验证码由阿里云生成+存储+下发+校验。 + - 发码:调 SendSmsVerifyCode,TemplateParam 用 `{"code":"##code##","min":...}` 占位,阿里云生成。 + - 校验:调 CheckSmsVerifyCode,阿里云返回 PASS / UNKNOWN。核验免费。 +→ 天然消除极光路径「内存存码、多 worker 不共享」的技术债(发码/校验可落不同 worker,阿里云统一裁决)。 + +**唯一本地态**:per-phone 连续失败计数(`_verify_attempts`),用于复刻极光「单码失败 +`SMS_MAX_VERIFY_ATTEMPTS` 次即作废」的防爆破语义 —— 刻意与极光一致,避免两 provider 行为不同 +导致排查困惑。其多 worker 降级特性与极光现状同级;另有 API 层登录频控(设备+IP)做硬兜底。 + +单号发送频控(冷却)交给阿里云 `Interval` 参数(命中→FREQUENCY_FAIL→429),本地不再维护冷却。 + +SDK 交互隔离在 `_call_send` / `_call_check` 两个薄封装(惰性 import + 惰性建 client,仿 wxpay +惰性加载),单测 monkeypatch 这两个即可,不触真 SDK / 网络。 +""" +from __future__ import annotations + +import json +import logging +import time +from threading import Lock + +from app.core.config import settings + +from .base import SmsError, mock_verify + +logger = logging.getLogger("shagua.sms.aliyun") + +# 阿里云路径唯一本地态:per-phone 连续失败次数(与极光同语义,防爆破)。 +_verify_attempts: dict[str, int] = {} # phone -> 连续失败次数 +_verify_seen: dict[str, float] = {} # phone -> 最近触碰 epoch(仅供 GC 老化) +_lock = Lock() +_GC_THRESHOLD = 10000 # 超此阈值,send 时顺手清老于验证码有效期的计数(仿极光 _gc) + +# 发码错误码 → (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。 +_SEND_ERRORS: dict[str, tuple[int, str]] = { + "MOBILE_NUMBER_ILLEGAL": (400, "手机号无效"), + "BUSINESS_LIMIT_CONTROL": (429, "今日发送次数过多,请明天再试"), + "FREQUENCY_FAIL": (429, "发送过于频繁,请稍后再试"), +} +# 需运维介入的配置/开通类错误:打 critical 日志(融合认证未开通 / 参数非法)。 +_SEND_CRITICAL_CODES = frozenset({"FUNCTION_NOT_OPENED", "INVALID_PARAMETERS"}) + +_client = None # 惰性构建的 SDK client(模块级缓存) + + +# ============================ 对外:发码 / 校验 ============================ + +def send_code(phone: str) -> int: + """发送验证码(阿里云生成+下发)。 + + Returns: 距下次可发的秒数(= ALIYUN_SMS_INTERVAL_SEC,冷却由阿里云 Interval 侧执行)。 + Raises: SmsError(手机号无效 400 / 过频·天级流控 429 / 未配置·未开通·其他 503)。 + """ + if settings.SMS_MOCK: + logger.info("[SMS-aliyun-MOCK] to %s**** (不真发)", phone[:3]) + return settings.ALIYUN_SMS_INTERVAL_SEC + if not settings.aliyun_sms_configured: + raise SmsError("短信服务未配置(缺阿里云凭证)", status_code=503) + + result = _call_send(phone) # 传输/SDK 异常在内部抛 SmsError(503) + + if result["success"] and result["code"] == "OK": + now = time.time() + with _lock: + _gc(now) # 顺手清老计数(超阈值才扫) + _verify_attempts.pop(phone, None) # 新码 = 新失败预算 + _verify_seen.pop(phone, None) + logger.info("[SMS-aliyun] sent to %s****", phone[:3]) + return settings.ALIYUN_SMS_INTERVAL_SEC + + code = result["code"] + logger.error("[SMS-aliyun] send failed code=%s msg=%s", code, result["message"]) + if code in _SEND_CRITICAL_CODES: + logger.critical("[SMS-aliyun] %s —— 需运维处理(融合认证未开通 / 参数非法)", code) + status, msg = _SEND_ERRORS.get(code, (503, "短信服务暂不可用,请稍后重试")) + raise SmsError(msg, status_code=status) + + +def verify_code(phone: str, code: str) -> bool: + """校验验证码(阿里云裁决)。 + + - **mock**:放行任意 N 位数字(provider 无关,同极光)。 + - **real**:先查本地失败计数(达上限即本地作废,不调阿里云,与极光一致)→ 调 CheckSmsVerifyCode: + PASS 清计数返 True(一次性);UNKNOWN 计数 +1 返 False;接口异常抛 SmsError(503)。 + """ + if settings.SMS_MOCK: + ok = mock_verify(code) + logger.info("[SMS-aliyun-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3]) + return ok + + # 失败计数是 best-effort:网络调用不持锁(不能锁跨 IO),故并发下同号可能多放行个位数次。 + # 无碍——API 层登录频控(设备+IP 5/时)是硬上限,阿里云码有效期 + DuplicatePolicy 亦兜底。 + with _lock: + if _verify_attempts.get(phone, 0) >= settings.SMS_MAX_VERIFY_ATTEMPTS: + return False # 已作废:保持计数(直到 send_code 重置),与极光「达上限即作废」一致 + + result = _call_check(phone, code) # 传输/SDK 异常在内部抛 SmsError(503) + + if not (result["success"] and result["code"] == "OK"): + # 接口层失败(非码错):降级 503,别误报「验证码错误」(400),便于区分排查。 + logger.error("[SMS-aliyun] check failed code=%s msg=%s", result["code"], result["message"]) + raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) + + if result["verify_result"] == "PASS": + with _lock: + _verify_attempts.pop(phone, None) # 验过即清(一次性) + _verify_seen.pop(phone, None) + return True + + # UNKNOWN:码错 / 过期 → 失败计数 +1(累计到上限即作废) + with _lock: + _verify_attempts[phone] = _verify_attempts.get(phone, 0) + 1 + _verify_seen[phone] = time.time() + return False + + +def _gc(now: float) -> None: + """超阈值时清理老于验证码有效期的失败计数(码早已在阿里云侧失效,计数无意义)。仅持锁调用。""" + if len(_verify_attempts) <= _GC_THRESHOLD: + return + cutoff = now - settings.ALIYUN_SMS_VALID_TIME_SEC + for p in [p for p, ts in _verify_seen.items() if ts < cutoff]: + _verify_attempts.pop(p, None) + _verify_seen.pop(p, None) + + +# ============================ SDK 接缝(单测 monkeypatch 这两个)============================ + +def _get_client(): + """惰性构建 dypns SDK client(仿 wxpay 惰性加载:jiguang-only 部署不加载 alibabacloud)。""" + global _client + if _client is None: + from alibabacloud_dypnsapi20170525.client import Client + from alibabacloud_tea_openapi import models as open_api_models + + cfg = open_api_models.Config( + access_key_id=settings.ALIYUN_SMS_ACCESS_KEY_ID, + access_key_secret=settings.ALIYUN_SMS_ACCESS_KEY_SECRET, + read_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000, # SDK 单位 ms + connect_timeout=settings.ALIYUN_SMS_TIMEOUT_SEC * 1000, + ) + cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT + _client = Client(cfg) + return _client + + +def _call_send(phone: str) -> dict: + """调 SendSmsVerifyCode。返回归一化 {success, code, message};import/建 client/调用 任一失败抛 SmsError(503)。""" + valid_min = max(1, settings.ALIYUN_SMS_VALID_TIME_SEC // 60) + template_param = json.dumps({"code": "##code##", "min": str(valid_min)}, ensure_ascii=False) + try: + # import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式) + from alibabacloud_dypnsapi20170525 import models as dypns_models + req = dypns_models.SendSmsVerifyCodeRequest( + phone_number=phone, + sign_name=settings.ALIYUN_SMS_SIGN_NAME, + template_code=settings.ALIYUN_SMS_TEMPLATE_CODE, + template_param=template_param, + code_length=settings.ALIYUN_SMS_CODE_LENGTH, + valid_time=settings.ALIYUN_SMS_VALID_TIME_SEC, + interval=settings.ALIYUN_SMS_INTERVAL_SEC, + scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None, + ) + body = _get_client().send_sms_verify_code(req).body + except Exception as e: + logger.exception("[SMS-aliyun] send_sms_verify_code 调用异常 phone=%s****", phone[:3]) + raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e + return {"success": bool(body.success), "code": body.code, "message": body.message} + + +def _call_check(phone: str, code: str) -> dict: + """调 CheckSmsVerifyCode。返回归一化 {success, code, message, verify_result};import/建 client/调用 任一失败抛 SmsError(503)。""" + try: + # import + 建 req + 调用 全在 try 内:任一 provider 侧失败都归一成 503(保「provider 出问题→503」不变式) + from alibabacloud_dypnsapi20170525 import models as dypns_models + req = dypns_models.CheckSmsVerifyCodeRequest( + phone_number=phone, + verify_code=code, + scheme_name=settings.ALIYUN_SMS_SCHEME_NAME or None, + ) + body = _get_client().check_sms_verify_code(req).body + except Exception as e: + logger.exception("[SMS-aliyun] check_sms_verify_code 调用异常 phone=%s****", phone[:3]) + raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e + verify_result = getattr(body.model, "verify_result", None) if body.model else None + return { + "success": bool(body.success), + "code": body.code, + "message": body.message, + "verify_result": verify_result, + } diff --git a/app/integrations/sms/base.py b/app/integrations/sms/base.py new file mode 100644 index 0000000..702910b --- /dev/null +++ b/app/integrations/sms/base.py @@ -0,0 +1,23 @@ +"""短信 provider 共享基座:业务异常 + provider 无关的 mock 校验。 + +各 provider(jiguang / aliyun)都 `from .base import SmsError`,api 层也从包入口拿到同一个 +`SmsError` —— 保证无论用哪个 provider,异常类型与 HTTP 码映射语义一致。 +""" +from __future__ import annotations + +from app.core.config import settings + + +class SmsError(Exception): + """业务异常。`status_code` 决定 api 层翻成哪个 HTTP 码: + 过频/每日超限 = 429(客户端等会再来),供应商不可用 = 503,手机号无效 = 400。 + """ + + def __init__(self, message: str, status_code: int = 429) -> None: + super().__init__(message) + self.status_code = status_code + + +def mock_verify(code: str) -> bool: + """mock 模式校验:放行任意 SMS_CODE_LENGTH 位数字(provider 无关,测试/开发便利,不真校验)。""" + return len(code) == settings.SMS_CODE_LENGTH and code.isdigit() diff --git a/app/integrations/sms.py b/app/integrations/sms/jiguang.py similarity index 91% rename from app/integrations/sms.py rename to app/integrations/sms/jiguang.py index eca2a71..336a141 100644 --- a/app/integrations/sms.py +++ b/app/integrations/sms/jiguang.py @@ -1,15 +1,15 @@ -"""短信验证码服务。 +"""极光短信 provider(自管码 Mode B)。 两种运行模式由 `SMS_MOCK` 切换: - **mock**(开发/测试,默认):不真发短信,验证码打到日志;校验**放行任意 N 位数字** (测试/开发便利)。真实校验逻辑(比对存码 / 一次性 / 防爆破)由 real 分支 + 单测覆盖。 - - **real**(生产 `SMS_MOCK=false`):本服务生成 N 位验证码 → 调极光短信 REST - `/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/ + - **real**(生产 `SMS_MOCK=false` 且 `SMS_PROVIDER=jiguang`):本服务生成 N 位验证码 → 调极光 + 短信 REST `/v1/messages` 发送(自定义验证码模式,极光只负责发,code 由本服务生成/保管/ 校验)→ 鉴权复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`(同一极光应用)。 验证码存储:**进程内存**(单 worker uvicorn 够用)。重启丢失(用户重发即可)。多 -worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis。 -见 docs/待办与技术债.md。 +worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移到 DB/Redis(或改用 aliyun provider, +其验证码由阿里云托管、无本地存码)。见 docs/待办与技术债.md。 防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) @@ -34,17 +34,9 @@ import httpx from app.core.config import settings -logger = logging.getLogger("shagua.sms") +from .base import SmsError, mock_verify - -class SmsError(Exception): - """业务异常。`status_code` 决定 api 层翻成哪个 HTTP 码: - 过频/每日超限 = 429(客户端等会再来),供应商不可用 = 503,手机号无效 = 400。 - """ - - def __init__(self, message: str, status_code: int = 429) -> None: - super().__init__(message) - self.status_code = status_code +logger = logging.getLogger("shagua.sms.jiguang") @dataclass @@ -126,7 +118,7 @@ def verify_code(phone: str, code: str) -> bool: - **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。 """ if settings.SMS_MOCK: - ok = len(code) == settings.SMS_CODE_LENGTH and code.isdigit() + ok = mock_verify(code) logger.info("[SMS-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3]) return ok diff --git a/docs/integrations/aliyun/CheckSmsVerifyCode.md b/docs/integrations/aliyun/CheckSmsVerifyCode.md new file mode 100644 index 0000000..3d8a79f --- /dev/null +++ b/docs/integrations/aliyun/CheckSmsVerifyCode.md @@ -0,0 +1,224 @@ +CheckSmsVerifyCode - 核验验证码 +更新时间:2026年3月19日 20:02:53 +核验短信验证码并返回核验是否成功的结果。 + +调试 +您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。 + + 调试 +授权信息 +下表是API对应的授权信息,可以在RAM权限策略语句的Action元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下: + +操作:是指具体的权限点。 + +访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。 + +资源类型:是指操作中支持授权的资源类型。具体说明如下: + +对于必选的资源类型,用前面加 * 表示。 + +对于不支持资源级授权的操作,用全部资源表示。 + +条件关键字:是指云产品自身定义的条件关键字。 + +关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。 + +放大查看 +操作 + +访问级别 + +资源类型 + +条件关键字 + +关联操作 + +dypns:CheckSmsVerifyCode + +none + +*全部资源 + +* + +无 无 +请求参数 +放大查看 +名称 + +类型 + +必填 + +描述 + +示例值 + +SchemeName +string + +否 + +方案名称,如果不填则为“默认方案”。最多不超过 20 个字符。 + +重要 如果发送接口的方案名称不为空,请确保该参数不为空且与发送接口的方案名称参数一致 +测试方案 + +CountryCode +string + +否 + +号码国家编码,默认为 86。 + +86 + +PhoneNumber +string + +是 + +手机号。 + +186****0000 + +OutId +string + +否 + +外部流水号。 + +12123231 + +VerifyCode +string + +是 + +验证码。 + +说明 +SendSmsVerifyCode 接口的字段 TemplateParam,配置方式有 2 种: + +{"code":"##code##","min":"5"} + +{"code":"123456","min":"5"} + +{"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。 + +{"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。 + +请您按照实际情况传入对应的验证码。 + +1231 + +CaseAuthPolicy +integer + +否 + +验证码大小写字母核验策略。取值: + +1:不区分大小写。 + +2:区分大小写。 + +1 + +返回参数 +放大查看 +名称 + +类型 + +描述 + +示例值 + +object + +AccessDeniedDetail +string + +访问被拒绝详细信息。 + +无 + +Message +string + +状态码的描述。 + +成功 + +Model +object + +请求结果数据。 + +OutId +string + +外部流水号。 + +1212312 + +VerifyResult +string + +短信验证码核验结果。取值: + +PASS:短信验证码核验成功。 + +UNKNOWN:短信验证码核验失败。 + +PASS + +Code +string + +接口请求状态码。 + +返回 OK 代表请求成功。 + +其他错误码,请参见返回码。 + +重要 接口请求成功不代表短信验证码核验成功,短信验证码核验结果仅以Model.VerifyResult参数返回值为准。 +OK + +Success +boolean + +接口调用是否成功。取值: + +true:接口调用成功。 + +false:接口调用失败。 + +重要 接口调用成功不代表短信验证码核验成功,短信验证码核验结果仅以Model.VerifyResult参数返回值为准。 +true + +RequestId +string + +CF8854E5-DB21-3E5D-A9B1-DDC752FD7384 + +示例 +正常返回示例 + +JSON格式 + +放大查看复制代码 +{ + "AccessDeniedDetail": "无", + "Message": "成功", + "Model": { + "OutId": "1212312", + "VerifyResult": "PASS" + }, + "Code": "OK", + "Success": true, + "RequestId": "CF8854E5-DB21-3E5D-A9B1-DDC752FD7384" +} diff --git a/docs/integrations/aliyun/SendSmsVerifyCode.md b/docs/integrations/aliyun/SendSmsVerifyCode.md new file mode 100644 index 0000000..71daf96 --- /dev/null +++ b/docs/integrations/aliyun/SendSmsVerifyCode.md @@ -0,0 +1,396 @@ +SendSmsVerifyCode - 发送短信验证码 +更新时间:2026年7月3日 09:54:53 +发送短信验证码。 + +接口说明 +由于运营商近期加强对短信签名的管控。您自定义的签名面临下发失败问题,推荐您使用号码认证控制台赠送的短信签名和模板进行短信认证。系统赠送签名必须搭配系统赠送模板使用。 + +请确保在使用该接口前,已充分了解号码认证服务产品的收费方式和价格,短信认证服务仅收取短信发送费用(按运营商回执状态计费,短信提交成功但运营商回执失败时不计费),核验服务免费。 + +调试 +您可以在OpenAPI Explorer中直接运行该接口,免去您计算签名的困扰。运行成功后,OpenAPI Explorer可以自动生成SDK代码示例。 + + 调试 +授权信息 +下表是API对应的授权信息,可以在RAM权限策略语句的Action元素中使用,用来给RAM用户或RAM角色授予调用此API的权限。具体说明如下: + +操作:是指具体的权限点。 + +访问级别:是指每个操作的访问级别,取值为写入(Write)、读取(Read)或列出(List)。 + +资源类型:是指操作中支持授权的资源类型。具体说明如下: + +对于必选的资源类型,用前面加 * 表示。 + +对于不支持资源级授权的操作,用全部资源表示。 + +条件关键字:是指云产品自身定义的条件关键字。 + +关联操作:是指成功执行操作所需要的其他权限。操作者必须同时具备关联操作的权限,操作才能成功。 + +放大查看 +操作 + +访问级别 + +资源类型 + +条件关键字 + +关联操作 + +dypns:SendSmsVerifyCode + +create + +*全部资源 + +* + +无 无 +请求参数 +放大查看 +名称 + +类型 + +必填 + +描述 + +示例值 + +SchemeName +string + +否 + +方案名称,如果不填则为“默认方案”。最多不超过 20 个字符。 + +测试方案 + +CountryCode +string + +否 + +号码国家编码。默认为 86,目前也仅支持国内号码发送。 + +86 + +PhoneNumber +string + +是 + +短信接收方手机号。 + +130****0000 + +SignName +string + +是 + +签名名称。暂不支持使用自定义签名,请使用系统赠送的签名,您可在赠送签名配置页面选择需要下发的签名。 + +恒创联众 + +TemplateCode +string + +是 + +短信模板 CODE。参数SignName选择赠送签名时,必须搭配赠送模板下发短信。您可在赠送模板配置页面选择适用您业务场景的模板。 + +100001 + +TemplateParam +string + +是 + +短信模板参数。验证码位置有两种传值方式: + +可使用"##code##"替代,由参数 CodeType 指定验证码生成规则; + +也可直接传入具体的验证码值,直接下发至接收方。 + +示例:如模板内容为:“您的验证码是${code},有效期${min}分钟,请勿告诉他人。”。 + +重要 上文中的 code 请替换成您实际申请的验证码模板中的参数名称 +该字段可传入{"code":"##code##","min":"5"}由系统根据规则生成验证码; + +或直接传入指定的验证码值{"code":"123456","min":"5"}。 + +说明 +{"code":"##code##","min":"5"}验证码是 api 动态生成的,阿里云接口可以完成校验。 + +{"code":"123456","min":"5"}验证码是用户配置的不是 api 动态生成,阿里云接口无法校验。 + +说明 +如果 JSON 中需要带换行符,请参照标准的 JSON 协议处理。 + +模板变量规范,请参见短信模板规范。 + +{"code":"##code##","min":"5"} + +SmsUpExtendCode +string + +否 + +上行短信扩展码。上行短信指发送给通信服务提供商的短信,用于定制某种服务、完成查询,或是办理某种业务等,需要收费,按运营商普通短信资费进行扣费。 + +说明 +扩展码是生成签名时系统自动默认生成的,不支持自行传入。无特殊需要此字段的用户请忽略此字段。如需使用,请联系您的商务经理。 + +1213123 + +OutId +string + +否 + +外部流水号。 + +外部流水号(透传) + +CodeLength +integer + +否 + +验证码长度支持 4~8 位长度,默认是 4 位。 + +4 + +ValidTime +integer + +否 + +验证码有效时长,单位秒,默认为 300 秒。 + +300 + +DuplicatePolicy +integer + +否 + +核验规则,当有效时间内对同场景内的同号码重复发送验证码时,旧验证码如何处理。 + +1:覆盖处理(默认),即旧验证码会失效掉。 + +2:保留,即多个验证码都是在有效期内都可以校验通过。 + +枚举值: + +1 : +覆盖 + +2 : +保留 + +1 + +Interval +integer + +否 + +时间间隔,单位:秒。即多久间隔可以发送一次验证码,用于频控,默认 60 秒。 + +60 + +CodeType +integer + +否 + +生成的验证码类型。当参数 TemplateParam 传入占位符时,此参数必填,将由系统根据指定的规则生成验证码。取值: + +1:纯数字(默认)。 + +2:纯大写字母。 + +3:纯小写字母。 + +4:大小字母混合。 + +5:数字+大写字母混合。 + +6:数字+小写字母混合。 + +7:数字+大小写字母混合。 + +枚举值: + +1 : +纯数字 + +2 : +纯大写字母 + +3 : +纯小写字母 + +4 : +大小字母混合 + +5 : +数字+大写字母混合 + +6 : +数字+小写字母混合 + +7 : +数字+大小写字母混合 + +1 + +ReturnVerifyCode +boolean + +否 + +是否返回验证码。取值: + +true:返回。 + +false:不返回。 + +true + +AutoRetry +integer + +否 + +是否自动替换签名重试(默认开启),可取值: + +1 开启自动重试功能,开启后,在验证码有效期内,当运营商返回明确的失败状态时,允许阿里云尽可能的尝试使用其他方式发送验证码,以提升发送成功率。其他方式包括且不限于:通过其他运营商重试、更换签名重试等 + +0 不开启自动重试 + +是否自动重试 + +返回参数 +放大查看 +名称 + +类型 + +描述 + +示例值 + +object + +AccessDeniedDetail +string + +访问被拒绝详细信息。 + +无 + +Message +string + +状态码的描述。 + +成功 + +RequestId +string + +请求 ID。 + +CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47 + +Model +object + +请求结果数据。 + +VerifyCode +string + +验证码。 + +4232 + +RequestId +string + +请求 ID。 + +a3671ccf-0102-4c8e-8797-a3678e091d09 + +OutId +string + +外部流水号。 + +1231231313 + +BizId +string + +业务 ID。 + +112231421412414124123^4 + +Code +string + +请求状态码。返回 OK 代表请求成功。其他错误码,请参见返回码列表。 + +OK + +Success +boolean + +请求是否成功。 + +true:请求成功。 + +false:请求失败。 + +true + +示例 +正常返回示例 + +JSON格式 + +放大查看复制代码 +{ + "AccessDeniedDetail": "无", + "Message": "成功 ", + "RequestId": "CC3BB6D2-2FDF-4321-9DCE-B38165CE4C47", + "Model": { + "VerifyCode": "4232", + "RequestId": "a3671ccf-0102-4c8e-8797-a3678e091d09", + "OutId": "1231231313", + "BizId": "112231421412414124123^4" + }, + "Code": "OK", + "Success": true +} +错误码 +放大查看 +HTTP status code + +错误码 + +错误信息 + +描述 + +400 MOBILE_NUMBER_ILLEGAL The mobile number is illegal. 手机号码格式错误 +400 BUSINESS_LIMIT_CONTROL The number has exceeded the limit for the day. 触发号码天级流控 +400 FREQUENCY_FAIL Check frequency fail. 频控校验未通过 +400 INVALID_PARAMETERS parameter is not valid. 非法参数 +400 FUNCTION_NOT_OPENED You have not opened this function. 没有开通融合认证功能 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md index c8f9bdd..d47b574 100644 --- a/docs/integrations/sms.md +++ b/docs/integrations/sms.md @@ -1,9 +1,22 @@ # 短信验证码(sms) -> 文件:`app/integrations/sms.py` | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md) +> 文件:`app/integrations/sms/`(分派器 `__init__` + `jiguang` / `aliyun` provider + `base`) | 关联接口:[auth-sms-send](../api/auth-sms-send.md) · [auth-sms-login](../api/auth-sms-login.md) | [← 集成索引](./README.md) ## 作用 -手机号 + 验证码登录的验证码发送 / 校验。**已接极光短信 REST**,由 `SMS_MOCK` 切 mock / real。 +手机号 + 验证码登录的验证码发送 / 校验。支持**可切换 provider**(`SMS_PROVIDER`):`jiguang`(默认,极光自管码)/ `aliyun`(阿里云号码认证托管码)。`SMS_MOCK` 另切 mock / real。 + +## 短信提供商(`SMS_PROVIDER`,可切换 + 灰度回退) +| | `jiguang`(默认) | `aliyun` | +|---|---|---| +| 验证码 | **本服务生成**、极光只下发、**本地内存校验** | **阿里云生成 + 下发 + 校验**(dypns 号码认证,Mode A,核验免费) | +| 发码 | 极光 `/v1/messages` | `SendSmsVerifyCode`(`##code##` 占位) | +| 校验 | 比对本地存码 | `CheckSmsVerifyCode` → `PASS` / `UNKNOWN` | +| 多 worker | ⚠️ 内存存码不共享(见已知局限) | ✅ 阿里云托管,天然共享 | +| 防爆破 | 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 | **同语义**(本地 per-phone 失败计数,刻意与极光一致) | +| 单号冷却 | 本地 `SMS_SEND_INTERVAL_SEC` | 交给阿里云 `Interval` | +| 校验降级 | 本地无网络调用 | 阿里云接口异常 → `/sms/login` 返 **503**(非 400,便于排查) | + +`aliyun` 需在**号码认证控制台开通「融合认证」**,用系统赠送签名 + 赠送模板;配置见 `.env.example` 的 `ALIYUN_SMS_*`,SDK 为 `alibabacloud_dypnsapi20170525`。**以下章节描述 `jiguang` provider(自管码)细节**。 | | mock(`SMS_MOCK=true`,默认 / 开发测试) | real(`SMS_MOCK=false`,生产) | |---|---|---| diff --git a/pyproject.toml b/pyproject.toml index 1c4c03a..3ab9552 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,9 @@ dependencies = [ # HTTP 客户端 (调极光 REST) "httpx>=0.27.0", + # 阿里云号码认证(dypns)短信验证码 provider(SMS_PROVIDER=aliyun 时用;签名由 SDK 处理) + "alibabacloud_dypnsapi20170525>=2.0.0", + # multipart form (FastAPI 表单上传依赖) "python-multipart>=0.0.9", diff --git a/tests/test_auth.py b/tests/test_auth.py index fe6b79b..42d9add 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -11,12 +11,13 @@ import time import pytest from app.integrations import sms +from app.integrations.sms import jiguang def _reset(phone: str) -> None: """清该号的进程内存状态,隔离 real 模式用例。""" - sms._codes.pop(phone, None) - sms._last_sent.pop(phone, None) + jiguang._codes.pop(phone, None) + jiguang._last_sent.pop(phone, None) class _OkResp: @@ -227,16 +228,16 @@ def test_sms_real_send_calls_jiguang(monkeypatch) -> None: captured.update(url=url, body=json, auth=headers.get("Authorization", "")) return _OkResp() - monkeypatch.setattr(sms.settings, "SMS_MOCK", False) - monkeypatch.setattr(sms.httpx, "post", _fake_post) + monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False) + monkeypatch.setattr(jiguang.httpx, "post", _fake_post) - sms.send_code(phone) + jiguang.send_code(phone) - assert captured["url"] == sms.settings.SMS_SEND_ENDPOINT + assert captured["url"] == jiguang.settings.SMS_SEND_ENDPOINT assert captured["body"]["mobile"] == phone - assert captured["body"]["sign_id"] == sms.settings.SMS_SIGN_ID - assert captured["body"]["temp_id"] == sms.settings.SMS_TEMPLATE_ID - assert captured["body"]["temp_para"]["code"] == sms._codes[phone].code + assert captured["body"]["sign_id"] == jiguang.settings.SMS_SIGN_ID + assert captured["body"]["temp_id"] == jiguang.settings.SMS_TEMPLATE_ID + assert captured["body"]["temp_para"]["code"] == jiguang._codes[phone].code assert captured["auth"].startswith("Basic ") @@ -244,33 +245,33 @@ def test_sms_real_verify_one_time_and_wrong(monkeypatch) -> None: """real 校验:错误码拒(不消费)→ 正确码成功 → 验过即作废。""" phone = "13455134000" _reset(phone) - monkeypatch.setattr(sms.settings, "SMS_MOCK", False) - monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp()) + monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False) + monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _OkResp()) - sms.send_code(phone) - code = sms._codes[phone].code + jiguang.send_code(phone) + code = jiguang._codes[phone].code wrong = "000000" if code != "000000" else "111111" - assert sms.verify_code(phone, wrong) is False - assert sms.verify_code(phone, code) is True - assert sms.verify_code(phone, code) is False # 已作废 + assert jiguang.verify_code(phone, wrong) is False + assert jiguang.verify_code(phone, code) is True + assert jiguang.verify_code(phone, code) is False # 已作废 def test_sms_real_verify_attempts_exhausted(monkeypatch) -> None: """real 校验:错误次数到上限即作废,正确码也不再通过(防爆破)。""" phone = "13466134000" _reset(phone) - monkeypatch.setattr(sms.settings, "SMS_MOCK", False) - monkeypatch.setattr(sms.settings, "SMS_MAX_VERIFY_ATTEMPTS", 3) - monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp()) + monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False) + monkeypatch.setattr(jiguang.settings, "SMS_MAX_VERIFY_ATTEMPTS", 3) + monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _OkResp()) - sms.send_code(phone) - code = sms._codes[phone].code + jiguang.send_code(phone) + code = jiguang._codes[phone].code wrong = "000000" if code != "000000" else "111111" for _ in range(3): - assert sms.verify_code(phone, wrong) is False - assert sms.verify_code(phone, code) is False # 超限作废 + assert jiguang.verify_code(phone, wrong) is False + assert jiguang.verify_code(phone, code) is False # 超限作废 def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None: @@ -284,36 +285,36 @@ def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None: def json(self): return {"error": {"code": 50014, "message": "no money"}} - monkeypatch.setattr(sms.settings, "SMS_MOCK", False) - monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _ErrResp()) + monkeypatch.setattr(jiguang.settings, "SMS_MOCK", False) + monkeypatch.setattr(jiguang.httpx, "post", lambda *a, **k: _ErrResp()) with pytest.raises(sms.SmsError) as ei: - sms.send_code(phone) + jiguang.send_code(phone) assert ei.value.status_code == 503 - assert phone not in sms._codes # 没发出去的码已清 - assert phone in sms._last_sent # 冷却保留:失败也限速 + assert phone not in jiguang._codes # 没发出去的码已清 + assert phone in jiguang._last_sent # 冷却保留:失败也限速 # 立即重试 → 被冷却挡下(429),不会再打极光 with pytest.raises(sms.SmsError) as ei2: - sms.send_code(phone) + jiguang.send_code(phone) assert ei2.value.status_code == 429 def test_sms_gc_purges_stale_only(monkeypatch) -> None: """GC 清过期码 / 旧冷却,但不动今天有效的(阈值设 0 强制每次扫)。""" - monkeypatch.setattr(sms, "_GC_THRESHOLD", 0) - sms._codes.clear() - sms._last_sent.clear() + monkeypatch.setattr(jiguang, "_GC_THRESHOLD", 0) + jiguang._codes.clear() + jiguang._last_sent.clear() now = time.time() - sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1) - sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999) - sms._last_sent["old"] = now - 99999 - sms._last_sent["recent"] = now + jiguang._codes["stale"] = jiguang._CodeRecord(code="111111", expires_at=now - 1) + jiguang._codes["fresh"] = jiguang._CodeRecord(code="222222", expires_at=now + 999) + jiguang._last_sent["old"] = now - 99999 + jiguang._last_sent["recent"] = now - sms._gc(now) + jiguang._gc(now) - assert "stale" not in sms._codes and "fresh" in sms._codes - assert "old" not in sms._last_sent and "recent" in sms._last_sent + assert "stale" not in jiguang._codes and "fresh" in jiguang._codes + assert "old" not in jiguang._last_sent and "recent" in jiguang._last_sent # ============================ 用户名 / 默认昵称 ============================ diff --git a/tests/test_sms_aliyun.py b/tests/test_sms_aliyun.py new file mode 100644 index 0000000..337d786 --- /dev/null +++ b/tests/test_sms_aliyun.py @@ -0,0 +1,185 @@ +"""阿里云短信 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 diff --git a/tests/test_sms_dispatch.py b/tests/test_sms_dispatch.py new file mode 100644 index 0000000..358a006 --- /dev/null +++ b/tests/test_sms_dispatch.py @@ -0,0 +1,47 @@ +"""SMS 分派器:按 settings.SMS_PROVIDER 路由到正确 provider。 + +契约:send_code / verify_code **每次调用**读 settings.SMS_PROVIDER 选 provider(支持运行时切换 / +灰度回退);默认 jiguang。此处 monkeypatch 两 provider 的实现为标记函数,断言路由命中 + 可秒切。 +""" +from __future__ import annotations + +from app.core.config import settings +from app.integrations import sms +from app.integrations.sms import aliyun, jiguang + + +def test_send_code_routes_by_provider_and_switches_per_call(monkeypatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(jiguang, "send_code", lambda phone: (calls.append("jiguang"), 60)[1]) + monkeypatch.setattr(aliyun, "send_code", lambda phone: (calls.append("aliyun"), 60)[1]) + + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + assert sms.send_code("13800138000") == 60 + monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun") + assert sms.send_code("13800138000") == 60 + + assert calls == ["jiguang", "aliyun"] # 每次按当前 provider 路由,运行时可切 + + +def test_verify_code_routes_by_provider(monkeypatch) -> None: + calls: list[str] = [] + monkeypatch.setattr(jiguang, "verify_code", lambda phone, code: (calls.append("jiguang"), True)[1]) + monkeypatch.setattr(aliyun, "verify_code", lambda phone, code: (calls.append("aliyun"), True)[1]) + + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + assert sms.verify_code("13800138000", "123456") is True + monkeypatch.setattr(settings, "SMS_PROVIDER", "aliyun") + assert sms.verify_code("13800138000", "123456") is True + + assert calls == ["jiguang", "aliyun"] + + +def test_unknown_provider_falls_back_to_jiguang(monkeypatch) -> None: + """SMS_PROVIDER 非 aliyun 一律走 jiguang(默认兜底,防误配把登录打挂)。""" + calls: list[str] = [] + monkeypatch.setattr(jiguang, "send_code", lambda phone: (calls.append("jiguang"), 60)[1]) + monkeypatch.setattr(aliyun, "send_code", lambda phone: (calls.append("aliyun"), 60)[1]) + monkeypatch.setattr(settings, "SMS_PROVIDER", "jiguang") + + sms.send_code("13800138000") + assert calls == ["jiguang"]