feat(auth): 测试账号免验证码登录 + 每次走引导 + 每日上限 (#69)
Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #69 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #69.
This commit is contained in:
@@ -39,6 +39,15 @@ SMS_MOCK=true
|
||||
SMS_CODE_TTL_SEC=300
|
||||
SMS_SEND_INTERVAL_SEC=60
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||
# 逻辑见 app/core/test_account.py,与其他业务解耦。
|
||||
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
|
||||
TEST_ACCOUNT_PHONE=
|
||||
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
|
||||
TEST_ACCOUNT_DAILY_LIMIT=500
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
|
||||
MT_CPS_APP_KEY=
|
||||
|
||||
+26
-2
@@ -15,6 +15,7 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
@@ -38,13 +39,17 @@ logger = logging.getLogger("shagua.auth")
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
|
||||
def _login_response(
|
||||
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
||||
) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
|
||||
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。"""
|
||||
tokens = issue_token_pair(user.id)
|
||||
return TokenWithUser(
|
||||
**tokens,
|
||||
user=UserOut.model_validate(user),
|
||||
onboarding_completed=onboarding_completed,
|
||||
force_onboarding=force_onboarding,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +87,12 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
|
||||
)
|
||||
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
|
||||
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
|
||||
if test_account.is_test_account(req.phone):
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
@@ -99,6 +110,19 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
||||
if test_account.is_test_account(req.phone):
|
||||
if not test_account.try_consume_quota():
|
||||
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
||||
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||
# onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
|
||||
# 不读/不写 onboarding_completion 表。
|
||||
return _login_response(user, onboarding_completed=False, force_onboarding=True)
|
||||
|
||||
if not verify_code(req.phone, req.code):
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
|
||||
@@ -84,6 +84,19 @@ class Settings(BaseSettings):
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
|
||||
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
|
||||
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
|
||||
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
|
||||
|
||||
@property
|
||||
def test_account_phone(self) -> str:
|
||||
"""规整后的测试手机号(去空白);空串=功能关闭。"""
|
||||
return self.TEST_ACCOUNT_PHONE.strip()
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
|
||||
MT_CPS_APP_KEY: str = ""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""测试账号:免验证码登录 + 每次重走新手引导 + 每日使用上限。
|
||||
|
||||
为打通 **release 包全流程**(无 SIM 卡、不走极光一键登录)而设的一个固定测试手机号。
|
||||
对这个号(且仅这个号)放开三件事:
|
||||
1. **免短信验证码**:`/sms/send` 不真发、`/sms/login` 跳过 `verify_code`,real 模式下同样生效
|
||||
—— 测试时直接填任意验证码即可登入,不用等真短信。
|
||||
2. **每次都走新手引导**:登录响应 `onboarding_completed` 恒为 false,不读/不依赖
|
||||
onboarding_completion 表,所以反复登录都能体验引导。
|
||||
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷。当天登录数超过上限即拒绝(429),
|
||||
次日自动归零。
|
||||
|
||||
手机号与上限都在 .env 配(`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改。
|
||||
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
|
||||
False,登录/短信回到原逻辑,零影响。
|
||||
|
||||
计数存**进程内存**(单 worker 够用,与 sms.py 同款约定):重启清零、多 worker 不共享。作为
|
||||
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone → 同一个 user,滥用面天然只
|
||||
限于这一个账号。要严格持久化/跨进程再迁 DB 或 Redis(同 sms.py 的技术债)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.test_account")
|
||||
|
||||
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)。
|
||||
_lock = Lock()
|
||||
_usage: tuple[str, int] = ("", 0)
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)。"""
|
||||
return bool(settings.test_account_phone)
|
||||
|
||||
|
||||
def is_test_account(phone: str) -> bool:
|
||||
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False。"""
|
||||
return is_enabled() and phone == settings.test_account_phone
|
||||
|
||||
|
||||
def try_consume_quota() -> bool:
|
||||
"""测试账号登录时调:当日计数 +1。
|
||||
|
||||
Returns:
|
||||
True —— 未超当日上限,本次放行(计数已 +1);
|
||||
False —— 已达上限,本次拒绝(计数不变)。
|
||||
|
||||
线程安全;跨天自动归零。上限取自 settings.TEST_ACCOUNT_DAILY_LIMIT(可在 .env 改)。
|
||||
"""
|
||||
global _usage
|
||||
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
|
||||
with _lock:
|
||||
today = _today()
|
||||
day, cnt = _usage
|
||||
if day != today: # 跨天归零
|
||||
cnt = 0
|
||||
if cnt >= limit:
|
||||
_usage = (today, cnt) # 已满,保持不变
|
||||
logger.warning(
|
||||
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
|
||||
)
|
||||
return False
|
||||
_usage = (today, cnt + 1)
|
||||
logger.info("测试账号 %s 第 %d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
|
||||
return True
|
||||
|
||||
|
||||
def _reset_for_test() -> None:
|
||||
"""仅供单测:清空进程内计数,隔离用例间状态。"""
|
||||
global _usage
|
||||
with _lock:
|
||||
_usage = ("", 0)
|
||||
@@ -48,6 +48,12 @@ class TokenWithUser(TokenPair):
|
||||
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
|
||||
"true → 客户端登录后直接进首页,跳过引导。",
|
||||
)
|
||||
force_onboarding: bool = Field(
|
||||
False,
|
||||
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
|
||||
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
|
||||
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
|
||||
)
|
||||
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""测试账号(app/core/test_account.py)行为测试。
|
||||
|
||||
覆盖四件事:
|
||||
- 免验证码登录(mock 与 real 模式都跳过校验);
|
||||
- 每次登录都重走新手引导(onboarding_completed 恒 false,即便完成表已有记录);
|
||||
- 每日登录上限(超过即 429),且只算 login、不算 send;
|
||||
- 功能关闭(TEST_ACCOUNT_PHONE 留空)时对任何号都不生效,回到原逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import test_account
|
||||
from app.integrations import sms
|
||||
|
||||
# 测试自注入的固定号:各用例用 monkeypatch 把它塞进 settings.TEST_ACCOUNT_PHONE,
|
||||
# 再断言行为。它与 .env 配的值【相互独立】——测试不读 .env(保证确定性 / CI 无 .env 也能跑),
|
||||
# 写成和 .env 同值只为直观;换任意 11 位号测试照样全过,二者无需保持一致。
|
||||
TEST_PHONE = "11111111111"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def enabled(monkeypatch):
|
||||
"""启用测试账号功能并清空当日计数;monkeypatch 与 _reset 保证用例间隔离。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", TEST_PHONE)
|
||||
test_account._reset_for_test()
|
||||
yield
|
||||
test_account._reset_for_test()
|
||||
|
||||
|
||||
# ============================ 开关 / 识别 ============================
|
||||
|
||||
def test_disabled_when_phone_unset(monkeypatch) -> None:
|
||||
"""TEST_ACCOUNT_PHONE 留空 = 整功能关闭,对测试号也不特殊处理。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", "")
|
||||
assert test_account.is_enabled() is False
|
||||
assert test_account.is_test_account(TEST_PHONE) is False
|
||||
|
||||
|
||||
def test_only_configured_phone_is_test_account(enabled) -> None:
|
||||
assert test_account.is_test_account(TEST_PHONE) is True
|
||||
assert test_account.is_test_account("13800138000") is False
|
||||
|
||||
|
||||
def test_phone_is_whitespace_trimmed(monkeypatch) -> None:
|
||||
""".env 里手机号前后带空格也能正确识别。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", f" {TEST_PHONE} ")
|
||||
assert test_account.is_test_account(TEST_PHONE) is True
|
||||
|
||||
|
||||
# ============================ 免验证码 + 新手引导 ============================
|
||||
|
||||
def test_login_skips_code_and_returns_tokens(client, enabled) -> None:
|
||||
"""测试号任意验证码即可登入(不用等真短信),拿到 JWT,onboarding 为 false。"""
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-a"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["user"]["phone"] == TEST_PHONE
|
||||
assert data["user"]["register_channel"] == "sms"
|
||||
assert data["onboarding_completed"] is False
|
||||
assert data["force_onboarding"] is True # 测试号:应用内重登也强制重走引导
|
||||
assert "access_token" in data and "refresh_token" in data
|
||||
|
||||
|
||||
def test_onboarding_always_false_even_if_completed(client, enabled) -> None:
|
||||
"""即便 onboarding_completion 表已有 (该用户, 该设备) 记录,测试号再登仍返回 false。"""
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
|
||||
device = "dev-onb"
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
|
||||
)
|
||||
uid = r.json()["user"]["id"]
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
onboarding_repo.mark_completed(db, user_id=uid, device_id=device)
|
||||
assert onboarding_repo.is_completed(db, user_id=uid, device_id=device) is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r2 = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
|
||||
)
|
||||
assert r2.json()["onboarding_completed"] is False # 仍重走引导
|
||||
|
||||
|
||||
# ============================ real 模式跳过 ============================
|
||||
|
||||
def test_login_skips_verify_in_real_mode(client, enabled, monkeypatch) -> None:
|
||||
"""real 模式(SMS_MOCK=false)普通号要真校验;测试号必须跳过 → 任意码仍 200。"""
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
r = client.post(
|
||||
"/api/v1/auth/sms/login",
|
||||
json={"phone": TEST_PHONE, "code": "9999"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_send_short_circuits_in_real_mode(client, enabled, monkeypatch) -> None:
|
||||
"""real 模式下测试号 /sms/send 不真打极光(否则缺号/缺 key 会 503),直接 200 放行进填码界面。"""
|
||||
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
|
||||
r = client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["sent"] is True
|
||||
assert body["cooldown_sec"] == 0
|
||||
|
||||
|
||||
# ============================ 每日上限 ============================
|
||||
|
||||
def test_daily_limit_blocks_after_threshold(client, enabled, monkeypatch) -> None:
|
||||
"""当日登录数到上限后再登 → 429。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 2)
|
||||
test_account._reset_for_test()
|
||||
|
||||
for _ in range(2):
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
|
||||
assert r.status_code == 429
|
||||
assert "上限" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
|
||||
"""额度只算 login、不算 send:狂发 send 后那唯一一次 login 仍能成功。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
|
||||
test_account._reset_for_test()
|
||||
|
||||
for _ in range(3):
|
||||
assert client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE}).status_code == 200
|
||||
|
||||
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 200
|
||||
# 第二次 login 才超限
|
||||
assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 429
|
||||
|
||||
|
||||
# ============================ 计数单元逻辑 ============================
|
||||
|
||||
def test_quota_resets_across_day(enabled, monkeypatch) -> None:
|
||||
"""跨天自动归零。"""
|
||||
monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
|
||||
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-23")
|
||||
assert test_account.try_consume_quota() is True
|
||||
assert test_account.try_consume_quota() is False # 当天超限
|
||||
|
||||
monkeypatch.setattr(test_account, "_today", lambda: "2026-06-24")
|
||||
assert test_account.try_consume_quota() is True # 次日归零放行
|
||||
|
||||
|
||||
def test_non_test_phone_unaffected_when_enabled(client, enabled) -> None:
|
||||
"""功能开启时,普通号走原 mock 流程(任意 6 位通过),不受测试号逻辑影响。"""
|
||||
phone = "13812341234"
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456", "device_id": "d1"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["user"]["phone"] == phone
|
||||
assert r.json()["force_onboarding"] is False # 普通号不强制重走,按原逻辑
|
||||
Reference in New Issue
Block a user