Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f449d3600 | |||
| c9ef4464f6 | |||
| ed76820e97 | |||
| e052fb778b | |||
| b0482ec157 | |||
| 0aee9d4dd0 | |||
| c6309f0f74 | |||
| 3b90e2f212 | |||
| b2ea6c727c | |||
| 5e706fd003 | |||
| 9c55344e85 | |||
| 4d3b73ae70 | |||
| 5dff56bbb2 | |||
| c734c00742 | |||
| 886e781a4f |
+39
-4
@@ -6,8 +6,17 @@ APP_NAME=shaguabijia-app-server
|
||||
APP_DEBUG=true
|
||||
|
||||
# ===== 数据库 =====
|
||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
# Docker 自动定位:run.bat/run.sh 会自动找 Docker Desktop 并启动——优先从 PATH 上的 docker CLI 反推
|
||||
# 安装目录(装在 D 盘等非默认盘符也能找到),再退到注册表 / 常见目录。仅当你的安装位置极特殊、自动
|
||||
# 探测失败时,才需下面这行显式指到 exe(值可含空格,直接写到行尾即可,无需引号):
|
||||
# DOCKER_DESKTOP_EXE=D:\Program Files\Docker\Docker\Docker Desktop.exe
|
||||
# 实在不想装/启 Docker → 把上面 DATABASE_URL 改成 sqlite 可降级跑(仅救急,PG 专有 SQL/严格性不被验证):
|
||||
# DATABASE_URL=sqlite:///./data/app.db
|
||||
|
||||
# ===== JWT =====
|
||||
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
@@ -33,12 +42,38 @@ 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 阿里云号码认证 / chuanglan 创蓝云智)=====
|
||||
# jiguang :本服务生成验证码,极光 REST 只负责下发,本地内存校验(复用上面极光 JG_* 凭证)。
|
||||
# aliyun :阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A,核验免费);缺凭证时 /sms/* 返 503。
|
||||
# 需在阿里云号码认证控制台开通「融合认证」,并使用系统赠送签名 + 赠送模板。
|
||||
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
|
||||
# 用 YZM 前缀验证码账号;服务器出网 IP 需在创蓝控制台加白名单(否则 117)。见 docs/integrations/chuanglan/tpl-send.md。
|
||||
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
|
||||
# --- 创蓝云智(253)---
|
||||
CHUANGLAN_SMS_ACCOUNT=
|
||||
CHUANGLAN_SMS_PASSWORD=
|
||||
CHUANGLAN_SMS_TEMPLATE_ID=1022457679
|
||||
# 短信签名文案【品牌】;模板已关联签名则留空。
|
||||
CHUANGLAN_SMS_SIGNATURE=
|
||||
CHUANGLAN_SMS_ENDPOINT=https://smssh.253.com/msg/sms/v2/tpl/send
|
||||
CHUANGLAN_SMS_TIMEOUT_SEC=10
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||
|
||||
@@ -49,6 +49,9 @@ secrets/*
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 本地 admin server(端口 8771)Windows 启动脚本,个人调试用,不入库
|
||||
/run8771.bat
|
||||
|
||||
# Claude Code 自动持久化的权限 allowlist / 个人本地设置(会话专属,不入库)。
|
||||
# 需要团队共享的 Claude 配置(commands/ 等)可单独 git add -f,不受此忽略影响。
|
||||
.claude/settings.json
|
||||
@@ -57,3 +60,6 @@ tests/meituan_coupon_bj.tsv
|
||||
tests/meituan_coupon_data.tsv
|
||||
tests/meituan_coupon_fz.tsv
|
||||
tests/meituan_coupon_xm.tsv
|
||||
|
||||
# git worktrees (superpowers 隔离工作区)
|
||||
.worktrees/
|
||||
|
||||
@@ -76,8 +76,8 @@ Endpoints under `app/api/internal/` are for server-to-server communication (pric
|
||||
|
||||
## Database
|
||||
|
||||
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
|
||||
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` auto-start it via `scripts/ensure_pg.py` + `docker-compose.yml`; `.env.example` ships the PG URL by default; pytest uses the same container's `shaguabijia_test` DB. **Local no longer uses SQLite** (the SQLite branch in `db/session.py` is retained as a fallback only).
|
||||
- **Prod**: native PostgreSQL — bootstrap with `scripts/init_postgres.py` (no Docker). Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
|
||||
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
|
||||
|
||||
@@ -89,7 +89,7 @@ All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` cl
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, ensures the Docker PG `shaguabijia_test` DB via `scripts/ensure_pg.py`, builds all tables with `Base.metadata.create_all()` (drop+create for a clean start), tears down with `drop_all()`.
|
||||
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
|
||||
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
|
||||
|
||||
|
||||
+11
-2
@@ -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(
|
||||
|
||||
@@ -141,6 +141,49 @@ class Settings(BaseSettings):
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 短信提供商(可切换:极光 / 阿里云号码认证 / 创蓝云智)=====
|
||||
# jiguang(默认):本服务生成验证码,极光只负责下发,本地内存校验(自管码,现状不变)。
|
||||
# aliyun:阿里云 dypns 号码认证,阿里云生成+下发+校验(Mode A);缺凭证时 /sms/* 返 503(优雅降级)。
|
||||
# chuanglan:创蓝云智(253)模板短信,本服务生成码、创蓝只下发、本地校验(Mode B,与极光同);缺凭证 503。
|
||||
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "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 读/连超时秒
|
||||
|
||||
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名(见 docs/integrations/chuanglan/tpl-send.md)---
|
||||
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
|
||||
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅用于本地算 HMAC 签名,不随请求上行)
|
||||
CHUANGLAN_SMS_TEMPLATE_ID: str = "" # 模板 ID(控制台创建)
|
||||
CHUANGLAN_SMS_SIGNATURE: str = "" # 短信签名文案【品牌】;模板已关联签名则留空
|
||||
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
|
||||
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10 # httpx 读/连超时秒
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
@property
|
||||
def chuanglan_sms_configured(self) -> bool:
|
||||
"""创蓝短信凭证齐全(缺则 SMS_PROVIDER=chuanglan 时 /sms/send 返 503,而非启动崩)。"""
|
||||
return bool(
|
||||
self.CHUANGLAN_SMS_ACCOUNT
|
||||
and self.CHUANGLAN_SMS_PASSWORD
|
||||
and self.CHUANGLAN_SMS_TEMPLATE_ID
|
||||
)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""短信验证码服务 —— 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` 子模块,行为零改动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
from . import aliyun, chuanglan, jiguang
|
||||
from .base import SmsError
|
||||
|
||||
__all__ = ["SmsError", "send_code", "verify_code"]
|
||||
|
||||
# provider 名 -> 模块;未知/缺省值回退 jiguang(默认兜底,防误配把登录打挂)。
|
||||
_PROVIDERS = {"aliyun": aliyun, "chuanglan": chuanglan}
|
||||
|
||||
|
||||
def _provider():
|
||||
"""按配置选 provider 模块(每次调用读 settings,支持运行时切换 / 测试注入)。"""
|
||||
return _PROVIDERS.get(settings.SMS_PROVIDER, 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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""创蓝云智(253)短信 provider(自管码 Mode B)。
|
||||
|
||||
创蓝 `tpl/send` v2 是**纯发送网关**(本服务生成码 → 放入 templateParamJson → 创蓝只下发,
|
||||
无校验接口),故与极光同为 **Mode B**:本服务生成/存储/校验验证码,创蓝只负责发。
|
||||
|
||||
**本模块的存码/冷却/一次性/防爆破/GC 机器与 [jiguang.py](jiguang.py) 是刻意的隔离复制**
|
||||
(设计见 docs/superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md):极光文件一行不动、
|
||||
零回归风险于登录关键路径的默认 provider;代价是两处 Mode B 并发逻辑重复,改动需同步。唯一新逻辑
|
||||
是 `_send_via_chuanglan`(HMAC-SHA256 签名 + httpx POST + 错误码映射)。
|
||||
|
||||
两种运行模式由 `SMS_MOCK` 切换:
|
||||
- **mock**(开发/测试,默认):不真发,验证码打日志;校验放行任意 N 位数字。
|
||||
- **real**(`SMS_MOCK=false` 且 `SMS_PROVIDER=chuanglan`):`secrets` 生成码 → 调创蓝 `tpl/send`
|
||||
下发(HMAC 签名,password 仅本地算签不上行)→ 校验比对本地存码(一次性 / 过期 / 防爆破)。
|
||||
|
||||
验证码存储:**进程内存**(单 worker 够用,多 worker 不共享,与极光同级技术债)。防刷同极光:
|
||||
单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件)+ 单设备/IP 频控(api 层)+ 单码失败 `SMS_MAX_VERIFY_ATTEMPTS`
|
||||
次即作废。运维侧另需在创蓝控制台配 **IP 白名单**(否则 117)。接口调研见 docs/integrations/chuanglan/tpl-send.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
from .base import SmsError, mock_verify
|
||||
|
||||
logger = logging.getLogger("shagua.sms.chuanglan")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CodeRecord:
|
||||
code: str
|
||||
expires_at: float
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
# 进程内存(单 worker 有效;多 worker 不共享,见模块 docstring)。与极光同结构。
|
||||
_codes: dict[str, _CodeRecord] = {} # phone -> 当前有效验证码
|
||||
_last_sent: dict[str, float] = {} # phone -> 上次发送 epoch(冷却)
|
||||
_lock = Lock()
|
||||
_GC_THRESHOLD = 10000 # 任一内存 dict 超此阈值,send 时顺手清过期项(防无限增长)
|
||||
|
||||
# 发码错误码(创蓝 `code`)→ (HTTP 码, 用户提示)。未列出的一律 503(供应商不可用)。
|
||||
_SEND_ERRORS: dict[str, tuple[int, str]] = {
|
||||
"103": (429, "发送过于频繁,请稍后再试"), # 提交速度过快
|
||||
"107": (400, "手机号无效"), # 手机号码错误
|
||||
}
|
||||
# 需运维介入的配置/开通/余额类错误:打 critical 日志(仍归 503)。
|
||||
_SEND_CRITICAL_CODES = frozenset({
|
||||
"109", # 无发送量/余额不足
|
||||
"117", # IP 未加白名单
|
||||
"102", # 密码错误
|
||||
"116", # 签名不合法
|
||||
"124", # 模板内容不匹配
|
||||
"152", # 模板不存在
|
||||
"101", # 账号不存在
|
||||
"118", # 无发送权限
|
||||
})
|
||||
|
||||
|
||||
def _gen_code() -> str:
|
||||
"""生成 N 位数字验证码(用 secrets 而非 random;允许前导 0)。"""
|
||||
return "".join(secrets.choice("0123456789") for _ in range(settings.SMS_CODE_LENGTH))
|
||||
|
||||
|
||||
def _gc(now: float) -> None:
|
||||
"""顺手清理过期内存项,防两个 dict 无限增长。仅在持锁时调用,且某 dict 超阈值才扫它。"""
|
||||
if len(_codes) > _GC_THRESHOLD:
|
||||
for p in [p for p, r in _codes.items() if now > r.expires_at]:
|
||||
_codes.pop(p, None)
|
||||
if len(_last_sent) > _GC_THRESHOLD:
|
||||
cutoff = now - settings.SMS_SEND_INTERVAL_SEC
|
||||
for p in [p for p, ts in _last_sent.items() if ts < cutoff]:
|
||||
_last_sent.pop(p, None)
|
||||
|
||||
|
||||
def send_code(phone: str) -> int:
|
||||
"""发送验证码。
|
||||
|
||||
Returns: 距下次可发的秒数(= SMS_SEND_INTERVAL_SEC)
|
||||
Raises: SmsError(过频 429 / 手机号无效 400 / 供应商失败 503)
|
||||
"""
|
||||
now = time.time()
|
||||
|
||||
# --- lock 内:防刷检查 + 预占(防并发重复发烧钱)---
|
||||
with _lock:
|
||||
_gc(now) # 顺手清过期内存(超阈值才扫)
|
||||
elapsed = now - _last_sent.get(phone, 0.0)
|
||||
if elapsed < settings.SMS_SEND_INTERVAL_SEC:
|
||||
remain = int(settings.SMS_SEND_INTERVAL_SEC - elapsed)
|
||||
raise SmsError(f"发送过于频繁,请 {remain}s 后再试")
|
||||
|
||||
code = _gen_code()
|
||||
# 预占:先记冷却/存码,释放锁后再发网络(发失败保留冷却,见下)
|
||||
_last_sent[phone] = now
|
||||
_codes[phone] = _CodeRecord(code=code, expires_at=now + settings.SMS_CODE_TTL_SEC)
|
||||
|
||||
# --- lock 外:真正发送(网络 IO 不持锁)---
|
||||
try:
|
||||
if settings.SMS_MOCK:
|
||||
logger.info("[SMS-chuanglan-MOCK] to %s**** code=%s (不真发)", phone[:3], code)
|
||||
else:
|
||||
_send_via_chuanglan(phone, code)
|
||||
logger.info("[SMS-chuanglan] sent to %s****", phone[:3])
|
||||
except Exception as e:
|
||||
# 发送失败:**保留冷却**(失败也限速,挡住余额不足/签名失效时前端重试狂打),
|
||||
# 只清掉没发出去的码(用户收不到,留着无意义且占内存)。
|
||||
with _lock:
|
||||
_codes.pop(phone, None)
|
||||
if isinstance(e, SmsError):
|
||||
raise
|
||||
logger.exception("[SMS-chuanglan] send failed phone=%s****", phone[:3])
|
||||
raise SmsError("验证码发送失败,请稍后重试", status_code=503) from e
|
||||
|
||||
return settings.SMS_SEND_INTERVAL_SEC
|
||||
|
||||
|
||||
def verify_code(phone: str, code: str) -> bool:
|
||||
"""校验验证码。
|
||||
|
||||
- **mock 模式**:放行任意 N 位数字(测试/开发便利,不真校验)。
|
||||
- **real 模式**:比对本服务存的码,匹配即作废(一次性);失败累计到上限也作废(防爆破)。
|
||||
"""
|
||||
if settings.SMS_MOCK:
|
||||
ok = mock_verify(code)
|
||||
logger.info("[SMS-chuanglan-MOCK] verify %s for %s****", "ok" if ok else "fail", phone[:3])
|
||||
return ok
|
||||
|
||||
with _lock:
|
||||
rec = _codes.get(phone)
|
||||
if rec is None:
|
||||
return False
|
||||
if time.time() > rec.expires_at:
|
||||
_codes.pop(phone, None)
|
||||
return False
|
||||
if rec.attempts >= settings.SMS_MAX_VERIFY_ATTEMPTS:
|
||||
_codes.pop(phone, None) # 试错过多,作废
|
||||
return False
|
||||
if secrets.compare_digest(code.encode("utf-8"), rec.code.encode("utf-8")):
|
||||
_codes.pop(phone, None) # 验过即作废
|
||||
return True
|
||||
rec.attempts += 1
|
||||
return False
|
||||
|
||||
|
||||
# ============================ 发送接缝(单测 monkeypatch 这两个 / httpx.post)============================
|
||||
|
||||
def _sign(password: str, timestamp: str, nonce: str) -> str:
|
||||
"""创蓝 HMAC-SHA256 签名:key=md5(password),msg=sorted([md5pwd,ts,nonce]) 拼接去空白,输出小写 hex。"""
|
||||
md5pwd = hashlib.md5(password.encode()).hexdigest() # 32 位小写 hex
|
||||
raw = "".join(sorted([md5pwd, timestamp, nonce])) # 字典序升序,无分隔符拼接
|
||||
raw = "".join(raw.split()) # 去所有空白(faithful;三段本无空白)
|
||||
return hmac.new(md5pwd.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _call_chuanglan(phone: str, code: str) -> dict:
|
||||
"""组装 + 签名 + POST 创蓝 tpl/send,返回解析后的响应 dict。
|
||||
|
||||
传输错误 / HTTP≠200 / 响应非 JSON 一律抛 SmsError(503)(保「provider 出问题→503」不变式);
|
||||
业务码(含 000000)由调用方 `_send_via_chuanglan` 判读。password 只用于算签,不入 body。
|
||||
"""
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = secrets.token_hex(16) # 32 位 hex
|
||||
body = {
|
||||
"account": settings.CHUANGLAN_SMS_ACCOUNT,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"phoneNumbers": phone,
|
||||
"templateId": settings.CHUANGLAN_SMS_TEMPLATE_ID,
|
||||
"templateParamJson": json.dumps([{"param1": code}]),
|
||||
}
|
||||
if settings.CHUANGLAN_SMS_SIGNATURE:
|
||||
body["signature"] = settings.CHUANGLAN_SMS_SIGNATURE
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-QA-Hmac-Signature": _sign(settings.CHUANGLAN_SMS_PASSWORD, timestamp, nonce),
|
||||
}
|
||||
try:
|
||||
resp = httpx.post(
|
||||
settings.CHUANGLAN_SMS_ENDPOINT,
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=settings.CHUANGLAN_SMS_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.exception("[SMS-chuanglan] 网络错误 phone=%s****", phone[:3])
|
||||
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error("[SMS-chuanglan] http=%s body=%s", resp.status_code, resp.text[:200])
|
||||
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503)
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.error("[SMS-chuanglan] 响应非 JSON: %s", resp.text[:200])
|
||||
raise SmsError("短信服务暂不可用,请稍后重试", status_code=503) from e
|
||||
|
||||
|
||||
def _send_via_chuanglan(phone: str, code: str) -> None:
|
||||
"""调创蓝 tpl/send 发送。成功静默返回;失败按错误码映射抛 SmsError。"""
|
||||
if not settings.chuanglan_sms_configured:
|
||||
raise SmsError("短信服务未配置(缺创蓝 account/password/templateId)", status_code=503)
|
||||
|
||||
result = _call_chuanglan(phone, code) # 传输/非200/解析异常在内部抛 SmsError(503)
|
||||
rcode = str(result.get("code"))
|
||||
if rcode == "000000":
|
||||
return
|
||||
|
||||
emsg = result.get("errorMsg") or ""
|
||||
logger.error("[SMS-chuanglan] send failed code=%s msg=%s", rcode, emsg)
|
||||
if rcode in _SEND_CRITICAL_CODES:
|
||||
logger.critical("[SMS-chuanglan] %s —— 需运维处理(余额/IP白名单/密码/签名/模板/账号)", rcode)
|
||||
status, msg = _SEND_ERRORS.get(rcode, (503, "短信服务暂不可用,请稍后重试"))
|
||||
raise SmsError(msg, status_code=status)
|
||||
@@ -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,22 @@
|
||||
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
|
||||
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
@@ -27,7 +27,21 @@ PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时
|
||||
|
||||
## 1. 本地起 PG + 跑通空库(半天)
|
||||
|
||||
### 1.1 装 PG
|
||||
### 1.0 推荐:Docker 一键起(本地开发/测试)
|
||||
|
||||
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
|
||||
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
|
||||
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
|
||||
```
|
||||
|
||||
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
|
||||
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
|
||||
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
|
||||
|
||||
### 1.1 装 PG(不用 Docker 时的手动方式)
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
|
||||
@@ -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. 没有开通融合认证功能
|
||||
@@ -0,0 +1,117 @@
|
||||
# 创蓝云智(253/蓝创云智)模板短信 v2 发送接口
|
||||
|
||||
> 官方文档:<https://doc.chuanglan.com/document/HAQYSZKH9HT5Z50L>
|
||||
> 用途:手机号 + 验证码登录的**验证码短信下发**(本服务生成码 → 创蓝只负责发送,属自管码 Mode B,与极光同模式)。
|
||||
> 本文件为**接口调研摘要**,供 `app/integrations/sms/chuanglan.py` 实现对照。以线上文档为准。
|
||||
|
||||
## 接口概览
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 请求地址 | `POST https://smssh.253.com/msg/sms/v2/tpl/send` |
|
||||
| Content-Type | `application/json`(UTF-8) |
|
||||
| 协议 | HTTPS |
|
||||
| 鉴权 | HMAC-SHA256 签名头 `X-QA-Hmac-Signature`(推荐)**或** body 明文 `password`(二选一) |
|
||||
|
||||
## 鉴权:两种方式(二选一,不可并用)
|
||||
|
||||
1. **HMAC 签名头(推荐,密码不上行)**:请求头带 `X-QA-Hmac-Signature`,body **不放** `password`。
|
||||
2. **明文密码**:body 放 `password`,不带签名头。
|
||||
|
||||
### HMAC-SHA256 签名算法
|
||||
|
||||
1. `md5Password = MD5(password)` —— 32 位**小写十六进制**。
|
||||
2. 取三个值 `[md5Password, timestamp, nonce]`,**按字典序升序排序**,**无分隔符拼接**,再 `replaceAll("\\s+", "")` 去除所有空白。
|
||||
3. `signature = HmacSHA256(key = md5Password, message = 上一步拼接串)` —— 输出**小写十六进制**。
|
||||
4. 放入请求头:`X-QA-Hmac-Signature: <signature>`。
|
||||
|
||||
> 注意 `key` 就是 `md5Password` 本身(32 位 hex 字符串),不是原始 password。`timestamp` / `nonce` 同时也是 body 字段,必须与签名里用的一致。
|
||||
|
||||
## 请求参数(body,JSON)
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `account` | String | 是 | API 账号;验证码短信用 **`YZM` 前缀**账号(如 `YZM0000001`) |
|
||||
| `timestamp` | String | 是 | Unix 秒级时间戳;**60 秒**内有效,过期报 139 |
|
||||
| `nonce` | String | 是 | 32 位随机串(防重放) |
|
||||
| `phoneNumbers` | String | 是 | 手机号,逗号分隔最多 1000 个;**YZM 验证码账号不支持批量,只能单号** |
|
||||
| `templateId` | String | 是 | 模板 ID(控制台创建 / 模板接口查询) |
|
||||
| `templateParamJson` | String | 条件 | 模板变量,JSON 字符串;模板有 `{s}` 占位符时必填(见下) |
|
||||
| `password` | String | 条件 | 仅在**不使用**签名头时放 body |
|
||||
| `signature` | String | 条件 | **短信签名文案**(如 `【创蓝云智】`);模板未关联签名时必填。**注意与鉴权头 `X-QA-Hmac-Signature` 是两回事** |
|
||||
| `report` | String | 否 | `"true"` 时接收状态回执 |
|
||||
| `callbackUrl` | String | 否 | 回执回调完整 URL |
|
||||
| `uid` | String | 否 | 自定义标识(≤256 字符),回执原样返回 |
|
||||
| `extend` | String | 否 | 数字扩展码(≤5 位),用于上行匹配 |
|
||||
|
||||
### `templateParamJson` 格式与 `{s}` 占位
|
||||
|
||||
- 模板内容用 `{s}` 作占位符,例:`您正在申请手机注册,验证码为:{s},5分钟内有效!`
|
||||
- `templateParamJson` 是 **JSON 数组,元素为对象**,键按 `param1`、`param2`…递增;第 1 个 `{s}` ← `param1`,第 2 个 ← `param2`。
|
||||
- 单条验证码(一个 `{s}` = 验证码)示例:`"[{\"param1\":\"123456\"}]"`
|
||||
|
||||
## 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"msgId": "25071018345400902898000000000001",
|
||||
"time": "20250710183454",
|
||||
"successNum": "1",
|
||||
"failNum": "0",
|
||||
"errorMsg": ""
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `code` | 状态码,`"000000"` = 成功 |
|
||||
| `msgId` | 消息 ID(32 位) |
|
||||
| `time` | 响应时间戳 |
|
||||
| `successNum` / `failNum` | 提交成功 / 失败条数 |
|
||||
| `errorMsg` | 错误描述(成功为空) |
|
||||
|
||||
## 响应 / 错误码(节选)
|
||||
|
||||
| code | 含义 | 归属 |
|
||||
|---|---|---|
|
||||
| `000000` | 成功 | — |
|
||||
| `101` | 账号不存在 | 客服 |
|
||||
| `102` | 密码错误 | 客服 |
|
||||
| `103` | 提交速度过快(超频) | 客服 |
|
||||
| `107` | 手机号码错误 | 客服 |
|
||||
| `109` | 无发送量(余额/套餐不足) | 销售 |
|
||||
| `110` | 不在发送时段 | 销售 |
|
||||
| `116` | 签名不合法 / 未带签名 | 服务 |
|
||||
| `117` | IP 未加白名单 | 服务 |
|
||||
| `118` | 账号无发送权限 | 服务 |
|
||||
| `124` | 模板内容不匹配 | 服务 |
|
||||
| `129` | JSON 格式错误 | 客服 |
|
||||
| `135` | 相同手机号内容重复 | 客服 |
|
||||
| `139` | 时间戳过期 | 客服 |
|
||||
| `152` | 模板不存在 | 服务 |
|
||||
| `158` | 退订文案不合规 | 客服 |
|
||||
|
||||
## 完整请求示例(单条验证码,明文密码方式省略 password 用签名头)
|
||||
|
||||
```json
|
||||
{
|
||||
"account": "YZM0000001",
|
||||
"timestamp": "1752143733",
|
||||
"nonce": "x4zfk0y5foqwx6cbnw3bfmimy98abqs1",
|
||||
"phoneNumbers": "17601337176",
|
||||
"templateId": "1021143438",
|
||||
"templateParamJson": "[{\"param1\":\"123456\"}]",
|
||||
"report": "true"
|
||||
}
|
||||
```
|
||||
|
||||
(HMAC 方式:另加请求头 `X-QA-Hmac-Signature: <算法输出>`,body 不含 `password`。)
|
||||
|
||||
## 接入要点
|
||||
|
||||
- **IP 白名单**:服务器出网 IP 必须在控制台加白,否则 117。
|
||||
- **验证码账号(YZM)**:无发送时段限制;单号发送、不支持批量。
|
||||
- **时间戳 60s**:`timestamp` 与本地时钟偏差过大会 139,注意服务器时间同步。
|
||||
- **退订文案**:仅支持 `拒收请回复R`,且必须在短信末尾(验证码短信一般无需)。
|
||||
- **签名 vs 鉴权头**:`signature`(body)= 短信开头的 `【品牌】` 文案;`X-QA-Hmac-Signature`(header)= 请求鉴权。二者含义完全不同,勿混。
|
||||
@@ -1,9 +1,26 @@
|
||||
# 短信验证码(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`(阿里云号码认证托管码)/ `chuanglan`(创蓝云智模板短信,自管码)。`SMS_MOCK` 另切 mock / real。
|
||||
|
||||
## 短信提供商(`SMS_PROVIDER`,可切换 + 灰度回退)
|
||||
| | `jiguang`(默认) | `aliyun` | `chuanglan` |
|
||||
|---|---|---|---|
|
||||
| 验证码模式 | 自管码 Mode B | 托管码 Mode A | 自管码 Mode B(与极光同) |
|
||||
| 验证码 | **本服务生成**、极光只下发、**本地内存校验** | **阿里云生成 + 下发 + 校验**(dypns 号码认证,核验免费) | **本服务生成**、创蓝只下发、**本地内存校验** |
|
||||
| 发码 | 极光 `/v1/messages` | `SendSmsVerifyCode`(`##code##` 占位) | 创蓝 `tpl/send` v2(HMAC 签名头,password 不上行) |
|
||||
| 校验 | 比对本地存码 | `CheckSmsVerifyCode` → `PASS` / `UNKNOWN` | 比对本地存码 |
|
||||
| 多 worker | ⚠️ 内存存码不共享(见已知局限) | ✅ 阿里云托管,天然共享 | ⚠️ 内存存码不共享(与极光同级债) |
|
||||
| 防爆破 | 单码失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废 | **同语义**(本地 per-phone 失败计数) | **同语义**(复制自极光) |
|
||||
| 单号冷却 | 本地 `SMS_SEND_INTERVAL_SEC` | 交给阿里云 `Interval` | 本地 `SMS_SEND_INTERVAL_SEC` |
|
||||
| 依赖 | `httpx`(复用) | SDK `alibabacloud_dypnsapi20170525` | `httpx` + 标准库 `hashlib`/`hmac`(**无新依赖**) |
|
||||
|
||||
- `aliyun` 需在**号码认证控制台开通「融合认证」**,用系统赠送签名 + 赠送模板;配置见 `.env.example` 的 `ALIYUN_SMS_*`,SDK 为 `alibabacloud_dypnsapi20170525`。
|
||||
- `chuanglan` 用 **YZM 前缀验证码账号**,服务器出网 IP 须在创蓝控制台**加白名单**(否则 117);Mode B 存码/冷却/校验逻辑是**从极光隔离复制**(极光文件不动),仅发送走 HMAC 签名。配置见 `.env.example` 的 `CHUANGLAN_SMS_*`,接口调研见 [chuanglan/tpl-send.md](chuanglan/tpl-send.md),设计见 [spec](../superpowers/specs/2026-07-26-chuanglan-sms-verify-design.md)。
|
||||
|
||||
**以下章节描述 `jiguang` provider(自管码)细节**(`chuanglan` 的存码/冷却/校验语义与之相同)。
|
||||
|
||||
| | mock(`SMS_MOCK=true`,默认 / 开发测试) | real(`SMS_MOCK=false`,生产) |
|
||||
|---|---|---|
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
# 本地开发切 Docker PostgreSQL 实现计划
|
||||
|
||||
> **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:** 让 app-server 本地开发运行与 pytest 都跑在 Docker 化的 PostgreSQL 16 上,退掉 SQLite,`run.bat`/`run.sh` 启动时自动检测并拉起 PG(必要时先启 Docker Desktop、缺镜像先拉)。
|
||||
|
||||
**Architecture:** 新增 `docker-compose.yml`(声明 PG 服务/卷/健康检查)+ `scripts/ensure_pg.py`(跨平台引导:探测→启 Docker→compose up→等就绪→幂等建测试库),由 `run.sh`/`run.bat`/`tests/conftest.py` 三处共用。`.env.example` 默认切 PG。`app/db/session.py` 与 `alembic/env.py` 已天然支持 PG,无需改。
|
||||
|
||||
**Tech Stack:** Docker Compose、`postgres:16-alpine`、Python 3.10+ 标准库(`socket`/`subprocess`/`urllib.parse`)、psycopg3(已装)、SQLAlchemy 2.0 + Alembic、pytest。
|
||||
|
||||
**工作目录:** 本计划在 worktree `.worktrees/local-dev-postgres-docker`(分支 `chore/local-dev-postgres-docker`,基于 `main`)内执行。下面所有路径相对该 worktree 根(= 仓库根)。
|
||||
|
||||
**设计依据:** [docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md](../specs/2026-07-08-local-dev-postgres-docker-design.md)
|
||||
|
||||
**连接参数(全程固定值):**
|
||||
- 镜像 `postgres:16-alpine`,容器名 `shaguabijia-pg`,宿主端口 `5432`
|
||||
- 用户 `shaguabijia_app`,dev 密码 `shaguabijia_dev_pw`(本地非机密)
|
||||
- 业务库 `shaguabijia`,测试库 `shaguabijia_test`,命名卷 `pgdata`
|
||||
- dev URL:`postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
|
||||
- test URL:`...@localhost:5432/shaguabijia_test`
|
||||
|
||||
**前置:** 执行机已安装 Docker Desktop。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Docker Compose + 测试库 initdb 脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`
|
||||
- Create: `docker/initdb/01-create-test-db.sql`
|
||||
|
||||
- [ ] **Step 1: 写 `docker-compose.yml`**
|
||||
|
||||
```yaml
|
||||
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `docker/initdb/01-create-test-db.sql`**
|
||||
|
||||
```sql
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
|
||||
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 起容器验证**
|
||||
|
||||
Run: `docker compose up -d`
|
||||
Expected: 拉取 `postgres:16-alpine`(首次)后 `Container shaguabijia-pg Started`。
|
||||
|
||||
- [ ] **Step 4: 验证两个库都在 + 健康**
|
||||
|
||||
Run: `docker compose exec -T postgres psql -U shaguabijia_app -d shaguabijia -tAc "SELECT datname FROM pg_database WHERE datname IN ('shaguabijia','shaguabijia_test') ORDER BY 1"`
|
||||
Expected 输出:
|
||||
```
|
||||
shaguabijia
|
||||
shaguabijia_test
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证 `.worktrees/` 与 `data/` 忽略不受影响、compose 无落盘到项目目录**
|
||||
|
||||
Run: `git status --short`
|
||||
Expected: 只列出本任务新增的 `docker-compose.yml`、`docker/initdb/01-create-test-db.sql`(数据在命名卷 `pgdata`,不在项目目录;`.worktrees/` 已忽略)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml docker/initdb/01-create-test-db.sql
|
||||
git commit -m "feat(dev): docker-compose 起本地 PostgreSQL(含测试库 initdb)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `scripts/ensure_pg.py` 引导脚本(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ensure_pg.py`
|
||||
- Test: `tests/test_ensure_pg.py`
|
||||
|
||||
> 说明:纯函数(URL 解析 / sqlite 判定 / 端口探测 / 平台命令映射 / sqlite 守卫 / 端口通时短路)走 TDD 单测;真正拉 Docker 的编排 `ensure()` 全链路靠 Task 3/5 的运行来验证(需真 Docker,不做单测)。此时 `conftest.py` 仍是 SQLite,不依赖 PG,单测可独立跑。
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `tests/test_ensure_pg.py`**
|
||||
|
||||
```python
|
||||
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from scripts.ensure_pg import (
|
||||
_docker_desktop_cmd,
|
||||
_is_sqlite,
|
||||
_parse_host_port,
|
||||
_port_open,
|
||||
ensure,
|
||||
)
|
||||
|
||||
|
||||
def test_is_sqlite():
|
||||
assert _is_sqlite("sqlite:///./data/app.db")
|
||||
assert _is_sqlite(" SQLite:///x ")
|
||||
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
|
||||
|
||||
|
||||
def test_parse_host_port_full():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_defaults():
|
||||
# 缺端口 → 5432
|
||||
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
|
||||
# 缺 host → localhost
|
||||
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_testdb():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_port_open_true():
|
||||
srv = socket.socket()
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
assert _port_open("127.0.0.1", port, timeout=1.0)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def test_port_open_false():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close() # 释放端口,无人监听 → 连接应失败
|
||||
assert not _port_open("127.0.0.1", port, timeout=0.3)
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows():
|
||||
cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
|
||||
assert cmd is not None
|
||||
assert cmd[0].endswith("Docker Desktop.exe")
|
||||
assert "Docker" in cmd[0]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_darwin():
|
||||
assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_linux():
|
||||
assert _docker_desktop_cmd("linux", "") is None
|
||||
|
||||
|
||||
def test_ensure_rejects_sqlite():
|
||||
# dev 守卫:sqlite 直接 False(不碰 Docker)
|
||||
assert ensure("sqlite:///./data/app.db") is False
|
||||
|
||||
|
||||
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
|
||||
# 端口通 → 直接 True,绝不触碰 docker
|
||||
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
|
||||
|
||||
def _boom():
|
||||
raise AssertionError("端口通时不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: FAIL —— `ModuleNotFoundError: No module named 'scripts.ensure_pg'`(还没建)。
|
||||
|
||||
- [ ] **Step 3: 写实现 `scripts/ensure_pg.py`**
|
||||
|
||||
```python
|
||||
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
|
||||
|
||||
被三处复用:
|
||||
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
|
||||
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
|
||||
|
||||
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
|
||||
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。全程无 SQLite 兜底。
|
||||
|
||||
生产用原生 PG(scripts/init_postgres.py),不走本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
|
||||
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(errors="backslashreplace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
APP_DB = "shaguabijia"
|
||||
TEST_DB = "shaguabijia_test"
|
||||
DB_USER = "shaguabijia_app"
|
||||
COMPOSE_SERVICE = "postgres"
|
||||
|
||||
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
|
||||
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
|
||||
POLL_INTERVAL = 3.0
|
||||
|
||||
SQLITE_FIX_HINT = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[ensure_pg] {msg}", flush=True)
|
||||
|
||||
|
||||
def _is_sqlite(url: str) -> bool:
|
||||
return url.strip().lower().startswith("sqlite")
|
||||
|
||||
|
||||
def _parse_host_port(url: str) -> tuple[str, int]:
|
||||
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
|
||||
parts = urlsplit(url)
|
||||
return (parts.hostname or "localhost"), (parts.port or 5432)
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
|
||||
"""按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
|
||||
if platform.startswith("win"):
|
||||
return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
|
||||
if platform == "darwin":
|
||||
return ["open", "-a", "Docker"]
|
||||
return None
|
||||
|
||||
|
||||
def _docker_ok(subcmd: str) -> bool:
|
||||
"""`docker version`(CLI 在不在)/`docker info`(daemon 起没起)成功与否。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", subcmd],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
|
||||
def _docker_cli_ok() -> bool:
|
||||
return _docker_ok("version")
|
||||
|
||||
|
||||
def _docker_daemon_ok() -> bool:
|
||||
return _docker_ok("info")
|
||||
|
||||
|
||||
def _start_docker_daemon() -> bool:
|
||||
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
|
||||
if _docker_daemon_ok():
|
||||
return True
|
||||
cmd = _docker_desktop_cmd(
|
||||
sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
)
|
||||
if cmd is None:
|
||||
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
|
||||
return False
|
||||
if sys.platform.startswith("win") and not Path(cmd[0]).exists():
|
||||
_log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
|
||||
return False
|
||||
_log(f"启动 Docker Desktop(首次冷启可能 30-60s)…")
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
_log(f"启动 Docker Desktop 失败:{e}")
|
||||
return False
|
||||
deadline = time.monotonic() + DOCKER_START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _docker_daemon_ok():
|
||||
_log("Docker 守护进程已就绪。")
|
||||
return True
|
||||
_log("等待 Docker 守护进程…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _compose_up() -> bool:
|
||||
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
_log(f"docker compose up 失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def _pg_isready() -> bool:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"pg_isready", "-U", DB_USER, "-d", APP_DB],
|
||||
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def _wait_pg_ready(host: str, port: int) -> bool:
|
||||
deadline = time.monotonic() + PG_READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(host, port) and _pg_isready():
|
||||
_log("PostgreSQL 已就绪。")
|
||||
return True
|
||||
_log("等待 PostgreSQL 就绪…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_test_db() -> None:
|
||||
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。"""
|
||||
check = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
|
||||
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
|
||||
cwd=ROOT, capture_output=True, text=True,
|
||||
)
|
||||
if check.returncode == 0 and check.stdout.strip() == "1":
|
||||
return
|
||||
_log(f"建测试库 {TEST_DB}…")
|
||||
subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
|
||||
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
|
||||
cwd=ROOT, check=False,
|
||||
)
|
||||
|
||||
|
||||
def ensure(database_url: str | None = None) -> bool:
|
||||
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。"""
|
||||
if database_url is None:
|
||||
from app.core.config import settings # 延迟导入,避免过早固化 settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
|
||||
if _is_sqlite(database_url):
|
||||
_log("检测到 DATABASE_URL 仍是 SQLite。本地开发/测试已切 PostgreSQL,请改成:")
|
||||
_log(f" DATABASE_URL={SQLITE_FIX_HINT}")
|
||||
return False
|
||||
|
||||
host, port = _parse_host_port(database_url)
|
||||
|
||||
if _port_open(host, port):
|
||||
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
|
||||
return True
|
||||
|
||||
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
|
||||
|
||||
if not _docker_cli_ok():
|
||||
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
|
||||
_log(" https://www.docker.com/products/docker-desktop/")
|
||||
return False
|
||||
if not _start_docker_daemon():
|
||||
return False
|
||||
if not _compose_up():
|
||||
return False
|
||||
if not _wait_pg_ready(host, port):
|
||||
return False
|
||||
|
||||
_ensure_test_db()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if ensure() else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: 11 passed。
|
||||
|
||||
- [ ] **Step 5: 手动冒烟(PG 已在跑时应秒过短路)**
|
||||
|
||||
Run: `python -m scripts.ensure_pg`
|
||||
Expected: 打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行,跳过 Docker。`,退出码 0。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/ensure_pg.py tests/test_ensure_pg.py
|
||||
git commit -m "feat(dev): scripts/ensure_pg.py 探测/拉起本地 Docker PostgreSQL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `run.sh` / `run.bat` 接入 ensure_pg
|
||||
|
||||
**Files:**
|
||||
- Modify: `run.sh`(在 `alembic upgrade head` 前插一步)
|
||||
- Modify: `run.bat`(同上)
|
||||
|
||||
- [ ] **Step 1: 改 `run.sh`**
|
||||
|
||||
在 `mkdir -p data` 之后、`"$PY" -m alembic upgrade head` 之前插入:
|
||||
|
||||
```bash
|
||||
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
|
||||
```
|
||||
|
||||
(`set -e` 已在文件顶部,ensure_pg 失败会自动终止脚本。)
|
||||
|
||||
- [ ] **Step 2: 改 `run.bat`**
|
||||
|
||||
在 `if not exist data mkdir data` 之后、`call "%PY%" -m alembic upgrade head` 之前插入:
|
||||
|
||||
```bat
|
||||
REM 确保本地 Docker PostgreSQL 就绪(没起会自动拉起 Docker + PG 容器)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL 未就绪^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 `run.sh`(PG 已在跑,应短路后继续 alembic + uvicorn)**
|
||||
|
||||
Run(Git Bash):`bash run.sh 8770`
|
||||
Expected: 依次出现 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行` → alembic 无报错 → uvicorn `Application startup complete`。`Ctrl-C` 停。
|
||||
|
||||
- [ ] **Step 4: 验证 `run.bat`(同上,Windows 原生)**
|
||||
|
||||
Run(cmd/PowerShell):`.\run.bat 8770`
|
||||
Expected: 同 Step 3。`Ctrl-C` 停。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add run.sh run.bat
|
||||
git commit -m "feat(dev): run.sh/run.bat 启动前确保 Docker PostgreSQL 就绪"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `.env.example` 默认切 PostgreSQL
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env.example`(第 8-10 行「数据库」段)
|
||||
|
||||
- [ ] **Step 1: 改 `.env.example` 的 DATABASE_URL**
|
||||
|
||||
把:
|
||||
```ini
|
||||
# ===== 数据库 =====
|
||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
```
|
||||
改成:
|
||||
```ini
|
||||
# ===== 数据库 =====
|
||||
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证(新 .env 从模板复制后能起服务)**
|
||||
|
||||
Run: `cp .env.example /tmp/env.check && grep '^DATABASE_URL=' /tmp/env.check`
|
||||
Expected: `DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .env.example
|
||||
git commit -m "feat(dev): .env.example 默认 DATABASE_URL 切 Docker PostgreSQL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `tests/conftest.py` 切 PostgreSQL 测试库
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/conftest.py`(整体替换:去掉临时 SQLite,改指 `shaguabijia_test` + 调 ensure_pg + fixture 改 drop/create)
|
||||
|
||||
- [ ] **Step 1: 整体替换 `tests/conftest.py`**
|
||||
|
||||
```python
|
||||
"""测试用 fixtures。
|
||||
|
||||
测试库用 Docker PG 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
|
||||
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 →
|
||||
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期建 engine)
|
||||
_TEST_DB_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
|
||||
)
|
||||
os.environ["DATABASE_URL"] = _TEST_DB_URL
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
|
||||
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
|
||||
os.environ.setdefault("JG_APP_KEY", "test-key")
|
||||
os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
|
||||
os.environ.setdefault("SMS_MOCK", "true")
|
||||
os.environ.setdefault("APP_ENV", "dev")
|
||||
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
|
||||
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
|
||||
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
|
||||
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
|
||||
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
|
||||
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
|
||||
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
# 2) 保证 Docker PG 就绪 + 测试库存在(必须在 import app.db.session 建 engine 之前)
|
||||
from scripts.ensure_pg import ensure
|
||||
|
||||
if not ensure(_TEST_DB_URL):
|
||||
raise RuntimeError(
|
||||
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
|
||||
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
|
||||
)
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_db() -> Iterator[None]:
|
||||
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 conftest 能引导 PG 并收集用例(选一个不涉 DB 的测试文件)**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: conftest 先打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行`(或拉起过程),随后 12 passed。说明「测试走 PG 引导」链路通、且纯函数测试不受影响。
|
||||
|
||||
- [ ] **Step 3: 验证建表落到 PG 测试库(跑一个 DB 相关用例)**
|
||||
|
||||
Run: `pytest tests/test_invite.py -v`
|
||||
Expected: 用例在 `shaguabijia_test` 上建表并执行(可能有个别红,留待 Task 6);关键是不再出现 SQLite 临时文件、engine 连的是 PG。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/conftest.py
|
||||
git commit -m "test(dev): conftest 切 shaguabijia_test(Docker PG),引导+drop/create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 全量跑 pytest on PG,逐个修红用例
|
||||
|
||||
> SQLite 宽松、PG 严格,切库会暴露一批真 bug(迁移指南 §2.2 已列)。本任务是**发现驱动**:先跑全量、按类别归因、按下述配方修,直到全绿。修改范围限被测业务/模型代码,不改测试来掩盖真 bug(除非测试本身依赖 SQLite 特性,如秒级时间精度)。
|
||||
|
||||
**Files:**
|
||||
- Modify: 视失败而定(常见:`app/models/*.py`、`app/**/repositories/*.py`、少量 `tests/*.py`)
|
||||
|
||||
- [ ] **Step 1: 全量跑,拿到失败清单**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: 大部分通过;记录所有 FAIL 的用例名与报错文本,按下面类别归因。
|
||||
|
||||
- [ ] **Step 2: 修「naive datetime / 时区」类**
|
||||
|
||||
定位:`git grep -n "utcnow()" app/`。把 `datetime.utcnow()` 改成 `datetime.now(timezone.utc)`(并 `from datetime import timezone`)。
|
||||
症状:PG `TIMESTAMPTZ` 与 naive datetime 比较/写入报错或结果错位;`tests/test_cps_admin.py` 已注释过 SQLite 忽略 tzinfo 的行为。
|
||||
|
||||
例:
|
||||
```python
|
||||
# 改前
|
||||
from datetime import datetime
|
||||
ts = datetime.utcnow()
|
||||
# 改后
|
||||
from datetime import datetime, timezone
|
||||
ts = datetime.now(timezone.utc)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修「字符串/整数隐式比较」类**
|
||||
|
||||
症状:SQLite 允许 `WHERE phone = 13800138000`(自动转型),PG 直接报类型错。定位报错用例引用的查询,确保比较两侧类型一致(手机号等一律按字符串传参 `:phone`,不要传裸 int)。
|
||||
|
||||
- [ ] **Step 3.5: 修「事务已中止」类**
|
||||
|
||||
症状:某用例后续报 `current transaction is aborted, commands ignored until end of transaction block`,根因是前一句 SQL 出错后业务代码缺 `db.rollback()`/`db.commit()` 边界。补上正确的 commit/rollback。
|
||||
|
||||
- [ ] **Step 4: 修「测试依赖 SQLite 特性」类(仅此类可改测试)**
|
||||
|
||||
症状:测试断言依赖 SQLite 秒级时间精度或 FK 不强制(见 `test_invite.py:328`、`test_compare_harvest.py:153` 的注释)。PG 下时间精度更高/FK 更严——调整测试数据(如手动拉开时间间隔、用合法 FK)使断言在 PG 下成立,不改业务逻辑。
|
||||
|
||||
- [ ] **Step 5: 反复跑到全绿**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: `N passed`(0 failed)。若仍有红,回到 Step 2-4 继续归因。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(db): 测试套件切 PostgreSQL 后修复严格性暴露的用例"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 文档更新
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/database/postgres-migration.md`(§1 增「本地 Docker 一键起」小节)
|
||||
- Modify: `CLAUDE.md`(DB 段注明 dev/test = Docker PG)
|
||||
- Modify: `scripts/init_postgres.py`(顶部注释区分生产/本地)
|
||||
|
||||
- [ ] **Step 1: `postgres-migration.md` 在「## 1. 本地起 PG」开头插入推荐做法**
|
||||
|
||||
在 `## 1. 本地起 PG + 跑通空库(半天)` 标题下、`### 1.1 装 PG` 之前插入:
|
||||
|
||||
```markdown
|
||||
### 1.0 推荐:Docker 一键起(本地开发/测试)
|
||||
|
||||
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
|
||||
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
|
||||
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
|
||||
```
|
||||
|
||||
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
|
||||
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
|
||||
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `CLAUDE.md` DB 段补充**
|
||||
|
||||
找到 DB 相关行(`**Prod**: PostgreSQL — just change DATABASE_URL...`)所在段,在其上方加一行:
|
||||
|
||||
```markdown
|
||||
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` 经 `scripts/ensure_pg.py` + `docker-compose.yml` 自动拉起;`.env.example` 默认即 PG 连接串;pytest 用同容器的 `shaguabijia_test` 库。**本地不再用 SQLite**。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `scripts/init_postgres.py` 顶部注释区分场景**
|
||||
|
||||
把模块 docstring 第一行下方(`新机器初始化用。前置:...` 那行)改为:
|
||||
|
||||
```python
|
||||
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
|
||||
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
|
||||
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证无坏链接/格式**
|
||||
|
||||
Run: `git diff --stat`
|
||||
Expected: 三个文档文件有改动,无其他文件被误改。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/database/postgres-migration.md CLAUDE.md scripts/init_postgres.py
|
||||
git commit -m "docs(dev): 记录本地 Docker PostgreSQL 用法,区分生产原生 PG 路径"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标准(对齐 spec §8 验收)
|
||||
|
||||
- [ ] 全新机器(装了 Docker Desktop、`.env` 从 `.env.example` 复制)跑 `run.bat`/`run.sh` 全自动拉起 PG 并起服务,无手动装 PG。
|
||||
- [ ] `docker ps` 见 `shaguabijia-pg` healthy;`shaguabijia` 与 `shaguabijia_test` 两库都在。
|
||||
- [ ] PG 已在跑时再跑 `run`,ensure_pg 秒过短路。
|
||||
- [ ] `pytest -q` 全绿(连 `shaguabijia_test`)。
|
||||
- [ ] `.env` 改回 sqlite 时,`python -m scripts.ensure_pg` 硬失败并打印正确 PG 串。
|
||||
- [ ] 能在 `admin/repositories` 写一段 PG 专有聚合(如 `count(*) FILTER (WHERE ...)`),`run` 手动跑通且相应 pytest 通过。
|
||||
|
||||
## Self-Review 记录(计划作者已核)
|
||||
|
||||
- **Spec 覆盖:** spec §9 待实现清单 8 项 → Task 1(compose+initdb)、Task 2(ensure_pg)、Task 3(run 接线)、Task 4(.env.example)、Task 5(conftest)、Task 6(修红用例)、Task 7(文档);「session.py 无需改」在 Header 与 spec §4.7 说明;「data/ 忽略」在 Task 1 Step 5 验证。无遗漏。
|
||||
- **占位符:** 全部步骤含真实代码/命令/期望输出。Task 6 是发现驱动,已用「类别+具体转换配方+定位命令」代替不可预知的逐条 diff——非占位。
|
||||
- **类型/命名一致:** `ensure(database_url=None)` 签名在 Task 2 定义,Task 5 以 `ensure(_TEST_DB_URL)` 调用一致;库名/用户/密码/端口全程为 Header 固定值;compose 服务名 `postgres` 与 ensure_pg `COMPOSE_SERVICE` 一致;`shaguabijia_test` 在 initdb SQL、`_ensure_test_db()`、conftest 三处一致。
|
||||
@@ -0,0 +1,281 @@
|
||||
# 本地开发切 Docker PostgreSQL —— 设计文档
|
||||
|
||||
> 让本地开发与测试统一跑在 Docker 化的 PostgreSQL 上,彻底退掉 SQLite。
|
||||
> `run.bat` / `run.sh` 启动时自动检测本机 PG,没起就拉起 Docker → 起 PG 容器(镜像缺失先拉),
|
||||
> 目的是让开发/大模型能放心用 PG 专有的高效聚合函数,不再为兼容 SQLite 而退化成"取基础数据后内存聚合"。
|
||||
>
|
||||
> 状态:已定稿(待用户复核)。作者对话日期:2026-07-08。
|
||||
> 关联:[postgres-migration.md](../../database/postgres-migration.md)(切引擎完整步骤)、`scripts/init_postgres.py`(生产原生 PG 初始化)。
|
||||
> ⚠️ **2026-07-27 增补(见 §10)**:D4 已从「sqlite 硬失败」松为「显式 SQLite 逃生舱」。§1-9 描述的是初版「彻底退掉 SQLite」设计;凡涉及「无 Docker / DATABASE_URL 是 sqlite 时如何处理」,**以 §10 为准**(测试仍只跑 PG 不变)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 问题
|
||||
当前开发环境默认用 SQLite(`DATABASE_URL=sqlite:///./data/app.db`),生产用 PostgreSQL 16。两套引擎并存,导致写数据访问代码时(尤其 `app/admin/repositories/` 的报表聚合)为了"两边都能跑",放弃 PG 专有能力(窗口函数、`FILTER`、`JSONB` 操作符、`GROUPING SETS` 等),改成"先查基础数据、再在 Python 内存里聚合"——既慢又啰嗦。
|
||||
|
||||
### 目标
|
||||
本地开发与测试都跑在 PG 上,SQLite 退出本地开发闭环。之后写 PG 专有 SQL 时:
|
||||
- 开发运行时(`run.bat`/`run.sh`)直接连 PG,手动验证可行;
|
||||
- `pytest` 也连 PG,PG 专有 SQL 在被测代码路径里也安全,不会因 SQLite 而挂——**这是"双库兼容代码彻底消失"的必要条件**。
|
||||
|
||||
### 非目标(本期不做)
|
||||
- 不动**生产**部署(生产仍是原生 PG16 + systemd,无 Docker;`init_postgres.py` 保持不变)。
|
||||
- 不做数据搬迁(MVP 阶段无真实用户数据,详见迁移指南背景假设)。
|
||||
- 不接 CI(仓库当前无 `.github/workflows`;若将来加 CI,再单独让 CI 起 PG service)。
|
||||
- 不引入 Redis / testcontainers / 连接池中间件。
|
||||
|
||||
---
|
||||
|
||||
## 2. 决策记录(本次对话已拍板)
|
||||
|
||||
| # | 决策点 | 结论 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D1 | PG 覆盖范围 | **dev 运行 + 测试都切 PG** | 只切运行时的话,被 SQLite 测试覆盖的代码路径(如 `test_cps_admin.py` 覆盖的 `admin/repositories/cps.py`)仍不能用 PG 专有 SQL,双库代码不会真正消失 |
|
||||
| D2 | 打包方式 | **方案 A:Compose + `scripts/ensure_pg.py`** | 唯一真正需要定制的部分(启动 Docker 守护进程、等 PG 就绪)集中到一个跨平台模块,`run.bat`/`run.sh`/`conftest.py` 共用;声明式的容器/卷/健康检查交给 Compose |
|
||||
| D3 | 宿主端口 | **5432**(与生产/文档一致) | 边界:若本机已有原生 PG 占 5432,`ensure_pg` 会探测到"PG 已在"直接复用它(可能连到不带业务库的实例)——见 §7 风险,文档提示 |
|
||||
| D4 | dev 下 `DATABASE_URL` 仍是 sqlite | **硬失败**(打印一行 fix 后非 0 退出) | 彻底断掉 SQLite 退路,符合"让大家都用 PG"的目标 |
|
||||
| D5 | dev 数据库密码 | 固定 `shaguabijia_dev_pw`,写进 compose + `.env.example` | 本地容器仅绑 `localhost`,非机密;保证 `.env.example` 复制即可用。生产密码另由 `init_postgres.py` 强随机生成,不复用 |
|
||||
| D6 | 改哪些启动脚本 | `run.bat` 与 `run.sh` **都改** | 仓库一贯保持两者同步 |
|
||||
| D7 | 镜像 | `postgres:16-alpine` | 对齐生产 PG16;alpine 体积小 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 现状(改动前)
|
||||
|
||||
- **配置**:`app/core/config.py` `DATABASE_URL` 默认 `sqlite:///./data/app.db`,pydantic-settings 从 `.env` 读(环境变量优先级高于 `.env` 文件)。
|
||||
- **引擎**:`app/db/session.py` 用 `_is_sqlite = DATABASE_URL.startswith("sqlite")` 分流——SQLite 加 `check_same_thread=False`、不建池;非 SQLite 加 `pool_size=10/max_overflow=20/pool_recycle=3600`。**已天然支持 PG,无需改。**
|
||||
- **迁移**:`alembic/env.py` 从 `settings.DATABASE_URL` 读连接串,`render_as_batch` 仅对 sqlite 开;PG 下自动关。**无 psycopg2 硬编码,切 PG 无需改。**
|
||||
- **驱动**:`pyproject.toml` 已装 `psycopg[binary]>=3.1`(psycopg3)。URL scheme 必须 `postgresql+psycopg://`(裸 `postgresql://` 会被 SQLAlchemy 路由到未安装的 psycopg2 → ModuleNotFoundError)。
|
||||
- **测试**:`tests/conftest.py` 在 import app 前把 `DATABASE_URL` 设成临时文件 SQLite;session 级 autouse fixture 做 `Base.metadata.create_all(engine)` / 结束 `drop_all`(schema 来自 model 而非 alembic,无逐用例 rollback,全会话共享一个库)。
|
||||
- **启动脚本**:`run.bat` / `run.sh` 均为:校验 `.env` 存在 → `mkdir data` → `alembic upgrade head` → uvicorn 监听 `0.0.0.0:8770`(`.sh` 带 `--reload --reload-dir app`)。
|
||||
- **现有 PG 资产**:`scripts/init_postgres.py`(交互式:建用户/建库/授权/写 .env/跑迁移,面向**已装好的原生 PG**)、`docs/database/postgres-migration.md`(切引擎完整步骤,含 §2 测试切 PG、§2.2 会暴露的真 bug 清单)。
|
||||
- **CI**:无(`.github/workflows` 不存在),故测试切 PG 无 CI 联动负担。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方案详解
|
||||
|
||||
### 4.1 新增 `docker-compose.yml`(app-server 根目录)
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
- `POSTGRES_USER` 设定后,该用户以超级用户身份创建并拥有 `POSTGRES_DB`,故能再建测试库。
|
||||
- 首启 initdb 脚本建测试库(见 4.2)。命名卷 `pgdata` 让数据跨重启留存。
|
||||
|
||||
### 4.2 新增 `docker/initdb/01-create-test-db.sql`
|
||||
```sql
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次。以 shaguabijia_app(超级用户)连 shaguabijia 库运行。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
```
|
||||
|
||||
### 4.3 新增 `scripts/ensure_pg.py`(纯标准库 + docker CLI,跨平台)
|
||||
对外同时暴露**可导入函数** `ensure()`(供 `conftest.py` 直接调)和 **CLI 入口** `if __name__ == "__main__": sys.exit(0 if ensure() else 1)`(供 `run` 以 `python -m scripts.ensure_pg` 跑)。`ensure()` 从 `app.core.config.settings` 读 `DATABASE_URL`,解析 host/port,主流程:
|
||||
1. **sqlite 守卫**:若 `DATABASE_URL` 以 `sqlite` 开头 → 打印"dev 已切 PG,请把 .env 的 DATABASE_URL 改成 `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`"→ 非 0 退出(D4)。
|
||||
2. **TCP 探测** `host:port`(stdlib `socket`,超时 1s)。通 → 打印"✅ PG 已就绪"直接返回(幂等:PG 已在跑时开销≈一次握手)。
|
||||
3. 不通 → `docker version` 探 CLI;缺失 → 中文报错"请先安装 Docker Desktop:https://www.docker.com/products/docker-desktop/" → 非 0 退出。
|
||||
4. `docker info` 探守护进程;不通 → 按平台启动:
|
||||
- Windows:`start "" "%ProgramFiles%\Docker\Docker\Docker Desktop.exe"`(找不到则报错让用户手动开)
|
||||
- macOS:`open -a Docker`
|
||||
- Linux:不自动 sudo,打印 `sudo systemctl start docker` 让用户执行后重试
|
||||
然后轮询 `docker info` 直到就绪或超时(默认 120s,每 3s 一次,打印进度)。
|
||||
5. `docker compose up -d`(Compose 在镜像缺失时**自动拉取**,首用拉 alpine ~90MB;有进度输出)。
|
||||
6. 轮询 healthcheck(`docker inspect` 的 health 状态)/ TCP 直到 PG 接受连接(默认 60s 超时)。
|
||||
7. **幂等确保测试库存在**(兼容"老 pgdata 卷没跑过 initdb"的情况):
|
||||
`docker compose exec -T postgres psql -U shaguabijia_app -tc "SELECT 1 FROM pg_database WHERE datname='shaguabijia_test'"`,不存在则 `CREATE DATABASE shaguabijia_test OWNER shaguabijia_app`。
|
||||
|
||||
失败即清晰中文报错 + 非 0 退出,**全程不回退 SQLite**。所有超时可用环境变量覆盖(如 `ENSURE_PG_DOCKER_TIMEOUT`)。
|
||||
|
||||
### 4.4 `run.bat` / `run.sh` 接线
|
||||
在 `alembic upgrade head` **之前**插一行调用,失败即退出:
|
||||
- `run.sh`:`"$PY" -m scripts.ensure_pg`(`set -e` 已在,失败自动退出)
|
||||
- `run.bat`:`call "%PY%" -m scripts.ensure_pg` + `if errorlevel 1 exit /b 1`
|
||||
|
||||
其余逻辑不动(`mkdir data` 保留给 media 等落盘目录)。
|
||||
|
||||
### 4.5 `.env.example` 默认切 PG
|
||||
把
|
||||
```ini
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
```
|
||||
改为
|
||||
```ini
|
||||
# 本地开发/测试统一用 Docker PG(run.bat/run.sh 会自动拉起容器;详见 docs/database/postgres-migration.md §本地 Docker 一键起)。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
### 4.6 `tests/conftest.py` 切 PG
|
||||
调整顶部顺序(仍必须在 `import app.*` 之前完成 env 设定):
|
||||
1. 设 `os.environ["DATABASE_URL"] = "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"`(测试库,永不碰 dev 业务库)。
|
||||
2. 调 `scripts.ensure_pg.ensure()`(保证容器在 + 测试库在;PG 已在时几乎零开销)。
|
||||
3. `import app...`。
|
||||
session 级 autouse fixture:改为 **`Base.metadata.drop_all(engine)` → `create_all(engine)`(开头先清干净,防持久卷里上一次跑残留的表/数据)→ yield → 结束 `drop_all`**;删掉临时 SQLite 文件相关代码。
|
||||
|
||||
> 预期:部分用例会因 PG 的严格性变红(SQLite 宽松、PG 严格),按迁移指南 §2.2 逐个修——常见为:字符串/整数隐式比较、`datetime.utcnow()` naive vs `TIMESTAMPTZ`、事务边界(`current transaction is aborted`)。这既是工作量也是本次改造的**直接收益**(暴露真 bug)。实现阶段需为"跑 pytest 并修红用例"单列步骤。
|
||||
|
||||
### 4.7 `app/db/session.py`
|
||||
**无需改动**——`_is_sqlite` 为假时自动走 PG 池化分支。
|
||||
|
||||
### 4.8 文档
|
||||
- `docs/database/postgres-migration.md` 增一节「本地 Docker 一键起 PG(推荐)」,指向 compose + `ensure_pg`,并说明它替代了 §1.1 的手动 brew/apt 装 PG。
|
||||
- `CLAUDE.md` 的 DB 段注明:dev/test = Docker PG(`run` 自动拉起);prod = 原生 PG(`init_postgres.py`)。
|
||||
- `scripts/init_postgres.py` 顶部注释补一句"本脚本面向生产原生 PG;本地开发用 docker-compose + scripts/ensure_pg"。
|
||||
|
||||
---
|
||||
|
||||
## 5. 连接参数汇总
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 镜像 | `postgres:16-alpine` |
|
||||
| 容器名 | `shaguabijia-pg` |
|
||||
| 宿主端口 | `5432` |
|
||||
| 超级/业务用户 | `shaguabijia_app` |
|
||||
| dev 密码 | `shaguabijia_dev_pw`(本地非机密) |
|
||||
| 业务库(dev 运行) | `shaguabijia` |
|
||||
| 测试库(pytest) | `shaguabijia_test` |
|
||||
| dev `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia` |
|
||||
| test `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test` |
|
||||
| 数据持久化 | 命名卷 `pgdata` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败处理矩阵(无 SQLite 兜底)
|
||||
|
||||
| 情形 | ensure_pg 行为 |
|
||||
|---|---|
|
||||
| `DATABASE_URL` 是 sqlite | 打印应改成的 PG 串 → 非 0 退出 |
|
||||
| PG 已在跑(TCP 通) | 打印"已就绪" → 返回 0(跳过 docker) |
|
||||
| 无 docker CLI | 提示装 Docker Desktop + 官网链接 → 非 0 退出 |
|
||||
| docker 守护进程未起 | 尝试按平台启动 Docker Desktop,轮询到就绪;超时则报错 → 非 0 退出 |
|
||||
| 镜像缺失 | `docker compose up -d` 自动拉取(不额外处理) |
|
||||
| 容器起了但 PG 未 ready | 轮询 healthcheck 到超时;超时报错 → 非 0 退出 |
|
||||
| 老 pgdata 卷缺测试库 | 幂等 `CREATE DATABASE shaguabijia_test` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与边界
|
||||
|
||||
- **端口占用(原生 PG 撞 5432)**:D3 选了 5432。若开发机已有原生 PG 监听 5432,step 2 的 TCP 探测会判"PG 已在"并复用它——但那个实例可能没有 `shaguabijia`/`shaguabijia_test` 库或用户,后续 `alembic upgrade head` / 测试会报连不上库或认证失败。**缓解**:文档提示"本机别再单独跑原生 PG";报错信息里提示检查是不是撞了原生 PG。
|
||||
- **首次启动慢**:首用需 Docker Desktop 冷启(~30–60s)+ 拉镜像(~数十秒~数分钟,视网络)。`ensure_pg` 全程打印进度,超时可配。
|
||||
- **`DATABASE_URL` 环境变量优先级**:pydantic-settings 里 shell 环境变量优先于 `.env`。若开发者 shell 残留旧的 `DATABASE_URL`(如指向 sqlite),会盖过 `.env`。sqlite 守卫(D4)能挡住 sqlite 残留;但若残留的是另一个 PG 串,则以它为准——文档提示。
|
||||
- **持久卷脏状态**:测试用 drop_all→create_all 开头清库,避免上次崩溃残留污染;dev 业务库随卷留存(符合预期)。
|
||||
- **Docker 未安装/公司网络拉镜像受限**:硬失败并给出明确指引;不提供 SQLite 退路是刻意选择(D4/目标)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 全新机器(装了 Docker Desktop、`.env` 从 `.env.example` 复制)执行 `run.bat`(或 `run.sh`):自动拉起 Docker→起 PG 容器→建库→`alembic upgrade head`→uvicorn 起在 8770,无手动装 PG 步骤。
|
||||
2. `docker ps` 见 `shaguabijia-pg` 健康;`psql`/客户端能连 `shaguabijia` 与 `shaguabijia_test` 两个库。
|
||||
3. PG 已在跑时再次 `run`,`ensure_pg` 秒过(不重复拉容器)。
|
||||
4. `pytest` 连 `shaguabijia_test` 跑;红用例全部修绿(PG 严格性暴露的问题)。
|
||||
5. `.env` 的 `DATABASE_URL` 改回 sqlite 时,`run`/`pytest` 硬失败并打印正确的 PG 串。
|
||||
6. 能在 `admin/repositories/` 里写一段 PG 专有聚合 SQL(如带 `FILTER (WHERE ...)` 的聚合),`run` 下手动跑通、相应 pytest 也通过——即"双库兼容负担消失"的实证。
|
||||
|
||||
---
|
||||
|
||||
## 9. 待实现清单(供 writing-plans 拆解)
|
||||
|
||||
- [ ] 新增 `docker-compose.yml`
|
||||
- [ ] 新增 `docker/initdb/01-create-test-db.sql`
|
||||
- [ ] 新增 `scripts/ensure_pg.py`(TCP 探测 / 启 Docker Desktop 轮询 / compose up / 等 healthy / 幂等建测试库 / sqlite 守卫)
|
||||
- [ ] `run.sh`、`run.bat` 接入 `ensure_pg`
|
||||
- [ ] `.env.example` 的 `DATABASE_URL` 切 PG
|
||||
- [ ] `tests/conftest.py` 切 `shaguabijia_test` + 调 `ensure_pg` + fixture 改 drop/create
|
||||
- [ ] 跑 `pytest`,按迁移指南 §2.2 修红用例
|
||||
- [ ] 文档:`postgres-migration.md` 增「本地 Docker 一键起」节;`CLAUDE.md` DB 段;`init_postgres.py` 注释
|
||||
- [ ] `.gitignore` 确认 `data/` 已忽略(compose 用命名卷,不落项目目录,无需额外忽略)
|
||||
|
||||
---
|
||||
|
||||
## 10. 增补(2026-07-27):D4 反转 —— 显式 SQLite 逃生舱
|
||||
|
||||
> 背景:§2 的 D4 定为「dev 下 `DATABASE_URL` 仍是 sqlite → 硬失败」,目的是彻底断掉 SQLite 退路。实践中这对「本机装不了 Docker」的开发者过于刚性——直接被卡死、连跑都跑不起来。本次(2026-07-27 对话)把 D4 从「硬失败」松成「**显式逃生舱**」:工具**从不替你静默切库**,但会在没 Docker 时告诉你怎么手动降级,且降级时每次启动都醒目告警。
|
||||
|
||||
### 10.1 决策更新
|
||||
|
||||
| # | 原决策 | 新决策 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D4′ | dev sqlite URL → 硬失败退出 | **放行 + 每次打印醒目降级横幅**(仍非静默) | 已手动改 `.env`=sqlite = 开发者的显式选择,尊重它;但吼一嗓子防止忘了自己在降级、把 PG 专有 SQL 提交上去 |
|
||||
| D8(新) | (无) | 无 docker CLI 时,报错里**追加逃生舱指路**(改 `.env`=sqlite),但仍非 0 退出 | 「显式」的关键:工具不替你切库,只指路;开发者改完 `.env` 再跑一次才真正降级 |
|
||||
|
||||
**未变**:D1(测试仍只跑 PG)、D2-D3、D5-D7 全部保留。逃生舱**只作用于 `run.sh`/`run.bat` 运行时**;`pytest` 仍写死连 PG 测试库(`conftest.py` 传 PG URL,sqlite 分支根本不触发),没 Docker 就 `raise`、跑不了完整套件——这正是 D1「测试上 PG 才能暴露真 bug」的初衷,刻意不给逃生舱。
|
||||
|
||||
### 10.2 代码改动(仅 `scripts/ensure_pg.py` 的 `ensure()`)
|
||||
|
||||
1. **sqlite 分支**(原 `return False`)→ 打印多行降级横幅后 `return True`。横幅点明:PG 专有 SQL/严格类型在此模式**不被验证**、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把 `DATABASE_URL` 改回 PG 串。
|
||||
2. **无 docker CLI 分支**(原仅提示装 Docker + `return False`)→ 追加一句「装不了 Docker?把 `.env` 的 `DATABASE_URL` 改成 `sqlite:///./data/app.db` 可降级运行」;**仍 `return False`**(run 脚本照常退出,开发者需显式改 .env 再跑)。
|
||||
3. **常量**:新增 `SQLITE_URL = "sqlite:///./data/app.db"`(逃生舱指路用);`SQLITE_FIX_HINT` 重命名 `PG_URL`(降级横幅"改回 PG"引用)。
|
||||
4. 更新模块 docstring 中「全程无 SQLite 兜底」一句,改述为「无 Docker/sqlite URL 时【显式】降级 SQLite(带醒目告警),测试侧不降级」。
|
||||
|
||||
**其余全不动**:`run.sh`/`run.bat`(sqlite 下 `ensure` 返 True → 照常 `alembic upgrade head` + uvicorn)、`docker-compose.yml`、`app/db/session.py`(SQLite 引擎分支本就保留为 fallback)、`tests/conftest.py`、`.env.example`(默认仍 PG)。
|
||||
|
||||
### 10.3 改完后行为矩阵(覆盖用户列的 5 场景)
|
||||
|
||||
| 场景 | `DATABASE_URL` | ensure_pg 行为 |
|
||||
|---|---|---|
|
||||
| ① 无 Docker | PG(默认) | 报错 + 指逃生舱 → 退出;开发者改 `.env`=sqlite → 再跑 → **放行 + 降级横幅**,alembic/uvicorn 跑 SQLite |
|
||||
| ② 有 Docker 未启动 | PG | 启 Docker Desktop → `compose up` → 等 ready → 建测试库(**不变**) |
|
||||
| ③ 有 Docker 已启动 | PG | `compose up` → 等 ready(**不变**) |
|
||||
| ④ PG 已在跑 | PG | TCP 通 → 秒过跳过 Docker(**不变**) |
|
||||
| ⑤ PG 起来后 | 任意 | run 脚本 `alembic upgrade head`(**不变**;SQLite 走 `render_as_batch`) |
|
||||
|
||||
### 10.4 风险
|
||||
|
||||
- **降级被忽视**:横幅仅在 `run` 启动时打印一次;若开发者用 IDE 直接起 uvicorn(绕过 run 脚本)则看不到。缓解:横幅足够醒目 + 文档强调;**不**引入 app 启动期重复告警(YAGNI)。
|
||||
- **测试无 Docker 跑不了**:刻意保留(D1)。文档提示无 Docker 者:要么装 Docker 跑全量测试,要么只在 CI/有 Docker 的机器上验证 PG 相关改动。
|
||||
|
||||
### 10.5 Redis 前瞻(不在本次)
|
||||
|
||||
§2 未涉及 Redis。②③ 场景未来若加 Redis 实例:在 `docker-compose.yml` 增 `redis` 服务即可,`docker compose up -d` 天然带起;仅当启动期有组件依赖 Redis 才需给 `ensure_pg` 加 redis readiness 探测。本次不做,方案对它友好。
|
||||
|
||||
### 10.6 附带修复:`_docker_cli_ok` 守护进程误判(2026-07-27)
|
||||
|
||||
诊断「装了 Docker Desktop 却报未检测到 docker」时发现的真 bug:`_docker_cli_ok()` 原用 `docker version`
|
||||
判断 CLI 是否存在,但该命令**要连 daemon**,守护进程没起时退非零 → 把「Docker 装了但没启动」
|
||||
误判成「没装 CLI」,`ensure()` 直接打印"请安装 Docker Desktop"并 `return False`,**绕过了专为需求②
|
||||
写的 `_start_docker_daemon()` 自动拉起逻辑**——需求②(有 Docker 未启动 → 自动启动)因此从未真正生效。
|
||||
修复:改用 `docker --version`(纯客户端、不连 daemon、退 0)。`_docker_daemon_ok()` 仍用 `docker info`
|
||||
(正确,该检查本就依赖 daemon)。实测机器:Docker Desktop 20.10.12 已装但引擎未起,修复前 `_docker_cli_ok()`
|
||||
误报 False,修复后 True。
|
||||
|
||||
### 10.7 附带修复:固定 compose 项目名 + 清理残留同名容器(2026-07-27)
|
||||
|
||||
诊断「`docker compose up` 报 `container name "/shaguabijia-pg" already in use`」时发现的又一 bug:compose
|
||||
项目名默认取运行目录 basename,在不同目录/worktree(如 `local-dev-postgres-docker` vs `shaguabijia-app-server`)
|
||||
之间切换会各自成一个项目;而 `docker-compose.yml` 写死了 `container_name: shaguabijia-pg`(全局唯一名),
|
||||
于是新项目 `up` 时要创建同名容器 → 撞上旧项目留下的那个 → 冲突。副作用:`pgdata` 卷也按项目名分裂
|
||||
成 `local-dev-postgres-docker_pgdata` / `shaguabijia-app-server_pgdata`,数据被切成两半。
|
||||
|
||||
修复(均在 `scripts/ensure_pg.py`,`docker-compose.yml` 不动、容器名仍是 `shaguabijia-pg`):
|
||||
1. 模块级 `os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")` —— 钉死项目名,无论从哪个
|
||||
目录/worktree 跑都是同一个项目、同一个卷 `shaguabijia_pgdata`,所有 `docker compose up/exec` 一致。
|
||||
2. `_compose_up()` 前置 `_remove_stale_container()`:若存在「同名但不属于本项目」的残留容器,先 `docker rm -f`
|
||||
再 up(靠 `docker ps --filter name/label` 判归属;数据在命名卷里,删容器不丢)。旧目录/worktree 留下的
|
||||
残留容器就此自动清掉,不需手动干预。
|
||||
|
||||
影响:本次修复后首跑,旧的 `shaguabijia-pg`(属项目 `local-dev-postgres-docker`)会被自动删除、在项目
|
||||
`shaguabijia` 下重建,挂载全新的 `shaguabijia_pgdata`(空库,`alembic upgrade head` 重建表)。旧数据仍留在
|
||||
`local-dev-postgres-docker_pgdata` 卷里(未删,可恢复);确认不需要后可 `docker volume rm` 清理两个旧卷。
|
||||
@@ -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. 数据流 — 阿里云 provider(Mode 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` 全绿。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 创蓝云智(253)短信验证服务 — 设计方案
|
||||
|
||||
- 日期:2026-07-26
|
||||
- 状态:已定稿,待实现
|
||||
- 范围:新增创蓝云智(253/蓝创云智)模板短信 provider,与现有极光 / 阿里云可切换
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
短信验证码服务已是**可切换 provider** 架构(`app/integrations/sms/`:`__init__` 分派 + `jiguang` / `aliyun` + `base`)。本次接入第三家 **创蓝云智** 作为新 provider。
|
||||
|
||||
创蓝 `tpl/send` v2 接口(调研见 `docs/integrations/chuanglan/tpl-send.md`)是**纯发送网关**:本服务生成验证码、放入 `templateParamJson`,创蓝只负责下发,**无校验接口**。故属 **Mode B(自管码)**,与极光同模式(本地生成/存储/校验),仅"发送调用"不同。
|
||||
|
||||
目标:接入创蓝作为可切换 provider;默认仍极光,opt-in 切换,灰度可秒回退。
|
||||
|
||||
## 2. 关键决策(已确认)
|
||||
|
||||
1. **Mode B 自管码**:本服务 `secrets` 生成 N 位码 → 存进程内存 → 创蓝 REST 只下发;`verify_code` 比对本地存码(一次性 + 失败 `SMS_MAX_VERIFY_ATTEMPTS` 次即作废)。与极光同语义。
|
||||
2. **代码组织 = 隔离复制(不重构极光)**:`chuanglan.py` **自带一份**存码/冷却/校验机器(从 `jiguang.py` 复制适配),**极光文件一行不动**。契合阿里云先例的 provider 隔离哲学,零回归风险于登录关键路径的默认 provider。代价:Mode B 并发逻辑在 jiguang / chuanglan 两处重复,日后改动需同步(YAGNI 权衡,已接受)。
|
||||
3. **鉴权 = HMAC 签名头**(`X-QA-Hmac-Signature`):password 仅用于本地算签、**不上行**。不做明文密码 body 模式。
|
||||
4. **无新依赖**:创蓝是普通 HTTPS POST,复用现有 `httpx` + 标准库 `hashlib`/`hmac`(对比阿里云需 SDK)。
|
||||
5. **可切换 + 回退**:`SMS_PROVIDER` 增 `chuanglan`;默认仍 `jiguang`;误配/未知值一律回退 `jiguang`(保持 `test_unknown_provider_falls_back_to_jiguang` 语义)。
|
||||
|
||||
## 3. 模块结构
|
||||
|
||||
```
|
||||
app/integrations/sms/
|
||||
__init__.py # 分派器: {"aliyun":aliyun,"chuanglan":chuanglan}.get(SMS_PROVIDER, jiguang)
|
||||
base.py # 不动(SmsError / mock_verify 复用)
|
||||
jiguang.py # 不动
|
||||
aliyun.py # 不动
|
||||
chuanglan.py # 新增(本设计)
|
||||
```
|
||||
|
||||
- `__init__.py` 继续 re-export `SmsError / send_code / verify_code`,`app/api/v1/auth.py` 导入不变。
|
||||
- 纯增量:只新增 `chuanglan.py` + 扩分派 dict + 加配置;不改极光/阿里云行为。
|
||||
|
||||
## 4. 数据流 — chuanglan provider(Mode B,复制自 jiguang)
|
||||
|
||||
### 4.1 发码 `chuanglan.send_code(phone) -> int`
|
||||
结构与 `jiguang.send_code` 一致:
|
||||
1. `_lock` 内:`_gc` → 单号冷却检查(`_last_sent`,`SMS_SEND_INTERVAL_SEC`,命中→`SmsError(429)`)→ `_gen_code()` 生成 N 位 → **预占**(写 `_last_sent` + `_codes[phone]=_CodeRecord(code, expires_at=now+SMS_CODE_TTL_SEC)`)。
|
||||
2. `_lock` 外:`SMS_MOCK` → 打日志不真发;否则 `_send_via_chuanglan(phone, code)`。
|
||||
3. 失败:**保留冷却**(失败也限速)、`_codes.pop(phone)`(没发出去的码删掉);`SmsError` 原样抛,其他异常 → `SmsError(503)`。
|
||||
4. 返回 `SMS_SEND_INTERVAL_SEC` 作客户端冷却秒数。
|
||||
|
||||
### 4.2 校验 `chuanglan.verify_code(phone, code) -> bool`
|
||||
与 `jiguang.verify_code` 一致:
|
||||
- `SMS_MOCK` → `mock_verify`(放行任意 N 位数字)。
|
||||
- real:`_lock` 内查 `_codes[phone]`;不存在/过期→False(并清);`attempts >= SMS_MAX_VERIFY_ATTEMPTS`→清+False(防爆破);`secrets.compare_digest` 匹配→清+True(一次性);否则 `attempts += 1` 返 False。
|
||||
|
||||
### 4.3 发送 `_send_via_chuanglan(phone, code)`(唯一新逻辑)
|
||||
1. 配置校验 `settings.chuanglan_sms_configured`(缺 account/password/templateId → `SmsError(503)`)。
|
||||
2. 组装:
|
||||
- `timestamp = str(int(time.time()))`、`nonce = secrets.token_hex(16)`(32 hex)
|
||||
- `body = {account, timestamp, nonce, phoneNumbers=phone, templateId, templateParamJson=json.dumps([{"param1": code}])}`;`CHUANGLAN_SMS_SIGNATURE` 非空则加 `signature` 字段。**HMAC 方式 body 不含 password。**
|
||||
3. 签名 `_sign(password, timestamp, nonce)`:
|
||||
```
|
||||
md5pwd = md5(password).hexdigest() # 32 位小写 hex
|
||||
s = "".join(sorted([md5pwd, timestamp, nonce])) # 字典序升序拼接
|
||||
s = "".join(s.split()) # 去空白(faithful,本例无空白)
|
||||
sig = hmac_sha256(key=md5pwd.encode(), msg=s.encode()).hexdigest() # 小写 hex
|
||||
```
|
||||
置请求头 `X-QA-Hmac-Signature: sig`、`Content-Type: application/json`。
|
||||
4. `httpx.post(CHUANGLAN_SMS_ENDPOINT, json=body, headers=..., timeout=CHUANGLAN_SMS_TIMEOUT_SEC)`;网络异常 → `SmsError(503)`。
|
||||
5. 解析 `resp.json()`:`code == "000000"` → 成功返回;否则按 §5 映射抛 `SmsError`。HTTP≠200 或 JSON 解析失败 → `SmsError(503)`。
|
||||
|
||||
## 5. 错误码映射(创蓝 `code` → SmsError.status_code)
|
||||
|
||||
| 创蓝 code | HTTP | 处理 |
|
||||
|---|---|---|
|
||||
| `000000` | — | 成功 return |
|
||||
| `103` | 429 | 超频,"发送过于频繁,请稍后再试" |
|
||||
| `107` | 400 | 手机号错误,"手机号无效" |
|
||||
| `109` | 503 | 无发送量/余额 → **critical 日志**(需充值) |
|
||||
| `117` | 503 | IP 未白名单 → **critical 日志**(需运维加白) |
|
||||
| `102` / `116` / `124` / `152` / `101` / `118` | 503 | 密码/签名/模板/账号/权限配置错 → **critical 日志** |
|
||||
| 其他 / `Success` 非 000000 / HTTP≠200 / 网络错 | 503 | "短信服务暂不可用,请稍后重试" |
|
||||
|
||||
- 映射用 `_SEND_ERRORS: dict[str,(int,str)]` + `_SEND_CRITICAL_CODES: frozenset`(仿 aliyun 写法)。
|
||||
|
||||
## 6. 防爆破 / 频控分工(与极光同级)
|
||||
|
||||
| 机制 | 实现 |
|
||||
|---|---|
|
||||
| 验证码存储 | 本地进程内存 `_codes`(与极光同,多 worker 不共享的技术债同级) |
|
||||
| 单号发送冷却 | 本地 `_last_sent`,`SMS_SEND_INTERVAL_SEC` |
|
||||
| 单设备+IP 频控 | API 层(`app/api/v1/auth.py`),**不变** |
|
||||
| 防爆破(单码失败 N 次作废)| 本地 `_CodeRecord.attempts`,`SMS_MAX_VERIFY_ATTEMPTS` |
|
||||
|
||||
- 创蓝控制台侧另建议叠加:**IP 白名单**(否则 117)+ 发送频控。
|
||||
|
||||
## 7. 配置项(`app/core/config.py` 新增)
|
||||
|
||||
```python
|
||||
SMS_PROVIDER: Literal["jiguang", "aliyun", "chuanglan"] = "jiguang"
|
||||
# --- 创蓝云智(253)模板短信,Mode B 自管码,httpx 直连 + HMAC 签名 ---
|
||||
CHUANGLAN_SMS_ACCOUNT: str = "" # YZM 前缀验证码账号
|
||||
CHUANGLAN_SMS_PASSWORD: str = "" # API 密码(仅本地算签,不上行)
|
||||
CHUANGLAN_SMS_TEMPLATE_ID: str = "" # 模板 ID
|
||||
CHUANGLAN_SMS_SIGNATURE: str = "" # 短信签名文案【品牌】;模板已带签名则留空
|
||||
CHUANGLAN_SMS_ENDPOINT: str = "https://smssh.253.com/msg/sms/v2/tpl/send"
|
||||
CHUANGLAN_SMS_TIMEOUT_SEC: int = 10 # httpx 读/连超时
|
||||
```
|
||||
|
||||
- 新增属性 `chuanglan_sms_configured`(仿 `aliyun_sms_configured`):account/password/templateId 齐全才为真。
|
||||
- 复用 `SMS_MOCK` / `SMS_CODE_LENGTH` / `SMS_CODE_TTL_SEC` / `SMS_SEND_INTERVAL_SEC` / `SMS_MAX_VERIFY_ATTEMPTS`(provider 无关的 Mode B 旋钮)。
|
||||
- `.env.example` 增 `CHUANGLAN_SMS_*` 块 + 注释。
|
||||
|
||||
### 模板变量敏感点
|
||||
- 默认按**单占位** `templateParamJson=[{"param1": code}]`(模板形如「您的验证码 {s},5分钟内有效」)。
|
||||
- 若控制台模板把「有效分钟」也做成第二个 `{s}`,实现时在此加 `param2`(改 `chuanglan.py` 一处)。
|
||||
|
||||
## 8. auth.py 改动
|
||||
|
||||
无。`send_code` / `verify_code` 签名与返回不变,分派层内部路由;两调用点现有 `try/except SmsError` 已覆盖 chuanglan 的 429/400/503。
|
||||
|
||||
## 9. 测试
|
||||
|
||||
- 现有测试:`SMS_MOCK=true` → 分派器短路,全绿不变。
|
||||
- 新增 `tests/test_sms_chuanglan.py`(monkeypatch `_send_via_chuanglan` 内 httpx 接缝,不发真网络):
|
||||
1. 发码成功(`code=000000`)→ 返回 cooldown、码入内存。
|
||||
2. 各错误码 → 对应 `SmsError.status_code`(103→429 / 107→400 / 109/117/其他→503)。
|
||||
3. HTTP≠200 / 网络异常 → `SmsError(503)`。
|
||||
4. 校验:匹配→True 且作废(一次性);过期→False;失败累计达上限→作废 False;不匹配→attempts+1 False。
|
||||
5. 冷却:`SMS_SEND_INTERVAL_SEC` 内二次发 → `SmsError(429)`。
|
||||
6. `_sign` 签名算法:对固定 (password, ts, nonce) 断言 HMAC 输出(独立复算比对)。
|
||||
- 扩 `tests/test_sms_dispatch.py`:`SMS_PROVIDER=chuanglan` 路由命中 chuanglan;未知值回退 jiguang。
|
||||
|
||||
## 10. YAGNI(明确不做)
|
||||
|
||||
- ❌ 不重构极光 / 不动阿里云。
|
||||
- ❌ 不做明文密码 body 模式(只 HMAC)。
|
||||
- ❌ 不做状态回执 `report` / `callbackUrl`。
|
||||
- ❌ 不做批量发送(验证码单号)。
|
||||
- ❌ 不做 DB/Redis 存码(与极光同级内存态,多 worker 债维持现状)。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
- `SMS_PROVIDER=chuanglan` 且配置齐全时:`/sms/send` 走创蓝 `tpl/send`、`/sms/login` 本地校验,真机可收码并登录。
|
||||
- `SMS_PROVIDER=jiguang`(默认)/ `aliyun`:行为与当前完全一致。
|
||||
- `SMS_MOCK=true`:任意 N 位数字通过,不真发。
|
||||
- 创蓝接口异常时:`/sms/send` 返回对应码(429/400/503),日志可区分(余额/白名单打 critical)。
|
||||
- `ruff check .` 通过;新增/现有 `pytest` 全绿。
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -30,7 +30,14 @@ if not exist .env (
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
|
||||
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Build/upgrade schema (idempotent; no-op if already at head)
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
|
||||
@@ -18,7 +18,8 @@ if [ ! -f .env ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p data # sqlite 文件所在目录
|
||||
mkdir -p data # 运行期落盘目录(媒体上传等)
|
||||
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
|
||||
"$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
|
||||
|
||||
# --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
@echo off
|
||||
REM Admin backend startup (Windows) - the :8771 peer of run.bat.
|
||||
REM
|
||||
REM Usage:
|
||||
REM cd shaguabijia-app-server
|
||||
REM run8771.bat
|
||||
REM
|
||||
REM Runs the ADMIN FastAPI app (app.admin.main:admin_app) on 127.0.0.1:8771 —
|
||||
REM a SEPARATE process from run.bat (which runs app.main:app on 8770). The admin
|
||||
REM web frontend (Next.js :3001) points at http://localhost:8771. Auto-reload on
|
||||
REM code change.
|
||||
REM
|
||||
REM Prerequisite (first time):
|
||||
REM conda activate pricebot ^&^& pip install -e .
|
||||
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
|
||||
REM
|
||||
REM Tip: shaguabijia-admin-web\start.bat starts user-api(8770) + admin-api(8771)
|
||||
REM + frontend(3001) in one go, if you prefer a single command.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Prefer the project virtualenv (.venv) so we never inherit a wrong
|
||||
REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import
|
||||
REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python.
|
||||
set "PY=python"
|
||||
if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe"
|
||||
|
||||
if not exist .env (
|
||||
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Build/upgrade schema (idempotent; no-op if already at head)
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Long-running foreground process. Ctrl+C to stop.
|
||||
"%PY%" -m uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771 --reload
|
||||
@@ -0,0 +1,394 @@
|
||||
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
|
||||
|
||||
被三处复用:
|
||||
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
|
||||
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
|
||||
|
||||
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
|
||||
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。
|
||||
|
||||
运行时(run.sh/run.bat)支持【显式】SQLite 逃生舱:DATABASE_URL 设为 sqlite → 放行并打印
|
||||
醒目降级横幅(绝不静默替你切库);无 docker CLI 时报错里也指路该逃生舱。测试侧
|
||||
(conftest 传 PG URL)不降级——sqlite 分支不触发,没 PG 直接 raise。详见设计文档 §10。
|
||||
|
||||
生产用原生 PG(scripts/init_postgres.py),不走本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
|
||||
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(errors="backslashreplace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
APP_DB = "shaguabijia"
|
||||
TEST_DB = "shaguabijia_test"
|
||||
DB_USER = "shaguabijia_app"
|
||||
COMPOSE_SERVICE = "postgres"
|
||||
CONTAINER_NAME = "shaguabijia-pg" # 必须与 docker-compose.yml 的 container_name 一致
|
||||
|
||||
# 钉死 compose 项目名:否则它默认取运行目录 basename,在不同目录/worktree 之间切会各自
|
||||
# 成一个项目 → 同一个固定 container_name 撞名报错、pgdata 卷还会按项目名分裂成多份。
|
||||
# 钉成 app 名后,无论从哪个目录/worktree 跑都是同一个项目、同一个卷。setdefault:尊重外部覆盖。
|
||||
os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")
|
||||
|
||||
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
|
||||
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
|
||||
# 单条 docker 探测/exec 命令的超时:防 Docker 守护进程半死(尤其 Windows 冷启)时
|
||||
# docker info / exec 无限挂起、绕过上面的总超时。
|
||||
DOCKER_CMD_TIMEOUT = int(os.environ.get("ENSURE_PG_CMD_TIMEOUT", "15"))
|
||||
POLL_INTERVAL = 3.0
|
||||
|
||||
PG_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
|
||||
)
|
||||
SQLITE_URL = "sqlite:///./data/app.db" # 无 Docker 时的显式降级逃生舱(仅 run 运行时)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[ensure_pg] {msg}", flush=True)
|
||||
|
||||
|
||||
def _is_sqlite(url: str) -> bool:
|
||||
return url.strip().lower().startswith("sqlite")
|
||||
|
||||
|
||||
def _parse_host_port(url: str) -> tuple[str, int]:
|
||||
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
|
||||
parts = urlsplit(url)
|
||||
return (parts.hostname or "localhost"), (parts.port or 5432)
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _win_docker_desktop_candidates(
|
||||
env: Mapping[str, str], docker_cli: str | None
|
||||
) -> list[PureWindowsPath]:
|
||||
"""Windows 上 Docker Desktop.exe 的候选路径(按优先级)。纯函数:不碰文件系统、不读注册表。
|
||||
|
||||
用 PureWindowsPath 解析,故在任意 OS 上跑单测都按 Windows 语义(反斜杠分隔),行为确定。
|
||||
优先级:
|
||||
① 环境变量 DOCKER_DESKTOP_EXE 显式指定(终极逃生舱,盘符随你);
|
||||
② 从 PATH 上的 docker CLI 反推——Docker Desktop 的 CLI 在
|
||||
<安装目录>\\resources\\bin\\docker.exe,往上几级即安装目录,天然跟随实际盘符
|
||||
(装在 D 盘就反推出 D 盘,不再写死 C 盘);多取几级容忍未来目录布局微调;
|
||||
③ 各 Program Files 变体下的标准安装路径兜底(覆盖常规 C 盘装)。
|
||||
调用方按序取第一个真实存在的。
|
||||
"""
|
||||
out: list[PureWindowsPath] = []
|
||||
override = (env.get("DOCKER_DESKTOP_EXE") or "").strip().strip('"')
|
||||
if override:
|
||||
out.append(PureWindowsPath(override))
|
||||
if docker_cli:
|
||||
for parent in list(PureWindowsPath(docker_cli).parents)[:4]:
|
||||
out.append(parent / "Docker Desktop.exe")
|
||||
for var in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"):
|
||||
root = env.get(var)
|
||||
if root:
|
||||
out.append(PureWindowsPath(root) / "Docker" / "Docker" / "Docker Desktop.exe")
|
||||
return out
|
||||
|
||||
|
||||
def _docker_desktop_from_registry() -> Path | None:
|
||||
"""从注册表尽力取 Docker Desktop.exe 位置(best-effort;非 Windows / 任何异常都当没找到)。
|
||||
|
||||
比路径猜测更权威且完全跟随实际盘符。探两处:
|
||||
- App Paths\\Docker Desktop.exe 的默认值(通常就是 exe 全路径);
|
||||
- Uninstall\\Docker Desktop 的 InstallLocation(安装目录,需再拼 exe 名)。
|
||||
"""
|
||||
try:
|
||||
import winreg
|
||||
except ImportError: # 非 Windows
|
||||
return None
|
||||
probes = (
|
||||
(winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Docker Desktop.exe", "", False),
|
||||
(winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Docker Desktop",
|
||||
"InstallLocation", True),
|
||||
)
|
||||
for hive, subkey, value_name, join_exe in probes:
|
||||
try:
|
||||
with winreg.OpenKey(hive, subkey) as key:
|
||||
val, _ = winreg.QueryValueEx(key, value_name)
|
||||
except OSError:
|
||||
continue # 键不存在/无权限 → 下一个
|
||||
if not val:
|
||||
continue
|
||||
exe = Path(val) / "Docker Desktop.exe" if join_exe else Path(val)
|
||||
if exe.exists():
|
||||
return exe
|
||||
return None
|
||||
|
||||
|
||||
def _find_docker_desktop_exe() -> Path | None:
|
||||
"""Windows 上尽力定位【真实存在】的 Docker Desktop.exe;遍历候选 + 注册表兜底,找不到返回 None。"""
|
||||
for cand in _win_docker_desktop_candidates(os.environ, shutil.which("docker")):
|
||||
if Path(cand).exists():
|
||||
return Path(cand)
|
||||
return _docker_desktop_from_registry()
|
||||
|
||||
|
||||
def _docker_desktop_cmd(platform: str) -> list[str] | None:
|
||||
"""按平台给出启动 Docker Desktop 的命令。
|
||||
|
||||
Windows:智能定位 exe(见 _find_docker_desktop_exe),找不到 → None。
|
||||
macOS:交给 `open -a Docker`。Linux:None(daemon 需 sudo,让用户手动)。
|
||||
"""
|
||||
if platform.startswith("win"):
|
||||
exe = _find_docker_desktop_exe()
|
||||
return [str(exe)] if exe else None
|
||||
if platform == "darwin":
|
||||
return ["open", "-a", "Docker"]
|
||||
return None
|
||||
|
||||
|
||||
def _docker_ok(subcmd: str) -> bool:
|
||||
"""`docker --version`(CLI 在不在,纯客户端)/`docker info`(daemon 起没起)成功与否。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", subcmd],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _docker_cli_ok() -> bool:
|
||||
# 必须用 `docker --version`(纯客户端,不连 daemon)而非 `docker version`
|
||||
# (后者要连 daemon,守护进程没起时退非零)——否则「装了 Docker 但没启动」
|
||||
# 会被误判成「没装 CLI」,直接绕过下面 _start_docker_daemon() 的自动拉起(需求②)。
|
||||
return _docker_ok("--version")
|
||||
|
||||
|
||||
def _docker_daemon_ok() -> bool:
|
||||
return _docker_ok("info")
|
||||
|
||||
|
||||
def _start_docker_daemon() -> bool:
|
||||
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
|
||||
if _docker_daemon_ok():
|
||||
return True
|
||||
cmd = _docker_desktop_cmd(sys.platform)
|
||||
if cmd is None:
|
||||
if sys.platform.startswith("win"):
|
||||
# docker CLI 在 PATH 上(否则走不到这)、却定位不到 Docker Desktop.exe:多为非标准安装位置
|
||||
_log("找不到 Docker Desktop.exe(已试:PATH 上 docker CLI 反推、注册表、常见安装目录)。")
|
||||
_log(" 确已安装 → 设环境变量 DOCKER_DESKTOP_EXE=<Docker Desktop.exe 全路径> 再重试,"
|
||||
"或先手动启动 Docker Desktop。")
|
||||
_log(f" 不想折腾 → 把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑(仅救急)。")
|
||||
else:
|
||||
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
|
||||
return False
|
||||
_log(f"启动 Docker Desktop(首次冷启可能 30-60s):{cmd[0]}")
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
_log(f"启动 Docker Desktop 失败:{e}")
|
||||
return False
|
||||
deadline = time.monotonic() + DOCKER_START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _docker_daemon_ok():
|
||||
_log("Docker 守护进程已就绪。")
|
||||
return True
|
||||
_log("等待 Docker 守护进程…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _ps_names(*filters: str) -> str:
|
||||
"""docker ps -a 按 filter 查容器名(每行一个);失败返回空串。"""
|
||||
args = ["docker", "ps", "-a", "--format", "{{.Names}}"]
|
||||
for f in filters:
|
||||
args += ["--filter", f]
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args, cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def _remove_stale_container() -> None:
|
||||
"""删掉「同名但不属于本 compose 项目」的残留容器(旧目录/worktree 建的)。
|
||||
|
||||
固定的 container_name 是全局唯一名:若旧项目留下一个同名容器,`docker compose up`
|
||||
会因撞名报 "container name already in use" 而失败。这里在 up 之前主动清掉它。
|
||||
数据在命名卷(<project>_pgdata)里,删容器不删卷、不丢数据。
|
||||
"""
|
||||
project = os.environ.get("COMPOSE_PROJECT_NAME", "")
|
||||
name_filter = f"name=^{CONTAINER_NAME}$"
|
||||
if CONTAINER_NAME not in _ps_names(name_filter).split():
|
||||
return # 没有同名容器
|
||||
ours = _ps_names(name_filter, f"label=com.docker.compose.project={project}")
|
||||
if CONTAINER_NAME in ours.split():
|
||||
return # 就是本项目的容器,compose 会自己 start/复用,别删
|
||||
_log(f"发现残留同名容器 {CONTAINER_NAME}(非本项目 '{project}'),删除以避免撞名"
|
||||
f"(数据在卷里,不丢)…")
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", CONTAINER_NAME], cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
_log(f"⚠️ 删除残留容器失败,可手动: docker rm -f {CONTAINER_NAME}")
|
||||
|
||||
|
||||
def _compose_up() -> bool:
|
||||
_remove_stale_container()
|
||||
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
_log(f"docker compose up 失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def _pg_isready() -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"pg_isready", "-U", DB_USER, "-d", APP_DB],
|
||||
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def _wait_pg_ready(host: str, port: int) -> bool:
|
||||
deadline = time.monotonic() + PG_READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(host, port) and _pg_isready():
|
||||
_log("PostgreSQL 已就绪。")
|
||||
return True
|
||||
_log("等待 PostgreSQL 就绪…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _test_db_exists() -> bool:
|
||||
"""测试库是否已存在(连业务库 shaguabijia 查 pg_database)。"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
|
||||
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return r.returncode == 0 and r.stdout.strip() == "1"
|
||||
|
||||
|
||||
def ensure_test_db() -> bool:
|
||||
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。返回测试库是否就绪。
|
||||
|
||||
公开给 conftest 单独调用:ensure() 在「端口已通」时会短路返回、不建测试库,
|
||||
所以测试侧需在 ensure() 之后再显式补一刀(best-effort)。
|
||||
"""
|
||||
if _test_db_exists():
|
||||
return True
|
||||
_log(f"建测试库 {TEST_DB}…")
|
||||
try:
|
||||
create = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
|
||||
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{e}")
|
||||
return False
|
||||
# returncode==0=建成功;非 0 但库已存在=与并发创建者竞争失败(42P04),仍算就绪
|
||||
if create.returncode == 0 or _test_db_exists():
|
||||
return True
|
||||
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{(create.stderr or '').strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def _warn_sqlite_degraded() -> None:
|
||||
"""DATABASE_URL 是 SQLite 时打印醒目降级横幅(显式逃生舱,非静默切库)。"""
|
||||
for line in (
|
||||
"⚠️ ================= 降级模式(SQLite) =================",
|
||||
"⚠️ DATABASE_URL 是 SQLite,不是 PostgreSQL。",
|
||||
"⚠️ PG 专有 SQL(窗口函数/FILTER/JSONB)与严格类型在此模式【不被验证】。",
|
||||
"⚠️ 提交前请在装了 Docker 的机器上用 PG 复跑;装好后把 DATABASE_URL 改回:",
|
||||
f"⚠️ {PG_URL}",
|
||||
"⚠️ ===================================================",
|
||||
):
|
||||
_log(line)
|
||||
|
||||
|
||||
def ensure(database_url: str | None = None) -> bool:
|
||||
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。
|
||||
|
||||
运行时若 DATABASE_URL 是 SQLite → 打印降级横幅并返回 True(显式逃生舱);
|
||||
conftest 传的是 PG URL,故测试侧永不走此分支。
|
||||
"""
|
||||
if database_url is None:
|
||||
from app.core.config import settings # 延迟导入,避免过早固化 settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
|
||||
if _is_sqlite(database_url):
|
||||
_warn_sqlite_degraded()
|
||||
return True
|
||||
|
||||
host, port = _parse_host_port(database_url)
|
||||
|
||||
if _port_open(host, port):
|
||||
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
|
||||
return True
|
||||
|
||||
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
|
||||
|
||||
if not _docker_cli_ok():
|
||||
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
|
||||
_log(" https://www.docker.com/products/docker-desktop/")
|
||||
_log(f"装不了 Docker?把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑")
|
||||
_log(" (PG 专有 SQL/严格性不被验证,仅救急);改完重跑 run.sh/run.bat。")
|
||||
return False
|
||||
if not _start_docker_daemon():
|
||||
return False
|
||||
if not _compose_up():
|
||||
return False
|
||||
if not _wait_pg_ready(host, port):
|
||||
return False
|
||||
if not ensure_test_db():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if ensure() else 1)
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。
|
||||
|
||||
新机器初始化用。前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
|
||||
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
|
||||
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
|
||||
用法:
|
||||
python scripts/init_postgres.py
|
||||
|
||||
+22
-15
@@ -1,22 +1,19 @@
|
||||
"""测试用 fixtures。
|
||||
|
||||
测试 DB 用临时文件 SQLite。
|
||||
- 不用 in-memory:in-memory 默认 per-connection,跨连接看不到表。
|
||||
- 用临时文件保证 SessionLocal 每次新连都看到同一份 schema。
|
||||
|
||||
顺序:set env(必须在 import app.* 之前) → import app → 建表 → TestClient
|
||||
测试库用 Docker PostgreSQL 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
|
||||
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 + 测试库存在 →
|
||||
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
|
||||
# 临时 db 文件路径,进程退出后清理
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp_db.name}"
|
||||
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期就建 engine)
|
||||
_TEST_DB_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
|
||||
)
|
||||
os.environ["DATABASE_URL"] = _TEST_DB_URL
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
|
||||
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
|
||||
os.environ.setdefault("JG_APP_KEY", "test-key")
|
||||
@@ -35,6 +32,18 @@ os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
# 2) 保证 Docker PG 就绪(必须在 import app.db.session 建 engine 之前)。
|
||||
# ensure() 在「端口已通」时会短路、不建测试库,故随后再显式补一刀 ensure_test_db()。
|
||||
from scripts.ensure_pg import ensure, ensure_test_db
|
||||
|
||||
if not ensure(_TEST_DB_URL):
|
||||
raise RuntimeError(
|
||||
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
|
||||
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
|
||||
)
|
||||
# best-effort 兜底建测试库(PG 已在跑但测试库缺失=老卷)。真缺库时下面 create_all 会明确报错。
|
||||
ensure_test_db()
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -45,13 +54,11 @@ from app.main import app
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_db() -> Iterator[None]:
|
||||
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
try:
|
||||
os.unlink(_tmp_db.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
+40
-39
@@ -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
|
||||
|
||||
|
||||
# ============================ 用户名 / 默认昵称 ============================
|
||||
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud
|
||||
from app.repositories.user import get_user_by_phone
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
|
||||
|
||||
@@ -46,6 +47,17 @@ def _get(db, trace_id: str) -> ComparisonRecord | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _make_user(client, phone: str) -> int:
|
||||
"""登录建号并返回其真实 user_id。
|
||||
|
||||
PG 强制 comparison_record.user_id → user.id 外键,须引用真实存在的用户;
|
||||
用本用例自己登录出的用户,不会与别的用例撞。
|
||||
"""
|
||||
client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
with SessionLocal() as db:
|
||||
return get_user_by_phone(db, phone).id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# repo 层
|
||||
# ============================================================
|
||||
@@ -143,17 +155,18 @@ def test_harvest_abort_missing_row_returns_none(client) -> None:
|
||||
def test_upsert_record_no_downgrade_after_harvest_success(client) -> None:
|
||||
"""harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。"""
|
||||
tid = _tid()
|
||||
# PG 强制 comparison_record.user_id → user.id 外键(SQLite 不强制,老写法用合成 id
|
||||
# 987654)。用本用例自己登录出的真实用户,既满足外键、又不与别的用例撞。
|
||||
uid = _make_user(client, "13800007701")
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
|
||||
payload = ComparisonRecordIn(
|
||||
trace_id=tid, business_type="food", status="cancelled",
|
||||
information="用户终止", comparison_results=[],
|
||||
)
|
||||
# 用一个不会与顺序自增用户撞的合成 id(SQLite 测试库 FK 不强制;别用小整数,
|
||||
# 否则会撞上别的测试 login 出来的真实 user_id → 记录混进那个用户的列表)。
|
||||
rec = crud.upsert_record(db, user_id=987654, payload=payload)
|
||||
rec = crud.upsert_record(db, user_id=uid, payload=payload)
|
||||
assert rec.status == "success" # 不降级
|
||||
assert rec.user_id == 987654 # 但补上了 user_id(原为 None)
|
||||
assert rec.user_id == uid # 但补上了 user_id(原为 None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from scripts.ensure_pg import (
|
||||
_docker_desktop_cmd,
|
||||
_docker_desktop_from_registry,
|
||||
_is_sqlite,
|
||||
_parse_host_port,
|
||||
_port_open,
|
||||
_win_docker_desktop_candidates,
|
||||
ensure,
|
||||
)
|
||||
|
||||
|
||||
def test_is_sqlite():
|
||||
assert _is_sqlite("sqlite:///./data/app.db")
|
||||
assert _is_sqlite(" SQLite:///x ")
|
||||
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
|
||||
|
||||
|
||||
def test_parse_host_port_full():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_defaults():
|
||||
# 缺端口 → 5432
|
||||
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
|
||||
# 缺 host → localhost
|
||||
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_testdb():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_port_open_true():
|
||||
srv = socket.socket()
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
assert _port_open("127.0.0.1", port, timeout=1.0)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def test_port_open_false():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close() # 释放端口,无人监听 → 连接应失败
|
||||
assert not _port_open("127.0.0.1", port, timeout=0.3)
|
||||
|
||||
|
||||
def test_win_candidates_override_wins():
|
||||
env = {
|
||||
"DOCKER_DESKTOP_EXE": r"X:\custom\Docker Desktop.exe",
|
||||
"ProgramFiles": r"C:\Program Files",
|
||||
}
|
||||
cands = [str(p) for p in _win_docker_desktop_candidates(env, None)]
|
||||
assert cands[0] == r"X:\custom\Docker Desktop.exe"
|
||||
|
||||
|
||||
def test_win_candidates_follow_cli_drive():
|
||||
# 核心:docker CLI 在 D 盘 → 反推出 D 盘的 Docker Desktop.exe(不再写死 C 盘)
|
||||
env = {"ProgramFiles": r"C:\Program Files"}
|
||||
cli = r"D:\Docker\Docker\resources\bin\docker.exe"
|
||||
cands = [str(p) for p in _win_docker_desktop_candidates(env, cli)]
|
||||
assert r"D:\Docker\Docker\Docker Desktop.exe" in cands
|
||||
# 兜底的 C 盘常见路径也仍在
|
||||
assert r"C:\Program Files\Docker\Docker\Docker Desktop.exe" in cands
|
||||
|
||||
|
||||
def test_win_candidates_no_cli_uses_program_files():
|
||||
env = {"ProgramFiles": r"C:\Program Files"}
|
||||
cands = [str(p) for p in _win_docker_desktop_candidates(env, None)]
|
||||
assert cands == [r"C:\Program Files\Docker\Docker\Docker Desktop.exe"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows_found(monkeypatch, tmp_path):
|
||||
exe = tmp_path / "Docker Desktop.exe"
|
||||
exe.write_text("") # 真实存在
|
||||
monkeypatch.setattr("scripts.ensure_pg._find_docker_desktop_exe", lambda: exe)
|
||||
assert _docker_desktop_cmd("win32") == [str(exe)]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows_not_found(monkeypatch):
|
||||
monkeypatch.setattr("scripts.ensure_pg._find_docker_desktop_exe", lambda: None)
|
||||
assert _docker_desktop_cmd("win32") is None
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_darwin():
|
||||
assert _docker_desktop_cmd("darwin") == ["open", "-a", "Docker"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_linux():
|
||||
assert _docker_desktop_cmd("linux") is None
|
||||
|
||||
|
||||
def test_registry_probe_never_raises():
|
||||
# best-effort:无论平台/有无键,只返回 Path 或 None,绝不抛
|
||||
r = _docker_desktop_from_registry()
|
||||
assert r is None or hasattr(r, "exists")
|
||||
|
||||
|
||||
def test_ensure_sqlite_escape_hatch(monkeypatch):
|
||||
# sqlite 是【显式降级逃生舱】:打印横幅、返回 True,且绝不触碰 docker
|
||||
def _boom():
|
||||
raise AssertionError("sqlite 分支不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("sqlite:///./data/app.db") is True
|
||||
|
||||
|
||||
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
|
||||
# 端口通 → 直接 True,绝不触碰 docker
|
||||
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
|
||||
|
||||
def _boom():
|
||||
raise AssertionError("端口通时不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
|
||||
@@ -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
|
||||
@@ -0,0 +1,286 @@
|
||||
"""创蓝云智(253)短信 provider(Mode B 自管码)单元测试。
|
||||
|
||||
HTTP 交互隔离在 chuanglan._call_chuanglan(薄封装:签名 + httpx POST + 解析),映射逻辑在
|
||||
chuanglan._send_via_chuanglan。本文件 monkeypatch 这两个接缝(或更底层 httpx.post)→ 不发真网络。
|
||||
测的是:HMAC 签名算法、请求体不上行 password、错误码→HTTP 码、自管码存/校验(与极光同语义)、mock 短路。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.integrations.sms import chuanglan
|
||||
from app.integrations.sms.base import SmsError
|
||||
|
||||
PHONE = "13800138000"
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""极简 httpx.Response 替身:只暴露 status_code / json() / text。"""
|
||||
|
||||
def __init__(self, status_code: int = 200, payload: dict | None = None, text: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload if payload is not None else {}
|
||||
self.text = text or json.dumps(self._payload)
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
def _configure(monkeypatch, *, mock: bool = False) -> None:
|
||||
"""配齐创蓝凭证 + 设 SMS_MOCK;清本地存码/冷却隔离用例。"""
|
||||
monkeypatch.setattr(settings, "SMS_MOCK", mock)
|
||||
monkeypatch.setattr(settings, "CHUANGLAN_SMS_ACCOUNT", "YZM0000001")
|
||||
monkeypatch.setattr(settings, "CHUANGLAN_SMS_PASSWORD", "secret")
|
||||
monkeypatch.setattr(settings, "CHUANGLAN_SMS_TEMPLATE_ID", "1021143438")
|
||||
monkeypatch.setattr(settings, "CHUANGLAN_SMS_SIGNATURE", "【创蓝云智】")
|
||||
chuanglan._codes.clear()
|
||||
chuanglan._last_sent.clear()
|
||||
|
||||
|
||||
def _ok_payload(**over) -> dict:
|
||||
p = {
|
||||
"code": "000000",
|
||||
"msgId": "25071018345400902898000000000001",
|
||||
"time": "20250710183454",
|
||||
"successNum": "1",
|
||||
"failNum": "0",
|
||||
"errorMsg": "",
|
||||
}
|
||||
p.update(over)
|
||||
return p
|
||||
|
||||
|
||||
# ============================ 签名算法 ============================
|
||||
|
||||
def test_sign_implements_documented_hmac() -> None:
|
||||
"""_sign = HmacSHA256(key=md5(password), msg=sorted([md5pwd,ts,nonce]) 拼接),小写 hex。"""
|
||||
password, ts, nonce = "secret", "1752143733", "0123456789abcdef0123456789abcdef"
|
||||
md5pwd = hashlib.md5(password.encode()).hexdigest()
|
||||
expected = hmac.new(
|
||||
md5pwd.encode(),
|
||||
"".join(sorted([md5pwd, ts, nonce])).encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
sig = chuanglan._sign(password, ts, nonce)
|
||||
assert sig == expected
|
||||
assert len(sig) == 64 and sig == sig.lower()
|
||||
|
||||
|
||||
def test_sign_changes_with_nonce() -> None:
|
||||
assert chuanglan._sign("p", "1", "nonceA") != chuanglan._sign("p", "1", "nonceB")
|
||||
|
||||
|
||||
# ============================ _call_chuanglan(HTTP 接缝)============================
|
||||
|
||||
def test_call_chuanglan_builds_signed_request_without_password(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
captured: dict = {}
|
||||
|
||||
def fake_post(url, **kw):
|
||||
captured.update(url=url, body=kw.get("json"), headers=kw.get("headers"), timeout=kw.get("timeout"))
|
||||
return _FakeResp(200, _ok_payload())
|
||||
|
||||
monkeypatch.setattr(httpx, "post", fake_post)
|
||||
|
||||
result = chuanglan._call_chuanglan(PHONE, "123456")
|
||||
|
||||
assert result["code"] == "000000"
|
||||
assert captured["url"] == settings.CHUANGLAN_SMS_ENDPOINT
|
||||
body = captured["body"]
|
||||
assert body["account"] == "YZM0000001"
|
||||
assert body["phoneNumbers"] == PHONE
|
||||
assert body["templateId"] == "1021143438"
|
||||
assert body["templateParamJson"] == json.dumps([{"param1": "123456"}])
|
||||
assert body["signature"] == "【创蓝云智】"
|
||||
assert "password" not in body # HMAC 方式:密码只用于算签,不上行
|
||||
assert len(body["nonce"]) == 32
|
||||
assert captured["headers"]["X-QA-Hmac-Signature"] == chuanglan._sign(
|
||||
"secret", body["timestamp"], body["nonce"]
|
||||
)
|
||||
assert captured["timeout"] == settings.CHUANGLAN_SMS_TIMEOUT_SEC
|
||||
|
||||
|
||||
def test_call_chuanglan_omits_signature_when_blank(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(settings, "CHUANGLAN_SMS_SIGNATURE", "")
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(httpx, "post", lambda url, **kw: captured.update(body=kw.get("json")) or _FakeResp(200, _ok_payload()))
|
||||
|
||||
chuanglan._call_chuanglan(PHONE, "123456")
|
||||
assert "signature" not in captured["body"] # 模板自带签名时不传
|
||||
|
||||
|
||||
def test_call_chuanglan_http_non_200_raises_503(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(httpx, "post", lambda url, **kw: _FakeResp(500, {}, "oops"))
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan._call_chuanglan(PHONE, "123456")
|
||||
assert ei.value.status_code == 503
|
||||
|
||||
|
||||
def test_call_chuanglan_network_error_raises_503(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
|
||||
def boom(url, **kw):
|
||||
raise httpx.ConnectError("down")
|
||||
|
||||
monkeypatch.setattr(httpx, "post", boom)
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan._call_chuanglan(PHONE, "123456")
|
||||
assert ei.value.status_code == 503
|
||||
|
||||
|
||||
# ============================ _send_via_chuanglan(错误码映射)============================
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,expected",
|
||||
[
|
||||
("000000", None), # 成功不抛
|
||||
("103", 429), # 超频
|
||||
("107", 400), # 手机号错误
|
||||
("109", 503), # 无发送量/余额
|
||||
("117", 503), # IP 未白名单
|
||||
("102", 503), # 密码错误
|
||||
("116", 503), # 签名不合法
|
||||
("124", 503), # 模板内容不匹配
|
||||
("152", 503), # 模板不存在
|
||||
("999999", 503), # 未知码兜底
|
||||
],
|
||||
)
|
||||
def test_send_via_chuanglan_maps_codes(monkeypatch, code, expected) -> None:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(chuanglan, "_call_chuanglan", lambda phone, c: _ok_payload(code=code, errorMsg=code))
|
||||
if expected is None:
|
||||
assert chuanglan._send_via_chuanglan(PHONE, "123456") is None
|
||||
else:
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan._send_via_chuanglan(PHONE, "123456")
|
||||
assert ei.value.status_code == expected
|
||||
|
||||
|
||||
def test_send_via_chuanglan_not_configured_raises_503_without_calling(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "SMS_MOCK", False)
|
||||
monkeypatch.setattr(settings, "CHUANGLAN_SMS_ACCOUNT", "") # 凭证缺
|
||||
|
||||
def _boom(phone, c):
|
||||
raise AssertionError("未配置时不应发起请求")
|
||||
|
||||
monkeypatch.setattr(chuanglan, "_call_chuanglan", _boom)
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan._send_via_chuanglan(PHONE, "123456")
|
||||
assert ei.value.status_code == 503
|
||||
|
||||
|
||||
# ============================ send_code(自管码,复制自极光)============================
|
||||
|
||||
def test_send_code_success_stores_and_returns_cooldown(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", lambda p, c: None)
|
||||
|
||||
assert chuanglan.send_code(PHONE) == settings.SMS_SEND_INTERVAL_SEC
|
||||
assert PHONE in chuanglan._codes
|
||||
assert len(chuanglan._codes[PHONE].code) == settings.SMS_CODE_LENGTH
|
||||
|
||||
|
||||
def test_send_code_cooldown_raises_429(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", lambda p, c: None)
|
||||
chuanglan.send_code(PHONE)
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan.send_code(PHONE)
|
||||
assert ei.value.status_code == 429
|
||||
|
||||
|
||||
def test_send_code_failure_keeps_cooldown_drops_code(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
|
||||
def boom(p, c):
|
||||
raise SmsError("no balance", status_code=503)
|
||||
|
||||
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan.send_code(PHONE)
|
||||
assert ei.value.status_code == 503
|
||||
assert PHONE not in chuanglan._codes # 没发出去的码删掉
|
||||
assert PHONE in chuanglan._last_sent # 冷却保留:失败也限速
|
||||
|
||||
|
||||
def test_send_code_unexpected_error_wrapped_503(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
|
||||
def boom(p, c):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
|
||||
with pytest.raises(SmsError) as ei:
|
||||
chuanglan.send_code(PHONE)
|
||||
assert ei.value.status_code == 503
|
||||
|
||||
|
||||
def test_send_code_mock_short_circuits_no_network(monkeypatch) -> None:
|
||||
_configure(monkeypatch, mock=True)
|
||||
|
||||
def boom(p, c):
|
||||
raise AssertionError("mock 不应发网络")
|
||||
|
||||
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
|
||||
assert chuanglan.send_code(PHONE) == settings.SMS_SEND_INTERVAL_SEC
|
||||
|
||||
|
||||
# ============================ verify_code(自管码,复制自极光)============================
|
||||
|
||||
def test_verify_code_success_is_one_time(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() + 300)
|
||||
assert chuanglan.verify_code(PHONE, "123456") is True
|
||||
assert chuanglan.verify_code(PHONE, "123456") is False # 验过即作废
|
||||
|
||||
|
||||
def test_verify_code_wrong_caps_then_invalidates(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() + 300)
|
||||
for _ in range(settings.SMS_MAX_VERIFY_ATTEMPTS):
|
||||
assert chuanglan.verify_code(PHONE, "000000") is False
|
||||
# 达失败上限即作废:即便随后给对的码也 False
|
||||
assert chuanglan.verify_code(PHONE, "123456") is False
|
||||
|
||||
|
||||
def test_verify_code_expired_false_and_cleared(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
chuanglan._codes[PHONE] = chuanglan._CodeRecord(code="123456", expires_at=time.time() - 1)
|
||||
assert chuanglan.verify_code(PHONE, "123456") is False
|
||||
assert PHONE not in chuanglan._codes
|
||||
|
||||
|
||||
def test_verify_code_no_record_false(monkeypatch) -> None:
|
||||
_configure(monkeypatch)
|
||||
assert chuanglan.verify_code(PHONE, "123456") is False
|
||||
|
||||
|
||||
def test_verify_code_mock_passes_any_ndigit(monkeypatch) -> None:
|
||||
_configure(monkeypatch, mock=True)
|
||||
assert chuanglan.verify_code(PHONE, "123456") is True # 6 位数字放行
|
||||
assert chuanglan.verify_code(PHONE, "12345") is False # 位数不对
|
||||
|
||||
|
||||
# ============================ 端点:SMS_PROVIDER=chuanglan 端到端路由 ============================
|
||||
|
||||
def test_sms_send_chuanglan_outage_returns_503(client, monkeypatch) -> None:
|
||||
"""SMS_PROVIDER=chuanglan 且发送时创蓝异常 → /sms/send 返 503(auth.py 现有 try/except 覆盖)。"""
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
|
||||
|
||||
def boom(p, c):
|
||||
raise SmsError("chuanglan down", status_code=503)
|
||||
|
||||
monkeypatch.setattr(chuanglan, "_send_via_chuanglan", boom)
|
||||
r = client.post("/api/v1/auth/sms/send", json={"phone": "13812345678"})
|
||||
assert r.status_code == 503, r.text
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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, chuanglan, 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(chuanglan, "send_code", lambda phone: (calls.append("chuanglan"), 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
|
||||
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
|
||||
assert sms.send_code("13800138000") == 60
|
||||
|
||||
assert calls == ["jiguang", "aliyun", "chuanglan"] # 每次按当前 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(chuanglan, "verify_code", lambda phone, code: (calls.append("chuanglan"), 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
|
||||
monkeypatch.setattr(settings, "SMS_PROVIDER", "chuanglan")
|
||||
assert sms.verify_code("13800138000", "123456") is True
|
||||
|
||||
assert calls == ["jiguang", "aliyun", "chuanglan"]
|
||||
|
||||
|
||||
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"]
|
||||
Reference in New Issue
Block a user