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:
2026-06-24 03:53:52 +08:00
committed by marco
parent 4a9d3f1d1a
commit d8c7a6d1ef
6 changed files with 299 additions and 2 deletions
+26 -2
View File
@@ -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")
+13
View File
@@ -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 = ""
+80
View File
@@ -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)
+6
View File
@@ -48,6 +48,12 @@ class TokenWithUser(TokenPair):
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
force_onboarding: bool = Field(
False,
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
)
# ===== 极光一键登录 =====