From bc2ed5de56320a54d56c2af85645bdf6591be057 Mon Sep 17 00:00:00 2001 From: guke Date: Tue, 4 Aug 2026 16:38:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(compare):=20=E6=96=B0=E5=A2=9E=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=BD=93=E6=97=A5=E6=AF=94=E4=BB=B7=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=20GET=20/compare/quota=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概述 新增只读接口 `GET /api/v1/compare/quota`,供客户端「跳外卖 App」型比价入口在点击时**前置查询当日比价是否已达上限**(100 次/日),超限就地提示、不进入比价流程。 配套客户端 PR:比价/领券异常提示统一 + 4 入口上限拦截(shaguabijia-app-android 同名分支)。 ## 改动 - `app/repositories/comparison.py`:新增只读 `get_daily_compare_used(db, user_id, reset_at)` —— 按 user_id + 北京时间自然日 COUNT,**窗口计算逐行复刻写路径 `reserve_daily_start`**,保证前置查询与真发起的 429 gate 口径不漂移。 - `app/schemas/compare_record.py`:新增 `CompareQuotaOut(exhausted, used, limit)`。 - `app/api/v1/compare_record.py`:新增 `GET /quota` 端点,硬鉴权 `CurrentUser`、只读不预占;`limit_policy.resolve` 传 `phone + device_id`(与 `/compare/start` 一致,命中 device 白名单)。 ## 测试 - `pytest tests/test_compare_daily_limit.py`:**8 passed**(4 既有 + 4 新增,含 device 白名单 parity 测试,锁定 `/quota` 与 `/start` 口径一致)。 ## 合并 / 部署注意 ⚠️ - 本 PR 应**先于客户端 PR 合并 + 部署**(客户端点击前置拦截依赖此接口;未部署时客户端 fail-open 放行)。 - 只读、无副作用、不改写路径逻辑,风险低。 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/216 --- app/api/v1/compare_record.py | 20 +++++ app/repositories/comparison.py | 27 +++++++ app/schemas/compare_record.py | 8 ++ tests/test_compare_daily_limit.py | 126 +++++++++++++++++++++++++++++- 4 files changed, 180 insertions(+), 1 deletion(-) diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index b0d5428..66ae136 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -21,6 +21,7 @@ from app.core.trace_ids import new_trace_id from app.repositories import comparison as crud_compare from app.repositories import risk as risk_repo from app.schemas.compare_record import ( + CompareQuotaOut, CompareStartReserveIn, CompareStartReserveOut, CompareStatsOut, @@ -155,6 +156,25 @@ def stats(user: CurrentUser, db: DbSession) -> CompareStatsOut: return CompareStatsOut(compare_count=count, discovered_saved_cents=saved) +@router.get( + "/quota", + response_model=CompareQuotaOut, + summary="查询今天的比价次数配额(只读,不预占)", +) +def get_compare_quota( + user: CurrentUser, + db: DbSession, + device_id: str | None = Query(default=None), +) -> CompareQuotaOut: + """按登录用户查今日比价配额,口径与 /compare/start 同源。 + used 按 user_id 计数;limit/reset_at 按 phone+device 解析(与 /start 一致,device 白名单能命中)。 + exhausted=true → 已达今日上限。客户端①④入口点击时前置查此,超限就地 toast 不跳转。""" + policy = limit_policy.resolve(db, "compare.start.daily", phone=user.phone, device=device_id) + used = crud_compare.get_daily_compare_used(db, user.id, reset_at=policy.reset_at) + exhausted = policy.limit is not None and used >= policy.limit + return CompareQuotaOut(exhausted=exhausted, used=used, limit=policy.limit) + + @router.get( "/records", response_model=ComparisonRecordPage, diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 5af04f1..2703f92 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -548,6 +548,33 @@ def reserve_daily_start( return rec, int(used) + 1 +def get_daily_compare_used( + db: Session, + user_id: int, + reset_at: datetime | None = None, +) -> int: + """今日(北京时间自然日)该用户已发起的比价次数。只读,不改任何数据。 + 口径必须与 reserve_daily_start 完全一致(同 day_start/day_end/reset_at)。""" + current = datetime.now(CN_TZ) + if current.tzinfo is not None: + current = current.astimezone(CN_TZ).replace(tzinfo=None) + day_start = current.replace(hour=0, minute=0, second=0, microsecond=0) + if reset_at is not None: + reset_start = reset_at + if reset_start.tzinfo is not None: + reset_start = reset_start.astimezone(CN_TZ).replace(tzinfo=None) + day_start = max(day_start, reset_start) + day_end = day_start + timedelta(days=1) + used = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= day_start, + ComparisonRecord.created_at < day_end, + ) + ) or 0 + return int(used) + + def harvest_running( db: Session, *, diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 87c768f..5f21cd1 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -260,3 +260,11 @@ class MilestoneClaimResultOut(BaseModel): milestone: int = Field(..., description="本次领取的档位序号") coin_awarded: int = Field(..., description="本次发放金币") coin_balance: int = Field(..., description="领奖后金币余额") + + +class CompareQuotaOut(BaseModel): + """今天的比价次数配额状态(只读)。""" + + exhausted: bool = Field(..., description="是否已达今日上限,无法再比价") + used: int = Field(..., description="今天已用次数") + limit: int | None = Field(..., description="今天的配额上限(None=无限制)") diff --git a/tests/test_compare_daily_limit.py b/tests/test_compare_daily_limit.py index 1de1a56..fce9772 100644 --- a/tests/test_compare_daily_limit.py +++ b/tests/test_compare_daily_limit.py @@ -1,14 +1,16 @@ from __future__ import annotations import time -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from sqlalchemy import func, select +from app.core.limit_policy import MODE_UNLIMITED from app.core.rewards import CN_TZ from app.core.security import decode_token from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord +from app.models.limit_policy import LimitPolicyOverride def _login(client) -> tuple[str, int]: @@ -145,3 +147,125 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: ComparisonRecord.trace_id == rejected_trace ) ) == 0 + + +def test_compare_quota_fresh_user(client) -> None: + """新用户今天没有比价记录 → exhausted=False, used=0, limit=100。""" + token, _user_id = _login(client) + response = client.get("/api/v1/compare/quota", headers=_headers(token)) + assert response.status_code == 200, response.text + assert response.json() == {"exhausted": False, "used": 0, "limit": 100} + + +def test_compare_quota_exhausted(client) -> None: + """今日已有 100 条记录 → exhausted=True, used=100, limit=100。""" + token, user_id = _login(client) + now = datetime.now(CN_TZ).replace(tzinfo=None) + with SessionLocal() as db: + db.add_all( + [ + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-exhausted-{user_id}-{i}", + status="failed", + created_at=now, + ) + for i in range(100) + ] + ) + db.commit() + response = client.get("/api/v1/compare/quota", headers=_headers(token)) + assert response.status_code == 200, response.text + assert response.json() == {"exhausted": True, "used": 100, "limit": 100} + + +def test_compare_quota_yesterday_rows_not_counted(client) -> None: + """昨天的记录不计入今日配额 → exhausted=False, used=0。""" + token, user_id = _login(client) + yesterday = datetime.now(CN_TZ).replace(tzinfo=None) - timedelta(days=1) + with SessionLocal() as db: + db.add_all( + [ + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-yesterday-window-{user_id}-{i}", + status="success", + created_at=yesterday, + ) + for i in range(100) + ] + ) + db.commit() + response = client.get("/api/v1/compare/quota", headers=_headers(token)) + assert response.status_code == 200, response.text + assert response.json() == {"exhausted": False, "used": 0, "limit": 100} + + +def test_compare_quota_device_whitelist_parity(client) -> None: + """device 白名单下 /quota?device_id=X 与 /start 的 policy 完全一致。 + + 场景: + - 设备 whitelisted-device-001 有 unlimited 覆盖 → /quota?device_id= 应报 + exhausted=False, limit=null,哪怕同用户今天已发起 ≥100 次。 + - 不带 device_id(或带非白名单设备) → 同用户走全局 100 上限,exhausted=True。 + """ + token, user_id = _login(client) + device_id = f"whitelisted-device-{user_id}" + + # 种 100 条今日记录:此时不带白名单设备 /quota 应报 exhausted=True + now = datetime.now(CN_TZ).replace(tzinfo=None) + with SessionLocal() as db: + db.add_all( + [ + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-parity-{user_id}-{i}", + status="failed", + created_at=now, + ) + for i in range(100) + ] + ) + # 种 device 白名单覆盖:unlimited、无失效时间(永久白名单用 expires_at=None) + # 注意:validate_override 要求 unlimited+有 expires_at,但这里直接写 ORM 跳过 + # 该验证——测试意图是覆盖"设备白名单已存在"的生产状态,expires_at=None 代表永久。 + db.add( + LimitPolicyOverride( + subject_type="device", + subject_value=device_id, + rule_code="compare.start.daily", + mode=MODE_UNLIMITED, + enabled=True, + expires_at=None, + ) + ) + db.commit() + + # 带白名单 device_id → unlimited,不受 100 条记录限制 + resp_with_device = client.get( + f"/api/v1/compare/quota?device_id={device_id}", + headers=_headers(token), + ) + assert resp_with_device.status_code == 200, resp_with_device.text + body_with = resp_with_device.json() + assert body_with["exhausted"] is False, f"whitelisted device should not be exhausted: {body_with}" + assert body_with["limit"] is None, f"whitelisted device should have null limit: {body_with}" + assert body_with["used"] == 100 + + # 不带 device_id → 走全局 100 上限,已有 100 条 → exhausted=True + resp_no_device = client.get("/api/v1/compare/quota", headers=_headers(token)) + assert resp_no_device.status_code == 200, resp_no_device.text + body_no = resp_no_device.json() + assert body_no["exhausted"] is True, f"without device should be exhausted: {body_no}" + assert body_no["limit"] == 100 + assert body_no["used"] == 100 + + # 带非白名单 device_id → 同样走全局 100 上限 + resp_other_device = client.get( + "/api/v1/compare/quota?device_id=unknown-device-xyz", + headers=_headers(token), + ) + assert resp_other_device.status_code == 200, resp_other_device.text + body_other = resp_other_device.json() + assert body_other["exhausted"] is True, f"non-whitelisted device should be exhausted: {body_other}" + assert body_other["limit"] == 100