跳轉到

流程與同步策略

本文件定義 SolidFocus App 與後端伺服器之間的完整資料流程與同步策略,包含:

  • App 與 API 的互動時機
  • Local DB 與 Server DB 的資料同步邏輯
  • 8 個關鍵 Flow(App 啟動、Login、運動、上傳、歷史記錄、Logout、課程列表、Token Refresh)
  • Offline-First 架構實作細節

設計原則

1. 離線優先架構

App 設計為「離線優先」,所有核心功能在無網路時仍可運作:

📱 Local DB (Source of Truth for App)
    ↕️ 非同步同步
☁️  Server DB (Source of Truth for Account)

原則:

  • 運動中:完全不依賴網路,所有資料先存 local
  • 運動後:有網路時自動上傳,無網路時暫存 queue
  • 離線期間:App 可正常運作(查看歷史、開始新運動)
  • Login 後:同步 local 與 server 資料

2. 手機資料優先,再與伺服器同步

資料計算與產生以手機端為主:

Workout Metrics Calculation (App 負責)
    ↓
Session Summary Generation (App 負責)
    ↓
Upload to Server (Server 只負責儲存與驗證)
    ↓
UserStats Update (Server 根據 sessions 計算)

理由:

  • 支援離線運動
  • 減少即時網路依賴
  • 設備端有完整 BLE 資料

3. 裝置綁定機制

初期版本採用持久化裝置綁定策略

  • 裝置綁定是持久的,登入/登出只是身份驗證狀態
  • 一個帳號只能綁定一個裝置
  • 使用 User.bound_device_id 綁定裝置
  • Token 過期不解除綁定,不清空本地資料
  • 只有手動登出才解除綁定,清空本地資料

使用者情境

情境 行為 本地資料 綁定狀態
首次登入 建立綁定 開始記錄 已綁定
Token 過期 (15-60 min) 自動 refresh 保留 維持綁定
Refresh token 過期 (30-90 天) 提示重新登入 保留 維持綁定
手動登出 解除綁定 清空 解除綁定
新裝置登入 (未解綁) 拒絕登入 N/A 原裝置仍綁定

理由:

  • 解決 Token 過期導致本地資料丟失的問題
  • 使用者幾乎永遠不需重新輸入密碼 (Refresh Token 機制)
  • 簡化開發複雜度,降低初期版本成本

4. GDPR 合規設計

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

核心原則:

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

整合方式:

  • SSO 登入流程:初期版本僅支援 SSO (Apple/Google/Facebook),首次 SSO 登入時 Server 自動建立帳號(gdpr_consent_given = FALSE)
  • Consent 檢查:整合到 Flow 1 (App 啟動) 和 Flow 2 (登入流程),首次或既有使用者登入後檢查並取得 consent
  • 帳號刪除:新增 Flow 9,提供完整的刪除流程
  • 資料模型:User, UserConsent, DeletionRequest(詳見 04.2 Data Models)
  • API 設計:/users/me/gdpr/consent, /users/me/gdpr/delete-account, /meta/gdpr/consent-text(詳見 04.1 API Specification)

使用者不同意 Consent 的處理:

根據 GDPR「自由同意」原則,使用者有權拒絕同意:

  • ✅ 不強制同意:提供「不同意」按鈕,符合 GDPR 要求
  • ✅ 拒絕提供服務:資料收集是服務必要條件,不同意則無法使用
  • ✅ 二次確認:顯示說明對話框,給使用者「再考慮」機會
  • ✅ 清空本地資料:使用者確定退出後,清空 Local 資料並返回 Welcome Screen
  • ✅ 保留 device_id:保留 device_id,方便使用者改變心意後重新註冊
  • ⚠️ 首次 SSO 登入特殊處理:若為首次 SSO 登入(Server 剛建立帳號),不同意時會刪除剛建立的帳號,避免產生未同意 consent 的孤兒帳號

資料來源定義

Local Database (App)

技術選型: AsyncStorage / Realm / WatermelonDB

儲存內容:

資料類型 欄位 說明
Auth access_token JWT Token
refresh_token Refresh Token
user_profile User 基本資料
device_id App 產生的裝置 UUID
GDPR user_profile.gdpr_consent_given 使用者是否已給予 consent
user_profile.gdpr_consent_version Consent 版本(例如 "1.0")
user_profile.gdpr_consent_date Consent 時間戳記
Sessions sessions[] 運動記錄(含上傳狀態)
upload_status pending / uploaded
Stats workout_streak 連續運動天數
workout_total_* 總計統計
Trainings trainings[] 課程列表(快取)
cache_timestamp 快取時間

Server Database (Backend)

參考: 04.2 Data Models

Master Data:

  • User - 使用者帳號
  • UserStats - 使用者統計(由 sessions 計算)
  • Session - 運動記錄
  • Training - 課程資料

原則:

  • Server 為帳號資料的 single source of truth
  • 換機時以 Server 資料為準

核心 Flow 定義

Flow 1: App 啟動與初始化流程

重要設計原則:

  • 使用者必須先 Login (步驟 3),才能進入資料同步流程
    • 如果沒有 token 或 token 驗證失敗 → 重新進入登入流程
    • Flow 2 是 atomic login procedure,包含登入 + GDPR consent 檢查 + 下載基礎資料 (sessions/stats)
  • Token 驗證通過後會進行 GDPR Consent 檢查 (步驟 3.5)
    • 用途: 已登入使用者重新啟動 App 時檢查 consent 狀態
    • 重要: 首次登入時,Flow 2 已執行 consent 檢查,返回 Flow 1 時直接跳到步驟 4,不會重複檢查
    • 情境覆蓋:
      • 首次登入 → Flow 2 步驟 4 檢查(登入流程中)
      • App 重啟(已登入)→ Flow 1 步驟 3.5 檢查(啟動流程中)
  • Consent 檢查通過後會立即進行資料同步 (步驟 4)(上傳 pending sessions、更新課程列表、同步課程影片)
  • 資料同步完成後才進行 BLE 連線 (步驟 5)
  • BLE 連線必須完成,才能進入主畫面 (步驟 6)
  • 未登入卡在登入畫面,資料同步時顯示 "Syncing data...",未連線 BLE 卡在設備列表畫面
  • 看到主畫面時,一定已經 Login + GDPR Consent + 資料同步 + BLE 連線完成
┌─────────────────────────────────────────────────────────────────┐
│ App Launch                                                      │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 初始化 Redux/Zustand Store                                    │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 初始化 Device ID (見註①)                                      │
│    - 檢查 AsyncStorage: 'app_device_id'                          │
│    - 若無 → 產生新 UUID 並儲存                                     │
│    - 若有 → 從 App 內部儲存元件載入                                 │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. Check Login State: 讀取 SecureStore                           │
│    - access_token                                               │
│    - refresh_token                                              │
│    - user_profile                                               │
└─────────────────────────────────────────────────────────────────┘
    ↓
    ├─ 無 Token ──→ 顯示 LoginScreen ──→ [Flow 2: Login]
    │              (不清空 Local 資料,保留 device_id)
    │
    └─ 有 Token ──→ 檢查 access_token 有效期
                      ↓
                      ├─ Access token 有效 ──→ [繼續至步驟 4]
                      │
                      └─ Access token 過期 ──→ 自動 Refresh (見 Flow 8)
                                                ↓
                                                API: POST /auth/refresh
                                                Headers: Bearer <refresh_token>
                                                Body: { device_id }
                                                ↓
                                                ├─ Success (200) ──→ 更新 Local tokens ──→ [繼續至步驟 4]
                                                │                     - access_token
                                                │                     - refresh_token (rolling)
                                                │
                                                └─ Failed (401) ──→ Refresh token 也過期
                                                                    ↓
                                                                    顯示 LoginScreen
                                                                    提示:「登入已過期,請重新登入」
                                                                    **不清空 Local 資料**
                                                                    ──→ [Flow 2: Login]
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3.5 GDPR Consent 檢查                                            │
│     檢查 user_profile.gdpr_consent_given                        │
│                                                                 │
│     **用途**: 已登入使用者重新啟動 App 時檢查                     │
│     **重要**: 首次登入時 Flow 2 已檢查,返回時跳過此步驟          │
└─────────────────────────────────────────────────────────────────┘
    ↓
    gdpr_consent_given?
    ├─ FALSE ──→ ┌──────────────────────────────────────────────┐
    │            │ 顯示阻擋式 Consent 對話框                      │
    │            │ (使用者必須同意才能繼續使用 App)               │
    │            └──────────────────────────────────────────────┘
    │            ↓
    │            ┌──────────────────────────────────────────────┐
    │            │ API: GET /meta/gdpr/consent-text             │
    │            │ 取得最新的 consent 文字與版本                  │
    │            │ Response:                                    │
    │            │ {                                            │
    │            │   "version": "1.0",                          │
    │            │   "text": "我們會收集您的運動資料...",         │
    │            │   "text_hash": "sha256..."                   │
    │            │ }                                            │
    │            └──────────────────────────────────────────────┘
    │            ↓
    │            ┌──────────────────────────────────────────────┐
    │            │ 顯示 Consent 對話框                           │
    │            │ - 顯示 consent 文字                           │
    │            │ - [不同意] [同意] 按鈕                         │
    │            │ - 無法關閉對話框(阻擋式)                      │
    │            └──────────────────────────────────────────────┘
    │            ↓
    │            使用者操作?
    │            ├─ 點擊「同意」──→ [繼續 consent 流程]
    │            │
    │            └─ 點擊「不同意」──→ ┌──────────────────────────────┐
    │                                 │ 顯示說明對話框:              │
    │                                 │ 「很抱歉,若不同意資料收集政策」│
    │                                 │ 「將無法使用本 App」          │
    │                                 │ [再考慮] [確定退出]          │
    │                                 └──────────────────────────────┘
    │                                 ↓
    │                                 ├─ [再考慮] ──→ 返回 Consent 對話框
    │                                 │
    │                                 └─ [確定退出] ──→ ┌──────────────────┐
    │                                                   │ 執行退出流程:    │
    │                                                   │ - 清空 Local 資料 │
    │                                                   │   (tokens, sessions, stats)│
    │                                                   │ - 保留 device_id  │
    │                                                   │ - **不刪除帳號**  │
    │                                                   │   (既有使用者)    │
    │                                                   │ ↓                │
    │                                                   │ Navigate to      │
    │                                                   │ Welcome Screen   │
    │                                                   └──────────────────┘
    │            ↓
    │            [使用者同意後繼續]
    │            ↓
    │            ┌──────────────────────────────────────────────┐
    │            │ API: POST /users/me/gdpr/consent             │
    │            │ Body:                                        │
    │            │ {                                            │
    │            │   "consent_version": "1.0",                  │
    │            │   "consent_text_hash": "sha256..."           │
    │            │ }                                            │
    │            │                                              │
    │            │ Server 端行為:                               │
    │            │ - 建立 UserConsent 稽核記錄                   │
    │            │ - 更新 User.gdpr_consent_given = TRUE        │
    │            │ - 更新 User.gdpr_consent_version             │
    │            │ - 更新 User.gdpr_consent_date                │
    │            └──────────────────────────────────────────────┘
    │            ↓
    │            ├─ Success (200) ──→ ┌──────────────────────────┐
    │            │                    │ 更新 Local user_profile: │
    │            │                    │ - gdpr_consent_given = T │
    │            │                    │ - gdpr_consent_version   │
    │            │                    │ - gdpr_consent_date      │
    │            │                    │ [繼續至步驟 4]            │
    │            │                    └──────────────────────────┘
    │            │
    │            └─ Failed (網路錯誤) ──→ ┌────────────────────────┐
    │                                    │ 顯示錯誤對話框          │
    │                                    │ 「無法連線,請重試」     │
    │                                    │ [重試] 按鈕             │
    │                                    └────────────────────────┘
    │
    └─ TRUE ──→ [直接繼續至步驟 4]
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. 資料同步                                                      │
│    顯示 "Syncing data..." 畫面                                   │
└─────────────────────────────────────────────────────────────────┘
    ↓
    ┌──────────────────────────────────────────────────────────┐
    │ 4.1 上傳 Pending Sessions                                 │
    │     - 查詢 Local DB: upload_status = 'pending'            │
    │     - 使用 Batch Upload Procedure (見獨立 section)         │
    │     - 顯示進度:「已同步 X/Y」                               │
    │     - 完成後顯示 Toast 訊息                                 │
    └──────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4.2 更新課程列表                                                  │
│     API: GET /trainings                                         │
│     Response 處理:                                               │
│     - 儲存 trainings[] 至 Local cache                            │
│     - 包含 video_url, video_size                                 │
│     - 記錄 cache_timestamp                                       │
│     - 如果失敗 → 保留舊快取,繼續流程                                │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4.3 同步課程影片                                                  │
│     判斷情境:                                                    │
│     - 如果 Local trainings 表為空 (剛登入完,來自 Flow 2)           │
│       → 下載所有課程影片                                           │
│     - 否則 (重新開 App)                                           │
│       → 比對 Local vs Server (trainings[])                       │
│       → 下載新增的課程影片                                         │
│       → 刪除已移除的課程影片                                       │
│                                                                 │
│     下載流程:                                                   │
│     - 從 video_url 下載影片檔案 (mp4)                             │
│     - 儲存至 Local 檔案系統 (FileSystem API)                      │
│     - 更新 Local training 記錄的 local_video_path                 │
│     - 顯示下載進度 (例: "Downloading videos... 2/5")              │
│                                                                 │
│     註:所有課程影片必須下載完成才能進入主畫面                         │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. BLE 裝置連線 (必須完成才能進入主畫面)                             │
│    5.1 檢查是否有儲存的裝置識別碼 (Complete Local Name)              │
└─────────────────────────────────────────────────────────────────┘
    ↓
    有儲存的裝置識別碼?
    ├─ 是 ──→ ┌──────────────────────────────────────────────┐
    │         │ 5.2 嘗試自動連線到該裝置                        │
    │         │     - 掃描廣播 SF_PRIMARY_SERVICE_UUID (0xFE01) │
    │         │     - 比對 Complete Local Name                │
    │         └──────────────────────────────────────────────┘
    │         ↓
    │         ├─ 連線成功 ──→ [跳至步驟 5.3]
    │         │
    │         └─ 連線失敗 (timeout 10秒) ──→ [繼續至手動連線]
    │
    └─ 否 ──→ [繼續至手動連線]
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 手動連線流程 (如果自動連線失敗或無儲存裝置)                        │
│    - 顯示設備列表畫面 (卡在此畫面,無法進入主畫面)                │
│    - 掃描廣播 SF_PRIMARY_SERVICE_UUID (0xFE01)                  │
│    - 顯示所有 SF-Bike-XXXX 或 SF-Rower-XXXX 裝置               │
│    - 使用者選擇設備                                              │
│    - 連線成功後儲存 Complete Local Name                          │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5.3 連線後設備初始化 (必須依序完成)                              │
│    - 讀取 Device Information Service (0x180A)                   │
│      • Manufacturer Name String (0x2A29): "SolidFocus"         │
│      • Model Number String (0x2A24): "SF-Bike-M1" 等           │
│    - 讀取 Fitness Machine Feature (0x2ACC)                      │
│      確認設備支援的功能 (阻力控制、心率、功率等)                  │
│    - 訂閱 Indoor Bike Data (0x2AD2) / Rower Data (0x2AD1)      │
│    - 訂閱 Fitness Machine Status (0x2ADA)                       │
│    - 取得控制權: Request Control (0x00)                          │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 6. Navigate to Main Tab Navigator                               │
│    此時 BLE 已連線,資料已同步完成 (包括課程影片)                 │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 7. Ready for Use                                                │
│    - BLE 已連線並初始化完成                                      │
│    - 課程列表已是最新                                            │
│    - 課程影片已全部下載完成                                       │
│    - 使用者可立即開始選擇訓練並運動                               │
└─────────────────────────────────────────────────────────────────┘

資料來源決策表:

步驟/資料 資料來源 操作 備註
Device ID Local Read & Write 首次啟動產生,之後重複使用
使用者資料 Local Read 快取 user_profile
統計數據 Local Read 快取 stats (由 Flow 2 下載)
Token 驗證 API (可選) POST /auth/refresh Token 過期時
無 token / 驗證失敗 Flow 2 調用 Flow 2 清除 Local 資料後調用
GDPR Consent 檢查 Local → Server GET /meta/gdpr/consent-text + POST /users/me/gdpr/consent 步驟 3.5:檢查 gdpr_consent_given,未同意時顯示阻擋式對話框
資料同步 Local → Server API 呼叫 每次 App 啟動時執行
Pending 上傳 Local → Server Batch Upload 使用 Batch Upload Procedure
課程列表更新 Server → Local GET /trainings 檢查新增/刪減課程
課程影片同步 Server → Local 檔案 下載/刪除影片 剛登入完:全部下載;重開 App:增量同步
BLE 裝置連線 BLE Device Scan & Connect 阻塞,必須完成才能進入主畫面
裝置識別碼 Local Read & Write 儲存 Complete Local Name
裝置型號 BLE (0x180A) Read Model Number String
裝置功能 BLE (0x2ACC) Read Fitness Machine Feature
主畫面 - Navigate 資料同步 + BLE 連線完成後才能進入

關鍵時序:

  1. Device ID 必須最早初始化 - 因為 Login 和 Refresh 時會用到
  2. Token 檢查與自動 Refresh - 使用 Refresh Token 自動更新 access_token
  3. Access token 過期 → 自動 refresh(不清空 Local 資料)
  4. Refresh token 也過期 → 提示重新登入(仍不清空 Local 資料)
  5. Flow 2 (Atomic Login) - 登入 + 下載基礎資料 (sessions/stats) + 建立裝置綁定
  6. Token 驗證通過後,立即進行資料同步 (步驟 4) - 顯示 "Syncing data..." 畫面
  7. 資料同步完成後,才進行 BLE 連線 - 確保課程資料是最新的
  8. 看到主畫面時,資料同步 + BLE 一定都已完成 - 確保可以立即開始運動
  9. BLE 連線完成後才讀取設備資訊與功能特徵

重要設計變更 (2025-12-24):

  • ✅ Token 過期不清空資料 - 使用 Refresh Token 機制,使用者幾乎永遠不需重新輸入密碼
  • ✅ 裝置綁定持久化 - 只有手動登出才解除綁定並清空資料
  • ✅ SecureStore 儲存 tokens - 使用 SecureStore 而非 AsyncStorage

Flow 2: 登入流程

重要設計原則:

  • Flow 2 是一個 atomic procedure(不可分割的原子操作)
  • 包含:登入 + 建立裝置綁定 + GDPR consent 檢查 + 下載基礎資料 (sessions/stats)
  • GDPR Consent 時機點(符合 GDPR 要求):
    • Consent 檢查在下載任何個人資料之前執行(步驟 4)
    • 必須取得使用者同意後,才下載 sessions 和 stats(步驟 5-6)
    • 與 Flow 1 步驟 3.5 的關係:
      • Flow 2 步驟 4:首次登入或重新登入時檢查
      • Flow 1 步驟 3.5:已登入使用者重新啟動 App 時檢查
      • 兩者服務不同情境,實際上不會重複執行
  • 重要變更: Local 在執行 Flow 2 前不一定是空的
  • 首次登入:Local 是空的
  • Refresh token 過期重新登入:Local 保留資料(sessions/stats),不清空
  • Flow 2 完成後返回 Flow 1 步驟 4(資料同步),跳過 Flow 1 步驟 3.5
┌─────────────────────────────────────────────────────────────────┐
│ User taps "Sign in with Apple"                                  │
└─────────────────────────────────────────────────────────────────┐
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. Apple SSO Authentication                                     │
│    - 取得 Apple ID Token                                        │
│    - 取得 device_id (從 Local 讀取,見註①)                      │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. API: POST /auth/sso/apple                                    │
│    Body:                                                        │
│      - id_token                                                 │
│      - device_id (App 產生的 UUID)                              │
│      - user_info (首次登入才有)                                  │
│                                                                 │
│    Server 端邏輯:                                               │
│    1. 檢查 Apple ID 是否已註冊                                   │
│       ├─ 不存在 → 建立新帳號(首次 SSO 登入):                    │
│       │            - 建立 User 記錄(gdpr_consent_given = FALSE)│
│       │            - 建立裝置綁定(bound_device_id = device_id)  │
│       │            - 產生 JWT tokens                            │
│       │                                                         │
│       └─ 已存在 → 裝置綁定檢查(非首次 SSO 登入):               │
│                   if user.bound_device_id == request.device_id: │
│                     允許登入 (同一裝置重新登入)                   │
│                   elif user.bound_device_id is None:            │
│                     建立綁定 (首次登入此裝置)                     │
│                   else:                                         │
│                     拒絕登入 (已綁定其他裝置)                     │
└─────────────────────────────────────────────────────────────────┘
    ↓
    ├─ Success (200) ──→ Response:
    │                   {
    │                     "access_token": "eyJ...",
    │                     "refresh_token": "eyJ...",
    │                     "expires_in": 3600,
    │                     "user_profile": {
    │                       "id": 123,
    │                       "email": "user@example.com",
    │                       "gdpr_consent_given": false,  // 首次為 false
    │                       ...
    │                     }
    │                   }
    │                   [繼續至步驟 3]
    │
    ├─ 403 device_already_bound ──→ 顯示錯誤對話框
    │                                「此帳號已在其他裝置登入」
    │                                「請先在原裝置登出,或聯繫客服」
    │                                [確定] ──→ 返回登入畫面
    │
    └─ 401 invalid_token ──→ 顯示錯誤:「Apple 登入失敗,請重試」
                              [確定] ──→ 返回登入畫面

    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. 儲存 Token 至 SecureStore                                     │
│    - access_token (15-60 分鐘有效)                               │
│    - refresh_token (30-90 天有效)                                │
│    - user_profile                                               │
│                                                                 │
│    使用 SecureStore (非 AsyncStorage),提高安全性                  │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. GDPR Consent 檢查                                              │
│    檢查 user_profile.gdpr_consent_given (從步驟 2 的 response)    │
│    檢查 is_new_user (從步驟 2 的 response)                        │
│                                                                  │
│    **用途**: 首次登入或重新登入時檢查                              │
│    **重要**: 必須在下載資料前取得同意(符合 GDPR 要求)             │
│                                                                  │
│    **情境說明**:                                                 │
│    - 首次 SSO 登入(is_new_user = true):為 FALSE,需要取得 consent│
│    - 既有使用者(已同意 consent):為 TRUE,直接跳過               │
│    - 既有使用者(升級後未同意):為 FALSE,需要補 consent           │
└─────────────────────────────────────────────────────────────────┘
    ↓
    gdpr_consent_given?
    ├─ FALSE ──→ ┌──────────────────────────────────────────────┐
    │            │ **取得 GDPR Consent**                         │
    │            │ (首次 SSO 登入 or 既有使用者補 Consent)        │
    │            │ 執行與 Flow 1 步驟 3.5 相同的 Consent 流程   │
    │            │                                              │
    │            │ 1. API: GET /meta/gdpr/consent-text          │
    │            │    取得最新的 consent 文字與版本              │
    │            │                                              │
    │            │ 2. 顯示阻擋式 Consent 對話框                  │
    │            │    - 顯示 consent 文字                       │
    │            │    - [不同意] [同意] 按鈕                     │
    │            │                                              │
    │            │ 3. 使用者操作?                               │
    │            │    ├─ 點擊「同意」→ 繼續至步驟 5             │
    │            │    └─ 點擊「不同意」→ 顯示說明對話框:        │
    │            │       「很抱歉,若不同意資料收集政策」          │
    │            │       「將無法使用本 App」                    │
    │            │       [再考慮] [確定退出]                     │
    │            │       ├─ [再考慮] → 返回 Consent 對話框      │
    │            │       └─ [確定退出] → 執行帳號刪除流程:      │
    │            │          • 檢查 is_new_user 欄位             │
    │            │          • 若 is_new_user = true:           │
    │            │            呼叫 POST /users/me/gdpr/delete-account│
    │            │            刪除剛建立的帳號                  │
    │            │          • 若 is_new_user = false:          │
    │            │            保留帳號,僅清空 Local 資料        │
    │            │          • 清空 Local 資料 (tokens, sessions, stats)│
    │            │          • 保留 device_id                    │
    │            │          • Navigate to Welcome Screen       │
    │            │                                              │
    │            │ 4. API: POST /users/me/gdpr/consent          │
    │            │    Body: {                                   │
    │            │      consent_version: "1.0",                 │
    │            │      consent_text_hash: "sha256..."          │
    │            │    }                                         │
    │            │                                              │
    │            │ 5. 更新 Local user_profile:                  │
    │            │    - gdpr_consent_given = TRUE               │
    │            │    - gdpr_consent_version                    │
    │            │    - gdpr_consent_date                       │
    │            └──────────────────────────────────────────────┘
    │            ↓
    │            [繼續至步驟 5]
    │
    └─ TRUE ──→ [直接繼續至步驟 5]
                 (**既有使用者且已同意 consent 走這條路徑**)
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. 下載 Server Sessions                                          │
│    API: GET /sessions                                           │
│    - 下載使用者所有運動記錄                                        │
│    - **已取得 GDPR consent,可以下載個人資料**                     │
│    - **合併策略** (處理重新登入時 Local 已有資料的情境):              |
│      • 檢查 Local 是否已有 sessions                               │
│      • 如果有:比對 client_session_id,只下載新的 sessions          │
│      • 如果無:全部下載                                            │
│    - 所有下載的 sessions 標記為 upload_status = 'uploaded'         │
│    - 如果失敗 → 見 Known Issues 章節                              │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 6. 下載 Server Stats                                             │
│    API: GET /users/me (或 GET /users/me/stats)                  │
│    - 下載使用者統計資料                                            │
│    - **已取得 GDPR consent,可以下載個人資料**                     │
│    - **合併策略**:                                               │
│      • 如果 Local stats 為空:直接儲存                             │
│      • 如果 Local 已有 stats:以 Server 為準覆蓋                   │
│    - 如果失敗 → 見 Known Issues 章節                              │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 7. Login Procedure 完成                                          │
│    - 返回 Flow 1 步驟 4(資料同步),跳過 Flow 1 步驟 3.5          │
│    - Flow 1 將繼續執行:上傳 pending + 課程同步                     │
└─────────────────────────────────────────────────────────────────┘

資料來源決策表:

步驟 資料來源 操作 備註
步驟 2: SSO 登入 API Server POST /auth/sso/apple 首次 SSO 登入時 Server 建立帳號(gdpr_consent_given = FALSE)
步驟 3: 儲存 Token Local Write 儲存 access_token, refresh_token, user_profile
步驟 4: GDPR Consent 檢查 Local → Server GET /meta/gdpr/consent-text + POST /users/me/gdpr/consent 首次 SSO 登入必定為 FALSE,必須在下載資料前取得 consent(符合 GDPR)
步驟 5: 下載 Sessions Server → Local Read & Write 已取得 consent 後下載所有運動記錄
步驟 6: 下載 Stats Server → Local Read & Write 已取得 consent 後下載統計資料
步驟 7: 返回 Flow 1 - Navigate 返回 Flow 1 步驟 4(資料同步),跳過 Flow 1 步驟 3.5

Known Issues:

  • ⚠️ 步驟 5-6 下載失敗時的處理策略(見 Known Issues 章節)

Flow 3: 開始運動流程

前提條件:

  • ✅ 使用者已通過 Flow 1,進入主畫面
  • ✅ BLE 已連線(Flow 1 步驟 5 保證)
  • ✅ 課程影片已下載完成(Flow 1 步驟 4.3 保證)
┌─────────────────────────────────────────────────────────────────┐
│ User taps "Start Workout" or selects Training                   │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 檢查 BLE 裝置連線狀態 (防禦性檢查)                             │
└─────────────────────────────────────────────────────────────────┘
    ↓
    ├─ 已連線 ──→ [繼續]
    │
    └─ 未連線 (異常情況) ──→ ┌────────────────────────────────┐
                             │ 顯示錯誤: "BLE 連線已中斷"      │
                             │ 提示: "請重新連線設備"          │
                             │ [返回設備列表] [重試]           │
                             └────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 讀取課程資料                                                   │
│    資料來源: Local (快取)                                        │
│    - Training metadata (stages, settings, etc.)                │
│    - Video file path (所有影片已在 App 啟動時下載完成)          │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. 初始化 WorkoutEngine                                          │
│    - 產生 session_id (UUID)                                     │
│    - 記錄 started_at (timestamp)                                │
│    - 訂閱 BLE Characteristics                                   │
│    - 初始化 VideoPlayer (如果有影片)                             │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. 運動進行中 (完全 Local,不使用網路)                            │
│    - 接收 BLE metrics 事件                                      │
│    - 計算即時統計 (平均功率、總距離等)                            │
│    - 每 N 秒儲存至 Local DB (防止 crash 遺失)                    │
│    - 播放本地影片 (從 local_video_path)                          │
│    - 調整阻力 (根據課程設定)                                      │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. User taps "Stop" or 完成課程                                  │
│    - 記錄 ended_at (timestamp)                                  │
│    - 產生 Session Summary                                       │
└─────────────────────────────────────────────────────────────────┘
    ↓
    進入 [Flow 4: 運動結束與上傳]

資料來源決策表:

資料/操作 資料來源 API 呼叫 備註
課程 metadata Local (快取) 無 從列表快取讀取
影片檔案 Local 檔案系統 無 所有影片已預先下載
BLE Metrics BLE Device 無 即時接收
Session 資料 Local 無 運動中不上傳

設計決策:

  • ✅ 所有影片預先下載 - App 啟動時同步下載(Flow 1 步驟 4.3)
  • ✅ 運動前無需等待 - 進入主畫面時影片已全部就緒,可立即開始運動
  • ✅ 簡化使用者體驗 - 不需要單獨管理影片下載

Flow 4: 運動結束與上傳流程

┌─────────────────────────────────────────────────────────────────┐
│ 結束健身階段並選擇儲存 (from Flow 3)                                │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 生成 Session Summary                                          │
│    - session_id (UUID)                                          │
│    - started_at, ended_at, duration                             │
│    - total_energy, total_distance                               │
│    - detail (JSONB: avg_hr, avg_power, etc.)                    │
│    - raw (Base64 BLE data, optional)                            │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 儲存至 Local DB                                               │
│    - upload_status = 'pending'                                  │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. 顯示 Workout Summary Screen (給使用者看結果)                    │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. 背景上傳 (非阻塞,使用者可繼續操作)                               │
└─────────────────────────────────────────────────────────────────┘
    ↓
    檢查網路狀態
    ├─ 有網路 ──→ ┌────────────────────────────────────────────────────┐
    │             │ 4.1 上傳當前 session + 所有 pending sessions        │
    │             │     策略:使用 batch upload,一次最多 20 筆        │
    │             │                                                    │
    │             │     - 收集當前 session + 所有 pending sessions      │
    │             │     - 分組:每組最多 20 筆                         │
    │             │     - API: POST /sessions/batch_upload             │
    │             │       Body: { sessions: [session1, ...] }         │
    │             └────────────────────────────────────────────────────┘
    │             ↓
    │             ├─ 200 Success ──→ ┌────────────────────────────────────┐
    │             │                  │ 根據 response 標記每個 session:    │
    │             │                  │ - 成功的標記為 'uploaded'          │
    │             │                  │ - Duplicate 的標記為 'uploaded'    │
    │             │                  │ - 失敗的保留為 'pending'           │
    │             │                  │                                    │
    │             │                  │ Stats 同步 (APP wins):             │
    │             │                  │ - response.updated_stats 包含      │
    │             │                  │   Server 計算的最新 stats          │
    │             │                  │ - 比對 Local stats                 │
    │             │                  │ - 如果不一致 → PATCH 覆蓋 Server   │
    │             │                  │ - 如果一致 → 更新 Local            │
    │             │                  │ - 詳細流程見下方                   │
    │             │                  │   「Batch Upload Procedure」       │
    │             │                  │                                    │
    │             │                  │ - Toast: "運動記錄已同步"          │
    │             │                  └────────────────────────────────────┘
    │             │
    │             └─ 其他錯誤 ──→ ┌──────────────────────────────────┐
    │                             │ - 整批保留 pending               │
    │                             │ - upload_retry_count++           │
    │                             │ - Toast: "將在下次開啟 App 時同步" │
    │                             └──────────────────────────────────┘
    │
    └─ 無網路 ──→ ┌────────────────────────────────────────────┐
                  │ - 保留 pending                             │
                  │ - Toast: "將在下次開啟 App 時同步"          │
                  └────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. User navigates away(使用者導航離開,背景處理已完成)           │
└─────────────────────────────────────────────────────────────────┘

資料來源決策表:

資料/操作 資料來源 API 呼叫 備註
Session Summary Local 無 WorkoutEngine 計算
儲存 Session Local 無 立即寫入
上傳 Session Local → Server POST /sessions/batch_upload 非阻塞背景上傳,response 包含 updated_stats
更新 Stats Local ↔ Server PATCH /users/me/stats (如需要) APP wins: Local 為準,不一致時執行 PATCH 覆蓋 Server

Batch Upload Procedure 核心邏輯

Batch Upload Procedure 是 session 上傳的核心程序,在以下兩個時機點被執行:

  1. Session 結束後:上傳當前 session + 所有 pending sessions(一起批次上傳)
  2. Login 後的 Sync:執行此程序上傳所有 pending sessions

流程圖

Batch Upload Procedure
   ↓
1. 查詢所有 upload_status = 'pending' 的 sessions(按 started_at 排序)
   ↓
2. 如果沒有 pending sessions → 直接返回
   ↓
3. 將 sessions 分組(每組最多 20 筆)
   ↓
4. For each batch:
   ├─ 呼叫 POST /sessions/batch_upload
   │  Body: { sessions: [session1, session2, ...] }  // 最多 20 筆
   │  ↓
   │  ├─ 200 Success → 處理 response
   │  │   Response 格式:
   │  │   {
   │  │     "success_count": 18,
   │  │     "duplicate_count": 2,
   │  │     "failed_sessions": [
   │  │       { "session_id": "uuid1", "error": "validation_error" }
   │  │     ]
   │  │   }
   │  │   ↓
   │  │   根據 response 標記每個 session:
   │  │   - 成功的 → 標記為 'uploaded'
   │  │   - Duplicate 的 → 標記為 'uploaded'
   │  │   - failed_sessions 中的 → 保留為 'pending', retry_count++
   │  │
   │  └─ API 呼叫失敗(網路錯誤、5xx 等)→ 整批保留為 'pending'
   └─ 繼續下一批(不中斷)
   ↓
5. ⭐ 全部批次完成後:Stats 同步驗證 (僅在最後一批)
   ├─ 收到 response.updated_stats (Server 計算的最新 stats)
   ├─ 計算 Local stats (從 Local sessions 計算)
   ├─ 比對 Local vs Server
   │  ├─ 一致 → 更新 Local = Server 回傳值
   │  └─ 不一致 → 執行 PATCH /users/me/stats (APP wins,強制覆蓋)
   └─ 顯示 Toast (optional)
   ↓
6. 完成後返回統計結果

設計重點

  1. 批次上傳:使用 POST /sessions/batch_upload,一次最多 20 筆,減少 API 呼叫次數
  2. 不中斷執行:即使某一批失敗,仍繼續上傳下一批
  3. 從最舊開始:按 started_at 排序,確保時間順序正確
  4. 細粒度狀態標記:API response 會指出每個 session 的結果(成功/重複/失敗)
  5. Duplicate 視為成功:如果 server 已經有該 session,標記為 uploaded
  6. 記錄 retry count:追蹤失敗次數,方便 debug
  7. 進度回調:支援 UI 顯示進度(Login sync 時使用)
  8. 返回結果統計:返回成功與失敗的 session 數量
  9. Stats 同步驗證:只在最後一批上傳完成後執行,確保 Local 與 Server stats 一致

API Response 處理邏輯

// 範例:上傳 20 筆 sessions
POST /sessions/batch_upload
Body: { sessions: [session1, session2, ..., session20] }

Response 200:
{
  "success_count": 17,      // 17 筆成功
  "duplicate_count": 2,     // 2 筆是 duplicate
  "failed_sessions": [      // 1 筆失敗
    {
      "session_id": "uuid-xxx",
      "error": "validation_error",
      "message": "Invalid duration"
    }
  ],
  "updated_stats": {        // Server 計算後的最新 stats
    "workout_total_count": 150,
    "workout_total_energy": 25000,
    "workout_total_distance": 450000,
    "workout_total_duration": 120000,
    "workout_streak": 7,
    "last_workout_date": "2025-12-24"
  }
}

App 處理邏輯:
- 17 筆成功的 → 標記為 'uploaded'
- 2 筆 duplicate 的 → 標記為 'uploaded'
- 1 筆失敗的 → 保留為 'pending', retry_count++

Stats 同步邏輯 (APP wins 策略):
- 收到 response.updated_stats 後,比對 Local stats
- 如果不一致 → 以 Local 為準,執行 PATCH /users/me/stats
- 如果一致 → 更新 Local = Server 回傳值
- 詳細邏輯見下方「Stats 同步策略」section

Stats 同步策略

當 batch upload 成功後,Server 會計算並回傳 updated_stats。App 需要驗證這個值是否與 Local 計算的一致。

同步流程:

  1. 標記 sessions 狀態:
  2. 標記成功的 sessions 為 'uploaded'
  3. 標記 duplicate sessions 為 'uploaded'
  4. 標記失敗的 sessions 保持 'pending'

  5. Stats 同步驗證(僅在最後一批執行):

  6. 收到 response.updated_stats(Server 計算的最新 stats)
  7. 計算 Local stats(從 Local sessions 計算)
  8. 比對 Local vs Server stats
  9. 如果任何欄位不一致:
    • APP wins - 以 Local 為準,執行 PATCH /users/me/stats 強制覆蓋 Server
    • 顯示 Toast「統計資料已同步」(optional)
  10. 如果一致:
    • 直接更新 Local = Server 回傳值

設計原則:

  • ✅ APP wins: Local 計算的 stats 永遠是 source of truth
  • ✅ 自動修復: 如果 Server stats 不一致,自動執行 PATCH 修復
  • ✅ 高效: 只在上傳 session 時才同步 stats(不需要每次啟動都 GET stats)
  • ✅ 時機正確: Session 上傳成功 = Stats 需要更新的時機

多批次上傳優化:

如果使用者一次上傳多批 sessions(例如 50 筆分成 3 批),優化為只在最後一批驗證 stats:

流程:

  1. 前面批次 (batch 1, 2):
  2. 標記 sessions 狀態
  3. 直接更新 Local stats = response.updated_stats
  4. 不執行 stats 比對

  5. 最後一批 (batch 3):

  6. 標記 sessions 狀態
  7. 更新 Local stats = response.updated_stats
  8. 執行 stats 比對與同步
  9. 如果不一致 → PATCH /users/me/stats

理由:節省 API calls,只在所有上傳完成後才驗證一次


Flow 5: 查看歷史記錄流程

重要設計原則:

  • 只讀取顯示,不上傳 - 歷史記錄頁面純粹是查看功能
  • 上傳只在兩個時間點 - App Launch (Flow 1 步驟 4.1) 和 Session End (Flow 4)
  • Pending sessions 顯示狀態 - 讓使用者知道哪些尚未同步
┌─────────────────────────────────────────────────────────────────┐
│ User navigates to History Tab                                   │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 讀取 Local DB                                                 │
│    資料來源: Local                                                │
│    - 查詢所有 sessions (包含 pending)                             │
│    - 排序: started_at DESC (最新的在前)                           │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 顯示歷史列表                                                   │
└─────────────────────────────────────────────────────────────────┘
    ↓
    User taps on a session ──→ 進入 Session Detail
                                ↓
                                顯示詳細資料 (從 Local 讀取)
                                - 所有資料都可正常顯示

資料來源決策表:

畫面/資料 資料來源 API 呼叫 備註
歷史列表 Local 無
Session Detail Local 無 從 Local detail JSONB 讀取

設計考量:

  • ✅ 快速回應: 列表立即從 Local 讀取,無需等待 API
  • ✅ 離線可用: 即使無網路也能查看歷史(包含 pending sessions)

Flow 6: 登出流程

重要設計原則:

  • 手動登出 = 解除裝置綁定 + 清空所有本地資料
  • 與 Token 過期的差異:Token 過期不清空資料,登出才清空
  • 這是唯一清空本地資料的時機點
┌─────────────────────────────────────────────────────────────────┐
│ User taps "Logout" in Settings                                  │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 檢查是否有 Pending Sessions                                   │
└─────────────────────────────────────────────────────────────────┘
    ↓
    有 pending sessions?
    ├─ 是 ──→ ┌──────────────────────────────────────────────┐
    │         │ 1.1 顯示確認對話框                            │
    │         │     "還有 N 筆運動記錄尚未上傳,確定登出?"    │
    │         │     [取消] [強制登出] [上傳後登出]            │
    │         └──────────────────────────────────────────────┘
    │         ↓
    │         ├─ [取消] ──→ 返回
    │         │
    │         ├─ [強制登出] ──→ [繼續步驟 2,不上傳]
    │         │                  (警告: 未上傳資料將遺失)
    │         │
    │         └─ [上傳後登出] ──→ ┌────────────────────────┐
    │                             │ 1.2 上傳所有 pending   │
    │                             │     (顯示 loading)     │
    │                             │     ↓                 │
    │                             │     完成後 → [繼續]    │
    │                             └────────────────────────┘
    │
    └─ 否 ──→ [繼續步驟 2]

    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. API: POST /auth/logout                                       │
│    Headers: Authorization: Bearer <access_token>                │
│    Body:                                                        │
│      - device_id                                                │
│      - refresh_token                                            │
│                                                                 │
│    Server 端行為:                                               │
│    - 解除 user.bound_device_id (設為 NULL)                      │
│    - 將 access_token 和 refresh_token 加入黑名單                │
│    - 記錄登出時間                                                │
└─────────────────────────────────────────────────────────────────┘
    ↓
    ├─ Success (200) ──→ [繼續至步驟 3]
    │
    └─ Failed (網路錯誤) ──→ [仍然繼續清除 Local]
                             註:Server 端綁定未解除,但 tokens 已刪除
                             下次登入時 Server 會檢測到同 device_id,允許登入

    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. 清除 Local Data                                               │
│    這是唯一清空本地資料的時機點                                      │
│                                                                 │
│    清除內容:                                                    │
│    - 刪除 tokens (access_token, refresh_token) from SecureStore │
│    - 刪除 user_profile                                          │
│    - 刪除所有 sessions (包含 pending)                            │
│    - 刪除 stats                                                 │
│    - 刪除所有課程影片檔案 (釋放儲存空間)                             │
│    - 清空課程列表快取                                             │
│    - **保留 device_id**(下次登入會重新綁定)                       │
│                                                                 │
│    理由:                                                        │
│    - ✅ 安全性更好,避免帳號資料殘留                                 │
│    - ✅ 符合「持久化裝置綁定」設計                                   │
│    - ✅ 下次登入時從 Server 重新下載                                │
│    - ✅ device_id 保留,方便重新綁定                               │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. 斷開 BLE 連線                                                 │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. Navigate to LoginScreen                                      │
└─────────────────────────────────────────────────────────────────┘

資料來源決策表:

操作 資料來源 API 呼叫 備註
檢查 pending Local 無 查詢 Local DB
上傳 pending Local → Server POST /sessions 使用者選擇
Logout API Server POST /auth/logout 清除綁定
清除 Local Local 無 刪除 tokens

Flow 7: 課程列表顯示流程

前提條件:

  • ✅ 課程同步已在 App 啟動時完成(見 Flow 1 步驟 4.2-4.3)
  • ✅ 所有影片已下載完成(進入主畫面前已完成)

設計原則:

  • ✅ Navigate 到列表時只讀 Local (不呼叫 API)
  • ✅ 立即顯示,無需等待

流程圖:

┌─────────────────────────────────────────────────────────────────┐
│ User navigates to Training List Screen                          │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 讀取 Local Cache                                              │
│    資料來源: Local DB (不呼叫 API)                                 │
│    - 查詢 trainings[] (包含 video_url, local_video_path 等)       │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 立即顯示列表                                                   │
│    - 課程封面、名稱、描述、時長、難度                                │
│    - 所有影片已下載完成,可立即開始                                  │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. User 可選操作                                                 │
│    - [點擊課程]: 直接開始運動 (影片已完整下載)                        │
└─────────────────────────────────────────────────────────────────┘

資料來源決策表:

畫面/資料 資料來源 API 呼叫時機 備註
課程列表顯示 Local (快取) 無 課程同步在 App 啟動時完成(Flow 1 步驟 4.2-4.3)
課程 metadata Local 無 從 trainings[] 快取讀取
課程影片播放 Local 檔案系統 無 影片已在 App 啟動時下載完成

UI 設計考量:

  • 列表項目顯示課程基本資訊
    • 課程封面、名稱、時長、難度
    • 所有影片已在 App 啟動時下載完成

Flow 8: Token Refresh 流程

核心設計:

  • 自動透明: 使用者無感知,API Client 自動處理
  • 不清空資料: Refresh 成功或失敗都不清空 Local 資料
  • Rolling Refresh Token: Server 可選擇性返回新的 refresh_token
┌─────────────────────────────────────────────────────────────────┐
│ Trigger: API Request 回傳 401 Unauthorized                       │
│ (任何 API 呼叫都可能觸發)                                         │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. APIClient Interceptor 攔截 401 Response                       │
│    (使用 Axios/Fetch interceptor)                               │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. 檢查是否為 Access Token 過期                                  │
│    - 檢查 error.code === 'token_expired' 或                     │
│    - 檢查 access_token 的 exp claim (JWT decode)                │
└─────────────────────────────────────────────────────────────────┘
    ↓
    是 Access Token 過期?
    ├─ 是 ──→ ┌──────────────────────────────────────────────┐
    │         │ 3. 嘗試 Refresh Token                         │
    │         │    API: POST /auth/refresh                   │
    │         │    Headers: Bearer <refresh_token>           │
    │         │    Body: { device_id }                       │
    │         │                                              │
    │         │    Server 端驗證:                            │
    │         │    - refresh_token 是否有效                  │
    │         │    - refresh_token 是否在黑名單              │
    │         │    - device_id 是否與綁定一致                │
    │         └──────────────────────────────────────────────┘
    │         ↓
    │         ├─ Success (200) ──→ ┌────────────────────────────────┐
    │         │                    │ Response:                      │
    │         │                    │ {                              │
    │         │                    │   "access_token": "eyJ...",    │
    │         │                    │   "refresh_token": "eyJ...",   │
    │         │                    │   "expires_in": 3600           │
    │         │                    │ }                              │
    │         │                    │                                │
    │         │                    │ - 更新 SecureStore tokens      │
    │         │                    │ - 重試原 API request           │
    │         │                    │ - **不清空 Local 資料**        │
    │         │                    └────────────────────────────────┘
    │         │
    │         └─ Failed (401) ──→ ┌────────────────────────────────┐
    │                             │ Refresh token 也過期 (30-90天) │
    │                             │                                │
    │                             │ - 刪除 SecureStore tokens      │
    │                             │ - **不清空 Local 資料**        │
    │                             │ - Navigate to LoginScreen      │
    │                             │ - 提示:「登入已過期,請重新登入」│
    │                             │                                │
    │                             │ 使用者重新登入後:              │
    │                             │ - Flow 2 會下載 Server 資料    │
    │                             │ - 與 Local 資料合併            │
    │                             └────────────────────────────────┘
    │
    └─ 否 ──→ 其他 401 錯誤 (例如權限不足),直接回傳給 caller

處理機制:

  • ✅ 自動處理: API Client 使用 Interceptor 自動攔截 401
  • ✅ 透明重試: Refresh 成功後自動重試原 API,使用者無感知
  • ✅ 不清空資料: 無論 Refresh 成功或失敗,都不清空 Local 資料
  • ✅ Rolling Refresh Token: Server 可選擇性返回新的 refresh_token,提高安全性
  • ✅ 併發控制: 多個 API 同時觸發 401 時,只執行一次 refresh

Token 有效期建議:

  • Access token: 15-60 分鐘(建議 30 分鐘)
  • Refresh token: 30-90 天(建議 60 天)

Flow 9: 帳號刪除流程(GDPR 合規)

前提條件:

  • ✅ 使用者已登入
  • ✅ 使用者進入設定頁面,點擊「刪除帳號」按鈕

重要設計原則:

  • 硬刪除:刪除所有雲端資料(WorkoutSession、UserStats、SocialAccount、UserConsent、User)
  • 稽核日誌:保留 DeletionRequest 記錄(使用 hash 的 user_id,無法逆向查詢)
  • 立即登出:刪除完成後,token 失效,使用者立即登出
  • 不可復原:刪除後無法復原,需要使用者確認
┌─────────────────────────────────────────────────────────────────┐
│ User taps "刪除帳號" in Settings                                   │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. 顯示警告對話框                                                  │
│    內容:                                                         │
│    - 「此操作將永久刪除您的所有資料,無法復原」                        │
│    - 「包含:運動記錄、統計資料、個人資料」                           │
│    - 輸入框:請輸入 "DELETE" 確認                                   │
│    - [取消] [確認刪除]                                              │
└─────────────────────────────────────────────────────────────────┘
    ↓
    使用者輸入 "DELETE" 並點擊 [確認刪除]?
    ├─ 否 ──→ [返回設定頁面]
    │
    └─ 是 ──→ [繼續至步驟 2]
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. API: POST /users/me/gdpr/delete-account                      │
│    Headers: Authorization: Bearer <access_token>                │
│    Body: { confirm: "DELETE" }                                  │
│                                                                 │
│    Server 端邏輯(同步執行):                                     │
│    1. 建立 DeletionRequest 記錄                                  │
│       - user_id_hash = SHA256(user_id)                         │
│       - requested_at = NOW()                                   │
│       - status = 'pending' (初始狀態)                            │
│                                                                 │
│    2. 開始 Atomic Transaction:                                  │
│       - 刪除所有 WorkoutSession 記錄                             │
│       - 刪除 UserStats 記錄                                      │
│       - 刪除所有 SocialAccount 記錄                              │
│       - 刪除所有 UserConsent 記錄                                │
│       - 刪除 User 記錄(包含所有 GDPR 欄位)                        │
│       - 更新 DeletionRequest (status = 'completed', completed_at = NOW()) │
│       - 將 access_token 和 refresh_token 加入黑名單              │
│                                                                 │
│    3. 提交 Transaction                                          │
│                                                                 │
│    4. 回傳 200 OK                                               │
│                                                                 │
│    錯誤處理:                                                     │
│    - Transaction 失敗 → 回滾所有變更                             │
│    - 更新 DeletionRequest (status = 'failed', notes = 錯誤訊息)  │
│    - 回傳 500 錯誤                                               │
└─────────────────────────────────────────────────────────────────┘
    ↓
    ├─ Success (200) ──→ Response:
    │                   {
    │                     "message": "帳號刪除成功",
    │                     "deleted_at": "2025-12-24T10:30:00Z"
    │                   }
    │                   [繼續至步驟 3]
    │
    ├─ 400 invalid_confirmation ──→ 顯示錯誤:「確認碼不正確」
    │                                [返回步驟 1]
    │
    └─ 其他錯誤 (網路錯誤、5xx) ──→ 顯示錯誤:「刪除失敗,請稍後重試」
                                     [返回設定頁面]
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 3. 清除 Local Data(與 Logout 流程相同)                            │
│    - 刪除 tokens (access_token, refresh_token) from SecureStore │
│    - 刪除 user_profile                                          │
│    - 刪除所有 sessions                                           │
│    - 刪除 stats                                                 │
│    - 刪除所有課程影片檔案                                          │
│    - 清空課程列表快取                                             │
│    - **保留 device_id**(未來可能重新註冊)                         │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 4. 斷開 BLE 連線                                                 │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. 顯示確認畫面                                                   │
│    「您的帳號已成功刪除」                                          │
│    [確定]                                                       │
└─────────────────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────────────────┐
│ 6. Navigate to Welcome Screen (或 LoginScreen)                  │
└─────────────────────────────────────────────────────────────────┘

資料來源決策表:

操作 資料來源 API 呼叫 備註
刪除帳號 API Server POST /users/me/gdpr/delete-account 硬刪除所有雲端資料
清除 Local Local 無 與 Logout 流程相同

設計要點:

  • ✅ 雙重確認:輸入 "DELETE" 確認,防止誤操作
  • ✅ 硬刪除:刪除所有雲端資料,不是軟刪除
  • ✅ 稽核日誌:保留 DeletionRequest 記錄(使用 hash 的 user_id)
  • ✅ 立即登出:token 加入黑名單,使用者立即登出
  • ✅ 清空 Local:與 Logout 流程相同,釋放儲存空間
  • ✅ 符合 GDPR 本質:使用者想刪除帳號 = 不想保留任何資料

與 Flow 6 (登出流程) 的差異:

項目 Flow 6: 登出 Flow 9: 帳號刪除
雲端資料 保留 硬刪除(包含所有 sessions, stats, user)
稽核日誌 記錄登出時間 建立 DeletionRequest 稽核記錄
確認機制 簡單確認 雙重確認(輸入 "DELETE")
可復原性 ✅ 可重新登入 ❌ 不可復原
Local 資料 清空 清空(相同)
BLE 連線 斷開 斷開(相同)

錯誤處理與上傳策略

上傳時機點

採用「兩個時間點上傳」策略,簡化背景 retry 機制:

  1. Session 結束後:
  2. 立即嘗試上傳該 session
  3. 成功後執行 Batch Upload Procedure 上傳其他 pending sessions

  4. Login 後的 Sync:

  5. 執行 Batch Upload Procedure 上傳所有 pending sessions
  6. 顯示進度與 pending count
  7. 10 秒後可以 skip(不阻塞使用者)

錯誤處理策略

錯誤情境 處理策略 使用者體驗
POST /sessions 失敗 保留為 'pending',記錄 retry count Toast: "將在下次開啟 App 時同步"(不驚擾)
POST /sessions 回傳 400 duplicate 標記為 'uploaded'(server 已有) 無提示(靜默處理)
無網路連線 保留為 'pending' Toast: "將在下次開啟 App 時同步"
Login sync 超過 10 秒 顯示 Skip 按鈕,允許使用者跳過 使用者可選擇繼續等待或進入 App
GET /sessions 失敗 使用 Local 資料 繼續使用快取
GET /trainings 失敗 使用 Local 快取 Toast: "無法更新課程列表"
Refresh Token 失敗 清除 Local,導回登入 顯示「請重新登入」

Duplicate Session 處理

情境:POST /sessions 回傳 400 duplicate_session

處理邏輯:

  • Server 已經有這筆 session(之前上傳成功了)
  • 直接標記 Local session 為 'uploaded'
  • 不更新 Local stats(避免重複計算)
  • 不顯示任何提示(靜默處理)

設計範圍決策

以下為初期版本的設計範圍與限制:

單一裝置登入 (Device Binding)

  • ✅ 一個帳號只能在一個裝置上登入
  • ✅ 使用 User.bound_device_id 綁定裝置
  • ✅ 如果在新裝置登入,舊裝置會被自動登出(需確認機制)
  • ⚠️ 重要: 此欄位在 client spec 中已被移除

原由:

  • 簡化開發複雜度,降低初期版本成本
  • 避免多裝置資料同步衝突
  • 降低 Server 端儲存與計算成本

已知限制與待解決問題

已知限制

以下為初期版本的設計範圍與限制,暫時擱置,留待實作階段或後續迭代處理。

限制 1: Flow 2 下載失敗處理策略

問題描述:

在 Flow 2 (登入流程) 的步驟 5-6 中,如果下載 sessions 或 stats 失敗(例如網路中斷),會出現以下狀況:

  • Token 已經儲存 (步驟 3 完成)
  • GDPR Consent 已取得 (步驟 4 完成)
  • 使用者技術上已「登入」
  • 但 Local database 是空的(沒有 sessions/stats)

可能的方案:

顯示重試對話框

  • 保留 token
  • 顯示對話框「資料同步失敗,是否重試?」
  • 提供 [重試] [跳過] 按鈕
  • 優點: 給使用者控制權
  • 缺點: 增加 UI 複雜度

文件影響:

  • Flow 2 步驟 5-6 標註「如果失敗 → 見 Known Issues」

待討論問題

以下問題需要與 SolidFocus RD 討論確認。

待討論: GET /sessions 的資料量處理

問題: 如果使用者有大量 sessions(例如 1000 筆),Login 時是否需要全部下載?

待確認:

  • [ ] 預期使用者最多有多少筆 sessions?
  • [ ] GET /sessions 是否有資料量限制?
  • [ ] 是否需要分頁或日期篩選?

建議:

  • 初期版本不分頁,假設使用者 sessions 數量在合理範圍內(< 500 筆)
  • 未來可考慮增加日期範圍篩選(例如:只下載最近 3 個月)

補充說明

註①: Device ID 的產生與儲存

定義: device_id 是 App 自己產生的 UUID,用於識別裝置並綁定至使用者帳號(成為 Server 端的 User.bound_device_id)

為何不用 Hardware ID?

  • ❌ iOS: 從 iOS 5 開始禁止取得 UDID(隱私政策)
  • ❌ Android: IMEI/MAC 需要額外權限,不建議使用
  • ✅ 解決方案: App 自己產生 UUID,跨平台一致

產生時機: ⭐ App 首次啟動時產生(在 Flow 1 步驟 2),並永久儲存

在 Flow 中的位置

  • ✅ Flow 1 (App 啟動) - 步驟 2: 初始化 Device ID
    • 檢查是否已存在
    • 若無 → 產生新 UUID 並儲存
    • 若有 → 載入至 Store
  • ✅ Flow 2 (Login) - 步驟 1: 讀取已初始化的 Device ID
    • 不在此處產生,而是讀取已存在的值

實作方式

  • 使用 AsyncStorage 儲存 Device ID
  • App 首次啟動時產生 UUID
  • 之後每次啟動從 AsyncStorage 讀取
  • 特性:
    • ✅ 跨平台一致(iOS/Android 使用相同邏輯)
    • ✅ 不需要額外權限
    • ✅ 符合隱私政策
    • ⚠️ App 重裝會產生新的 ID

未來可能發展

  • 使用 Keychain/Keystore 儲存 Device ID
  • 即使 App 重裝也能保留 Device ID
  • 提供更好的使用者體驗(換機不需重新綁定)