流程與同步策略¶
本文件定義 SolidFocus App 與後端伺服器之間的完整資料流程與同步策略,包含:
- App 與 API 的互動時機
- Local DB 與 Server DB 的資料同步邏輯
- 8 個關鍵 Flow(App 啟動、Login、運動、上傳、歷史記錄、Logout、課程列表、Token Refresh)
- Offline-First 架構實作細節
設計原則¶
1. 離線優先架構¶
App 設計為「離線優先」,所有核心功能在無網路時仍可運作:
原則:
- 運動中:完全不依賴網路,所有資料先存 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 連線完成後才能進入 |
關鍵時序:
- Device ID 必須最早初始化 - 因為 Login 和 Refresh 時會用到
- Token 檢查與自動 Refresh - 使用 Refresh Token 自動更新 access_token
- Access token 過期 → 自動 refresh(不清空 Local 資料)
- Refresh token 也過期 → 提示重新登入(仍不清空 Local 資料)
- Flow 2 (Atomic Login) - 登入 + 下載基礎資料 (sessions/stats) + 建立裝置綁定
- Token 驗證通過後,立即進行資料同步 (步驟 4) - 顯示 "Syncing data..." 畫面
- 資料同步完成後,才進行 BLE 連線 - 確保課程資料是最新的
- 看到主畫面時,資料同步 + BLE 一定都已完成 - 確保可以立即開始運動
- 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 上傳的核心程序,在以下兩個時機點被執行:
- Session 結束後:上傳當前 session + 所有 pending sessions(一起批次上傳)
- 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. 完成後返回統計結果
設計重點¶
- 批次上傳:使用
POST /sessions/batch_upload,一次最多 20 筆,減少 API 呼叫次數 - 不中斷執行:即使某一批失敗,仍繼續上傳下一批
- 從最舊開始:按
started_at排序,確保時間順序正確 - 細粒度狀態標記:API response 會指出每個 session 的結果(成功/重複/失敗)
- Duplicate 視為成功:如果 server 已經有該 session,標記為 uploaded
- 記錄 retry count:追蹤失敗次數,方便 debug
- 進度回調:支援 UI 顯示進度(Login sync 時使用)
- 返回結果統計:返回成功與失敗的 session 數量
- 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 計算的一致。
同步流程:
- 標記 sessions 狀態:
- 標記成功的 sessions 為 'uploaded'
- 標記 duplicate sessions 為 'uploaded'
-
標記失敗的 sessions 保持 'pending'
-
Stats 同步驗證(僅在最後一批執行):
- 收到
response.updated_stats(Server 計算的最新 stats) - 計算 Local stats(從 Local sessions 計算)
- 比對 Local vs Server stats
- 如果任何欄位不一致:
- APP wins - 以 Local 為準,執行
PATCH /users/me/stats強制覆蓋 Server - 顯示 Toast「統計資料已同步」(optional)
- APP wins - 以 Local 為準,執行
- 如果一致:
- 直接更新 Local = Server 回傳值
設計原則:
- ✅ APP wins: Local 計算的 stats 永遠是 source of truth
- ✅ 自動修復: 如果 Server stats 不一致,自動執行 PATCH 修復
- ✅ 高效: 只在上傳 session 時才同步 stats(不需要每次啟動都 GET stats)
- ✅ 時機正確: Session 上傳成功 = Stats 需要更新的時機
多批次上傳優化:
如果使用者一次上傳多批 sessions(例如 50 筆分成 3 批),優化為只在最後一批驗證 stats:
流程:
- 前面批次 (batch 1, 2):
- 標記 sessions 狀態
- 直接更新 Local stats = response.updated_stats
-
不執行 stats 比對
-
最後一批 (batch 3):
- 標記 sessions 狀態
- 更新 Local stats = response.updated_stats
- 執行 stats 比對與同步
- 如果不一致 → 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 機制:
- Session 結束後:
- 立即嘗試上傳該 session
-
成功後執行 Batch Upload Procedure 上傳其他 pending sessions
-
Login 後的 Sync:
- 執行 Batch Upload Procedure 上傳所有 pending sessions
- 顯示進度與 pending count
- 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
- 提供更好的使用者體驗(換機不需重新綁定)