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

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