跳轉到

資料模型

概述

城紹提供的內容(2025-12-22 版本)

  • 完整的資料庫 Schema(包含欄位名稱與型別的 SQLModel 類別)
  • 資料表關聯(Foreign Keys、OneToOne、OneToMany)
  • JSONB 用於異質裝置資料儲存
  • 架構原則(Offline-First、APP 端計算指標)

尚未提供的內容

  • 驗證規則(如密碼強度、email 格式)
  • 索引策略細節
  • Cascade delete 行為

資料庫技術

目前預計使用:PostgreSQL 16+ 搭配 SQLModel ORM

設計哲學

  1. 離線優先:APP 是資料來源的真相,Server 是資料封存中心
  2. 異質數據標準化:使用 JSONB 儲存異質裝置資料(腳踏車、划船機、踏步機)
  3. 批次優於即時:減少 API 呼叫次數,支援批次上傳與冪等性
  4. 客戶端計算:APP 計算所有指標(卡路里、距離、XP),Server 僅儲存結果
  5. 彈性 JSON 欄位:使用 JSONB 支援 Schema 演進,無需資料庫遷移

需要再確認的部份

  • 使用 client_session_id + X-Idempotency-Key header 實現冪等上傳
  • 具體的索引策略
  • Cascade delete 行為
  • 業務邏輯演算法(參見下方「業務規則」章節)

實體關聯圖 (Entity Relationship Diagram)

┌─────────────────────────────────────────────────────────────────────┐
│         SolidFocus Data Model (v3.0 - Offline-First + GDPR)        │
└─────────────────────────────────────────────────────────────────────┘

┌──────────────────────┐           ┌──────────────────────┐
│       User           │           │   SocialAccount      │
├──────────────────────┤           ├──────────────────────┤
│ id (PK)              │◄─────────┤ id (PK)              │
│ user_uuid (UQ, IDX)  │    1:N    │ user_id (FK)         │
│ email (UNIQUE)       │           │ provider             │
│ hashed_password      │           │ provider_uid         │
│ display_name         │           │ extra_data (JSONB)   │
│ avatar_url           │           │ created_at           │
│ gender (m/f/o)       │           └──────────────────────┘
│ height_cm            │
│ weight_kg            │           ┌──────────────────────┐
│ is_active            │           │   UserConsent        │
│ created_at           │           ├──────────────────────┤
│ gdpr_consent_given   │◄─────────┤ id (PK)              │
│ gdpr_consent_version │    1:N    │ user_id (FK)         │
│ gdpr_consent_date    │           │ consent_version      │
└──────────────────────┘           │ consent_text_hash    │
         │                          │ consented_at         │
         │ 1:1                      │ ip_address           │
         │                          └──────────────────────┘
┌──────────────────────────────┐
│         UserStats            │        ┌──────────────────────┐
├──────────────────────────────┤        │  DeletionRequest     │
│ user_id (PK, FK)             │◄──┐    ├──────────────────────┤
│ total_distance_km            │   │    │ id (PK)              │
│ total_duration_seconds       │   │    │ user_id_hash (IDX)   │
│ total_calories               │   │    │ request_type         │
│ total_xp                     │   │    │ requested_at         │
│ current_level                │   │    │ completed_at         │
│ workout_streak_days          │   │    │ status               │
│ last_workout_date            │   │    │ notes                │
└──────────────────────────────┘   │    └──────────────────────┘
         ▲                          │
         │                          │    ⚠️  DeletionRequest 使用
         │ APP calculates,          │        hashed user_id,無法
         │ Server archives          │        逆向查詢(GDPR 合規)
         │                          │
┌──────────────────────────────┐   │    ┌──────────────────────┐
│      WorkoutSession          │   │    │      Training        │
├──────────────────────────────┤   │    ├──────────────────────┤
│ id (UUID, PK)                │   │    │ id (PK)              │
│ client_session_id (UQ, IDX)  │   │    │ title                │
│ user_id (FK)                 │───┼───►│ type (course/game/   │
│ training_id (FK, NULLABLE)   │───┼───►│      free)           │
│ internal_course_name         │   │N:1 │ cover_url            │
│ source_type (backend/local/  │   │    │ video_url            │
│              free)            │   │    │ settings (JSONB)     │
│ device_type (bike/rower/     │   │    │ supported_devices    │
│              treadmill)       │   │    │   (JSONB array)      │
│ start_time                   │   │    │ enabled              │
│ end_time                     │   │    │ created_at           │
│ duration_seconds             │   │    └──────────────────────┘
│ total_calories               │   │
│ total_distance_meters        │   │
│ xp_earned                    │   └──────────────┐
│ metrics_summary (JSONB)      │                   │
│ time_series_data (JSONB[])   │                   │
│ created_at                   │                   │
└──────────────────────────────┘                   ▼
                                            (Aggregated to UserStats)

GDPR 刪除流程:
  POST /users/me/gdpr/delete-account
  建立 DeletionRequest (status: pending)
  Atomic Transaction 開始:
    - 刪除所有 WorkoutSession
    - 刪除 UserStats
    - 刪除所有 SocialAccount
    - 刪除所有 UserConsent
    - 刪除 User
    - 更新 DeletionRequest (status: completed, completed_at = NOW())
    - 將 tokens 加入黑名單
  Transaction 提交
  回傳 200 OK
  (失敗時 → status: failed)

資料模型 (Data Models)

1. User

用途:儲存使用者帳號資訊。

資料表名稱users

欄位

欄位 型別 限制條件 說明
id INTEGER PRIMARY KEY, AUTO INCREMENT 使用者唯一識別碼(內部使用)
user_uuid UUID UNIQUE, NOT NULL, INDEXED 使用者 UUID(API 回應中使用)
email VARCHAR(255) UNIQUE, NOT NULL, INDEXED 使用者 email(用於登入 / 聯絡)
hashed_password VARCHAR(255) NULLABLE 雜湊後的密碼(SSO 專用使用者為 NULL)
display_name VARCHAR(100) NOT NULL 公開顯示名稱
avatar_url VARCHAR(500) NULLABLE 頭像圖片 URL
gender VARCHAR(1) NOT NULL, DEFAULT 'o', CHECK 性別:"m"、"f"、"o"
height_cm FLOAT NULLABLE, CHECK >= 0 使用者身高(公分)
weight_kg FLOAT NULLABLE, CHECK >= 0 使用者體重(公斤)
is_active BOOLEAN NOT NULL, DEFAULT TRUE 帳號啟用狀態
gdpr_consent_given BOOLEAN NOT NULL, DEFAULT FALSE 使用者是否已給予 GDPR consent
gdpr_consent_version VARCHAR(20) NULLABLE 使用者同意的 consent 版本
gdpr_consent_date TIMESTAMPTZ NULLABLE 使用者給予 consent 的時間戳記
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() 帳號建立時間戳記

限制條件

  • email 必須在所有使用者中唯一
  • user_uuid 必須唯一(API 回應使用,而非 id
  • gender 必須為以下其一:'m'、'f'、'o'
  • height_cm 必須介於 0-300 公分
  • weight_kg 必須介於 0-500 公斤

業務規則

使用者識別

  • id:內部資料庫 ID(不在 API 中暴露)
  • user_uuid:公開識別碼(用於 API 回應)
  • 使用者建立時自動產生

顯示名稱

  • 必填欄位(無預設值)
  • 可透過 PATCH /users/me 更新
  • 必須為 1-100 字元

Email

  • 無法透過 API 變更(需客服介入)
  • 用於密碼重設與通知
  • 必須唯一

密碼

  • SSO 專用使用者為 NULL(Apple/Google/Facebook)
  • 使用 bcrypt 或 Argon2 雜湊
  • 永不在 API 回應中暴露

個人檔案欄位

  • height_cmweight_kg:選填,用於卡路里計算
  • avatar_url:選填,透過 POST /users/me/avatar 更新
  • gender:預設為 'o'(其他)

GDPR 合規欄位

  • gdpr_consent_given:使用者是否已給予 GDPR consent(預設為 FALSE)
  • gdpr_consent_version:使用者同意的 consent 版本(例如 "1.0")
  • gdpr_consent_date:使用者給予 consent 的時間戳記
  • App 啟動時檢查此欄位,無需 JOIN user_consents 資料表
  • 如果 gdpr_consent_given = FALSE,必須顯示阻擋式 consent 對話框
  • 當建立 UserConsent 記錄時,同時更新這些欄位

2. UserStats

用途:儲存使用者累計運動統計資料(可從 APP 強制同步)。

資料表名稱user_stats

欄位

欄位 型別 限制條件 說明
user_id INTEGER PRIMARY KEY, FOREIGN KEY (users.id) 參照 User(OneToOne 關聯)
total_distance_km FLOAT NOT NULL, DEFAULT 0, CHECK >= 0 累計距離(公里)
total_duration_seconds INTEGER NOT NULL, DEFAULT 0, CHECK >= 0 累計運動時間(秒)
total_calories FLOAT NOT NULL, DEFAULT 0, CHECK >= 0 累計消耗卡路里(kCal)
total_xp INTEGER NOT NULL, DEFAULT 0, CHECK >= 0 累計經驗值
current_level INTEGER NOT NULL, DEFAULT 1, CHECK >= 1 使用者等級(依據 XP)
workout_streak_days INTEGER NOT NULL, DEFAULT 0, CHECK >= 0 目前運動連續天數
last_workout_date TIMESTAMPTZ NULLABLE 最後一次運動完成日期

限制條件

  • 與 User 為 OneToOne 關聯(透過 user_id 的 PRIMARY KEY 強制執行)
  • 所有數值欄位必須為非負數
  • current_level 必須至少為 1

業務規則

從 APP 強制同步(v3.0 變更):

  • APP 是使用者統計資料的真相來源
  • PATCH /users/me/stats 允許 APP 覆寫 Server 數值
  • Server 接受 APP 計算的數值
  • 使用情境:當 APP 有更新的資料時,解決同步衝突

更新策略

  • APP 在每次運動後更新本地統計資料
  • APP 定期透過 PATCH /users/me/stats 同步至 Server
  • Server 不會自動計算(客戶確認:APP 計算,Server 僅儲存)

未定義項目(需要後續討論):

  • Workout streak 連續天數計算邏輯
  • Level 等級計算門檻
  • XP 經驗值計算公式

3. SocialAccount

用途:儲存使用者的 SSO(Social Sign-On)供應商資訊。

資料表名稱social_accounts

欄位

欄位 型別 限制條件 說明
id INTEGER PRIMARY KEY, AUTO INCREMENT SocialAccount 唯一識別碼
user_id INTEGER FOREIGN KEY (users.id), NOT NULL, INDEXED 參照 User
provider VARCHAR(50) NOT NULL, INDEXED, CHECK SSO 供應商:"apple"、"google"、"facebook"
provider_uid VARCHAR(255) NOT NULL, INDEXED 供應商的唯一使用者識別碼(sub claim)
extra_data JSONB NOT NULL, DEFAULT '{}' 額外的供應商資料(email、name、tokens)
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() 首次連結時間戳記

限制條件

  • 對 (provider, provider_uid) 的複合唯一限制 - 相同供應商帳號只能連結一次
  • provider 必須為以下其一:'apple'、'google'、'facebook'
  • extra_data 儲存為 JSONB(非 JSON)

業務規則

帳號連結

  • 當使用者首次使用 SSO 登入時:
    • 如果 email 已存在於 User 資料表 → 連結至現有使用者
    • 如果 email 不存在 → 建立新的 User + 連結 SocialAccount
  • 一個使用者可以有多個 SocialAccount(例如 Apple + Google)
  • 一個 SocialAccount(provider+provider_uid)只能連結至一個 User

額外資料範例

{
  "email": "user@privaterelay.appleid.com",
  "email_verified": true,
  "name": {
    "firstName": "John",
    "lastName": "Doe"
  },
  "apple_id_token_expires_at": "2025-12-18T10:30:00Z"
}

支援的 SSO

  • 第一階段:Apple SSO(優先)
  • 未來:Google SSO
  • 未來:Facebook Login

API Endpoint

  • 單一統一端點:POST /auth/sso/{provider}
  • Provider 參數:applegooglefacebook

4. WorkoutSession

用途:儲存個別運動 session 記錄(從 APP 透過批次或單筆上傳)。

資料表名稱workout_sessions

欄位

欄位 型別 限制條件 說明
id UUID PRIMARY KEY Session 唯一識別碼(Server 產生)
client_session_id VARCHAR(255) UNIQUE, NOT NULL, INDEXED Client 產生的冪等性金鑰(UUIDv4)
user_id INTEGER FOREIGN KEY (users.id), NOT NULL, INDEXED 參照 User
source_type VARCHAR(20) NOT NULL, DEFAULT 'free', INDEXED, CHECK 來源類型:"backend"、"local"、"free"
training_id INTEGER FOREIGN KEY (trainings.id), NULLABLE 參照 Training(自由騎乘為 NULL)
internal_course_name VARCHAR(255) NULLABLE APP 內建課程名稱(如適用)
device_type VARCHAR(20) NOT NULL, INDEXED, CHECK 裝置類型:"bike"、"rower"、"treadmill"
start_time TIMESTAMPTZ NOT NULL 運動開始時間(APP 時間戳記)
end_time TIMESTAMPTZ NOT NULL 運動結束時間(APP 時間戳記)
duration_seconds INTEGER NOT NULL, CHECK > 0 運動時長,不含暫停時間(秒)
total_calories FLOAT NOT NULL, CHECK >= 0 總消耗熱量(kCal)- APP 計算
total_distance_meters FLOAT NOT NULL, CHECK >= 0 總距離(公尺)- APP 計算
xp_earned INTEGER NOT NULL, DEFAULT 0, CHECK >= 0 獲得的 XP - APP 計算
metrics_summary JSONB NOT NULL, DEFAULT '{}' 異質裝置摘要指標
time_series_data JSONB[] NOT NULL, DEFAULT '[]' 時間序列資料(用於圖表)
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() Server 上傳時間戳記

限制條件

  • id(UUID)必須全域唯一(primary key)
  • client_session_id 必須全域唯一(冪等性金鑰,已建立索引)
  • end_time 必須晚於 start_time
  • duration_seconds 必須為正數(> 0)
  • device_type 必須為以下其一:'bike''rower''treadmill'
  • source_type 必須為以下其一:'backend''local''free'
  • total_caloriestotal_distance_metersxp_earned 必須為非負數(>= 0)
  • 如果 source_type = 'backend',則必須提供 training_id
  • 如果 source_type = 'local',則應提供 internal_course_name

業務規則

使用 client_session_id 實現冪等性

格式:APP 產生的 UUIDv4
範例:550e8400-e29b-41d4-a716-446655440000
  • APP 使用 UUIDv4 產生 client_session_id
  • 在批次上傳時與 X-Idempotency-Key header 搭配使用
  • Backend 檢查 client_session_id 以偵測重複項目
  • 針對重複項目回傳 200 OK(冪等行為)
  • 即使重試上傳也能確保不會有重複的 session

時間戳記處理

  • start_timeend_time:由 APP 設定(實際運動時間)
  • created_at:由 Server 設定(上傳/同步時間)
  • 所有時間戳記皆為時區感知(ISO 8601 格式,UTC)

時長計算

elapsed_time = end_time - start_time    (包含暫停時間)
duration_seconds = actual workout time   (不含暫停時間)
  • ✅ 儲存 duration_seconds(純運動時間,由 APP 計算)
  • elapsed_time 可從 end_time - start_time 導出
  • 範例:30 分鐘 session,其中有 5 分鐘暫停
    • start_time: 2025-12-19T10:00:00Z
    • end_time: 2025-12-19T10:30:00Z
    • duration_seconds: 1500(25 分鐘)

來源類型邏輯

  • 'backend':Session 遵循 Backend training 課程(必須有 training_id
  • 'local':Session 遵循 APP 內建課程(必須有 internal_course_name
  • 'free':自由騎乘/划船,無課程(無 training_idinternal_course_name

異質裝置儲存

  • 同一資料表支援 bike、rower、treadmill 資料
  • 裝置專屬欄位儲存於 JSONB metrics_summary
  • 不同裝置有不同的可用指標
  • Backend 不驗證指標欄位(APP 的責任)

JSONB 結構範例

這個部份再麻煩城紹提供之前踏步機的資料,如

  • 機器多久傳到 MQTT 一次
  • 一次內含多少筆 raw data
  • JSONB 的範例... 等

5. Training

用途:儲存 training 課程範本與設定。

資料表名稱trainings

欄位

欄位 型別 限制條件 說明
id INTEGER PRIMARY KEY, AUTO INCREMENT Training 唯一識別碼
type VARCHAR(50) NOT NULL, INDEX Training 類型:'course'、'game'、'free'
name VARCHAR(255) NOT NULL Training/課程名稱
settings JSONB NOT NULL, DEFAULT '{}' 彈性設定(時長、難度、階段等)
supported_devices JSONB[] NOT NULL, DEFAULT '[]' 裝置類型陣列:["bike", "rower", "treadmill"]
cover VARCHAR(500) NULLABLE 封面圖片 URL 路徑
video_url VARCHAR(500) NULLABLE 影片 URL(HLS/m3u8 格式)
enabled BOOLEAN NOT NULL, DEFAULT TRUE Training 是否開放給使用者
created_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() 記錄建立時間戳記
updated_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() 最後更新時間戳記

限制條件

  • type 必須為以下其一:'course''game''free'
  • supported_devices 必須包含有效的裝置類型:['bike', 'rower', 'treadmill']
  • supported_devices 陣列至少要有一個裝置(不允許空陣列)

業務規則

Training 類型

  • course:結構化課程,包含影片與階段(例如「台灣風景之旅」)
  • game:遊戲化運動,包含互動元素(例如「龍舟競賽」)
  • free:自由騎乘/划船,無結構化課程(可能有影片背景)

裝置篩選

  • APP 可透過 device_type 參數查詢 training:GET /trainings?device_type=bike
  • 僅回傳 device_type IN supported_devices 的 training
  • 範例:supported_devices = ["bike", "rower"] 的 Training 會同時出現在 bike 與 rower 使用者的列表中

Enabled 旗標

  • enabled = true:Training 出現在列表/詳細資料端點
  • enabled = false:對使用者隱藏 Training(用於草稿或已棄用內容)

JSONB 結構範例

再由雙方制定 JSONB 格式。

💡 設計備註

  • JSONB 欄位允許彈性的 Schema 演進,無需資料庫遷移
  • 不同 training 類型可以有不同的 settings 結構
  • supported_devices 支援多裝置 training 資料庫(一個 training,多種裝置類型)
  • 建議在應用層實作 JSON Schema 驗證以確保型別安全

用途:記錄使用者的 GDPR 同意記錄,支援版本追蹤。

資料表名稱user_consents

欄位

欄位 型別 限制條件 說明
id INTEGER PRIMARY KEY, AUTO INCREMENT UserConsent 唯一識別碼
user_id INTEGER FOREIGN KEY (users.id), NOT NULL, INDEXED 參照 User
consent_version VARCHAR(20) NOT NULL Consent 政策版本(例如:"1.0"、"1.1")
consent_text_hash VARCHAR(64) NOT NULL Consent 文字的 SHA256 hash(確保知道使用者同意的內容)
consented_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() 使用者同意的時間戳記
ip_address VARCHAR(45) NULLABLE 使用者的 IP 位址(選填,用於稽核軌跡)

限制條件

  • 對 (user_id, consent_version) 的複合唯一限制 - 同一使用者對同一版本只能有一筆 consent 記錄
  • consent_text_hash 使用 SHA256 演算法(64 字元十六進位字串)
  • ip_address 支援 IPv4 與 IPv6(最多 45 字元)

業務規則

GDPR 合規要求

  • 歐盟上架的 App 必須在首次使用前取得明確 consent
  • Consent 不可預設勾選(必須明確 opt-in)
  • 必須保存 consent 記錄供稽核使用

Consent 版本追蹤

  • consent_version:追蹤不同版本的 consent 政策(例如:"1.0"、"1.1")
  • consent_text_hash:確保我們知道使用者同意的確切內容
  • 當 consent 政策更新時,使用者需重新同意新版本

建立時機

  • 使用者首次登入時,顯示 consent 對話框
  • 使用者接受後,呼叫 POST /users/me/gdpr/consent 建立記錄
  • 同時更新 User.gdpr_consent_given = TRUE

既有使用者處理

  • 部署 GDPR 功能後,既有使用者的 gdpr_consent_given = FALSE
  • 下次登入時顯示 consent 對話框
  • 接受後繼續使用 App

IP 位址記錄

  • 選填欄位,用於稽核軌跡(證明 consent 來自特定地點)
  • 可由 Backend 從 request header 取得
  • 如隱私考量,可設為 NULL

7. DeletionRequest

用途:記錄帳號刪除請求的稽核日誌(GDPR 合規證明)。

資料表名稱deletion_requests

欄位

欄位 型別 限制條件 說明
id INTEGER PRIMARY KEY, AUTO INCREMENT DeletionRequest 唯一識別碼
user_id_hash VARCHAR(64) NOT NULL, INDEXED 使用者 ID 的 SHA256 hash(不可逆向查詢)
request_type VARCHAR(50) NOT NULL, DEFAULT 'full_account' 刪除類型(初期版本僅支援 'full_account')
requested_at TIMESTAMPTZ NOT NULL, DEFAULT NOW() 刪除請求時間戳記
completed_at TIMESTAMPTZ NULLABLE 刪除完成時間戳記
status VARCHAR(20) NOT NULL, DEFAULT 'pending', CHECK 處理狀態:"pending"、"completed"、"failed"
notes TEXT NULLABLE 備註(錯誤訊息或管理員註記)

限制條件

  • status 必須為以下其一:'pending''completed''failed'
  • user_id_hash 使用 SHA256 演算法(64 字元十六進位字串)
  • completed_at 只有在 status = 'completed''failed' 時才設定

業務規則

GDPR 合規要求

  • 系統必須保存刪除行為的稽核日誌,作為合規證明
  • 日誌不可包含個人資料(僅保留 metadata)
  • 使用 hash 的 user ID,無法逆向查詢已刪除的使用者

刪除流程

  1. 使用者呼叫 POST /users/me/gdpr/delete-account
  2. Backend 在同一 request 中執行:
    • 建立 DeletionRequest 記錄(status: pending,記錄 requested_atuser_id_hash
    • 開始 Atomic Transaction
      • 刪除所有 WorkoutSession 記錄
      • 刪除 UserStats 記錄
      • 刪除所有 SocialAccount 記錄
      • 刪除所有 UserConsent 記錄
      • 刪除 User 記錄
      • 更新 DeletionRequeststatus: completed,設定 completed_at
      • 將 access_token 和 refresh_token 加入黑名單
    • 提交 Transaction
  3. 回傳 200 OK(使用者立即登出)

錯誤處理

  • 如果 transaction 失敗,回滾所有變更
  • 在 exception handler 中更新 DeletionRequest.status = 'failed'
  • 將錯誤訊息記錄於 notes 欄位
  • 回傳 500 錯誤給使用者

Status 狀態

  • pending(預設):刪除請求已建立,等待執行(初始狀態)
  • completed:刪除成功完成(transaction 提交成功)
  • failed:刪除過程中發生錯誤(transaction 失敗,記錄於 notes 欄位)

狀態轉換

  • pendingcompleted(正常流程)
  • pendingfailed(異常流程)

正常流程下,所有記錄都是 completed。如果系統崩潰,可能會留下 pending 狀態的記錄(可用於後續稽核)。

Hash 使用原理

  • user_id_hash = SHA256(user_id)
  • 用途:證明「系統有執行刪除」,但無法識別是哪個使用者
  • 符合 GDPR「不保留個人資料」原則

刪除類型

  • 'full_account':刪除所有雲端資料(初期版本唯一選項)
  • 未來可擴充:'partial_data'(刪除特定運動記錄)

重要原則

  • 刪除是 硬刪除(hard delete),不是軟刪除
  • 刪除後無法復原
  • 初期版本無 grace period(立即刪除)

同步刪除設計

  • 初期版本使用同步刪除(API request 內完成所有操作)
  • 使用 atomic transaction 確保資料一致性
  • 不需要背景任務系統(Celery / BackgroundTasks)
  • 刪除操作應該很快(< 1 秒),因為初期階段資料量不大
  • 使用者體驗更好:點擊刪除後立即看到結果
  • pending 狀態用於追蹤請求建立到執行之間的狀態,即使是同步執行也能提供清晰的語義
  • 未來如果資料量變大,可以改為非同步刪除(新增 processing 狀態表示背景任務執行中)

GDPR 合規說明

App 將在歐盟上架,因此必須符合 GDPR (通用資料保護規則) 合規要求。

核心要求

  1. 首次使用明確同意:使用阻擋式 consent 對話框,使用者必須明確同意才能使用 App
  2. 帳號刪除功能:提供硬刪除所有雲端資料的功能
  3. 刪除稽核日誌:保留刪除行為的稽核記錄,用於合規證明

資料模型關聯

User Model (參見 User 章節):

  • 新增 gdpr_consent_givengdpr_consent_versiongdpr_consent_date 欄位
  • 用途:快速檢查 consent 狀態,無需 JOIN user_consents 資料表
  • App 啟動時檢查 gdpr_consent_given,決定是否顯示 consent 對話框

UserConsent Model (參見 UserConsent 章節):

  • 記錄每次 consent 的完整稽核日誌
  • 支援版本追蹤(consent_version
  • 記錄 consent 文字的 hash(consent_text_hash)確保知道使用者同意的確切內容
  • 選填記錄 IP 位址(ip_address)用於稽核軌跡

DeletionRequest Model (參見 DeletionRequest 章節):

  • 記錄帳號刪除請求的稽核日誌
  • 使用 hash 的 user ID(user_id_hash),無法逆向查詢已刪除的使用者
  • 追蹤刪除狀態(status: pending/completed/failed)
  • 初期版本為同步刪除,使用 atomic transaction 確保資料一致性
  • 狀態轉換:pending → completed(成功)或 pending → failed(失敗)
  • 符合 GDPR「不保留個人資料」原則

版本更新處理

當 consent 政策更新時:

  1. Backend 更新 /meta/gdpr/consent-text 的版本號與文字
  2. 既有使用者登入時,Backend 比對 User.gdpr_consent_version 與最新版本
  3. 如果版本不符,要求使用者重新同意新版本
  4. 建立新的 UserConsent 記錄,追蹤多個版本的 consent 歷史

合規證明

Consent 證明:

  • UserConsent 資料表保留所有 consent 記錄
  • 記錄 consent 文字的 SHA256 hash
  • 選填記錄 IP 位址用於地理位置證明

刪除證明:

  • DeletionRequest 資料表保留刪除稽核日誌
  • 使用 hash 的 user ID,無法逆向查詢
  • 記錄刪除請求時間、完成時間、狀態

索引策略 (Indexes Strategy)

效能考量(v3.0)

資料表 索引 用途 類型 在 SQLModel 中定義
users idx_users_email 登入查詢(頻繁) B-tree UNIQUE unique=True
idx_users_user_uuid 透過 user_uuid 查詢 API B-tree UNIQUE unique=True, index=True
user_stats idx_user_stats_user_id OneToOne 關聯查詢 Unique B-tree unique=True
social_accounts idx_social_accounts_user_id 查詢使用者的社交帳號 B-tree foreign_key, index=True
idx_social_accounts_provider_uid SSO 登入查詢 Unique composite unique=True on (provider, provider_uid)
workout_sessions idx_sessions_client_session_id 冪等性檢查 B-tree UNIQUE unique=True, index=True
idx_sessions_user_id 使用者的 session 列表 B-tree foreign_key, index=True
idx_sessions_device_type 依裝置篩選 B-tree index=True
idx_sessions_source_type 依來源篩選 B-tree index=True
idx_sessions_start_time 最近 session 排序 B-tree DESC ⚠️ 手動(Alembic migration)
trainings idx_trainings_type 依類型篩選 B-tree index=True
idx_trainings_enabled 篩選已啟用課程 B-tree ⚠️ 手動(Alembic migration)
user_consents idx_user_consents_user_id 查詢使用者的 consent 記錄 B-tree foreign_key, index=True
idx_user_consents_user_version 防止重複 consent Unique composite unique=True on (user_id, consent_version)
deletion_requests idx_deletion_requests_hash 查詢特定使用者的刪除記錄 B-tree index=True
idx_deletion_requests_status 查詢待處理的刪除請求 B-tree index=True

索引原理(v3.0 + GDPR)

唯一索引(防止重複):

  • users.email:防止重複帳號
  • users.user_uuid:API 使用 UUID 進行使用者識別
  • workout_sessions.client_session_id冪等性關鍵 - 防止重複上傳
  • social_accounts.(provider, provider_uid):防止重複的 SSO 連結

效能索引(查詢最佳化):

  • workout_sessions.device_type:依裝置篩選 session(bike/rower/treadmill)
  • workout_sessions.source_type:依來源篩選(backend/local/free)
  • workout_sessions.start_time DESC:快速排序最近的 session
  • trainings.type:依類型篩選 training(course/game/free)
  • trainings.enabled:僅顯示已啟用的 training

Foreign Key 索引(關聯查詢):

  • 所有 user_id foreign key 都已建立索引
  • 所有 training_id foreign key 都已建立索引

GDPR 相關索引(合規查詢):

  • user_consents.user_id:查詢使用者的 consent 歷史
  • user_consents.(user_id, consent_version):防止重複 consent(複合唯一索引)
  • deletion_requests.user_id_hash:查詢特定使用者的刪除記錄(用於稽核)
  • deletion_requests.status:查詢待處理的刪除請求(背景任務使用)

💡 實作備註:索引將在 Backend 實作期間建立。需監控查詢效能,並視需要新增額外索引。


資料完整性規則 (Data Integrity Rules)

資料庫層級限制條件

客戶提供的資料庫架構

  • 已定義 Foreign Key 關聯
  • 已定義 NOT NULL 限制條件
  • 已在 email、user_uuid、client_session_id 上定義唯一限制條件

唯一限制條件

  • users.email UNIQUE ✅
  • users.user_uuid UNIQUE ✅
  • user_stats.user_id UNIQUE(OneToOne 關聯)✅
  • workout_sessions.client_session_id UNIQUE(冪等性金鑰)✅
  • (social_accounts.provider, social_accounts.provider_uid) UNIQUE composite ✅

⚠️ 未定義項目(需要後續討論):

  • Foreign Key Cascade 行為(ON DELETE CASCADE/SET NULL)
  • Check Constraints 驗證規則(Enum 驗證、數值驗證)
  • 應用層驗證(FastAPI)

設計討論 (Design Discussions)

關鍵設計哲學變更

🎯 Offline-First 架構

  • APP 是運動資料的真相來源
  • Server 是資料封存與同步中心
  • 無裝置綁定 - 使用者可使用任何裝置
  • 批次上傳搭配冪等性以確保可靠性

🎯 異質裝置儲存

  • 使用 JSONB 儲存異質裝置資料(bike/rower/treadmill)
  • 對指標不進行嚴格的 Schema 驗證
  • Vue admin panel 依據 device_type 動態渲染

根據 v3.0 的設計決策整理

1. 使用 JSONB 儲存異質裝置指標

決策:對 metrics_summarytime_series_data 使用 JSONB

原理

  • 不同裝置有不同的可用指標(bike 有 cadence,rower 有 stroke rate)
  • 新增裝置類型時無需進行 Schema 遷移
  • Backend 不驗證指標欄位 - APP 的責任
  • Vue admin panel 使用 GET /meta/schemas 取得動態欄位定義

範例:Bike 有 avg_cadence,rower 有 avg_stroke_rate - 完全不同的結構儲存於同一個 JSONB 欄位。


2. 批次上傳搭配冪等性

決策:使用 client_session_id(UUIDv4)+ X-Idempotency-Key header

實作方式POST /sessions/batch_upload

原理

  • 支援離線運動記錄與批次同步
  • 即使重試也能防止重複上傳
  • 減少 API 呼叫次數(一次上傳多個 session)
  • 簡單的 UUIDv4 格式(不需要 MAC 前綴)

流程

APP 產生 client_session_id → 本地儲存 →
網路可用 → 使用 Idempotency-Key 批次上傳 →
Server 檢查 client_session_id → 針對重複項目回傳 200 OK

3. APP 計算所有內容

決策:Server 儲存 APP 傳送的內容,不進行伺服器端計算

APP 的責任

  • 計算 total_caloriestotal_distance_metersxp_earned
  • 產生 metrics_summary(平均值/最大值)
  • 產生 time_series_data 用於圖表
  • 計算 duration_seconds(不含暫停時間)

Server 的責任

  • 原樣儲存資料(不驗證指標數值)
  • 提供資料封存與檢索
  • 透過 PATCH /users/me/stats 支援 UserStats 強制同步(APP 優先)

原理

  • Offline-First - APP 必須在無 Server 的情況下運作
  • 異質裝置 - Server 不知道裝置特定邏輯
  • 更簡單的 Backend - 無複雜業務邏輯

4. UserStats 強制同步(APP 優先)

決策:允許 PATCH /users/me/stats,APP 可覆寫 Server 數值

v3.0 的新行為

  • APP 在離線運動期間在本地追蹤統計資料
  • 當 APP 偵測到差異時,會強制同步至 Server
  • Server 無條件接受 APP 數值(APP 優先)
  • Server 上無自動計算觸發

使用情境

APP 離線 → 5 次運動 → APP 統計:總計 10 小時
Server 上線 → 僅有 5 小時(錯過的同步)
APP 傳送 PATCH /users/me/stats → total_duration_seconds: 36000
Server 更新 → 現在與 APP 一致

其他待討論的問題

⚠️ 1. Meta Schemas 更新頻率

問題GET /meta/schemas 端點應多久呼叫一次?

考量因素

  • Schema 變更不頻繁(僅在新增裝置類型時)
  • 可快取 24 小時
  • 或使用基於版本的快取失效

建議:在 v1 中實作快取策略


⚠️ 2. 強制更新機制

問題:如何透過 GET /meta/app_version 實作強制 App 更新?

考量因素

  • 如果使用者使用舊版本會發生什麼?
  • API 應該封鎖舊版本嗎?
  • 還是只在 APP 中顯示警告?

建議:在實作前定義政策