跳轉到

Fitness Devices & App 後端平台設計規格書

| 版本 | 日期 | 狀態 | 摘要 |

| v3.0 | 2025-12-19 | 正式發佈 | 架構轉型:離線優先(Offline First)與數據去中心化控制 及混合型設備統整|

1. 核心設計摘要 (Executive Summary)

本系統旨在解決「多樣化器材數據整合」與「不穩定網路環境下的數據保存」兩大挑戰。

1.1 架構轉變

  • 移除即時控制 (No MQTT):後端不再主動控制設備,亦不處理毫秒級的即時遙測。所有運動控制邏輯(如阻力變化、瓦數計算)完全下放至 APP 端執行。
  • 離線優先 (Offline First):APP 是訓練數據的第一保存點 (Source of Truth)。後端扮演「數據歸檔中心」與「課程發佈中心」。
  • 異質數據標準化:利用 PostgreSQL 的 JSONB 特性,在同一張資料表中儲存不同器材(划船機、飛輪、跑步機)的特有數據,避免過度擴張資料表數量。

2. 業務流程與同步策略 (Sequence Diagram)

採用 「儲存後轉發 (Store-and-Forward)」 模式。APP 永遠先寫入本地資料庫 (SQLite),待網路可用時再批次上傳。

2.1 離線訓練與同步流程圖

sequenceDiagram participant User participant APP as APP (Local Logic) participant LocalDB as SQLite (Device) participant API as Backend API participant DB as PostgreSQL Note over APP, LocalDB: 階段一:離線訓練 User->>APP: 開始訓練 (無網路) APP->>APP: 讀取本地快取的課程設定 APP->>APP: 執行運動並計算瓦數 APP->>APP: 生成 client_session_uuid (UUIDv4) APP->>LocalDB: 寫入訓練結果 (Status: PENDING) User->>APP: 查看歷史紀錄 (讀取 LocalDB) Note over APP, DB: 階段二:網路恢復與同步 APP->>APP: 偵測到網路連線 APP->>LocalDB: 查詢所有 Status=PENDING 的紀錄 loop 每一筆未上傳紀錄 APP->>API: POST /api/v1/sessions/batch_upload Note right of APP: Header: X-Idempotency-Key activate API API->>DB: 檢查 client_session_uuid 是否存在? alt 數據已存在 (重複上傳) DB-->>API: 存在 API-->>APP: 200 OK (忽略寫入) else 數據不存在 (新紀錄) API->>DB: 寫入 WorkoutSession (JSONB) DB-->>API: 寫入成功 API->>API: 觸發背景任務 (更新用戶累計數據) API-->>APP: 201 Created end deactivate API APP->>LocalDB: 更新 Status=SYNCED end

Fitness APP後端管理平台流程示意圖.png

3. 資料庫模型設計 (SQLModel / PostgreSQL)

採用混合式架構:核心欄位使用關聯式欄位 (Relational),異質數據(器材特有參數)使用 JSONB 文件式儲存。

3.1 資料表解說

資料表 說明 核心欄位摘要
users 基本用戶資料 UUID, Email, 身高, 體重, 性別, 密碼
social_accounts 第三方綁定 Apple/Google/Facebook Provider UID
user_stats 累積統計數據 里程, 總 XP, 目前等級 (支援 APP 直接 Patch)
trainings 課程資料庫 阻力腳本, 支持設備, 影片/封面 URL
workout_sessions 運動歷史紀錄 冪等 ID, 來源 (內建/後台), 運動詳情, TimeSeries

3.2 DB Schema

# --- 用戶模組 ---
class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_uuid: uuid.UUID = Field(default_factory=uuid.uuid4, unique=True, index=True)
    email: str = Field(unique=True, index=True)
    hashed_password: Optional[str] = None
    display_name: str
    avatar_url: Optional[str] = None
    gender: str = Field(default="o") # m, f, o
    height_cm: Optional[float] = None  
    weight_kg: Optional[float] = None  
    is_active: bool = True
    created_at: datetime = Field(default_factory=datetime.utcnow)

class SocialAccount(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key="users.id")
    provider: str # 'apple', 'google', 'facebook'
    provider_uid: str = Field(index=True)
    extra_data: dict = Field(default={}, sa_column=Column(JSONB))

class UserStat(SQLModel, table=True):
    user_id: int = Field(foreign_key="users.id", primary_key=True)
    total_distance_km: float = 0.0
    total_duration_seconds: int = 0
    total_calories: float = 0.0
    total_xp: int = 0
    current_level: int = 1
    workout_streak_days: int = 0
    last_workout_date: Optional[datetime] = None

# --- 課程模組 ---
class Training(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    title: str
    type: str = Field(index=True) # course, game, free
    cover_url: str
    video_url: Optional[str] = None
    settings: dict = Field(default={}, sa_column=Column(JSONB)) # 阻力腳本
    supported_devices: List[str] = Field(default=[], sa_column=Column(JSONB)) # ["bike", "rower"]
    enabled: bool = True

# --- 紀錄模組 ---
class WorkoutSession(SQLModel, table=True):
    id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
    client_session_id: str = Field(unique=True, index=True) # 離線生成的唯一 ID
    user_id: int = Field(foreign_key="users.id", index=True)

    source_type: str = Field(default="free", index=True) # backend, local, free
    training_id: Optional[int] = Field(default=None, foreign_key="trainings.id", nullable=True)
    internal_course_name: Optional[str] = None # 若為 APP 內建課程則填寫此名

    device_type: str = Field(index=True) # bike, rower, treadmill

    start_time: datetime
    end_time: datetime
    duration_seconds: int
    total_calories: float         # 來自 APP 運算
    total_distance_meters: float  # 來自 APP 運算
    xp_earned: int = 0            # 來自 APP 運算

    metrics_summary: dict = Field(default={}, sa_column=Column(JSONB))
    time_series_data: List[dict] = Field(default=[], sa_column=Column(JSONB))
    created_at: datetime = Field(default_factory=datetime.utcnow)

4. API Endpoints 規劃

4.1 認證模組 (Auth)

Method Endpoint 說明
POST /auth/register 帳號註冊 (僅限 Email/Password)
POST /auth/login 帳號登入 (回傳 JWT)
POST /auth/password/request-reset 忘記密碼 (寄送驗證碼/信件)
POST /auth/password/reset 重設密碼 (帶入驗證碼)
POST /auth/sso/{provider} SSO 登入 (provider = apple/google/facebook)
POST /auth/logout 登出 (撤銷 Refresh Token)
POST /auth/refresh 刷新 Access Token

4.2 使用者模組 (User)

Method Endpoint 說明
GET /users/me 取得個人資料 (含 UserStat)
PATCH /users/me 更新身高、體重、暱稱、性別
PATCH /users/me/stats 同步累計數據 (強制以 APP 端的累計里程/XP/等級覆蓋伺服器)
POST /users/me/avatar 上傳大頭貼

4.3 訓練內容 (Training)

Method Endpoint 說明
GET /trainings 取得課程列表 (支援設備篩選、分頁)
GET /trainings/{id} 取得單一課程詳細設定

4.4 紀錄同步 (Session)

Method Endpoint 說明
GET /trainings 取得課程列表 (支援 device_type 篩選)
GET /trainings/{id} 取得單一課程 JSONB 腳本
POST /sessions/batch_upload 批次同步 離線生成的運動紀錄
POST /sessions 單筆運動紀錄上傳
GET /sessions 取得歷史運動列表
GET /sessions/{id} 取得運動詳情 (含曲線圖數據)
DELETE /sessions/{id} 刪除運動紀錄

4.5 系統設定 (Meta)

Method Endpoint 說明
GET /meta/schemas 取得各器材 (Rower/Bike) 的數據欄位定義 (供 VUE 後台動態渲染)
GET /meta/app_version 檢查 APP 是否需要強制更新

5. 前端後台設計建議 (Vue 3 + Element Plus)

採用 Schema-Driven 設計,前端不寫死欄位,根據 device_type 與後端提供的 Schema 動態渲染表格與詳細資訊。

<!-- 動態展開列範例 -->
<el-table-column type="expand">
  <template #default="props">
    <el-descriptions title="詳細數據">
      <el-descriptions-item 
        v-for="(value, key) in props.row.metrics_summary" 
        :key="key" 
        :label="formatLabel(props.row.device_type, key)">
        {{ value }}
      </el-descriptions-item>
    </el-descriptions>
  </template>
</el-table-column>

6. 部署架構 (Docker Compose)

version: '3.8'
    services:
    backend:
        image: fitness-backend:latest
        command: uvicorn app.main:app --host 0.0.0.0 --port 8000
        environment:
        - DATABASE_URL=postgresql://user:pass@db:5432/fitness_db
        depends_on:
        - db

    db:
        image: postgres:16-alpine
        environment:
        POSTGRES_DB: fitness_db
        volumes:
        - ./pg_data:/var/lib/postgresql/data

    redis:
        image: redis:7-alpine
        command: redis-server --appendonly yes