- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
425 lines
11 KiB
Python
425 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Phase 5: API 数据模型 (Pydantic v2)
|
||
=====================================
|
||
统一前端-后端数据契约,与 Phase 4 frontend/src/types/index.ts 对齐。
|
||
|
||
使用方式:
|
||
from api_models import ChildResponse, BookResponse, ReadingRecordResponse, ...
|
||
"""
|
||
|
||
from datetime import date, datetime
|
||
from typing import Optional, List, Any
|
||
from pydantic import BaseModel, Field
|
||
|
||
# ============================================================
|
||
# 孩子模型
|
||
# ============================================================
|
||
class ChildBase(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=50, description="孩子姓名")
|
||
age: int = Field(..., ge=0, le=18, description="年龄")
|
||
avatar_url: Optional[str] = Field(None, max_length=500, description="头像URL")
|
||
|
||
|
||
class ChildCreate(ChildBase):
|
||
pass
|
||
|
||
|
||
class ChildUpdate(BaseModel):
|
||
name: Optional[str] = Field(None, min_length=1, max_length=50)
|
||
age: Optional[int] = Field(None, ge=0, le=18)
|
||
avatar_url: Optional[str] = Field(None, max_length=500)
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class ChildResponse(ChildBase):
|
||
id: int
|
||
total_points: int = 0
|
||
is_active: bool = True
|
||
created_at: str
|
||
updated_at: str
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class ChildListResponse(BaseModel):
|
||
"""孩子列表(家长端多孩子视图)"""
|
||
children: List[ChildResponse]
|
||
total: int
|
||
|
||
|
||
# ============================================================
|
||
# 书籍模型
|
||
# ============================================================
|
||
class BookBase(BaseModel):
|
||
title: str = Field(..., min_length=1, max_length=200)
|
||
author: Optional[str] = Field(None, max_length=100)
|
||
page_count: int = Field(..., gt=0)
|
||
cover_image_path: Optional[str] = None
|
||
|
||
|
||
class BookCreate(BookBase):
|
||
pass
|
||
|
||
|
||
class BookUpdate(BaseModel):
|
||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||
author: Optional[str] = Field(None, max_length=100)
|
||
page_count: Optional[int] = Field(None, gt=0)
|
||
cover_image_path: Optional[str] = None
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class BookResponse(BookBase):
|
||
id: int
|
||
is_active: bool = True
|
||
created_at: str
|
||
updated_at: str
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
# ============================================================
|
||
# 阅读打卡模型
|
||
# ============================================================
|
||
class ReadingRecordCreate(BaseModel):
|
||
book_id: int = Field(..., gt=0)
|
||
read_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$")
|
||
duration_minutes: int = Field(..., gt=0)
|
||
pages_read: Optional[int] = Field(None, ge=0)
|
||
photo_id: Optional[int] = None
|
||
note: Optional[str] = Field(None, max_length=500)
|
||
|
||
|
||
class ReadingRecordResponse(BaseModel):
|
||
id: int
|
||
child_id: int
|
||
book_id: int
|
||
book_title: Optional[str] = None
|
||
read_date: str
|
||
duration_minutes: int
|
||
pages_read: Optional[int] = None
|
||
points_earned: int = 0
|
||
photo_id: Optional[int] = None
|
||
photo_url: Optional[str] = None
|
||
note: Optional[str] = None
|
||
created_at: str
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class ReadingStatsResponse(BaseModel):
|
||
total_sessions: int = 0
|
||
total_minutes: int = 0
|
||
total_pages: int = 0
|
||
total_books: int = 0
|
||
total_points_earned: int = 0
|
||
current_streak: int = 0
|
||
longest_streak: int = 0
|
||
avg_minutes_per_day: float = 0.0
|
||
avg_pages_per_day: float = 0.0
|
||
|
||
|
||
class TodayRecordsResponse(BaseModel):
|
||
records: List[ReadingRecordResponse]
|
||
today_points: int = 0
|
||
|
||
|
||
# ============================================================
|
||
# 积分模型
|
||
# ============================================================
|
||
class PointsBalanceResponse(BaseModel):
|
||
child_id: int
|
||
balance: int
|
||
total_earned: int
|
||
total_spent: int
|
||
current_streak: int
|
||
longest_streak: int
|
||
streak_bonus_rate: float
|
||
|
||
|
||
class PointsHistoryItem(BaseModel):
|
||
id: int
|
||
child_id: int
|
||
points: int
|
||
type: str # earn / redeem / refund / transfer_in / transfer_out
|
||
balance_after: int
|
||
reference: Optional[str] = None
|
||
description: Optional[str] = None
|
||
created_at: str
|
||
|
||
|
||
class PointsPreviewResponse(BaseModel):
|
||
base_points: int
|
||
streak_bonus: float
|
||
weekend_bonus: float
|
||
first_read_bonus: float
|
||
total_points: int
|
||
breakdown: dict
|
||
|
||
|
||
class PointsRuleItem(BaseModel):
|
||
key: str
|
||
value: Any
|
||
description: str
|
||
default_value: Any
|
||
|
||
|
||
class PointsRuleUpdate(BaseModel):
|
||
base_points_per_minute: Optional[float] = None
|
||
base_points_per_page: Optional[float] = None
|
||
streak_bonus_3_days: Optional[float] = None
|
||
streak_bonus_7_days: Optional[float] = None
|
||
streak_bonus_14_days: Optional[float] = None
|
||
streak_bonus_30_days: Optional[float] = None
|
||
streak_bonus_100_days: Optional[float] = None
|
||
weekend_multiplier: Optional[float] = None
|
||
first_read_multiplier: Optional[float] = None
|
||
max_points_per_session: Optional[int] = None
|
||
min_duration_minutes: Optional[int] = None
|
||
|
||
|
||
# ============================================================
|
||
# 奖品模型
|
||
# ============================================================
|
||
class RewardBase(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=100)
|
||
description: Optional[str] = Field(None, max_length=500)
|
||
points_required: int = Field(..., gt=0)
|
||
stock: int = Field(-1, ge=-1)
|
||
image_url: Optional[str] = None
|
||
daily_limit: int = Field(3, ge=1)
|
||
cooldown_minutes: int = Field(0, ge=0)
|
||
|
||
|
||
class RewardCreate(RewardBase):
|
||
pass
|
||
|
||
|
||
class RewardUpdate(BaseModel):
|
||
name: Optional[str] = None
|
||
description: Optional[str] = None
|
||
points_required: Optional[int] = None
|
||
stock: Optional[int] = None
|
||
image_url: Optional[str] = None
|
||
daily_limit: Optional[int] = None
|
||
cooldown_minutes: Optional[int] = None
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class RewardResponse(RewardBase):
|
||
id: int
|
||
is_active: bool = True
|
||
created_at: str
|
||
updated_at: str
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class AffordableReward(RewardResponse):
|
||
"""可兑换奖品(含差额信息)"""
|
||
balance_gap: int = 0 # 还差多少积分(0=够换)
|
||
can_redeem: bool = True
|
||
|
||
|
||
# ============================================================
|
||
# 兑换模型
|
||
# ============================================================
|
||
class RedemptionCreate(BaseModel):
|
||
reward_id: int = Field(..., gt=0)
|
||
|
||
|
||
class RedemptionEligibilityResponse(BaseModel):
|
||
eligible: bool
|
||
reason: Optional[str] = None
|
||
balance: int
|
||
points_required: int
|
||
stock_remaining: int
|
||
|
||
|
||
class RedemptionResponse(BaseModel):
|
||
id: int
|
||
child_id: int
|
||
child_name: Optional[str] = None
|
||
reward_id: int
|
||
reward_name: Optional[str] = None
|
||
points_spent: int
|
||
status: str # pending / fulfilled / cancelled
|
||
fulfilled_at: Optional[str] = None
|
||
cancelled_at: Optional[str] = None
|
||
created_at: str
|
||
|
||
|
||
class RedemptionResult(BaseModel):
|
||
success: bool
|
||
record_id: Optional[int] = None
|
||
reward_name: Optional[str] = None
|
||
points_spent: int = 0
|
||
balance_after: int = 0
|
||
status: Optional[str] = None
|
||
|
||
|
||
class RedemptionSummaryResponse(BaseModel):
|
||
total_redeemed: int = 0
|
||
total_pending: int = 0
|
||
total_fulfilled: int = 0
|
||
total_cancelled: int = 0
|
||
total_points_spent: int = 0
|
||
today_count: int = 0
|
||
|
||
|
||
# ============================================================
|
||
# 照片模型
|
||
# ============================================================
|
||
class PhotoUploadResponse(BaseModel):
|
||
id: int
|
||
child_id: int
|
||
file_path: str
|
||
thumbnail_path: str
|
||
original_filename: str
|
||
file_size_bytes: int
|
||
width: Optional[int] = None
|
||
height: Optional[int] = None
|
||
sha256_hash: str
|
||
url: str
|
||
thumbnail_url: str
|
||
created_at: str
|
||
|
||
|
||
class PhotoResponse(BaseModel):
|
||
id: int
|
||
child_id: int
|
||
file_path: str
|
||
thumbnail_path: str
|
||
original_filename: str
|
||
file_size_bytes: int
|
||
width: Optional[int] = None
|
||
height: Optional[int] = None
|
||
reading_record_id: Optional[int] = None
|
||
url: str
|
||
thumbnail_url: str
|
||
created_at: str
|
||
|
||
|
||
class PhotoStatsResponse(BaseModel):
|
||
total_count: int
|
||
total_size_bytes: int
|
||
total_size_mb: float
|
||
|
||
|
||
# ============================================================
|
||
# 家长仪表盘模型
|
||
# ============================================================
|
||
class DashboardChildItem(BaseModel):
|
||
id: int
|
||
name: str
|
||
age: int
|
||
avatar_url: Optional[str] = None
|
||
total_points: int
|
||
stats: Optional[ReadingStatsResponse] = None
|
||
points: Optional[dict] = None
|
||
|
||
|
||
class DashboardResponse(BaseModel):
|
||
children: List[DashboardChildItem]
|
||
total_children: int
|
||
total_reading_minutes: int = 0
|
||
total_books: int = 0
|
||
|
||
|
||
class TransferPointsResult(BaseModel):
|
||
success: bool
|
||
from_balance: int = 0
|
||
to_balance: int = 0
|
||
|
||
|
||
class ChildComparisonItem(BaseModel):
|
||
child_id: int
|
||
name: str
|
||
total_points: int
|
||
current_streak: int
|
||
monthly_sessions: int
|
||
monthly_minutes: int
|
||
avg_pages_per_day: float
|
||
|
||
|
||
# ============================================================
|
||
# 通用模型
|
||
# ============================================================
|
||
class APIResponse(BaseModel):
|
||
"""统一 API 响应格式"""
|
||
code: int = 0
|
||
data: Optional[Any] = None
|
||
message: str = "ok"
|
||
|
||
|
||
class PaginationParams(BaseModel):
|
||
limit: int = Field(50, ge=1, le=200)
|
||
offset: int = Field(0, ge=0)
|
||
|
||
|
||
class ErrorResponse(BaseModel):
|
||
"""错误响应"""
|
||
code: int
|
||
message: str
|
||
detail: Optional[str] = None
|
||
|
||
|
||
# ============================================================
|
||
# 错误码枚举
|
||
# ============================================================
|
||
class ErrorCode:
|
||
# 1xxx: 孩子
|
||
CHILD_CREATE_FAILED = 1001
|
||
CHILD_NOT_FOUND = 1002
|
||
CHILD_UPDATE_FAILED = 1003
|
||
|
||
# 2xxx: 书籍
|
||
BOOK_CREATE_FAILED = 2001
|
||
BOOK_NOT_FOUND = 2002
|
||
BOOK_UPDATE_FAILED = 2003
|
||
|
||
# 3xxx: 打卡
|
||
DUPLICATE_CHECKIN = 3001
|
||
RECORD_CREATE_FAILED = 3002
|
||
CHILD_ID_REQUIRED = 3003
|
||
|
||
# 4xxx: 积分
|
||
POINTS_RULE_UPDATE_FAILED = 4001
|
||
POINTS_INSUFFICIENT = 4002
|
||
|
||
# 5xxx: 奖品
|
||
REWARD_CREATE_FAILED = 5001
|
||
REWARD_NOT_FOUND = 5002
|
||
REWARD_UPDATE_FAILED = 5003
|
||
|
||
# 6xxx: 兑换
|
||
REDEEM_NOT_ELIGIBLE = 6001
|
||
REDEEM_FAILED = 6002
|
||
FULFILL_FAILED = 6003
|
||
FULFILL_ERROR = 6004
|
||
CANCEL_FAILED = 6005
|
||
CANCEL_ERROR = 6006
|
||
|
||
# 7xxx: 照片
|
||
PHOTO_EMPTY = 7001
|
||
PHOTO_UPLOAD_FAILED = 7002
|
||
PHOTO_NOT_FOUND = 7003
|
||
PHOTO_DELETE_FAILED = 7004
|
||
PHOTO_LINK_FAILED = 7005
|
||
PHOTO_LINK_ERROR = 7006
|
||
|
||
# 8xxx: 家长
|
||
TRANSFER_FAILED = 8001
|
||
TRANSFER_ERROR = 8002
|
||
|
||
# 9xxx: 设置
|
||
SETTINGS_UPDATE_FAILED = 9001
|
||
|
||
# 通用
|
||
SERVER_ERROR = 9999
|