◀ 上一章: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. 設計原則
  2. 資料來源定義
  3. 核心 Flow 定義
  4. 錯誤處理與 Retry 策略
  5. 待討論問題

設計原則

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 連線完成後才能進入

關鍵時序:

  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: 登入流程 (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 上傳的核心程序,在以下兩個時機點被執行:

  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 天)

錯誤處理與上傳策略

上傳時機點

採用「兩個時間點上傳」策略,簡化背景 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(避免重複計算)
  • 不顯示任何提示(靜默處理)

設計範圍決策

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

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 仍存在,可確保資料不重複

影響範圍:

待確認:

  • [ ] 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
  • 提供更好的使用者體驗(換機不需重新綁定)

🔗 相關章節


◀ 上一章:BLE 設備整合 | 下一章:本地資料庫 Schema ▶