From d8c7a6d1efea004e354f0f1b27d31360adede8ce Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Wed, 24 Jun 2026 03:53:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B5=8B=E8=AF=95=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=85=8D=E9=AA=8C=E8=AF=81=E7=A0=81=E7=99=BB=E5=BD=95?= =?UTF-8?q?=20+=20=E6=AF=8F=E6=AC=A1=E8=B5=B0=E5=BC=95=E5=AF=BC=20+=20?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E4=B8=8A=E9=99=90=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/69 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- .env.example | 9 ++ app/api/v1/auth.py | 28 ++++++- app/core/config.py | 13 +++ app/core/test_account.py | 80 ++++++++++++++++++ app/schemas/auth.py | 6 ++ tests/test_test_account.py | 165 +++++++++++++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 app/core/test_account.py create mode 100644 tests/test_test_account.py diff --git a/.env.example b/.env.example index 646b275..53f1e80 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 2ba8521..d5d21c2 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -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") diff --git a/app/core/config.py b/app/core/config.py index 8b3fe68..3976b2a 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 = "" diff --git a/app/core/test_account.py b/app/core/test_account.py new file mode 100644 index 0000000..ca64e06 --- /dev/null +++ b/app/core/test_account.py @@ -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) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 600baaf..3133de0 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -48,6 +48,12 @@ class TokenWithUser(TokenPair): description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。" "true → 客户端登录后直接进首页,跳过引导。", ) + force_onboarding: bool = Field( + False, + description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】" + "(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed " + "分工:后者管 App 启动路由,本字段管应用内登录后的去向。", + ) # ===== 极光一键登录 ===== diff --git a/tests/test_test_account.py b/tests/test_test_account.py new file mode 100644 index 0000000..721ef56 --- /dev/null +++ b/tests/test_test_account.py @@ -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 # 普通号不强制重走,按原逻辑