- Backend: FastAPI + SQLAlchemy + Celery (Python 3.11+) - Frontend: Vue 3 + TypeScript + Pinia + Tailwind - Admin Frontend: separate Vue 3 app for management - Docker Compose: 9 services orchestration - Specs: design prototypes, memory system PRD, product roadmap Cleanup performed: - Removed temporary debug scripts from backend root - Removed deprecated admin_app.py (embedded UI) - Removed duplicate docs from admin-frontend - Updated .gitignore for Vite cache and egg-info
202 lines
5.8 KiB
Python
202 lines
5.8 KiB
Python
"""Story universe APIs."""
|
|
|
|
from typing import Any
|
|
|
|
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, StoryUniverse, User
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class StoryUniverseCreate(BaseModel):
|
|
"""Create universe payload."""
|
|
|
|
name: str = Field(..., min_length=1, max_length=100)
|
|
protagonist: dict[str, Any]
|
|
recurring_characters: list[dict[str, Any]] = Field(default_factory=list)
|
|
world_settings: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class StoryUniverseUpdate(BaseModel):
|
|
"""Update universe payload."""
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
|
protagonist: dict[str, Any] | None = None
|
|
recurring_characters: list[dict[str, Any]] | None = None
|
|
world_settings: dict[str, Any] | None = None
|
|
|
|
|
|
class AchievementCreate(BaseModel):
|
|
"""Achievement payload."""
|
|
|
|
type: str = Field(..., min_length=1, max_length=50)
|
|
description: str = Field(..., min_length=1, max_length=200)
|
|
|
|
|
|
class StoryUniverseResponse(BaseModel):
|
|
"""Universe response."""
|
|
|
|
id: str
|
|
child_profile_id: str
|
|
name: str
|
|
protagonist: dict[str, Any]
|
|
recurring_characters: list[dict[str, Any]]
|
|
world_settings: dict[str, Any]
|
|
achievements: list[dict[str, Any]]
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class StoryUniverseListResponse(BaseModel):
|
|
"""Universe list response."""
|
|
|
|
universes: list[StoryUniverseResponse]
|
|
total: int
|
|
|
|
|
|
async def _get_profile_or_404(
|
|
profile_id: str,
|
|
user: User,
|
|
db: AsyncSession,
|
|
) -> ChildProfile:
|
|
result = await db.execute(
|
|
select(ChildProfile).where(
|
|
ChildProfile.id == profile_id,
|
|
ChildProfile.user_id == user.id,
|
|
)
|
|
)
|
|
profile = result.scalar_one_or_none()
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="档案不存在")
|
|
return profile
|
|
|
|
|
|
async def _get_universe_or_404(
|
|
universe_id: str,
|
|
user: User,
|
|
db: AsyncSession,
|
|
) -> StoryUniverse:
|
|
result = await db.execute(
|
|
select(StoryUniverse)
|
|
.join(ChildProfile, StoryUniverse.child_profile_id == ChildProfile.id)
|
|
.where(
|
|
StoryUniverse.id == universe_id,
|
|
ChildProfile.user_id == user.id,
|
|
)
|
|
)
|
|
universe = result.scalar_one_or_none()
|
|
if not universe:
|
|
raise HTTPException(status_code=404, detail="宇宙不存在")
|
|
return universe
|
|
|
|
|
|
@router.get("/profiles/{profile_id}/universes", response_model=StoryUniverseListResponse)
|
|
async def list_universes(
|
|
profile_id: str,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List universes for a child profile."""
|
|
await _get_profile_or_404(profile_id, user, db)
|
|
result = await db.execute(
|
|
select(StoryUniverse)
|
|
.where(StoryUniverse.child_profile_id == profile_id)
|
|
.order_by(StoryUniverse.updated_at.desc())
|
|
)
|
|
universes = result.scalars().all()
|
|
return StoryUniverseListResponse(universes=universes, total=len(universes))
|
|
|
|
|
|
@router.post(
|
|
"/profiles/{profile_id}/universes",
|
|
response_model=StoryUniverseResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_universe(
|
|
profile_id: str,
|
|
payload: StoryUniverseCreate,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create a story universe."""
|
|
await _get_profile_or_404(profile_id, user, db)
|
|
universe = StoryUniverse(child_profile_id=profile_id, **payload.model_dump())
|
|
db.add(universe)
|
|
await db.commit()
|
|
await db.refresh(universe)
|
|
return universe
|
|
|
|
|
|
@router.get("/universes/{universe_id}", response_model=StoryUniverseResponse)
|
|
async def get_universe(
|
|
universe_id: str,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get one universe."""
|
|
universe = await _get_universe_or_404(universe_id, user, db)
|
|
return universe
|
|
|
|
|
|
@router.put("/universes/{universe_id}", response_model=StoryUniverseResponse)
|
|
async def update_universe(
|
|
universe_id: str,
|
|
payload: StoryUniverseUpdate,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a story universe."""
|
|
universe = await _get_universe_or_404(universe_id, user, db)
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
for key, value in updates.items():
|
|
setattr(universe, key, value)
|
|
await db.commit()
|
|
await db.refresh(universe)
|
|
return universe
|
|
|
|
|
|
@router.delete("/universes/{universe_id}")
|
|
async def delete_universe(
|
|
universe_id: str,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a story universe."""
|
|
universe = await _get_universe_or_404(universe_id, user, db)
|
|
await db.delete(universe)
|
|
await db.commit()
|
|
return {"message": "Deleted"}
|
|
|
|
|
|
@router.post("/universes/{universe_id}/achievements", response_model=StoryUniverseResponse)
|
|
async def add_achievement(
|
|
universe_id: str,
|
|
payload: AchievementCreate,
|
|
user: User = Depends(require_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add an achievement to a universe."""
|
|
universe = await _get_universe_or_404(universe_id, user, db)
|
|
|
|
achievements = list(universe.achievements or [])
|
|
key = (payload.type.strip(), payload.description.strip())
|
|
existing = {
|
|
(str(item.get("type", "")).strip(), str(item.get("description", "")).strip())
|
|
for item in achievements
|
|
if isinstance(item, dict)
|
|
}
|
|
if key not in existing:
|
|
achievements.append({"type": key[0], "description": key[1]})
|
|
universe.achievements = achievements
|
|
await db.commit()
|
|
await db.refresh(universe)
|
|
|
|
return universe
|