Files
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- 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>
2026-07-10 22:37:56 +08:00

425 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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