◀ 上一章:BLE 設備整合 | 下一章:本地資料庫 Schema ▶
本文件定義 SolidFocus App 與 Backend Server 之間的完整資料流程與同步策略,包含:
- App 與 API 的互動時機
- Local DB 與 Server DB 的資料同步邏輯
- 8 個關鍵 Flow(App 啟動、Login、運動、上傳、歷史記錄、Logout、課程列表、Token Refresh)
- Offline-First 架構實作細節
⚠️ IMPORTANT (Updated 2025-12-24):
本文件包含 Fugu 的流程設計提案,但許多 API 實作細節(request/response 格式、錯誤處理、狀態碼等)尚未經 SolidFocus RD 確認。
已確認內容:
- ✅ Offline-First 架構原則(from client spec)
- ✅ Database schema field names(from client spec)
- ✅ Batch Upload 概念(from client spec)
未確認內容 (Fugu 提案):
- ⚠️ 具體 API request/response 格式
- ⚠️ Error handling 策略
- ⚠️ 裝置綁定機制與 JWT Refresh Token 設計(新增 2025-12-24)
- ⚠️ Token 有效期設定(access_token / refresh_token)
Status: 需與 SolidFocus RD 討論確認後更新
目錄¶
設計原則¶
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 機制)
- 簡化開發複雜度,降低初期版本成本
資料來源定義¶
Local Database (App)¶
技術選型: AsyncStorage / Realm / WatermelonDB
儲存內容:
| 資料類型 | 欄位 | 說明 |
|---|---|---|
| Auth | access_token |
JWT Token |
refresh_token |
Refresh Token | |
user_profile |
User 基本資料 | |
device_id |
App 產生的裝置 UUID | |
| 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 驗證失敗 → 清除所有 Local 資料,調用 Flow 2 (登入流程)
- Flow 2 是 atomic login procedure,包含登入 + 下載基礎資料 (sessions/stats)
- Token 驗證通過後會立即進行資料同步 (步驟 4)(上傳 pending sessions、更新課程列表、同步課程影片)
- 資料同步完成後才進行 BLE 連線 (步驟 5)
- BLE 連線必須完成,才能進入主畫面 (步驟 6)
- 未登入卡在登入畫面,資料同步時顯示 "Syncing data...",未連線 BLE 卡在設備列表畫面
- 看到主畫面時,一定已經 Login + 資料同步 + 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]
↓
┌─────────────────────────────────────────────────────────────────┐
│ 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 資料後調用 |
| 資料同步 | 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: 登入流程 (Atomic Login Procedure)¶
重要設計原則:
- Flow 2 是一個 atomic procedure(不可分割的原子操作)
- 包含:登入 + 建立裝置綁定 + 下載基礎資料 (sessions/stats)
- 重要變更: Local 在執行 Flow 2 前不一定是空的
- 首次登入:Local 是空的
- Refresh token 過期重新登入:Local 保留資料(sessions/stats),不清空
- Flow 2 完成後返回 Flow 1 步驟 4(資料同步)
┌─────────────────────────────────────────────────────────────────┐
│ 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 端邏輯 (裝置綁定檢查): │
│ 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": { ... }
│ }
│ [繼續至步驟 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. 下載 Server Sessions │
│ API: GET /sessions │
│ - 下載使用者所有運動記錄 │
│ - **合併策略** (處理重新登入時 Local 已有資料的情境): |
│ • 檢查 Local 是否已有 sessions │
│ • 如果有:比對 client_session_id,只下載新的 sessions │
│ • 如果無:全部下載 │
│ - 所有下載的 sessions 標記為 upload_status = 'uploaded' │
│ - 如果失敗 → 見 Known Issues 章節 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 5. 下載 Server Stats │
│ API: GET /users/me (或 GET /users/me/stats) │
│ - 下載使用者統計資料 │
│ - **合併策略**: │
│ • 如果 Local stats 為空:直接儲存 │
│ • 如果 Local 已有 stats:以 Server 為準覆蓋 │
│ - 如果失敗 → 見 Known Issues 章節 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 6. Login Procedure 完成 │
│ - 返回 Flow 1 步驟 4(資料同步) │
│ - Flow 1 將繼續執行:上傳 pending + 課程同步 │
└─────────────────────────────────────────────────────────────────┘
資料來源決策表:
| 步驟 | 資料來源 | 操作 | 備註 |
|---|---|---|---|
| 登入 API | Server | Write | 取得 tokens |
| 儲存 Token | Local | Write | 儲存 access_token, refresh_token, user_profile |
| 下載 Sessions | Server → Local | Read & Write | 下載所有運動記錄 |
| 下載 Stats | Server → Local | Read & Write | 下載統計資料 |
| 返回 Flow 1 | - | Navigate | 繼續執行 Flow 1 步驟 4 |
Known Issues:
- ⚠️ 步驟 4-5 下載失敗時的處理策略(見 Known Issues 章節)
Flow 3: 開始運動流程¶
前提條件:
- ✅ 使用者已通過 Flow 1,進入主畫面
- ✅ BLE 已連線(Flow 1 步驟 5 保證)
- ✅ 課程影片已下載完成(Flow 1 步驟 4 或 Flow 2 步驟 5 保證)
┌─────────────────────────────────────────────────────────────────┐
│ 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 或 Flow 2 步驟 5)
- ✅ 運動前無需等待 - 進入主畫面時影片已全部就緒,可立即開始運動
- ✅ 簡化使用者體驗 - 不需要單獨管理影片下載
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 天)
錯誤處理與上傳策略¶
上傳時機點¶
採用「兩個時間點上傳」策略,簡化背景 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(避免重複計算)
- 不顯示任何提示(靜默處理)
設計範圍決策¶
以下為初期版本的設計範圍與限制:
1. 單一裝置登入 (Device Binding)¶
- ✅ 一個帳號只能在一個裝置上登入
- ✅ 使用
User.bound_device_id綁定裝置 - ✅ 如果在新裝置登入,舊裝置會被自動登出(需確認機制)
- ⚠️ 重要: 此欄位在 client spec 中已被移除,見 Q0
原由:
- 簡化開發複雜度,降低初期版本成本
- 避免多裝置資料同步衝突
- 降低 Server 端儲存與計算成本
已知限制與待解決問題¶
已知限制¶
以下為初期版本的設計範圍與限制,暫時擱置,留待實作階段或後續迭代處理。
限制 1: Flow 2 下載失敗處理策略¶
問題描述:
在 Flow 2 (登入流程) 的步驟 4-5 中,如果下載 sessions 或 stats 失敗(例如網路中斷),會出現以下狀況:
- Token 已經儲存 (步驟 3 完成)
- 使用者技術上已「登入」
- 但 Local database 是空的(沒有 sessions/stats)
可能的方案:
顯示重試對話框
- 保留 token
- 顯示對話框「資料同步失敗,是否重試?」
- 提供 [重試] [跳過] 按鈕
- 優點: 給使用者控制權
- 缺點: 增加 UI 複雜度
文件影響:
- Flow 2 步驟 4-5 標註「如果失敗 → 見 Known Issues」
待討論問題¶
以下問題需要與 SolidFocus RD 討論確認。
待討論 1: Device Binding 機制 - User.bound_device_id 欄位缺失¶
問題: ⚠️ 關鍵問題 - Client spec 中 User.bound_device_id 欄位已被移除,但初期版本需要此欄位實作單一裝置綁定
目前設計需求:
- 初期版本採用單一裝置綁定策略(一個帳號只能在一個裝置上登入)
- 需要
User.bound_device_id欄位來記錄綁定的裝置 - Login 時檢查
bound_device_id,如果不匹配則回傳403 device_bound錯誤 - Logout 時清除
bound_device_id,解除裝置綁定
Client spec 現況:
- ❌
User.bound_device_id欄位在 v3.0 spec 中已被移除 - ❌ 無法實作單一裝置綁定機制
- ✅
client_session_id仍存在,可確保資料不重複
影響範圍:
- Flow 2: 登入流程 (Atomic Login Procedure) 步驟 2 - 403 device_bound 錯誤處理
- Flow 6: 登出流程 步驟 2 - 清除 bound_device_id
- 註①: Device ID 的產生與儲存(見下方)
待確認:
- [ ] SolidFocus RD 是否保留
User.bound_device_id欄位? - [ ] 如果移除,是否改用其他機制實作單一裝置綁定?(例如:token based)
- [ ] 或是初期版本直接支援多裝置登入?(需重新評估複雜度)
建議:
- 方案 A: 保留
User.bound_device_id欄位(建議) - 最簡單,符合初期版本需求 - 方案 B: 使用 token + device_id 驗證 - 複雜度中等
待討論 2: 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
- 提供更好的使用者體驗(換機不需重新綁定)
🔗 相關章節¶
- 03.1 App 架構 - App 整體架構
- 03.2 BLE 設備整合 - BLE 整合
- 03.4 本地資料庫 Schema - WatermelonDB Schema
- 第 4 章:API Contract - Backend API 規格