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