Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48de7a64f1 |
@@ -20,6 +20,8 @@ from app.db.session import SessionLocal
|
|||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
from app.repositories import comparison as crud_compare
|
from app.repositories import comparison as crud_compare
|
||||||
from app.schemas.compare_record import (
|
from app.schemas.compare_record import (
|
||||||
|
CompareStartReserveIn,
|
||||||
|
CompareStartReserveOut,
|
||||||
CompareStatsOut,
|
CompareStatsOut,
|
||||||
ComparisonRecordCreatedOut,
|
ComparisonRecordCreatedOut,
|
||||||
ComparisonRecordDetailOut,
|
ComparisonRecordDetailOut,
|
||||||
@@ -35,6 +37,41 @@ logger = logging.getLogger("shagua.compare_record")
|
|||||||
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/start",
|
||||||
|
response_model=CompareStartReserveOut,
|
||||||
|
summary="预占一次当日比价发起次数(每人每天最多100次)",
|
||||||
|
)
|
||||||
|
def reserve_compare_start(
|
||||||
|
payload: CompareStartReserveIn,
|
||||||
|
user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
) -> CompareStartReserveOut:
|
||||||
|
try:
|
||||||
|
_, used = crud_compare.reserve_daily_start(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
trace_id=payload.trace_id,
|
||||||
|
business_type=payload.business_type,
|
||||||
|
device_id=payload.device_id,
|
||||||
|
)
|
||||||
|
except crud_compare.DailyCompareStartLimitExceeded:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="今日已比价超过100次,请明天再试",
|
||||||
|
) from None
|
||||||
|
except crud_compare.ComparisonTraceOwnershipError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="比价任务标识冲突,请重新发起",
|
||||||
|
) from None
|
||||||
|
return CompareStartReserveOut(
|
||||||
|
limit=crud_compare.DAILY_COMPARE_START_LIMIT,
|
||||||
|
used=used,
|
||||||
|
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/record",
|
"/record",
|
||||||
response_model=ComparisonRecordCreatedOut,
|
response_model=ComparisonRecordCreatedOut,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import Session, defer
|
from sqlalchemy.orm import Session, defer
|
||||||
@@ -14,8 +14,19 @@ from app.core.rewards import CN_TZ
|
|||||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||||
from app.models.comparison import ComparisonRecord
|
from app.models.comparison import ComparisonRecord
|
||||||
from app.models.savings import SavingsRecord
|
from app.models.savings import SavingsRecord
|
||||||
|
from app.models.user import User
|
||||||
from app.schemas.compare_record import ComparisonRecordIn
|
from app.schemas.compare_record import ComparisonRecordIn
|
||||||
|
|
||||||
|
DAILY_COMPARE_START_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
|
class DailyCompareStartLimitExceeded(Exception):
|
||||||
|
"""The authenticated user has consumed today's comparison-start quota."""
|
||||||
|
|
||||||
|
|
||||||
|
class ComparisonTraceOwnershipError(Exception):
|
||||||
|
"""A trace id already belongs to a different authenticated user."""
|
||||||
|
|
||||||
|
|
||||||
def _yuan_to_cents(yuan: float | None) -> int | None:
|
def _yuan_to_cents(yuan: float | None) -> int | None:
|
||||||
"""元(float)→ 分(int)。None 透传。"""
|
"""元(float)→ 分(int)。None 透传。"""
|
||||||
@@ -243,6 +254,78 @@ def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_daily_start(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: int,
|
||||||
|
trace_id: str,
|
||||||
|
business_type: str = "food",
|
||||||
|
device_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[ComparisonRecord, int]:
|
||||||
|
"""Atomically reserve one of a user's 100 Beijing-day comparison starts.
|
||||||
|
|
||||||
|
``trace_id`` makes client retries idempotent. Locking the user row serializes
|
||||||
|
concurrent starts for one account, so parallel requests cannot both consume
|
||||||
|
the final available slot. The reservation is the existing ``running``
|
||||||
|
comparison row; later result reporting updates that same row.
|
||||||
|
"""
|
||||||
|
db.execute(select(User.id).where(User.id == user_id).with_for_update()).scalar_one()
|
||||||
|
|
||||||
|
existing = _get_by_trace(db, trace_id)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.user_id not in (None, user_id):
|
||||||
|
raise ComparisonTraceOwnershipError
|
||||||
|
if existing.user_id is None:
|
||||||
|
existing.user_id = user_id
|
||||||
|
if existing.device_id is None and device_id:
|
||||||
|
existing.device_id = device_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
|
||||||
|
existing_at = existing.created_at
|
||||||
|
if existing_at.tzinfo is not None:
|
||||||
|
existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None)
|
||||||
|
day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
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 existing, int(used)
|
||||||
|
|
||||||
|
current = now or 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)
|
||||||
|
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
|
||||||
|
if used >= DAILY_COMPARE_START_LIMIT:
|
||||||
|
raise DailyCompareStartLimitExceeded
|
||||||
|
|
||||||
|
rec = ComparisonRecord(
|
||||||
|
trace_id=trace_id,
|
||||||
|
user_id=user_id,
|
||||||
|
business_type=business_type or "food",
|
||||||
|
device_id=device_id,
|
||||||
|
status="running",
|
||||||
|
created_at=current,
|
||||||
|
)
|
||||||
|
db.add(rec)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rec)
|
||||||
|
return rec, int(used) + 1
|
||||||
|
|
||||||
|
|
||||||
def harvest_running(
|
def harvest_running(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
# ===== 上报请求 =====
|
# ===== 上报请求 =====
|
||||||
|
|
||||||
class ComparisonItemIn(BaseModel):
|
class ComparisonItemIn(BaseModel):
|
||||||
@@ -198,6 +197,20 @@ class ComparisonRecordCreatedOut(BaseModel):
|
|||||||
id: int = Field(..., description="写入(或已存在)的记录 id")
|
id: int = Field(..., description="写入(或已存在)的记录 id")
|
||||||
|
|
||||||
|
|
||||||
|
class CompareStartReserveIn(BaseModel):
|
||||||
|
"""Reserve one authenticated comparison start before the agent begins."""
|
||||||
|
|
||||||
|
trace_id: str = Field(..., min_length=1, max_length=64)
|
||||||
|
business_type: str = Field(default="food", min_length=1, max_length=16)
|
||||||
|
device_id: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class CompareStartReserveOut(BaseModel):
|
||||||
|
limit: int
|
||||||
|
used: int
|
||||||
|
remaining: int
|
||||||
|
|
||||||
|
|
||||||
class CompareStatsOut(BaseModel):
|
class CompareStatsOut(BaseModel):
|
||||||
"""「我的」页省钱战绩卡(比价口径)聚合。"""
|
"""「我的」页省钱战绩卡(比价口径)聚合。"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client) -> tuple[str, int]:
|
||||||
|
phone = f"137{int(time.time() * 1000) % 100000000:08d}"
|
||||||
|
sent = client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||||
|
assert sent.status_code == 200, sent.text
|
||||||
|
logged_in = client.post(
|
||||||
|
"/api/v1/auth/sms/login",
|
||||||
|
json={"phone": phone, "code": "123456"},
|
||||||
|
)
|
||||||
|
assert logged_in.status_code == 200, logged_in.text
|
||||||
|
token = logged_in.json()["access_token"]
|
||||||
|
return token, int(decode_token(token, expected_type="access")["sub"])
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_requires_login(client) -> None:
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/compare/start",
|
||||||
|
json={"trace_id": "quota-no-auth", "business_type": "food"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_is_idempotent_by_trace_id(client) -> None:
|
||||||
|
token, user_id = _login(client)
|
||||||
|
payload = {
|
||||||
|
"trace_id": f"quota-idempotent-{user_id}",
|
||||||
|
"business_type": "ecom",
|
||||||
|
"device_id": "quota-device",
|
||||||
|
}
|
||||||
|
|
||||||
|
first = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
|
||||||
|
retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token))
|
||||||
|
|
||||||
|
assert first.status_code == 200, first.text
|
||||||
|
assert first.json() == {"limit": 100, "used": 1, "remaining": 99}
|
||||||
|
assert retry.status_code == 200, retry.text
|
||||||
|
assert retry.json() == first.json()
|
||||||
|
with SessionLocal() as db:
|
||||||
|
count = db.scalar(
|
||||||
|
select(func.count(ComparisonRecord.id)).where(
|
||||||
|
ComparisonRecord.trace_id == payload["trace_id"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record = db.execute(
|
||||||
|
select(ComparisonRecord).where(
|
||||||
|
ComparisonRecord.trace_id == payload["trace_id"]
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert count == 1
|
||||||
|
assert record.user_id == user_id
|
||||||
|
assert record.status == "running"
|
||||||
|
assert record.business_type == "ecom"
|
||||||
|
assert record.device_id == "quota-device"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None:
|
||||||
|
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-full-{user_id}-{index}",
|
||||||
|
status="failed",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
for index in range(99)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
ComparisonRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
trace_id=f"quota-yesterday-{user_id}",
|
||||||
|
status="success",
|
||||||
|
created_at=now - timedelta(days=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
final_allowed_trace = f"quota-final-allowed-{user_id}"
|
||||||
|
allowed = client.post(
|
||||||
|
"/api/v1/compare/start",
|
||||||
|
json={"trace_id": final_allowed_trace, "business_type": "food"},
|
||||||
|
headers=_headers(token),
|
||||||
|
)
|
||||||
|
assert allowed.status_code == 200, allowed.text
|
||||||
|
assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0}
|
||||||
|
|
||||||
|
rejected_trace = f"quota-rejected-{user_id}"
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/compare/start",
|
||||||
|
json={"trace_id": rejected_trace, "business_type": "food"},
|
||||||
|
headers=_headers(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert response.json()["detail"] == "今日已比价超过100次,请明天再试"
|
||||||
|
with SessionLocal() as db:
|
||||||
|
assert db.scalar(
|
||||||
|
select(func.count(ComparisonRecord.id)).where(
|
||||||
|
ComparisonRecord.trace_id == rejected_trace
|
||||||
|
)
|
||||||
|
) == 0
|
||||||
Reference in New Issue
Block a user