- UserConfig 模型添加 target_weight 字段 - 新增 POST /api/config 端点用于更新用户配置 - 设置页面添加目标体重输入框及目标 BMI 自动计算 - BMI 分析使用用户设置的目标体重计算达成预估 - 修复测试 fixture 的 save_config 参数问题 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
329 lines
9.6 KiB
Python
329 lines
9.6 KiB
Python
"""数据模型定义"""
|
||
|
||
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
|
||
target_weight: Optional[float] = None # 目标体重(公斤)
|
||
|
||
@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,
|
||
"target_weight": self.target_weight,
|
||
"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,
|
||
}
|