2026-01-22 12:57:26 +08:00
|
|
|
|
"""数据模型定义"""
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from datetime import date, time, datetime
|
|
|
|
|
|
from enum import Enum
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MealType(str, Enum):
|
|
|
|
|
|
BREAKFAST = "早餐"
|
|
|
|
|
|
LUNCH = "午餐"
|
|
|
|
|
|
DINNER = "晚餐"
|
|
|
|
|
|
SNACK = "加餐"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExerciseType(str, Enum):
|
|
|
|
|
|
RUNNING = "跑步"
|
|
|
|
|
|
SWIMMING = "游泳"
|
|
|
|
|
|
CYCLING = "骑行"
|
|
|
|
|
|
STRENGTH = "力量训练"
|
|
|
|
|
|
WALKING = "健走"
|
|
|
|
|
|
YOGA = "瑜伽"
|
|
|
|
|
|
OTHER = "其他"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DataSource(str, Enum):
|
|
|
|
|
|
MANUAL = "手动"
|
|
|
|
|
|
GARMIN = "garmin"
|
|
|
|
|
|
CODOON = "codoon"
|
|
|
|
|
|
CSV = "csv"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ActivityLevel(str, Enum):
|
|
|
|
|
|
SEDENTARY = "sedentary" # 久坐
|
|
|
|
|
|
LIGHT = "light" # 轻度活动
|
|
|
|
|
|
MODERATE = "moderate" # 中度活动
|
|
|
|
|
|
ACTIVE = "active" # 活跃
|
|
|
|
|
|
VERY_ACTIVE = "very_active" # 非常活跃
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Goal(str, Enum):
|
|
|
|
|
|
LOSE = "lose" # 减重
|
|
|
|
|
|
MAINTAIN = "maintain" # 维持
|
|
|
|
|
|
GAIN = "gain" # 增肌
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Exercise:
|
|
|
|
|
|
"""运动记录"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
date: date = field(default_factory=date.today)
|
|
|
|
|
|
type: str = ExerciseType.OTHER.value
|
|
|
|
|
|
duration: int = 0 # 分钟
|
|
|
|
|
|
calories: int = 0
|
|
|
|
|
|
distance: Optional[float] = None # 公里
|
|
|
|
|
|
heart_rate_avg: Optional[int] = None
|
|
|
|
|
|
source: str = DataSource.MANUAL.value
|
|
|
|
|
|
raw_data: Optional[dict] = None
|
|
|
|
|
|
notes: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"date": self.date.isoformat(),
|
|
|
|
|
|
"type": self.type,
|
|
|
|
|
|
"duration": self.duration,
|
|
|
|
|
|
"calories": self.calories,
|
|
|
|
|
|
"distance": self.distance,
|
|
|
|
|
|
"heart_rate_avg": self.heart_rate_avg,
|
|
|
|
|
|
"source": self.source,
|
|
|
|
|
|
"raw_data": json.dumps(self.raw_data) if self.raw_data else None,
|
|
|
|
|
|
"notes": self.notes,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Meal:
|
|
|
|
|
|
"""饮食记录"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
date: date = field(default_factory=date.today)
|
|
|
|
|
|
meal_type: str = MealType.LUNCH.value
|
|
|
|
|
|
description: str = ""
|
|
|
|
|
|
calories: int = 0
|
|
|
|
|
|
protein: Optional[float] = None # 克
|
|
|
|
|
|
carbs: Optional[float] = None # 克
|
|
|
|
|
|
fat: Optional[float] = None # 克
|
|
|
|
|
|
photo_path: Optional[str] = None
|
|
|
|
|
|
food_items: Optional[list] = None
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"date": self.date.isoformat(),
|
|
|
|
|
|
"meal_type": self.meal_type,
|
|
|
|
|
|
"description": self.description,
|
|
|
|
|
|
"calories": self.calories,
|
|
|
|
|
|
"protein": self.protein,
|
|
|
|
|
|
"carbs": self.carbs,
|
|
|
|
|
|
"fat": self.fat,
|
|
|
|
|
|
"photo_path": self.photo_path,
|
|
|
|
|
|
"food_items": json.dumps(self.food_items, ensure_ascii=False) if self.food_items else None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Sleep:
|
|
|
|
|
|
"""睡眠记录"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
date: date = field(default_factory=date.today)
|
|
|
|
|
|
bedtime: Optional[time] = None
|
|
|
|
|
|
wake_time: Optional[time] = None
|
|
|
|
|
|
duration: float = 0 # 小时
|
|
|
|
|
|
quality: int = 3 # 1-5
|
|
|
|
|
|
deep_sleep_mins: Optional[int] = None
|
|
|
|
|
|
source: str = DataSource.MANUAL.value
|
|
|
|
|
|
notes: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"date": self.date.isoformat(),
|
|
|
|
|
|
"bedtime": self.bedtime.isoformat() if self.bedtime else None,
|
|
|
|
|
|
"wake_time": self.wake_time.isoformat() if self.wake_time else None,
|
|
|
|
|
|
"duration": self.duration,
|
|
|
|
|
|
"quality": self.quality,
|
|
|
|
|
|
"deep_sleep_mins": self.deep_sleep_mins,
|
|
|
|
|
|
"source": self.source,
|
|
|
|
|
|
"notes": self.notes,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Weight:
|
|
|
|
|
|
"""体重记录"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
date: date = field(default_factory=date.today)
|
|
|
|
|
|
weight_kg: float = 0
|
|
|
|
|
|
body_fat_pct: Optional[float] = None
|
|
|
|
|
|
muscle_mass: Optional[float] = None
|
|
|
|
|
|
notes: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"date": self.date.isoformat(),
|
|
|
|
|
|
"weight_kg": self.weight_kg,
|
|
|
|
|
|
"body_fat_pct": self.body_fat_pct,
|
|
|
|
|
|
"muscle_mass": self.muscle_mass,
|
|
|
|
|
|
"notes": self.notes,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class UserConfig:
|
|
|
|
|
|
"""用户配置"""
|
|
|
|
|
|
age: Optional[int] = None
|
|
|
|
|
|
gender: Optional[str] = None # male / female
|
|
|
|
|
|
height: Optional[float] = None # 厘米
|
|
|
|
|
|
weight: Optional[float] = None # 公斤
|
|
|
|
|
|
activity_level: str = ActivityLevel.MODERATE.value
|
|
|
|
|
|
goal: str = Goal.MAINTAIN.value
|
2026-01-27 17:39:24 +08:00
|
|
|
|
target_weight: Optional[float] = None # 目标体重(公斤)
|
2026-01-22 12:57:26 +08:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def bmr(self) -> Optional[int]:
|
|
|
|
|
|
"""计算基础代谢率 (Mifflin-St Jeor 公式)"""
|
|
|
|
|
|
if not all([self.age, self.gender, self.height, self.weight]):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if self.gender == "male":
|
|
|
|
|
|
return int(10 * self.weight + 6.25 * self.height - 5 * self.age + 5)
|
|
|
|
|
|
else:
|
|
|
|
|
|
return int(10 * self.weight + 6.25 * self.height - 5 * self.age - 161)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def tdee(self) -> Optional[int]:
|
|
|
|
|
|
"""计算每日总消耗 (TDEE)"""
|
|
|
|
|
|
if not self.bmr:
|
|
|
|
|
|
return None
|
|
|
|
|
|
multipliers = {
|
|
|
|
|
|
ActivityLevel.SEDENTARY.value: 1.2,
|
|
|
|
|
|
ActivityLevel.LIGHT.value: 1.375,
|
|
|
|
|
|
ActivityLevel.MODERATE.value: 1.55,
|
|
|
|
|
|
ActivityLevel.ACTIVE.value: 1.725,
|
|
|
|
|
|
ActivityLevel.VERY_ACTIVE.value: 1.9,
|
|
|
|
|
|
}
|
|
|
|
|
|
return int(self.bmr * multipliers.get(self.activity_level, 1.55))
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"age": self.age,
|
|
|
|
|
|
"gender": self.gender,
|
|
|
|
|
|
"height": self.height,
|
|
|
|
|
|
"weight": self.weight,
|
|
|
|
|
|
"activity_level": self.activity_level,
|
|
|
|
|
|
"goal": self.goal,
|
2026-01-27 17:39:24 +08:00
|
|
|
|
"target_weight": self.target_weight,
|
2026-01-22 12:57:26 +08:00
|
|
|
|
"bmr": self.bmr,
|
|
|
|
|
|
"tdee": self.tdee,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class User:
|
|
|
|
|
|
"""用户档案"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
name: str = ""
|
|
|
|
|
|
created_at: datetime = field(default_factory=datetime.now)
|
|
|
|
|
|
is_active: bool = False
|
|
|
|
|
|
gender: Optional[str] = None # male / female
|
|
|
|
|
|
height_cm: Optional[float] = None # 身高(厘米)
|
|
|
|
|
|
weight_kg: Optional[float] = None # 体重(公斤)
|
|
|
|
|
|
age: Optional[int] = None # 年龄
|
|
|
|
|
|
# 认证相关字段
|
|
|
|
|
|
password_hash: Optional[str] = None # 密码哈希(bcrypt)
|
|
|
|
|
|
email: Optional[str] = None # 邮箱(可选)
|
|
|
|
|
|
is_admin: bool = False # 是否管理员
|
|
|
|
|
|
is_disabled: bool = False # 是否禁用
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def bmi(self) -> Optional[float]:
|
|
|
|
|
|
"""计算 BMI = 体重(kg) / 身高(m)²"""
|
|
|
|
|
|
if not self.height_cm or not self.weight_kg:
|
|
|
|
|
|
return None
|
|
|
|
|
|
height_m = self.height_cm / 100
|
|
|
|
|
|
return round(self.weight_kg / (height_m * height_m), 1)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def bmi_status(self) -> Optional[str]:
|
|
|
|
|
|
"""BMI 状态评估"""
|
|
|
|
|
|
bmi = self.bmi
|
|
|
|
|
|
if bmi is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if bmi < 18.5:
|
|
|
|
|
|
return "偏瘦"
|
|
|
|
|
|
elif bmi < 24:
|
|
|
|
|
|
return "正常"
|
|
|
|
|
|
elif bmi < 28:
|
|
|
|
|
|
return "偏胖"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return "肥胖"
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"name": self.name,
|
|
|
|
|
|
"created_at": self.created_at.isoformat(),
|
|
|
|
|
|
"is_active": self.is_active,
|
|
|
|
|
|
"gender": self.gender,
|
|
|
|
|
|
"height_cm": self.height_cm,
|
|
|
|
|
|
"weight_kg": self.weight_kg,
|
|
|
|
|
|
"age": self.age,
|
|
|
|
|
|
"bmi": self.bmi,
|
|
|
|
|
|
"bmi_status": self.bmi_status,
|
|
|
|
|
|
"email": self.email,
|
|
|
|
|
|
"is_admin": self.is_admin,
|
|
|
|
|
|
"is_disabled": self.is_disabled,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Invite:
|
|
|
|
|
|
"""邀请码"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
code: str = "" # 8位随机字符串
|
|
|
|
|
|
created_by: int = 0 # 创建者 user_id
|
|
|
|
|
|
used_by: Optional[int] = None # 使用者 user_id(null 表示未使用)
|
|
|
|
|
|
created_at: datetime = field(default_factory=datetime.now)
|
|
|
|
|
|
expires_at: Optional[datetime] = None # 过期时间(可选)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_used(self) -> bool:
|
|
|
|
|
|
return self.used_by is not None
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def is_expired(self) -> bool:
|
|
|
|
|
|
if self.expires_at is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return datetime.now() > self.expires_at
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"code": self.code,
|
|
|
|
|
|
"created_by": self.created_by,
|
|
|
|
|
|
"used_by": self.used_by,
|
|
|
|
|
|
"created_at": self.created_at.isoformat(),
|
|
|
|
|
|
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
|
|
|
|
"is_used": self.is_used,
|
|
|
|
|
|
"is_expired": self.is_expired,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class DataClearRequest:
|
|
|
|
|
|
"""数据清除请求"""
|
|
|
|
|
|
user_id: int = 0
|
|
|
|
|
|
mode: str = "all" # "range" | "type" | "all"
|
|
|
|
|
|
date_from: Optional[date] = None
|
|
|
|
|
|
date_to: Optional[date] = None
|
|
|
|
|
|
data_types: Optional[list] = None # ["exercise", "meal", "sleep", "weight"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class Reading:
|
|
|
|
|
|
"""阅读记录"""
|
|
|
|
|
|
id: Optional[int] = None
|
|
|
|
|
|
user_id: int = 1
|
|
|
|
|
|
date: date = field(default_factory=date.today)
|
|
|
|
|
|
title: str = "" # 书名
|
|
|
|
|
|
author: Optional[str] = None # 作者
|
|
|
|
|
|
cover_url: Optional[str] = None # 封面 URL
|
|
|
|
|
|
duration: int = 0 # 阅读时长(分钟)
|
|
|
|
|
|
mood: Optional[str] = None # 心情(😄😊😐😔😢)
|
|
|
|
|
|
notes: Optional[str] = None # 读后感
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": self.id,
|
|
|
|
|
|
"user_id": self.user_id,
|
|
|
|
|
|
"date": self.date.isoformat(),
|
|
|
|
|
|
"title": self.title,
|
|
|
|
|
|
"author": self.author,
|
|
|
|
|
|
"cover_url": self.cover_url,
|
|
|
|
|
|
"duration": self.duration,
|
|
|
|
|
|
"mood": self.mood,
|
|
|
|
|
|
"notes": self.notes,
|
|
|
|
|
|
}
|