feat(discovery): world visit tracking endpoint with atomic play_count increment, last_played_at, and deduplication
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""Discovery router - visit tracking and world browsing.
|
||||
|
||||
docs/planning/discovery.md (tasks 42726e60fe10, 300da1dcddf3, 33706f401ad9).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import OptionalUser
|
||||
from app.models.world import World, WorldVisibility
|
||||
from app.models.world_visit import WorldVisit
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
class VisitRequest(BaseModel):
|
||||
anonymous_token: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
|
||||
|
||||
class VisitResponse(BaseModel):
|
||||
play_count: int
|
||||
last_played_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
DEDUP_WINDOW_SECONDS = 60
|
||||
|
||||
|
||||
@router.post("/worlds/{world_id}/visit", response_model=VisitResponse)
|
||||
async def record_world_visit(
|
||||
world_id: int,
|
||||
request: Request,
|
||||
db: DbDep,
|
||||
current_user: OptionalUser = None,
|
||||
visit_data: VisitRequest | None = None,
|
||||
x_idempotency_key: Annotated[str | None, Header(alias="X-Idempotency-Key")] = None,
|
||||
idempotency_key: str | None = Query(None),
|
||||
):
|
||||
"""Record a visit to a world.
|
||||
|
||||
- Increments worlds.play_count atomically (World.play_count + 1 in UPDATE).
|
||||
- Updates worlds.last_played_at to func.now().
|
||||
- Records row in world_visits (with world_id, user_id nullable, visited_at).
|
||||
- Checks visibility: 404 if world not found or not PUBLIC and not owned by user.
|
||||
- Idempotent: repeated calls within deduplication window or with same token/key do not duplicate.
|
||||
"""
|
||||
# 1. Fetch world
|
||||
res = await db.execute(select(World).where(World.id == world_id))
|
||||
world = res.scalar_one_or_none()
|
||||
if not world:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||
|
||||
# 2. Check visibility: public or owned by requesting user
|
||||
if world.visibility != WorldVisibility.PUBLIC:
|
||||
if not current_user or world.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="World not found")
|
||||
|
||||
# Determine token / idempotency key
|
||||
explicit_key = (
|
||||
(visit_data.idempotency_key if visit_data else None)
|
||||
or (visit_data.anonymous_token if visit_data else None)
|
||||
or x_idempotency_key
|
||||
or idempotency_key
|
||||
)
|
||||
|
||||
fallback_host = request.client.host if request.client else "unknown_client"
|
||||
dedup_key = explicit_key or (f"client_{fallback_host}" if not current_user else None)
|
||||
|
||||
# 3. Deduplication check
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(seconds=DEDUP_WINDOW_SECONDS)
|
||||
is_duplicate = False
|
||||
|
||||
if current_user:
|
||||
dedup_res = await db.execute(
|
||||
select(WorldVisit).where(
|
||||
WorldVisit.world_id == world_id,
|
||||
WorldVisit.user_id == current_user.id,
|
||||
WorldVisit.visited_at >= cutoff,
|
||||
).limit(1)
|
||||
)
|
||||
if dedup_res.scalar_one_or_none():
|
||||
is_duplicate = True
|
||||
elif dedup_key:
|
||||
dedup_res = await db.execute(
|
||||
select(WorldVisit).where(
|
||||
WorldVisit.world_id == world_id,
|
||||
WorldVisit.anonymous_token == dedup_key,
|
||||
WorldVisit.visited_at >= cutoff,
|
||||
).limit(1)
|
||||
)
|
||||
if dedup_res.scalar_one_or_none():
|
||||
is_duplicate = True
|
||||
|
||||
if is_duplicate:
|
||||
return VisitResponse(
|
||||
play_count=world.play_count,
|
||||
last_played_at=world.last_played_at,
|
||||
)
|
||||
|
||||
# 4. Atomic increment and visit log in single transaction
|
||||
await db.execute(
|
||||
update(World)
|
||||
.where(World.id == world_id)
|
||||
.values(
|
||||
play_count=World.play_count + 1,
|
||||
last_played_at=func.now(),
|
||||
)
|
||||
)
|
||||
|
||||
visit = WorldVisit(
|
||||
world_id=world_id,
|
||||
user_id=current_user.id if current_user else None,
|
||||
anonymous_token=dedup_key if not current_user else None,
|
||||
)
|
||||
db.add(visit)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch or refresh to return updated fields
|
||||
await db.refresh(world)
|
||||
|
||||
return VisitResponse(
|
||||
play_count=world.play_count,
|
||||
last_played_at=world.last_played_at,
|
||||
)
|
||||
Reference in New Issue
Block a user