# 孩子档案数据模型 ## 概述 孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。 --- ## 一、数据库模型 ### 1.1 主表: child_profiles ```sql CREATE TABLE child_profiles ( -- 主键 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 外键: 所属用户 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 基础信息 name VARCHAR(50) NOT NULL, avatar_url VARCHAR(500), birth_date DATE, gender VARCHAR(10) CHECK (gender IN ('male', 'female', 'other')), -- 显式偏好 (家长填写) interests JSONB DEFAULT '[]', -- 示例: ["恐龙", "太空", "公主", "动物"] growth_themes JSONB DEFAULT '[]', -- 示例: ["勇气", "分享"] -- 隐式偏好 (系统学习) reading_preferences JSONB DEFAULT '{}', -- 示例: { -- "preferred_length": "medium", -- short/medium/long -- "preferred_style": "adventure", -- adventure/fairy/educational -- "tag_weights": {"恐龙": 5, "公主": 2, "太空": 3} -- } -- 统计数据 stories_count INTEGER DEFAULT 0, total_reading_time INTEGER DEFAULT 0, -- 秒 -- 时间戳 created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), -- 约束 CONSTRAINT unique_child_per_user UNIQUE (user_id, name) ); -- 索引 CREATE INDEX idx_child_profiles_user_id ON child_profiles(user_id); ``` ### 1.2 兴趣标签枚举 预定义的兴趣标签,前端展示用: ```python INTEREST_TAGS = { "animals": { "zh": "动物", "icon": "🐾", "subtags": ["恐龙", "猫咪", "狗狗", "兔子", "海洋动物"] }, "fantasy": { "zh": "奇幻", "icon": "✨", "subtags": ["公主", "王子", "魔法", "精灵", "龙"] }, "adventure": { "zh": "冒险", "icon": "🗺️", "subtags": ["太空", "海盗", "探险", "寻宝"] }, "vehicles": { "zh": "交通工具", "icon": "🚗", "subtags": ["汽车", "火车", "飞机", "火箭"] }, "nature": { "zh": "自然", "icon": "🌳", "subtags": ["森林", "海洋", "山川", "四季"] } } ``` ### 1.3 成长主题枚举 ```python GROWTH_THEMES = [ {"key": "courage", "zh": "勇气", "description": "克服恐惧,勇敢面对"}, {"key": "sharing", "zh": "分享", "description": "学会与他人分享"}, {"key": "friendship", "zh": "友谊", "description": "交朋友,珍惜友情"}, {"key": "honesty", "zh": "诚实", "description": "说真话,不撒谎"}, {"key": "independence", "zh": "独立", "description": "自己的事情自己做"}, {"key": "kindness", "zh": "善良", "description": "帮助他人,关爱弱小"}, {"key": "patience", "zh": "耐心", "description": "学会等待,不急躁"}, {"key": "curiosity", "zh": "好奇", "description": "探索未知,爱问为什么"} ] ``` --- ## 二、SQLAlchemy 模型 ```python # backend/app/db/models.py from sqlalchemy import Column, String, Date, Integer, ForeignKey, JSON from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship import uuid class ChildProfile(Base): __tablename__ = "child_profiles" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # 基础信息 name = Column(String(50), nullable=False) avatar_url = Column(String(500)) birth_date = Column(Date) gender = Column(String(10)) # 偏好 interests = Column(JSON, default=list) growth_themes = Column(JSON, default=list) reading_preferences = Column(JSON, default=dict) # 统计 stories_count = Column(Integer, default=0) total_reading_time = Column(Integer, default=0) # 时间戳 created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) # 关系 user = relationship("User", back_populates="child_profiles") story_universes = relationship("StoryUniverse", back_populates="child_profile", cascade="all, delete-orphan") @property def age(self) -> int | None: """计算年龄""" if not self.birth_date: return None today = date.today() return today.year - self.birth_date.year - ( (today.month, today.day) < (self.birth_date.month, self.birth_date.day) ) ``` --- ## 三、Pydantic Schema ```python # backend/app/schemas/child_profile.py from pydantic import BaseModel, Field from datetime import date from uuid import UUID class ChildProfileCreate(BaseModel): name: str = Field(..., min_length=1, max_length=50) birth_date: date | None = None gender: str | None = Field(None, pattern="^(male|female|other)$") interests: list[str] = Field(default_factory=list) growth_themes: list[str] = Field(default_factory=list) class ChildProfileUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=50) birth_date: date | None = None gender: str | None = Field(None, pattern="^(male|female|other)$") interests: list[str] | None = None growth_themes: list[str] | None = None avatar_url: str | None = None class ChildProfileResponse(BaseModel): id: UUID 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): profiles: list[ChildProfileResponse] total: int ``` --- ## 四、API 实现 ```python # backend/app/api/profiles.py from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from uuid import UUID router = APIRouter(prefix="/api/profiles", tags=["profiles"]) @router.get("", response_model=ChildProfileListResponse) async def list_profiles( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """获取当前用户的所有孩子档案""" profiles = await db.execute( select(ChildProfile) .where(ChildProfile.user_id == current_user.id) .order_by(ChildProfile.created_at) ) profiles = profiles.scalars().all() return ChildProfileListResponse(profiles=profiles, total=len(profiles)) @router.post("", response_model=ChildProfileResponse, status_code=201) async def create_profile( data: ChildProfileCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """创建孩子档案""" # 检查是否超过限制 (每用户最多5个孩子档案) count = await db.scalar( select(func.count(ChildProfile.id)) .where(ChildProfile.user_id == current_user.id) ) if count >= 5: raise HTTPException(400, "最多只能创建5个孩子档案") profile = ChildProfile(user_id=current_user.id, **data.model_dump()) db.add(profile) await db.commit() await db.refresh(profile) return profile @router.get("/{profile_id}", response_model=ChildProfileResponse) async def get_profile( profile_id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """获取单个孩子档案""" profile = await db.get(ChildProfile, profile_id) if not profile or profile.user_id != current_user.id: raise HTTPException(404, "档案不存在") return profile @router.put("/{profile_id}", response_model=ChildProfileResponse) async def update_profile( profile_id: UUID, data: ChildProfileUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """更新孩子档案""" profile = await db.get(ChildProfile, profile_id) if not profile or profile.user_id != current_user.id: raise HTTPException(404, "档案不存在") for key, value in data.model_dump(exclude_unset=True).items(): setattr(profile, key, value) await db.commit() await db.refresh(profile) return profile @router.delete("/{profile_id}", status_code=204) async def delete_profile( profile_id: UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """删除孩子档案""" profile = await db.get(ChildProfile, profile_id) if not profile or profile.user_id != current_user.id: raise HTTPException(404, "档案不存在") await db.delete(profile) await db.commit() ``` --- ## 五、隐式偏好学习 ### 5.1 行为事件 ```python class ReadingEvent(BaseModel): """阅读行为事件""" profile_id: UUID story_id: UUID event_type: Literal["started", "completed", "skipped", "replayed"] reading_time: int # 秒 timestamp: datetime ``` ### 5.2 偏好更新算法 ```python async def update_reading_preferences( db: AsyncSession, profile_id: UUID, story: Story, event: ReadingEvent ): """根据阅读行为更新隐式偏好""" profile = await db.get(ChildProfile, profile_id) prefs = profile.reading_preferences or {} tag_weights = prefs.get("tag_weights", {}) # 权重调整 weight_delta = { "completed": 1.0, # 完整阅读,正向 "replayed": 1.5, # 重复播放,强正向 "skipped": -0.5, # 跳过,负向 "started": 0.1 # 开始阅读,弱正向 } delta = weight_delta.get(event.event_type, 0) for tag in story.tags: current = tag_weights.get(tag, 0) tag_weights[tag] = max(0, current + delta) # 不低于0 # 更新阅读长度偏好 if event.event_type == "completed": word_count = len(story.content) if word_count < 300: length_pref = "short" elif word_count < 600: length_pref = "medium" else: length_pref = "long" # 简单的移动平均 prefs["preferred_length"] = length_pref prefs["tag_weights"] = tag_weights profile.reading_preferences = prefs await db.commit() ``` --- ## 六、数据迁移 ```python # backend/alembic/versions/xxx_add_child_profiles.py def upgrade(): op.create_table( 'child_profiles', sa.Column('id', sa.UUID(), nullable=False), sa.Column('user_id', sa.UUID(), nullable=False), sa.Column('name', sa.String(50), nullable=False), sa.Column('avatar_url', sa.String(500)), sa.Column('birth_date', sa.Date()), sa.Column('gender', sa.String(10)), sa.Column('interests', sa.JSON(), server_default='[]'), sa.Column('growth_themes', sa.JSON(), server_default='[]'), sa.Column('reading_preferences', sa.JSON(), server_default='{}'), sa.Column('stories_count', sa.Integer(), server_default='0'), sa.Column('total_reading_time', sa.Integer(), server_default='0'), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) op.create_index('idx_child_profiles_user_id', 'child_profiles', ['user_id']) def downgrade(): op.drop_index('idx_child_profiles_user_id') op.drop_table('child_profiles') ``` --- ## 七、隐私与安全 ### 7.1 数据加密 敏感字段(姓名、出生日期)在存储时加密: ```python from cryptography.fernet import Fernet class EncryptedChildProfile: """加密存储的孩子档案""" @staticmethod def encrypt_name(name: str, key: bytes) -> str: f = Fernet(key) return f.encrypt(name.encode()).decode() @staticmethod def decrypt_name(encrypted: str, key: bytes) -> str: f = Fernet(key) return f.decrypt(encrypted.encode()).decode() ``` ### 7.2 访问控制 - 孩子档案只能被创建者访问 - 删除用户时级联删除所有孩子档案 - API 层强制校验 `user_id` 归属 ### 7.3 数据保留 - 用户可随时删除孩子档案 - 删除后 30 天内可恢复(软删除) - 30 天后永久删除