Data Models
目錄 (Table of Contents)¶
概述 (Overview)¶
⚠️ IMPORTANT CLARIFICATION (Updated 2025-12-24):
What the Client (SolidFocus) Provided:
- ✅ Complete database schema (SQLModel classes with field names and types)
- ✅ Table relationships (Foreign Keys, OneToOne, OneToMany)
- ✅ JSONB usage for device-agnostic storage
- ✅ Architecture principles (Offline-First, APP calculates metrics)
What the Client Did NOT Provide:
- ❌ Business logic details (e.g., XP calculation formula, workout streak algorithm)
- ❌ Validation rules (e.g., password strength, email format)
- ❌ Index strategy details
- ❌ Cascade delete behavior
Current Status: This document includes:
- Client's schema (field names, types, relationships) - ✅ Confirmed
- Fugu's business logic proposals (calculations, validations) - ⚠️ Needs approval
Database Technology¶
Confirmed by Client: PostgreSQL 16+ with SQLModel ORM
Rationale (from client spec):
- Excellent JSON/JSONB support (for heterogeneous device data - client's key requirement)
- Strong data integrity and ACID compliance
- Good performance with proper indexing
- Native timezone-aware timestamp support
- SQLModel provides type-safe ORM with Pydantic validation
Design Philosophy (v3.0 - From Client Spec)¶
Client-Defined Principles:
- Offline-First (離線優先): APP is source of truth, server is data archive center
- Device-Agnostic Storage (異質數據標準化): Use JSONB for heterogeneous device data (bike, rower, treadmill)
- Batch over Real-time: Minimize API calls, support batch upload with idempotency
- Client-side Calculation: APP calculates ALL metrics (calories, distance, XP), server stores results
- No Device Binding: Users can log in from multiple devices (v3.0 change)
- Flexible JSON Fields: Use JSONB for evolving schemas without migrations
Fugu's Interpretations (need client confirmation):
- Idempotent upload using
client_session_id+X-Idempotency-Keyheader - Specific index strategy
- Cascade delete behavior
- Business logic algorithms (see "Business Rules" sections below)
實體關聯圖 (Entity Relationship Diagram)¶
┌─────────────────────────────────────────────────────────────────────┐
│ SolidFocus Data Model (v3.0 - Offline-First) │
└─────────────────────────────────────────────────────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ User │ │ SocialAccount │
├──────────────────────┤ ├──────────────────────┤
│ id (PK) │◄─────────┤ id (PK) │
│ user_uuid (UQ, IDX) │ 1:N │ user_id (FK) │
│ email (UNIQUE) │ │ provider │
│ hashed_password │ │ provider_uid │
│ display_name │ │ extra_data (JSONB) │
│ avatar_url │ │ created_at │
│ gender (m/f/o) │ └──────────────────────┘
│ height_cm │
│ weight_kg │
│ is_active │
│ created_at │
└──────────────────────┘
│
│ 1:1
│
┌──────────────────────────────┐
│ UserStats │
├──────────────────────────────┤
│ user_id (PK, FK) │◄────── OneToOne relationship
│ total_distance_km │
│ total_duration_seconds │ (Can be force-synced from APP
│ total_calories │ via PATCH /users/me/stats)
│ total_xp │
│ current_level │
│ workout_streak_days │
│ last_workout_date │
└──────────────────────────────┘
▲
│
│ APP calculates, Server archives
│
┌──────────────────────────────┐ ┌──────────────────────┐
│ WorkoutSession │ │ Training │
├──────────────────────────────┤ ├──────────────────────┤
│ id (UUID, PK) │ │ id (PK) │
│ client_session_id (UQ, IDX) │ │ title │
│ user_id (FK) │───┐ │ type (course/game/ │
│ training_id (FK, NULLABLE) │───┼──────►│ free) │
│ internal_course_name │ │ N:1 │ cover_url │
│ source_type (backend/local/ │ │ │ video_url │
│ free) │ │ │ settings (JSONB) │
│ device_type (bike/rower/ │ │ │ supported_devices │
│ treadmill) │ │ │ (JSONB array) │
│ start_time │ │ │ enabled │
│ end_time │ │ │ created_at │
│ duration_seconds │ │ └──────────────────────┘
│ total_calories │ │
│ total_distance_meters │ │
│ xp_earned │ │ User owns multiple Sessions
│ metrics_summary (JSONB) │ └──────────────┐
│ time_series_data (JSONB[]) │ │
│ created_at │ │
└──────────────────────────────┘ ▼
(Aggregated to UserStats)
資料模型 (Data Models)¶
1. User¶
Purpose: Store user account information (no device binding in v3.0).
Table Name: users
Fields¶
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTO INCREMENT | User unique identifier (internal) |
user_uuid |
UUID | UNIQUE, NOT NULL, INDEXED | User UUID (exposed in API responses) |
email |
VARCHAR(255) | UNIQUE, NOT NULL, INDEXED | User email (for login/contact) |
hashed_password |
VARCHAR(255) | NULLABLE | Hashed password (NULL for SSO-only users) |
display_name |
VARCHAR(100) | NOT NULL | Public display name |
avatar_url |
VARCHAR(500) | NULLABLE | Avatar image URL |
gender |
VARCHAR(1) | NOT NULL, DEFAULT 'o', CHECK | Gender: "m", "f", "o" |
height_cm |
FLOAT | NULLABLE, CHECK >= 0 | User height in centimeters |
weight_kg |
FLOAT | NULLABLE, CHECK >= 0 | User weight in kilograms |
is_active |
BOOLEAN | NOT NULL, DEFAULT TRUE | Account active status |
created_at |
TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Account creation timestamp |
Constraints¶
✅ Confirmed:
- email must be unique across all users
- user_uuid must be unique (used in API responses instead of id)
- gender must be one of: 'm', 'f', 'o'
- height_cm must be between 0-300 cm
- weight_kg must be between 0-500 kg
Business Rules¶
- User Identification:
id: Internal database ID (not exposed in API)user_uuid: Public identifier (used in API responses)-
Auto-generated on user creation
-
Display Name:
- Required field (no default)
- Can be updated via
PATCH /users/me -
Must be 1-100 characters
-
Email:
- Cannot be changed via API (requires support intervention)
- Used for password reset and notifications
-
Must be unique
-
Password:
- NULL for SSO-only users (Apple/Google/Facebook)
- Hashed using bcrypt or Argon2
-
Never exposed in API responses
-
Profile Fields:
height_cm,weight_kg: Optional, used for calorie calculationsavatar_url: Optional, updated viaPOST /users/me/avatargender: Defaults to 'o' (other)
2. UserStats¶
Purpose: Store aggregated user workout statistics (can be force-synced from APP).
Table Name: user_stats
Fields¶
| Field | Type | Constraints | Description |
|---|---|---|---|
user_id |
INTEGER | PRIMARY KEY, FOREIGN KEY (users.id) | Reference to User (OneToOne) |
total_distance_km |
FLOAT | NOT NULL, DEFAULT 0, CHECK >= 0 | Total distance in kilometers |
total_duration_seconds |
INTEGER | NOT NULL, DEFAULT 0, CHECK >= 0 | Total workout duration (seconds) |
total_calories |
FLOAT | NOT NULL, DEFAULT 0, CHECK >= 0 | Total calories burned (kCal) |
total_xp |
INTEGER | NOT NULL, DEFAULT 0, CHECK >= 0 | Total experience points |
current_level |
INTEGER | NOT NULL, DEFAULT 1, CHECK >= 1 | User level (based on XP) |
workout_streak_days |
INTEGER | NOT NULL, DEFAULT 0, CHECK >= 0 | Current workout streak (consecutive days) |
last_workout_date |
TIMESTAMPTZ | NULLABLE | Last workout completion date |
Constraints¶
✅ Confirmed:
- OneToOne relationship with User (enforced by PRIMARY KEY on user_id)
- All numeric fields must be non-negative
- current_level must be at least 1
Business Rules¶
-
Force-Sync from APP (v3.0 Change):
-
APP is source of truth for user stats
PATCH /users/me/statsallows APP to override server values- Server accepts APP's calculated values (mentioned in client's description: "強制以 APP 端的累計里程/XP/等級覆蓋伺服器")
-
Use case: Resolve sync conflicts when APP has more recent data
-
Update Strategy (from client architecture):
-
APP updates local stats after each workout
- APP syncs to server via
PATCH /users/me/statsperiodically - Server does NOT auto-calculate (client confirmed: APP calculates, server stores)
⚠️ 未定義項目 (需要後續討論):
- Workout streak 連續天數計算邏輯
- Level 等級計算門檻
- XP 經驗值計算公式
3. SocialAccount¶
Purpose: Store SSO (Social Sign-On) provider information for users.
Table Name: social_accounts
Fields¶
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTO INCREMENT | SocialAccount unique identifier |
user_id |
INTEGER | FOREIGN KEY (users.id), NOT NULL, INDEXED | Reference to User |
provider |
VARCHAR(50) | NOT NULL, INDEXED, CHECK | SSO provider: "apple", "google", "facebook" |
provider_uid |
VARCHAR(255) | NOT NULL, INDEXED | Provider's unique user identifier (sub claim) |
extra_data |
JSONB | NOT NULL, DEFAULT '{}' | Additional provider data (email, name, tokens) |
created_at |
TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | First link timestamp |
Constraints¶
✅ Confirmed:
- Composite unique constraint on (provider, provider_uid) - same provider account can only link once
- provider must be one of: 'apple', 'google', 'facebook'
- extra_data stored as JSONB (not JSON)
Business Rules¶
- Account Linking:
- When user signs in with SSO for first time:
- If email exists in User table → link to existing user
- If email doesn't exist → create new User + link SocialAccount
- One user can have multiple SocialAccounts (e.g., Apple + Google)
-
One SocialAccount (provider+provider_uid) can only link to one User
-
Extra Data Example:
{ "email": "user@privaterelay.appleid.com", "email_verified": true, "name": { "firstName": "John", "lastName": "Doe" }, "apple_id_token_expires_at": "2025-12-18T10:30:00Z" } -
Provider Support:
- ✅ Phase 1: Apple SSO (priority)
- ⚠️ Future: Google SSO
-
⚠️ Future: Facebook Login
-
API Endpoint:
- Single unified endpoint:
POST /auth/sso/{provider} - Provider parameter:
apple,google, orfacebook
4. WorkoutSession¶
Purpose: Store individual workout session records (uploaded from APP via batch or single upload).
Table Name: workout_sessions
Fields¶
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
UUID | PRIMARY KEY | Session unique identifier (server-generated) |
client_session_id |
VARCHAR(255) | UNIQUE, NOT NULL, INDEXED | Client-generated idempotency key (UUIDv4) |
user_id |
INTEGER | FOREIGN KEY (users.id), NOT NULL, INDEXED | Reference to User |
source_type |
VARCHAR(20) | NOT NULL, DEFAULT 'free', INDEXED, CHECK | Source: "backend", "local", "free" |
training_id |
INTEGER | FOREIGN KEY (trainings.id), NULLABLE | Reference to Training (NULL for free ride) |
internal_course_name |
VARCHAR(255) | NULLABLE | APP built-in course name (if applicable) |
device_type |
VARCHAR(20) | NOT NULL, INDEXED, CHECK | Device: "bike", "rower", "treadmill" |
start_time |
TIMESTAMPTZ | NOT NULL | Workout start time (APP timestamp) |
end_time |
TIMESTAMPTZ | NOT NULL | Workout end time (APP timestamp) |
duration_seconds |
INTEGER | NOT NULL, CHECK > 0 | Workout duration excluding pauses (seconds) |
total_calories |
FLOAT | NOT NULL, CHECK >= 0 | Total energy burned (kCal) - calculated by APP |
total_distance_meters |
FLOAT | NOT NULL, CHECK >= 0 | Total distance (meters) - calculated by APP |
xp_earned |
INTEGER | NOT NULL, DEFAULT 0, CHECK >= 0 | XP earned - calculated by APP |
metrics_summary |
JSONB | NOT NULL, DEFAULT '{}' | Device-agnostic summary metrics |
time_series_data |
JSONB[] | NOT NULL, DEFAULT '[]' | Time-series data for charting |
created_at |
TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Server upload timestamp |
Constraints¶
✅ Confirmed (v3.0):
- id (UUID) must be globally unique (primary key)
- client_session_id must be globally unique (idempotency key, indexed)
- end_time must be after start_time
- duration_seconds must be positive (> 0)
- device_type must be one of: 'bike', 'rower', 'treadmill'
- source_type must be one of: 'backend', 'local', 'free'
- total_calories, total_distance_meters, xp_earned must be non-negative (>= 0)
- If source_type = 'backend', then training_id must be provided
- If source_type = 'local', then internal_course_name should be provided
Business Rules¶
- Idempotency with
client_session_id:Format: UUIDv4 generated by APP Example: 550e8400-e29b-41d4-a716-446655440000 - APP generates
client_session_idusing UUIDv4 - Used in batch upload with
X-Idempotency-Keyheader - Backend checks
client_session_idfor duplicate detection - Returns 200 OK for duplicates (idempotent behavior)
-
Ensures no duplicate sessions even when retrying uploads
-
Timestamp Handling:
start_timeandend_time: Set by APP (actual workout time)created_at: Set by server (upload/sync time)-
All timestamps are timezone-aware (ISO 8601 format with UTC)
-
Duration Calculation:
elapsed_time = end_time - start_time (includes pauses) duration_seconds = actual workout time (excludes pauses) - ✅ Store
duration_seconds(pure workout time, calculated by APP) - ~~elapsed_time~~ can be derived from
end_time - start_time -
Example: 30-minute session with 5-minute pause
start_time: 2025-12-19T10:00:00Zend_time: 2025-12-19T10:30:00Zduration_seconds: 1500 (25 minutes)
-
Source Type Logic:
'backend': Session follows a backend training course (must havetraining_id)'local': Session follows an APP built-in course (must haveinternal_course_name)-
'free': Free ride/row with no course (notraining_idorinternal_course_name) -
Device-Agnostic Storage:
- Same table supports bike, rower, treadmill data
- Device-specific fields stored in JSONB
metrics_summary - Different devices have different available metrics
- Backend doesn't validate metric fields (APP responsibility)
JSONB Structure Examples¶
metrics_summary Example (Bike):
{
"avg_heart_rate": 145,
"max_heart_rate": 170,
"min_heart_rate": 120,
"avg_power": 180,
"max_power": 250,
"avg_cadence": 80,
"max_cadence": 95,
"avg_speed_kmh": 25.5,
"max_speed_kmh": 32.0,
"avg_resistance_level": 5,
"max_resistance_level": 8,
"calories_breakdown": {
"active": 230.5,
"basal": 20.0
},
"heart_rate_zones": {
"zone1_seconds": 120,
"zone2_seconds": 480,
"zone3_seconds": 900,
"zone4_seconds": 300,
"zone5_seconds": 0
}
}
metrics_summary Example (Rower):
{
"avg_heart_rate": 155,
"max_heart_rate": 180,
"avg_stroke_rate": 24,
"max_stroke_rate": 32,
"avg_power": 220,
"max_power": 310,
"avg_pace_500m_seconds": 120,
"best_pace_500m_seconds": 105,
"total_strokes": 720
}
time_series_data Example (for charting):
[
{
"timestamp": 0,
"power": 150,
"heart_rate": 130,
"cadence": 75,
"speed_kmh": 22.0,
"resistance_level": 4
},
{
"timestamp": 5,
"power": 160,
"heart_rate": 135,
"cadence": 78,
"speed_kmh": 23.5,
"resistance_level": 4
},
{
"timestamp": 10,
"power": 180,
"heart_rate": 140,
"cadence": 80,
"speed_kmh": 25.0,
"resistance_level": 5
}
]
- Timestamps are in seconds from workout start
- Sampling interval: Typically 5-10 seconds (APP decides)
- Fields vary by device type
- Used for generating workout charts in APP/admin panel
5. Training¶
Purpose: Store training course templates and configurations.
Table Name: trainings
Fields¶
| Field | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTO INCREMENT | Training unique identifier |
type |
VARCHAR(50) | NOT NULL, INDEX | Training type: 'course', 'game', 'free' |
name |
VARCHAR(255) | NOT NULL | Training/course name |
settings |
JSONB | NOT NULL, DEFAULT '{}' | Flexible settings (duration, difficulty, stages, etc.) |
supported_devices |
JSONB[] | NOT NULL, DEFAULT '[]' | Array of device types: ["bike", "rower", "treadmill"] |
cover |
VARCHAR(500) | NULLABLE | Cover image URL path |
video_url |
VARCHAR(500) | NULLABLE | Video URL (HLS/m3u8 format) |
enabled |
BOOLEAN | NOT NULL, DEFAULT TRUE | Whether training is available to users |
created_at |
TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Record creation timestamp |
updated_at |
TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Last update timestamp |
Constraints¶
✅ Confirmed (v3.0):
- type must be one of: 'course', 'game', 'free'
- supported_devices must contain valid device types: ['bike', 'rower', 'treadmill']
- At least one device in supported_devices array (empty array not allowed)
Business Rules¶
- Training Types:
- course: Structured course with video and stages (e.g., "Taiwan Scenic Tour")
- game: Gamified workout with interactive elements (e.g., "Dragon Boat Race")
-
free: Free ride/row with no structured course (may have video background)
-
Device Filtering:
- APP can query trainings by
device_typeparameter:GET /trainings?device_type=bike - Returns only trainings where
device_type IN supported_devices -
Example: Training with
supported_devices = ["bike", "rower"]shows up for both bike and rower users -
Enabled Flag:
enabled = true: Training appears in list/detail endpointsenabled = false: Training hidden from users (for drafts or deprecated content)
JSONB Structure Examples¶
settings Example (Course):
{
"duration_minutes": 45,
"difficulty": "medium",
"has_video": true,
"target_heart_rate_zone": "zone2",
"tags": ["scenic", "taiwan", "climbing"],
"calories_estimate": 350,
"distance_estimate_km": 15.0,
"stages": [
{
"order": 1,
"duration_seconds": 300,
"resistance_level": 3,
"target_cadence_rpm": 70,
"target_power_watts": 150,
"description": "Warm-up: North Coast"
},
{
"order": 2,
"duration_seconds": 1800,
"resistance_level": 7,
"target_cadence_rpm": 80,
"target_power_watts": 200,
"description": "Climb: Yilan Mountains"
},
{
"order": 3,
"duration_seconds": 600,
"resistance_level": 4,
"target_cadence_rpm": 75,
"target_power_watts": 170,
"description": "Cool-down: Hualien Coast"
}
]
}
settings Example (Game):
{
"duration_minutes": 30,
"difficulty": "hard",
"game_type": "dragon_boat_race",
"opponent_count": 3,
"reward_xp": 500,
"tags": ["competitive", "multiplayer"],
"supported_devices": ["rower"]
}
supported_devices Example:
["bike", "rower"]
- Training available for both bike and rower devices
- APP filters by device type when querying training list
Video Delivery:
- Format: HLS (HTTP Live Streaming) - .m3u8 playlist
- Storage: Object storage (S3/GCS) + CDN
- Access: Public URLs or signed URLs (for access control)
- Adaptive bitrate for different network conditions
💡 Design Notes:
- JSONB fields allow flexible schema evolution without database migrations
- Different training types can have different settings structures
- supported_devices enables multi-device training library (one training, multiple device types)
- Consider JSON Schema validation in application layer for type safety
索引策略 (Indexes Strategy)¶
Performance Considerations (v3.0)¶
| Table | Index | Purpose | Type | Defined in SQLModel |
|---|---|---|---|---|
| users | idx_users_email |
Login queries (frequent) | B-tree UNIQUE | ✅ unique=True |
idx_users_user_uuid |
API lookups by user_uuid | B-tree UNIQUE | ✅ unique=True, index=True |
|
| user_stats | idx_user_stats_user_id |
OneToOne relationship lookup | Unique B-tree | ✅ unique=True |
| social_accounts | idx_social_accounts_user_id |
Find user's social accounts | B-tree | ✅ foreign_key, index=True |
idx_social_accounts_provider_uid |
SSO login lookup | Unique composite | ✅ unique=True on (provider, provider_uid) |
|
| workout_sessions | idx_sessions_client_session_id |
Idempotency checks | B-tree UNIQUE | ✅ unique=True, index=True |
idx_sessions_user_id |
User's sessions list | B-tree | ✅ foreign_key, index=True |
|
idx_sessions_device_type |
Filter by device | B-tree | ✅ index=True |
|
idx_sessions_source_type |
Filter by source | B-tree | ✅ index=True |
|
idx_sessions_start_time |
Recent sessions sorting | B-tree DESC | ⚠️ Manual (Alembic migration) | |
| trainings | idx_trainings_type |
Filter by type | B-tree | ✅ index=True |
idx_trainings_enabled |
Filter enabled courses | B-tree | ⚠️ Manual (Alembic migration) |
Index Rationale (v3.0)¶
- Unique Indexes (Prevent Duplicates):
users.email: Prevent duplicate accountsusers.user_uuid: API uses UUID for user identificationworkout_sessions.client_session_id: Critical for idempotency - prevents duplicate uploadssocial_accounts.(provider, provider_uid): Prevent duplicate SSO links-
❌ REMOVED:
bound_device_id(no device binding in v3.0) -
Performance Indexes (Query Optimization):
workout_sessions.device_type: Filter sessions by device (bike/rower/treadmill)workout_sessions.source_type: Filter by source (backend/local/free)workout_sessions.start_time DESC: Fast sorting for recent sessionstrainings.type: Filter trainings by type (course/game/free)-
trainings.enabled: Show only enabled trainings -
Foreign Key Indexes (Relationship Queries):
- All
user_idforeign keys are indexed - All
training_idforeign keys are indexed
💡 Implementation Note: Indexes will be created during backend implementation. Monitor query performance and add additional indexes as needed.
資料完整性規則 (Data Integrity Rules)¶
Database-Level Constraints¶
✅ 客戶提供的資料庫架構:
- Foreign Key relationships defined
- NOT NULL constraints defined
- Unique constraints on email, user_uuid, client_session_id
Unique Constraints:
users.emailUNIQUE ✅users.user_uuidUNIQUE ✅user_stats.user_idUNIQUE (OneToOne relationship) ✅workout_sessions.client_session_idUNIQUE (idempotency key) ✅(social_accounts.provider, social_accounts.provider_uid)UNIQUE composite ✅
⚠️ 未定義項目 (需要後續討論):
- Foreign Key Cascade 行為 (ON DELETE CASCADE/SET NULL)
- Check Constraints 驗證規則 (Enum validations, numeric validations)
- Application-level validations (FastAPI)
設計討論 (Design Discussions)¶
Key Design Philosophy Changes¶
🎯 Offline-First Architecture: - APP is source of truth for workout data - Server is data archive and sync center - No device binding - users can use any device - Batch upload with idempotency for reliability
🎯 Device-Agnostic Storage: - JSONB for heterogeneous device data (bike/rower/treadmill) - No strict schema validation on metrics - Vue admin panel dynamically renders based on device_type
Confirmed Design Decisions (v3.0)¶
✅ 1. JSONB for Device-Agnostic Metrics
Decision: Use JSONB for metrics_summary and time_series_data
Rationale:
- Different devices have different available metrics (bike has cadence, rower has stroke rate)
- No need to migrate schema when adding new device types
- Backend doesn't validate metric fields - APP responsibility
- Vue admin panel uses GET /meta/schemas for dynamic field definitions
Example: Bike has avg_cadence, rower has avg_stroke_rate - completely different structures stored in same JSONB field.
✅ 2. Batch Upload with Idempotency
Decision: Use client_session_id (UUIDv4) + X-Idempotency-Key header
Implementation: POST /sessions/batch_upload
Rationale: - Supports offline workout recording and batch sync - Prevents duplicate uploads even with retries - Reduces API calls (upload multiple sessions at once) - Simple UUIDv4 format (no MAC prefix needed)
Flow:
APP generates client_session_id → Stores locally →
Network available → Batch upload with Idempotency-Key →
Server checks client_session_id → Returns 200 OK for duplicates
✅ 3. APP Calculates Everything
Decision: Server stores what APP sends, no server-side computation
APP Responsibilities:
- Calculate total_calories, total_distance_meters, xp_earned
- Generate metrics_summary (avg/max values)
- Generate time_series_data for charts
- Calculate duration_seconds (excludes pauses)
Server Responsibilities:
- Store data as-is (no validation of metric values)
- Provide data archive and retrieval
- Support force-sync of UserStats via PATCH /users/me/stats (APP wins)
Rationale: - Offline-First - APP must work without server - Device-agnostic - server doesn't know device-specific logic - Simpler backend - no complex business logic
✅ 4. UserStats Force Sync (APP Wins)
Decision: Allow PATCH /users/me/stats where APP can override server values
New Behavior in v3.0: - APP tracks stats locally during offline workouts - When APP detects divergence, it force-syncs to server - Server accepts APP values unconditionally (APP wins) - No automatic calculation triggers on server
Use Case:
APP offline → 5 workouts → APP stats: 10 hours total
Server online → Only has 5 hours (missed syncs)
APP sends PATCH /users/me/stats → total_duration_seconds: 36000
Server updates → Now matches APP
✅ 5. No Device Binding
Decision: Remove bound_device_id from User model
Rationale: - Users can use multiple devices (phone, tablet) - No need to unbind when switching devices - Simpler user experience - Session tracking doesn't depend on device binding
Open Questions for Future¶
⚠️ 1. Avatar Storage Location
Question: Where to store user avatar images?
Options: - A) Object storage (S3/GCS) with signed URLs - B) Base64 in database (simple but not scalable) - C) Local filesystem with CDN
Recommendation: Discuss with SolidFocus RD team
⚠️ 2. Meta Schemas Update Frequency
Question: How often should GET /meta/schemas endpoint be called?
Considerations: - Schemas change infrequently (only when adding new device types) - Could cache for 24 hours - Or version-based cache invalidation
Recommendation: Implement caching strategy in v1
⚠️ 3. Force Update Mechanism
Question: How to implement forced app updates via GET /meta/app_version?
Considerations: - What happens if user on old version? - Should API block old versions? - Or just show warning in APP?
Recommendation: Define policy before implementation
總結 (Summary)¶
Data Model Overview¶
| Model | Purpose | Key Features (v3.0) |
|---|---|---|
| User | Account management | UUID identification, avatar support, no device binding |
| UserStats | Aggregated statistics | Force-sync from APP (APP wins), renamed fields |
| SocialAccount | SSO integration | Apple/Google/Facebook, provider_uid field |
| WorkoutSession | Workout records | Batch upload, idempotency, device-agnostic JSONB metrics |
| Training | Course templates | Multi-device support, type field (course/game/free) |
Implementation Status¶
v3.0 (Current):
- ✅ SQLModel data models defined
- ✅ Offline-First architecture designed
- ✅ Batch upload strategy specified
- ✅ Device-agnostic storage (JSONB)
- ✅ SSO support (Apple/Google/Facebook)
- ✅ UserStats force-sync (APP wins)
Future Enhancements:
- ⚠️ Avatar object storage implementation
- ⚠️ Meta schemas caching strategy
- ⚠️ Force update mechanism
- ⚠️ Soft delete with audit trail
- ⚠️ Email/password authentication
Next Steps¶
- Backend Implementation (SolidFocus RD)
- Set up PostgreSQL 16+ database
- Implement SQLModel models in FastAPI
- Create Alembic migrations
- Implement batch upload endpoint with idempotency
-
Implement UserStats force-sync endpoint
-
APP Development (Fugu)
- Implement local database matching server schema
- Build batch upload sync logic
- Implement UserStats local calculation
-
Test offline-first workflows
-
Integration Testing
- Test batch upload with idempotency
- Test offline workout and sync
- Test UserStats force-sync behavior
-
Verify device-agnostic metrics storage
-
Documentation Updates
- Update 04.1 API Specification with batch upload endpoint