wip: snapshot full local workspace state
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
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
This commit is contained in:
@@ -1,429 +1,429 @@
|
||||
# 孩子档案数据模型
|
||||
|
||||
## 概述
|
||||
|
||||
孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库模型
|
||||
|
||||
### 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 天后永久删除
|
||||
# 孩子档案数据模型
|
||||
|
||||
## 概述
|
||||
|
||||
孩子档案是记忆智能系统的核心,存储孩子的基础信息、兴趣偏好和阅读行为数据。
|
||||
|
||||
---
|
||||
|
||||
## 一、数据库模型
|
||||
|
||||
### 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 天后永久删除
|
||||
|
||||
Reference in New Issue
Block a user