Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9036bc5a08 |
@@ -13,7 +13,8 @@ class AdminComparisonListItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
# 软鉴权/匿名下帧0 建行时 user_id 可能暂缺(见 models.comparison 注释);admin 全看含孤儿行,故可空。
|
||||
user_id: int | None = None
|
||||
phone: str | None = None # join User 瞬态(非 DB 列)
|
||||
nickname: str | None = None # join User 瞬态
|
||||
business_type: str
|
||||
|
||||
@@ -21,7 +21,6 @@ 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,
|
||||
@@ -156,25 +155,6 @@ 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,
|
||||
|
||||
@@ -548,33 +548,6 @@ 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,
|
||||
*,
|
||||
|
||||
@@ -260,11 +260,3 @@ 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=无限制)")
|
||||
|
||||
@@ -842,6 +842,36 @@ def test_comparison_records_show_readable_device_and_rom_version(
|
||||
assert detail.json()["platforms"][0]["is_best"] is True
|
||||
|
||||
|
||||
def test_comparison_records_list_tolerates_orphan_null_user(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""帧0 建行但 user_id 暂缺的孤儿记录(软鉴权/匿名),admin 列表必须能序列化、不 500。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(
|
||||
ComparisonRecord(
|
||||
user_id=None,
|
||||
trace_id="comparison-orphan-null-user",
|
||||
status="success",
|
||||
store_name="孤儿比价专用店ZZZ",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
response = admin_client.get(
|
||||
"/admin/api/comparison-records",
|
||||
params={"store": "孤儿比价专用店ZZZ"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
items = response.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["user_id"] is None
|
||||
assert items[0]["trace_id"] == "comparison-orphan-null-user"
|
||||
|
||||
|
||||
def test_comparison_records_show_real_order_status(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import 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]:
|
||||
@@ -147,125 +145,3 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user