資料模型¶
概述¶
城紹提供的內容(2025-12-22 版本):
- 完整的資料庫 Schema(包含欄位名稱與型別的 SQLModel 類別)
- 資料表關聯(Foreign Keys、OneToOne、OneToMany)
- JSONB 用於異質裝置資料儲存
- 架構原則(Offline-First、APP 端計算指標)
尚未提供的內容:
- 驗證規則(如密碼強度、email 格式)
- 索引策略細節
- Cascade delete 行為
資料庫技術¶
目前預計使用:PostgreSQL 16+ 搭配 SQLModel ORM
設計哲學¶
- 離線優先:APP 是資料來源的真相,Server 是資料封存中心
- 異質數據標準化:使用 JSONB 儲存異質裝置資料(腳踏車、划船機、踏步機)
- 批次優於即時:減少 API 呼叫次數,支援批次上傳與冪等性
- 客戶端計算:APP 計算所有指標(卡路里、距離、XP),Server 僅儲存結果
- 彈性 JSON 欄位:使用 JSONB 支援 Schema 演進,無需資料庫遷移
需要再確認的部份:
- 使用
client_session_id+X-Idempotency-Keyheader 實現冪等上傳 - 具體的索引策略
- 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_cm、weight_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 參數:
apple、google或facebook
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_timeduration_seconds必須為正數(> 0)device_type必須為以下其一:'bike'、'rower'、'treadmill'source_type必須為以下其一:'backend'、'local'、'free'total_calories、total_distance_meters、xp_earned必須為非負數(>= 0)- 如果
source_type = 'backend',則必須提供training_id - 如果
source_type = 'local',則應提供internal_course_name
業務規則¶
使用 client_session_id 實現冪等性:
- APP 使用 UUIDv4 產生
client_session_id - 在批次上傳時與
X-Idempotency-Keyheader 搭配使用 - Backend 檢查
client_session_id以偵測重複項目 - 針對重複項目回傳 200 OK(冪等行為)
- 即使重試上傳也能確保不會有重複的 session
時間戳記處理:
start_time與end_time:由 APP 設定(實際運動時間)created_at:由 Server 設定(上傳/同步時間)- 所有時間戳記皆為時區感知(ISO 8601 格式,UTC)
時長計算:
- ✅ 儲存
duration_seconds(純運動時間,由 APP 計算) elapsed_time可從end_time - start_time導出- 範例:30 分鐘 session,其中有 5 分鐘暫停
start_time: 2025-12-19T10:00:00Zend_time: 2025-12-19T10:30:00Zduration_seconds: 1500(25 分鐘)
來源類型邏輯:
'backend':Session 遵循 Backend training 課程(必須有training_id)'local':Session 遵循 APP 內建課程(必須有internal_course_name)'free':自由騎乘/划船,無課程(無training_id或internal_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 驗證以確保型別安全
6. UserConsent¶
用途:記錄使用者的 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,無法逆向查詢已刪除的使用者
刪除流程:
- 使用者呼叫
POST /users/me/gdpr/delete-account - Backend 在同一 request 中執行:
- 建立
DeletionRequest記錄(status: pending,記錄requested_at與user_id_hash) - 開始 Atomic Transaction:
- 刪除所有
WorkoutSession記錄 - 刪除
UserStats記錄 - 刪除所有
SocialAccount記錄 - 刪除所有
UserConsent記錄 - 刪除
User記錄 - 更新
DeletionRequest(status: completed,設定completed_at) - 將 access_token 和 refresh_token 加入黑名單
- 刪除所有
- 提交 Transaction
- 建立
- 回傳 200 OK(使用者立即登出)
錯誤處理:
- 如果 transaction 失敗,回滾所有變更
- 在 exception handler 中更新
DeletionRequest.status = 'failed' - 將錯誤訊息記錄於
notes欄位 - 回傳 500 錯誤給使用者
Status 狀態:
pending(預設):刪除請求已建立,等待執行(初始狀態)completed:刪除成功完成(transaction 提交成功)failed:刪除過程中發生錯誤(transaction 失敗,記錄於notes欄位)
狀態轉換:
pending→completed(正常流程)pending→failed(異常流程)
正常流程下,所有記錄都是 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 (通用資料保護規則) 合規要求。
核心要求¶
- 首次使用明確同意:使用阻擋式 consent 對話框,使用者必須明確同意才能使用 App
- 帳號刪除功能:提供硬刪除所有雲端資料的功能
- 刪除稽核日誌:保留刪除行為的稽核記錄,用於合規證明
資料模型關聯¶
User Model (參見 User 章節):
- 新增
gdpr_consent_given、gdpr_consent_version、gdpr_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 政策更新時:
- Backend 更新
/meta/gdpr/consent-text的版本號與文字 - 既有使用者登入時,Backend 比對
User.gdpr_consent_version與最新版本 - 如果版本不符,要求使用者重新同意新版本
- 建立新的
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:快速排序最近的 sessiontrainings.type:依類型篩選 training(course/game/free)trainings.enabled:僅顯示已啟用的 training
Foreign Key 索引(關聯查詢):
- 所有
user_idforeign key 都已建立索引 - 所有
training_idforeign 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.emailUNIQUE ✅users.user_uuidUNIQUE ✅user_stats.user_idUNIQUE(OneToOne 關聯)✅workout_sessions.client_session_idUNIQUE(冪等性金鑰)✅(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_summary 與 time_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_calories、total_distance_meters、xp_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 中顯示警告?
建議:在實作前定義政策