Compare commits

...

3 Commits

Author SHA1 Message Date
guke 1e7f6024c7 Merge main into feat/aliyun-sms-verify
冲突仅 app/api/v1/auth.py::sms_login,合并两侧改动:
  - 保留(本分支)阿里云 provider 的 verify_code try/except —— 校验降级抛 SmsError 时透出其状态码(503),不误报 400。
  - 保留(main)风控失败事件 record_behavior_event —— 但仅在「验证码错误」(ok 为 False)时记;
    provider 降级 503 已在 try/except 提前 raise,不计入「验证失败」风控。
wechat_bind_phone_sms 的 try/except 自动合并、无冲突(main 未在该处加风控)。

验证:全量 pytest 552 passed / 8 failed(8 个均为合并前已存在、与本次无关)。
2026-07-26 00:42:31 +08:00
guke 7a2b7cb8ed 功能:阿里云号码认证短信 provider + sms.py 拆为可切换 provider 包
现有 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) <noreply@anthropic.com>
2026-07-25 23:51:30 +08:00
guke deb7730773 docs: 阿里云短信验证服务设计spec
Mode A(阿里云托管码) + 可切换 provider(SMS_PROVIDER) + 官方 SDK;
防爆破保留与极光一致的单码失败计数,校验降级返回 503。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 17:58:44 +08:00
15 changed files with 1403 additions and 61 deletions
+18 -2
View File
@@ -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 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
+11 -2
View File
@@ -292,7 +292,12 @@ 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:
# 校验码错误才记风控失败事件(provider 降级 503 已在上面提前 raise,不算「验证失败」)
risk_repo.record_behavior_event(
db,
event_type=risk_repo.EVENT_SMS_LOGIN,
@@ -456,7 +461,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(
+25
View File
@@ -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 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
+33
View File
@@ -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)
+193
View File
@@ -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,
}
+23
View File
@@ -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()
@@ -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
@@ -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"
}
@@ -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. 没有开通融合认证功能
+15 -2
View File
@@ -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`,生产) |
|---|---|---|
@@ -0,0 +1,182 @@
# 阿里云短信验证服务 — 设计方案
- 日期:2026-07-25
- 状态:已定稿,待实现
- 范围:新增阿里云 dypns(号码认证服务)短信验证码 provider,与现有极光短信可切换
## 1. 背景与目标
现有短信验证码服务 `app/integrations/sms.py`:本服务**本地生成**验证码、存**进程内存**、极光 REST 仅负责下发;`verify_code()` 比对本地内存(一次性 + 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废)。docstring 已标注"内存存码、多 worker 不共享"为技术债。
阿里云文档(`docs/integrations/aliyun/`)为 **号码认证服务 dypns**`SendSmsVerifyCode` + `CheckSmsVerifyCode`:该产品由**阿里云生成并校验**验证码(`{"code":"##code##"}` 模式),核验免费。
目标:接入阿里云该套接口作为一个新的短信 provider,可与极光切换。
## 2. 关键决策(已确认)
1. **验证码模式 = Mode A(阿里云托管码)**:发码用 `SendSmsVerifyCode` + `##code##` 占位符,阿里云生成/存储/下发;校验用 `CheckSmsVerifyCode`,阿里云返回 `PASS/UNKNOWN`。本服务不再本地生成/存储验证码。
2. **可切换 Provider**:新增 `SMS_PROVIDER=jiguang|aliyun` 开关,`send_code/verify_code` 按 provider 分派;保留极光作回退(短信=花钱+登录关键路径,灰度上线/融合认证未开通时可秒切回极光)。
3. **官方 SDK**:调阿里云 dypns 用 `alibabacloud_dypnsapi20170525`,签名/加签由 SDK 处理。
4. **防爆破与极光一致**(排查一致性):阿里云路径**保留**与极光相同的"单码失败 N 次即作废"本地计数,而非改用 API 层频控,避免两 provider 行为不一致导致排查困惑。
## 3. 模块结构(`sms.py` 单文件升级为 provider 包)
```
app/integrations/sms/
__init__.py # 公开 API + 分派器:send_code / verify_code / SmsError
# - SMS_MOCK=true 短路(不碰任何 provider)
# - 按 settings.SMS_PROVIDER 选 jiguang / aliyun
base.py # SmsError(沿用现定义)+ Provider 协议(send_code/verify_code 签名约定)
jiguang.py # 现有自管码逻辑原样迁入(内存存码/冷却/一次性/防爆破 全保留,行为零改动)
aliyun.py # 新增:SendSmsVerifyCode 发码 + CheckSmsVerifyCode 校验 + 本地失败计数
```
- `__init__.py` 继续 re-export `SmsError / send_code / verify_code`,故 `app/api/v1/auth.py:37`
`from app.integrations.sms import SmsError, send_code, verify_code` **导入不变**
- 纯增量重构:极光逻辑整体迁入 `jiguang.py`,对外行为零变化。
## 4. 数据流 — 阿里云 providerMode A
### 4.1 发码 `aliyun.send_code(phone) -> int`
1. 校验 `settings.aliyun_sms_configured`(缺 AK/SignName/TemplateCode → `SmsError(503)`)。
2.`SendSmsVerifyCode`
- `PhoneNumber=phone`
- `SignName=ALIYUN_SMS_SIGN_NAME``TemplateCode=ALIYUN_SMS_TEMPLATE_CODE`
- `TemplateParam = json({"code":"##code##","min": str(ALIYUN_SMS_VALID_TIME_SEC//60)})`
- `CodeLength=ALIYUN_SMS_CODE_LENGTH``ValidTime=ALIYUN_SMS_VALID_TIME_SEC``Interval=ALIYUN_SMS_INTERVAL_SEC`
- `SchemeName=ALIYUN_SMS_SCHEME_NAME`(可空)
3. 成功(`body.Success and body.Code=="OK"`)→ **清本地失败计数**(新码=新预算)→ 返回 `ALIYUN_SMS_INTERVAL_SEC` 作客户端冷却秒数。
4. 失败 → 按 §6 错误码映射抛 `SmsError`
### 4.2 校验 `aliyun.verify_code(phone, code) -> bool`
1. **本地失败计数**`attempts >= SMS_MAX_VERIFY_ATTEMPTS` → 直接 `False`(码已作废,不调阿里云)。
2.`CheckSmsVerifyCode(PhoneNumber, VerifyCode=code, SchemeName)`
3. `body.Model.VerifyResult`
- `"PASS"` → 清计数,返回 `True`(一次性)。
- `"UNKNOWN"``attempts += 1`,返回 `False`(码错/过期)。
4. 网络错误 / 接口非 `OK` → 抛 `SmsError(503)`**不静默返回 False**,区分"阿里云挂了"与"码错了";网络错误不计入 attempts)。
本服务**不存验证码**,仅存一个 per-phone 失败计数(见 §7)。
## 5. 分派器 & mock`__init__.py`
```
send_code(phone):
if settings.SMS_MOCK: # 短路:不碰 provider(测试/开发)
log placeholder code; return cooldown
return _provider().send_code(phone)
verify_code(phone, code):
if settings.SMS_MOCK: # 放行任意 N 位数字(沿用现 mock 语义)
return len(code)==SMS_CODE_LENGTH and code.isdigit()
return _provider().verify_code(phone, code)
_provider(): jiguang if settings.SMS_PROVIDER=="jiguang" else aliyun
```
- mock 语义提到分派层、provider 无关 → 现有 28 个测试文件(conftest 设 `SMS_MOCK=true`)全部零改动通过。
## 6. 错误映射
### 发码(阿里云错误码 → SmsError.status_code
| 阿里云码 | HTTP | 说明 |
|---|---|---|
| `MOBILE_NUMBER_ILLEGAL` | 400 | 手机号格式错误 |
| `BUSINESS_LIMIT_CONTROL` | 429 | 号码天级流控 |
| `FREQUENCY_FAIL` | 429 | 频控(`Interval` 命中) |
| `FUNCTION_NOT_OPENED` | 503 | 融合认证未开通(**critical 日志**,需运维开通) |
| `INVALID_PARAMETERS` | 503 | 参数错误(配置/模板问题,**critical 日志** |
| 其他非 OK / `Success=false` / 网络错误 | 503 | 供应商不可用 |
### 校验
- `PASS` → True`UNKNOWN` → False;接口异常/网络错误 → `SmsError(503)`
## 7. 防爆破 / 频控分工
| 机制 | 极光(Mode B | 阿里云(Mode A |
|---|---|---|
| 验证码存储 | 本地内存 | **阿里云托管**(消除多 worker 存码债) |
| 单号发送冷却 | 本地 `_last_sent` 60s | **交给阿里云 `Interval`**(无本地状态),命中→429 |
| 单设备+IP 频控 | API 层 5/时、20/天 | 同左,**不变** |
| **防爆破(单码失败 N 次即作废)** | 本地 `_CodeRecord.attempts` | **本地 per-phone 计数**,与极光同语义(§4.2 |
- 阿里云路径的**唯一本地状态** = per-phone 失败计数 `dict[phone,int]` + `Lock` + GC(仿极光 `_gc`)。
- 多 worker 降级:失败计数按 worker 各计,effective 上限 = N×workers;与极光现状**同级**(属刻意保留的一致性),且 API 层登录频控(`sms-login-device` 设备+IP 5/时)提供硬兜底。
- 计数复位:`send_code` 成功清计数、`verify` PASS 清计数(新码/验过即新预算)。
- API 层设备频控与测试账号短路(`app/core/test_account.py`**完全不动**。
## 8. 配置项(`app/core/config.py` 新增)
```python
SMS_PROVIDER: str = "jiguang" # jiguang | aliyun;默认极光(保持现状,上线后切 aliyun)
# --- 阿里云 dypns 号码认证 ---
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_configured`(仿 `mt_cps_configured`):AK_ID/AK_SECRET/SignName/TemplateCode 齐全才为真;`SMS_PROVIDER=aliyun` 但未配 → `send_code``SmsError(503)`
- 复用现有 `SMS_MOCK``SMS_CODE_LENGTH`mock 校验位数)、`SMS_MAX_VERIFY_ATTEMPTS`(防爆破上限,两 provider 共用)。
### 配置敏感点
- `TemplateParam` 变量名(`code`/`min`)须与控制台所选**赠送模板**一致。融合认证验证码模板通常即 `code`+`min`,按此硬编码并加注释;若模板变量名不同,改 `aliyun.py` 该处即可。
- `SchemeName` 在 send 与 check 必须一致,故用**单一** `ALIYUN_SMS_SCHEME_NAME` 供两处,避免不匹配(CheckSmsVerifyCode 文档明确警告)。
## 9. auth.py 改动(最小)
`verify_code` 现在可能抛 `SmsError`(阿里云降级 503)。两处调用点各包一层 `try/except SmsError → HTTPException(e.status_code)`,与 `send_code` 现有写法一致:
- `app/api/v1/auth.py` `sms_login`(约 L185
- `app/api/v1/auth.py` `wechat_bind_phone_sms`(约 L325
`send_code` 调用点已 try/except `SmsError`,无需改。
## 10. 依赖 & SDK
- `pyproject.toml``alibabacloud_dypnsapi20170525`(连带 `alibabacloud-tea-openapi` 等)。
- SDK 同步阻塞调用 → 与现有 sync 端点 + sync httpx 风格一致(FastAPI 跑 threadpool,无碍)。
- `aliyun.py` 内**惰性 import SDK + 惰性建 client**(仿 `wxpay` 惰性加载证书):`SMS_PROVIDER=jiguang` 时不加载 alibabacloud,启动保持精简。
- SDK 调用形态(实现时按实际包名/字段核对):
```python
from alibabacloud_dypnsapi20170525.client import Client
from alibabacloud_dypnsapi20170525 import models as dypns_models
from alibabacloud_tea_openapi import models as open_api_models
cfg = open_api_models.Config(access_key_id=..., access_key_secret=...)
cfg.endpoint = settings.ALIYUN_SMS_ENDPOINT
client = Client(cfg)
resp = client.send_sms_verify_code(dypns_models.SendSmsVerifyCodeRequest(...))
# resp.body.code / resp.body.success / resp.body.model.verify_code
resp = client.check_sms_verify_code(dypns_models.CheckSmsVerifyCodeRequest(...))
# resp.body.model.verify_result == "PASS"
```
## 11. 测试
- 现有测试:`SMS_MOCK=true` → 分派器短路,全绿不变。
- 新增 `tests/test_sms_aliyun.py`monkeypatch SDK client,不发真网络):
1. 发码成功 → 返回 cooldown、清计数。
2. 各错误码 → 对应 `SmsError.status_code`400/429/503)。
3. 校验 `PASS`→True(清计数)/ `UNKNOWN`→False(计数 +1)/ 接口异常→`SmsError(503)`。
4. 失败计数达 `SMS_MAX_VERIFY_ATTEMPTS` → 直接 False,不再调阿里云。
5. `send_code` 成功复位计数。
- 新增分派测试:`SMS_PROVIDER` 切换选中正确 provider`SMS_MOCK` 优先于 provider。
## 12. YAGNI(明确不做)
- ❌ 不做 Redis/DB 存码(Mode A 无需;极光路径内存债维持现状,非本次范围)。
- ❌ 不改极光任何行为、不动 API 层频控/测试账号逻辑。
- ❌ 不做多签名/多模板轮换(单签名单模板足够)。
- ❌ 不把失败计数持久化/跨进程(刻意保留与极光同级的本地态)。
## 13. 验收标准
- `SMS_PROVIDER=aliyun` 且配置齐全时:`/sms/send` 走 `SendSmsVerifyCode`、`/sms/login` 走 `CheckSmsVerifyCode`,真机可收码并登录。
- `SMS_PROVIDER=jiguang`(默认):行为与当前完全一致。
- `SMS_MOCK=true`:任意 N 位数字通过,不发真短信。
- 阿里云接口异常时:`/sms/login` 返回 503(非 400),日志可区分。
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
+3
View File
@@ -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",
+40 -39
View File
@@ -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
# ============================ 用户名 / 默认昵称 ============================
+185
View File
@@ -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
+47
View File
@@ -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"]