Compare commits

..

1 Commits

Author SHA1 Message Date
lowmaster-chen 0cf48241f9 调整首页滚动条用户名称打码规则
改了什么:将 savings-feed 用户名脱敏改为 PRD 规则;有昵称按长度打码,无昵称真实用户显示 用户*****后两位;种子和兜底继续合成多样化脱敏名。
为什么改:避免旧规则出现 用户********xxx,与产品要求的用户标识打码格式保持一致。
验证方式:本地请求 /api/v1/platform/savings-feed?limit=8,确认返回 用户*****xx、首字+**、首字+***+末 等新格式。
2026-06-23 15:33:53 +08:00
7 changed files with 37 additions and 333 deletions
-9
View File
@@ -34,15 +34,6 @@ 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=
+2 -26
View File
@@ -15,7 +15,6 @@ 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
@@ -39,17 +38,13 @@ logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def _login_response(
user, *, onboarding_completed: bool, force_onboarding: bool = False
) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。"""
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
tokens = issue_token_pair(user.id)
return TokenWithUser(
**tokens,
user=UserOut.model_validate(user),
onboarding_completed=onboarding_completed,
force_onboarding=force_onboarding,
)
@@ -87,12 +82,6 @@ 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:
@@ -110,19 +99,6 @@ 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
@@ -79,19 +79,6 @@ 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
@@ -1,80 +0,0 @@
"""测试账号:免验证码登录 + 每次重走新手引导 + 每日使用上限。
为打通 **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)
+35 -34
View File
@@ -6,13 +6,13 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
脱敏名规则(全 feed 统一,按字符算、中英文皆适用):
- 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→***手 / SaveKing→S******g),
2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵);
- 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3(用户********618)
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成
假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名;
种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。
脱敏名规则(对齐 PRD「用户标识打码规则」,按字符算、中英文皆适用):
- 有昵称 → 昵称脱敏:n≥5「首+***+末」(AAA省钱小能手→A***手 / Micky_ 毛→M***),
n=4「首+**+末」(你好不好→你**好),n≤3「首+**」(小确呀→小** / 阿强→阿** / 喵→喵**);
- 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定
真实条按上述 PRD 规则脱敏。种子 / 兜底无真实用户(无昵称/无 id,套不上「用户*****+id后2位」),
仍本地合成多样假名再走同一套昵称脱敏(避开同屏撞名),让横条不至清一色;种子留空 / 旧「用户****xxx」
模板名一律重新合成(自愈历史种子,无需迁移)。
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
@@ -44,12 +44,12 @@ _REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合真名 + 网络昵称语料(中/英)
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
# 没昵称(真实用户)→「用户」+5星+id 后 2(用户*****08)
# 种子/兜底没有真实用户(无昵称/无 id),本地姓/名字池组合真名 + 网络昵称语料(中/英)合成假名,
# 再走同一套昵称脱敏,让横条像真实异质用户群、不清一色(PRD 只约束真实用户,种子是运营填充)
_ANON_STARS = "*" * 5 # 「用户」匿名串固定 5 星(用户*****xx,对齐 PRD)
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
@@ -86,19 +86,23 @@ _NICK_EN = [
def _mask_nickname(nick: str) -> str:
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。"""
"""昵称脱敏(按字符,中英文皆可,对齐 PRD「用户标识打码规则」):
n≥5 → 首+***+末(AAA省钱小能手→A***手 / Micky_ 毛→M***毛);
n=4 → 首+**+末(你好不好→你**好 / Lady→L**y);
n≤3 → 首+**(小确呀→小** / 阿强→阿** / 喵→喵** / Lay→L**)。
调用方需保证 nick 已 strip 且非空。"""
n = len(nick)
if n >= 3:
return nick[0] + "*" * (n - 2) + nick[-1]
if n == 2:
return nick[0] + "*"
return "用户" + nick
if n >= 5:
return nick[0] + "***" + nick[-1]
if n == 4:
return nick[0] + "**" + nick[-1]
return nick[0] + "**"
def _anon_by_id(user_id: int) -> str:
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
def _mask_anon(user_id: int) -> str:
"""没昵称(真实用户,对齐 PRD):「用户」+ 5 星 + id 后 2 位(不足 2 位前补 0):用户*****08。
注:user_id 为整数,「id 后 2 位」取末两位数字(PRD 示例「用户*****x8」的 x 为占位符)。"""
return "用户" + _ANON_STARS + f"{user_id % 100:02d}"
def _synth_full_name(rng: random.Random) -> str:
@@ -118,27 +122,24 @@ def _synth_full_name(rng: random.Random) -> str:
def _synth_masked_name(rng: random.Random) -> str:
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
"""合成一条「已脱敏」假名(供种子 / 兜底用,真实用户已改走 PRD 规则)。用传入 rng 决定一切随机。"""
full = _synth_full_name(rng).strip()
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
(同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」;
少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。"""
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
nick = (nickname or "").strip()
if nick:
return _mask_nickname(nick)
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
seeded = random.Random(user_id)
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
return _mask_anon(user_id)
def _synth_name(rng: random.Random) -> str:
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 2 位(用户*****08)。"""
if rng.random() < 0.15:
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
return _synth_masked_name(rng)
@@ -148,7 +149,7 @@ def _unique_name(used: set[str]) -> str:
n = _synth_name(_rng)
if n not in used:
return n
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
return "用户" + _ANON_STARS + f"{_rng.randint(0, 99):02d}" # 兜底:数字尾号天然好去重
def _validate_amount(min_cents: int, max_cents: int) -> None:
-6
View File
@@ -48,12 +48,6 @@ class TokenWithUser(TokenPair):
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
"true → 客户端登录后直接进首页,跳过引导。",
)
force_onboarding: bool = Field(
False,
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
)
# ===== 极光一键登录 =====
-165
View File
@@ -1,165 +0,0 @@
"""测试账号(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 # 普通号不强制重走,按原逻辑