Files
dreamweaver/backend/app/api/profiles.py
zhangtuo e9d7f8832a Initial commit: clean project structure
- 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
2026-01-20 18:20:03 +08:00

281 lines
7.9 KiB
Python

"""Child profile APIs."""
from datetime import date
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, 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, Story, StoryUniverse, User
router = APIRouter()
MAX_PROFILES_PER_USER = 5
class ChildProfileCreate(BaseModel):
"""Create profile payload."""
name: str = Field(..., min_length=1, max_length=50)
birth_date: date | None = None
gender: str | None = Field(default=None, pattern="^(male|female|other)$")
interests: list[str] = Field(default_factory=list)
growth_themes: list[str] = Field(default_factory=list)
avatar_url: str | None = None
class ChildProfileUpdate(BaseModel):
"""Update profile payload."""
name: str | None = Field(default=None, min_length=1, max_length=50)
birth_date: date | None = None
gender: str | None = Field(default=None, pattern="^(male|female|other)$")
interests: list[str] | None = None
growth_themes: list[str] | None = None
avatar_url: str | None = None
class ChildProfileResponse(BaseModel):
"""Profile response."""
id: str
name: str
avatar_url: str | None
birth_date: date | None
gender: str | None
age: int | None
interests: list[str]
growth_themes: list[str]
stories_count: int
total_reading_time: int
class Config:
from_attributes = True
class ChildProfileListResponse(BaseModel):
"""Profile list response."""
profiles: list[ChildProfileResponse]
total: int
class TimelineEvent(BaseModel):
"""Timeline event item."""
date: str
type: Literal["story", "achievement", "milestone"]
title: str
description: str | None = None
image_url: str | None = None
metadata: dict | None = None
class TimelineResponse(BaseModel):
"""Timeline response."""
events: list[TimelineEvent]
@router.get("/profiles", response_model=ChildProfileListResponse)
async def list_profiles(
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""List child profiles for current user."""
result = await db.execute(
select(ChildProfile)
.where(ChildProfile.user_id == user.id)
.order_by(ChildProfile.created_at.desc())
)
profiles = result.scalars().all()
return ChildProfileListResponse(profiles=profiles, total=len(profiles))
@router.post("/profiles", response_model=ChildProfileResponse, status_code=status.HTTP_201_CREATED)
async def create_profile(
payload: ChildProfileCreate,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Create a new child profile."""
count = await db.scalar(
select(func.count(ChildProfile.id)).where(ChildProfile.user_id == user.id)
)
if count and count >= MAX_PROFILES_PER_USER:
raise HTTPException(status_code=400, detail="最多只能创建 5 个孩子档案")
existing = await db.scalar(
select(ChildProfile.id).where(
ChildProfile.user_id == user.id,
ChildProfile.name == payload.name,
)
)
if existing:
raise HTTPException(status_code=409, detail="该档案名称已存在")
profile = ChildProfile(user_id=user.id, **payload.model_dump())
db.add(profile)
await db.commit()
await db.refresh(profile)
return profile
@router.get("/profiles/{profile_id}", response_model=ChildProfileResponse)
async def get_profile(
profile_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get one child profile."""
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
@router.put("/profiles/{profile_id}", response_model=ChildProfileResponse)
async def update_profile(
profile_id: str,
payload: ChildProfileUpdate,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Update a child profile."""
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="档案不存在")
updates = payload.model_dump(exclude_unset=True)
if "name" in updates:
existing = await db.scalar(
select(ChildProfile.id).where(
ChildProfile.user_id == user.id,
ChildProfile.name == updates["name"],
ChildProfile.id != profile_id,
)
)
if existing:
raise HTTPException(status_code=409, detail="该档案名称已存在")
for key, value in updates.items():
setattr(profile, key, value)
await db.commit()
await db.refresh(profile)
return profile
@router.delete("/profiles/{profile_id}")
async def delete_profile(
profile_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a child profile."""
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="档案不存在")
await db.delete(profile)
await db.commit()
return {"message": "Deleted"}
@router.get("/profiles/{profile_id}/timeline", response_model=TimelineResponse)
async def get_profile_timeline(
profile_id: str,
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get profile growth timeline."""
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="档案不存在")
events: list[TimelineEvent] = []
# 1. Milestone: Profile Created
events.append(TimelineEvent(
date=profile.created_at.isoformat(),
type="milestone",
title="初次相遇",
description=f"创建了档案 {profile.name}"
))
# 2. Stories
stories_result = await db.execute(
select(Story).where(Story.child_profile_id == profile_id)
)
for s in stories_result.scalars():
events.append(TimelineEvent(
date=s.created_at.isoformat(),
type="story",
title=s.title,
image_url=s.image_url,
metadata={"story_id": s.id, "mode": s.mode}
))
# 3. Achievements (from Universe)
universes_result = await db.execute(
select(StoryUniverse).where(StoryUniverse.child_profile_id == profile_id)
)
for u in universes_result.scalars():
if u.achievements:
for ach in u.achievements:
if isinstance(ach, dict):
obt_at = ach.get("obtained_at")
# Fallback
if not obt_at:
obt_at = u.updated_at.isoformat()
events.append(TimelineEvent(
date=obt_at,
type="achievement",
title=f"获得成就:{ach.get('type')}",
description=ach.get('description'),
metadata={"universe_id": u.id, "source_story_id": ach.get("source_story_id")}
))
# Sort by date desc
events.sort(key=lambda x: x.date, reverse=True)
return TimelineResponse(events=events)