Some checks are pending
Build and Push Docker Images / changes (push) Waiting to run
Build and Push Docker Images / build-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (push) Blocked by required conditions
Build and Push Docker Images / build-admin-frontend (push) Blocked by required conditions
121 lines
3.5 KiB
Python
121 lines
3.5 KiB
Python
"""Reading event APIs."""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.deps import require_user
|
|
from app.db.database import get_db
|
|
from app.db.models import ChildProfile, MemoryItem, ReadingEvent, Story, User
|
|
|
|
router = APIRouter()
|
|
|
|
EVENT_WEIGHTS: dict[str, float] = {
|
|
"completed": 1.0,
|
|
"replayed": 1.5,
|
|
"started": 0.1,
|
|
"skipped": -0.5,
|
|
}
|
|
|
|
|
|
class ReadingEventCreate(BaseModel):
|
|
"""Reading event payload."""
|
|
|
|
child_profile_id: str
|
|
story_id: int | None = None
|
|
event_type: Literal["started", "completed", "skipped", "replayed"]
|
|
reading_time: int = Field(default=0, ge=0)
|
|
|
|
|
|
class ReadingEventResponse(BaseModel):
|
|
"""Reading event response."""
|
|
|
|
id: int
|
|
child_profile_id: str
|
|
story_id: int | None
|
|
event_type: str
|
|
reading_time: int
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
@router.post("/reading-events", response_model=ReadingEventResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_reading_event(
|
|
payload: ReadingEventCreate,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a reading event and update profile stats/memory."""
|
|
result = await db.execute(
|
|
select(ChildProfile).where(
|
|
ChildProfile.id == payload.child_profile_id,
|
|
ChildProfile.user_id == user.id,
|
|
)
|
|
)
|
|
profile = result.scalar_one_or_none()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="孩子档案不存在")
|
|
|
|
story = None
|
|
if payload.story_id is not None:
|
|
result = await db.execute(
|
|
select(Story).where(
|
|
Story.id == payload.story_id,
|
|
Story.user_id == user.id,
|
|
)
|
|
)
|
|
story = result.scalar_one_or_none()
|
|
if not story:
|
|
raise HTTPException(status_code=404, detail="故事不存在")
|
|
|
|
if payload.reading_time:
|
|
profile.total_reading_time = (profile.total_reading_time or 0) + payload.reading_time
|
|
|
|
if payload.event_type in {"completed", "replayed"} and payload.story_id is not None:
|
|
existing = await db.scalar(
|
|
select(ReadingEvent.id).where(
|
|
ReadingEvent.child_profile_id == payload.child_profile_id,
|
|
ReadingEvent.story_id == payload.story_id,
|
|
ReadingEvent.event_type.in_(["completed", "replayed"]),
|
|
)
|
|
)
|
|
if existing is None:
|
|
profile.stories_count = (profile.stories_count or 0) + 1
|
|
|
|
event = ReadingEvent(
|
|
child_profile_id=payload.child_profile_id,
|
|
story_id=payload.story_id,
|
|
event_type=payload.event_type,
|
|
reading_time=payload.reading_time,
|
|
)
|
|
db.add(event)
|
|
|
|
weight = EVENT_WEIGHTS.get(payload.event_type, 0.0)
|
|
if story and weight > 0:
|
|
db.add(
|
|
MemoryItem(
|
|
child_profile_id=payload.child_profile_id,
|
|
universe_id=story.universe_id,
|
|
type="recent_story",
|
|
value={
|
|
"story_id": story.id,
|
|
"title": story.title,
|
|
"event_type": payload.event_type,
|
|
},
|
|
base_weight=weight,
|
|
last_used_at=datetime.now(timezone.utc),
|
|
ttl_days=90,
|
|
)
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(event)
|
|
|
|
return event
|