mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
879 lines
34 KiB
Python
879 lines
34 KiB
Python
"""SQLite storage layer for Mi Fitness MCP."""
|
|
|
|
import json
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from mi_fitness_mcp.models import (
|
|
AbnormalHeartBeatEvent,
|
|
BodyMeasurement,
|
|
DailyActivity,
|
|
HeartRateSample,
|
|
SleepSession,
|
|
SpO2Sample,
|
|
StressSample,
|
|
Workout,
|
|
)
|
|
|
|
|
|
class Database:
|
|
"""SQLite database manager."""
|
|
|
|
def __init__(self, db_path: Path | str):
|
|
"""Initialize database.
|
|
|
|
Args:
|
|
db_path: Path to SQLite database file
|
|
"""
|
|
self.db_path = Path(db_path)
|
|
self._init_db()
|
|
|
|
def _init_db(self) -> None:
|
|
"""Initialize database schema."""
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
with self._get_connection() as conn:
|
|
# 日常活动表
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS daily_activity (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
date TEXT NOT NULL,
|
|
steps INTEGER NOT NULL DEFAULT 0,
|
|
distance_m REAL NOT NULL DEFAULT 0,
|
|
active_kcal REAL NOT NULL DEFAULT 0,
|
|
total_kcal REAL,
|
|
floors INTEGER,
|
|
active_minutes INTEGER,
|
|
UNIQUE(user_id, date, device_id)
|
|
)
|
|
""")
|
|
|
|
# 睡眠记录表
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS sleep_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
sleep_id TEXT NOT NULL,
|
|
start_at TIMESTAMP NOT NULL,
|
|
end_at TIMESTAMP NOT NULL,
|
|
duration_minutes INTEGER NOT NULL,
|
|
time_asleep_minutes INTEGER NOT NULL,
|
|
time_awake_minutes INTEGER NOT NULL,
|
|
sleep_score INTEGER,
|
|
is_nap BOOLEAN DEFAULT FALSE,
|
|
stages TEXT, -- JSON array of sleep stages
|
|
UNIQUE(user_id, sleep_id)
|
|
)
|
|
""")
|
|
|
|
# 运动记录表
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS workouts (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
workout_id TEXT NOT NULL,
|
|
activity_type TEXT NOT NULL,
|
|
start_at TIMESTAMP NOT NULL,
|
|
end_at TIMESTAMP NOT NULL,
|
|
duration_minutes INTEGER NOT NULL,
|
|
distance_m REAL,
|
|
calories_kcal REAL,
|
|
avg_heart_rate_bpm INTEGER,
|
|
max_heart_rate_bpm INTEGER,
|
|
avg_pace_sec_per_km REAL,
|
|
max_pace_sec_per_km REAL,
|
|
total_steps INTEGER,
|
|
UNIQUE(user_id, workout_id)
|
|
)
|
|
""")
|
|
|
|
# 身体测量表
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS body_measurements (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
timestamp TIMESTAMP NOT NULL,
|
|
weight_kg REAL NOT NULL,
|
|
bmi REAL,
|
|
body_fat_pct REAL,
|
|
muscle_mass_kg REAL,
|
|
water_pct REAL,
|
|
bone_mass_kg REAL,
|
|
visceral_fat_score INTEGER,
|
|
basal_metabolism_kcal INTEGER,
|
|
metabolic_age INTEGER,
|
|
UNIQUE(user_id, timestamp, device_id)
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS heart_rate_samples (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
timestamp TIMESTAMP NOT NULL,
|
|
bpm INTEGER NOT NULL,
|
|
sample_type TEXT NOT NULL,
|
|
UNIQUE(user_id, timestamp, sample_type)
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS spo2_samples (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
timestamp TIMESTAMP NOT NULL,
|
|
spo2_pct INTEGER NOT NULL,
|
|
UNIQUE(user_id, timestamp)
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS stress_samples (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
timestamp TIMESTAMP NOT NULL,
|
|
stress_score INTEGER NOT NULL,
|
|
level TEXT NOT NULL,
|
|
UNIQUE(user_id, timestamp)
|
|
)
|
|
""")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS abnormal_heart_beat_events (
|
|
id TEXT PRIMARY KEY,
|
|
provider TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_record_id TEXT,
|
|
user_id TEXT NOT NULL,
|
|
device_id TEXT,
|
|
timezone TEXT DEFAULT 'UTC',
|
|
collected_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
event_id TEXT NOT NULL,
|
|
start_at TIMESTAMP NOT NULL,
|
|
end_at TIMESTAMP NOT NULL,
|
|
duration_seconds INTEGER NOT NULL,
|
|
UNIQUE(user_id, event_id)
|
|
)
|
|
""")
|
|
|
|
# 同步状态表
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS sync_state (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
data_type TEXT NOT NULL UNIQUE,
|
|
last_sync_at TIMESTAMP,
|
|
last_record_timestamp TIMESTAMP,
|
|
records_count INTEGER DEFAULT 0
|
|
)
|
|
""")
|
|
|
|
# 创建索引以提升查询性能
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_activity_user_date
|
|
ON daily_activity(user_id, date)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_sleep_user_start
|
|
ON sleep_sessions(user_id, start_at)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_workouts_user_start
|
|
ON workouts(user_id, start_at)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_measurements_user_ts
|
|
ON body_measurements(user_id, timestamp)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_heart_rate_user_ts
|
|
ON heart_rate_samples(user_id, timestamp)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_spo2_user_ts
|
|
ON spo2_samples(user_id, timestamp)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_stress_user_ts
|
|
ON stress_samples(user_id, timestamp)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_abnormal_hr_user_start
|
|
ON abnormal_heart_beat_events(user_id, start_at)
|
|
""")
|
|
|
|
conn.commit()
|
|
|
|
@contextmanager
|
|
def _get_connection(self):
|
|
"""Get database connection with row factory."""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
yield conn
|
|
finally:
|
|
conn.close()
|
|
|
|
def insert_daily_activity(self, activity: DailyActivity) -> bool:
|
|
"""Insert or update daily activity record.
|
|
|
|
Returns:
|
|
True if record was inserted, False if updated
|
|
"""
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO daily_activity (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, date, steps, distance_m, active_kcal,
|
|
total_kcal, floors, active_minutes
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
timezone = excluded.timezone,
|
|
collected_at = excluded.collected_at,
|
|
steps = excluded.steps,
|
|
distance_m = excluded.distance_m,
|
|
active_kcal = excluded.active_kcal,
|
|
total_kcal = excluded.total_kcal,
|
|
floors = excluded.floors,
|
|
active_minutes = excluded.active_minutes,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
activity.id,
|
|
activity.provider,
|
|
activity.source_type,
|
|
activity.source_record_id,
|
|
activity.user_id,
|
|
activity.device_id,
|
|
activity.timezone,
|
|
activity.collected_at.isoformat() if activity.collected_at else None,
|
|
activity.date,
|
|
activity.steps,
|
|
activity.distance_m,
|
|
activity.active_kcal,
|
|
activity.total_kcal,
|
|
activity.floors,
|
|
activity.active_minutes,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_sleep_session(self, sleep: SleepSession) -> bool:
|
|
"""Insert or update sleep session record."""
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO sleep_sessions (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, sleep_id, start_at, end_at, duration_minutes,
|
|
time_asleep_minutes, time_awake_minutes, sleep_score, is_nap, stages
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, sleep_id) DO UPDATE SET
|
|
duration_minutes = excluded.duration_minutes,
|
|
time_asleep_minutes = excluded.time_asleep_minutes,
|
|
time_awake_minutes = excluded.time_awake_minutes,
|
|
sleep_score = excluded.sleep_score,
|
|
stages = excluded.stages,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
sleep.id,
|
|
sleep.provider,
|
|
sleep.source_type,
|
|
sleep.source_record_id,
|
|
sleep.user_id,
|
|
sleep.device_id,
|
|
sleep.timezone,
|
|
sleep.collected_at.isoformat() if sleep.collected_at else None,
|
|
sleep.sleep_id,
|
|
sleep.start_at.isoformat(),
|
|
sleep.end_at.isoformat(),
|
|
sleep.duration_minutes,
|
|
sleep.time_asleep_minutes,
|
|
sleep.time_awake_minutes,
|
|
sleep.sleep_score,
|
|
sleep.is_nap,
|
|
json.dumps([s.model_dump() for s in sleep.stages]),
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_workout(self, workout: Workout) -> bool:
|
|
"""Insert or update workout record."""
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO workouts (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, workout_id, activity_type, start_at, end_at,
|
|
duration_minutes, distance_m, calories_kcal, avg_heart_rate_bpm,
|
|
max_heart_rate_bpm, avg_pace_sec_per_km, max_pace_sec_per_km, total_steps
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, workout_id) DO UPDATE SET
|
|
duration_minutes = excluded.duration_minutes,
|
|
distance_m = excluded.distance_m,
|
|
calories_kcal = excluded.calories_kcal,
|
|
avg_heart_rate_bpm = excluded.avg_heart_rate_bpm,
|
|
max_heart_rate_bpm = excluded.max_heart_rate_bpm,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
workout.id,
|
|
workout.provider,
|
|
workout.source_type,
|
|
workout.source_record_id,
|
|
workout.user_id,
|
|
workout.device_id,
|
|
workout.timezone,
|
|
workout.collected_at.isoformat() if workout.collected_at else None,
|
|
workout.workout_id,
|
|
workout.activity_type,
|
|
workout.start_at.isoformat(),
|
|
workout.end_at.isoformat(),
|
|
workout.duration_minutes,
|
|
workout.distance_m,
|
|
workout.calories_kcal,
|
|
workout.avg_heart_rate_bpm,
|
|
workout.max_heart_rate_bpm,
|
|
workout.avg_pace_sec_per_km,
|
|
workout.max_pace_sec_per_km,
|
|
workout.total_steps,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_body_measurement(self, measurement: BodyMeasurement) -> bool:
|
|
"""Insert or update body measurement record."""
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO body_measurements (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, timestamp, weight_kg, bmi, body_fat_pct,
|
|
muscle_mass_kg, water_pct, bone_mass_kg, visceral_fat_score,
|
|
basal_metabolism_kcal, metabolic_age
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
weight_kg = excluded.weight_kg,
|
|
bmi = excluded.bmi,
|
|
body_fat_pct = excluded.body_fat_pct,
|
|
muscle_mass_kg = excluded.muscle_mass_kg,
|
|
water_pct = excluded.water_pct,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
measurement.id,
|
|
measurement.provider,
|
|
measurement.source_type,
|
|
measurement.source_record_id,
|
|
measurement.user_id,
|
|
measurement.device_id,
|
|
measurement.timezone,
|
|
measurement.collected_at.isoformat() if measurement.collected_at else None,
|
|
measurement.timestamp.isoformat(),
|
|
measurement.weight_kg,
|
|
measurement.bmi,
|
|
measurement.body_fat_pct,
|
|
measurement.muscle_mass_kg,
|
|
measurement.water_pct,
|
|
measurement.bone_mass_kg,
|
|
measurement.visceral_fat_score,
|
|
measurement.basal_metabolism_kcal,
|
|
measurement.metabolic_age,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_heart_rate_sample(self, sample: HeartRateSample) -> bool:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO heart_rate_samples (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, timestamp, bpm, sample_type
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
timezone = excluded.timezone,
|
|
collected_at = excluded.collected_at,
|
|
timestamp = excluded.timestamp,
|
|
bpm = excluded.bpm,
|
|
sample_type = excluded.sample_type,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
sample.id,
|
|
sample.provider,
|
|
sample.source_type,
|
|
sample.source_record_id,
|
|
sample.user_id,
|
|
sample.device_id,
|
|
sample.timezone,
|
|
sample.collected_at.isoformat() if sample.collected_at else None,
|
|
sample.timestamp.isoformat(),
|
|
sample.bpm,
|
|
sample.sample_type,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_spo2_sample(self, sample: SpO2Sample) -> bool:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO spo2_samples (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, timestamp, spo2_pct
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
timezone = excluded.timezone,
|
|
collected_at = excluded.collected_at,
|
|
timestamp = excluded.timestamp,
|
|
spo2_pct = excluded.spo2_pct,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
sample.id,
|
|
sample.provider,
|
|
sample.source_type,
|
|
sample.source_record_id,
|
|
sample.user_id,
|
|
sample.device_id,
|
|
sample.timezone,
|
|
sample.collected_at.isoformat() if sample.collected_at else None,
|
|
sample.timestamp.isoformat(),
|
|
sample.spo2_pct,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_stress_sample(self, sample: StressSample) -> bool:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO stress_samples (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, timestamp, stress_score, level
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
timezone = excluded.timezone,
|
|
collected_at = excluded.collected_at,
|
|
timestamp = excluded.timestamp,
|
|
stress_score = excluded.stress_score,
|
|
level = excluded.level,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
sample.id,
|
|
sample.provider,
|
|
sample.source_type,
|
|
sample.source_record_id,
|
|
sample.user_id,
|
|
sample.device_id,
|
|
sample.timezone,
|
|
sample.collected_at.isoformat() if sample.collected_at else None,
|
|
sample.timestamp.isoformat(),
|
|
sample.stress_score,
|
|
sample.level,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def insert_abnormal_heart_beat_event(self, event: AbnormalHeartBeatEvent) -> bool:
|
|
with self._get_connection() as conn:
|
|
cursor = conn.execute(
|
|
"""
|
|
INSERT INTO abnormal_heart_beat_events (
|
|
id, provider, source_type, source_record_id, user_id, device_id,
|
|
timezone, collected_at, event_id, start_at, end_at, duration_seconds
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, event_id) DO UPDATE SET
|
|
timezone = excluded.timezone,
|
|
collected_at = excluded.collected_at,
|
|
start_at = excluded.start_at,
|
|
end_at = excluded.end_at,
|
|
duration_seconds = excluded.duration_seconds,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
""",
|
|
(
|
|
event.id,
|
|
event.provider,
|
|
event.source_type,
|
|
event.source_record_id,
|
|
event.user_id,
|
|
event.device_id,
|
|
event.timezone,
|
|
event.collected_at.isoformat() if event.collected_at else None,
|
|
event.event_id,
|
|
event.start_at.isoformat(),
|
|
event.end_at.isoformat(),
|
|
event.duration_seconds,
|
|
),
|
|
)
|
|
conn.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
def update_sync_state(self, data_type: str, last_record_ts: datetime | None = None) -> None:
|
|
"""Update sync state for a data type."""
|
|
with self._get_connection() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO sync_state (data_type, last_sync_at, last_record_timestamp)
|
|
VALUES (?, CURRENT_TIMESTAMP, ?)
|
|
ON CONFLICT(data_type) DO UPDATE SET
|
|
last_sync_at = CURRENT_TIMESTAMP,
|
|
last_record_timestamp = excluded.last_record_timestamp
|
|
""",
|
|
(data_type, last_record_ts.isoformat() if last_record_ts else None),
|
|
)
|
|
conn.commit()
|
|
|
|
def get_sync_state(self, data_type: str) -> dict[str, Any] | None:
|
|
"""Get sync state for a data type."""
|
|
with self._get_connection() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM sync_state WHERE data_type = ?",
|
|
(data_type,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def query_daily_activity(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Query daily activity records."""
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM daily_activity
|
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
|
ORDER BY date
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_sleep_sessions(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Query sleep session records."""
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM sleep_sessions
|
|
WHERE user_id = ?
|
|
AND substr(start_at, 1, 10) >= ? AND substr(start_at, 1, 10) <= ?
|
|
ORDER BY start_at
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_workouts(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Query workout records."""
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM workouts
|
|
WHERE user_id = ?
|
|
AND substr(start_at, 1, 10) >= ? AND substr(start_at, 1, 10) <= ?
|
|
ORDER BY start_at
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_body_measurements(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Query body measurement records."""
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM body_measurements
|
|
WHERE user_id = ?
|
|
AND substr(timestamp, 1, 10) >= ? AND substr(timestamp, 1, 10) <= ?
|
|
ORDER BY timestamp
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_heart_rate_samples(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM heart_rate_samples
|
|
WHERE user_id = ?
|
|
AND substr(timestamp, 1, 10) >= ? AND substr(timestamp, 1, 10) <= ?
|
|
ORDER BY timestamp
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_spo2_samples(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM spo2_samples
|
|
WHERE user_id = ?
|
|
AND substr(timestamp, 1, 10) >= ? AND substr(timestamp, 1, 10) <= ?
|
|
ORDER BY timestamp
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_stress_samples(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM stress_samples
|
|
WHERE user_id = ?
|
|
AND substr(timestamp, 1, 10) >= ? AND substr(timestamp, 1, 10) <= ?
|
|
ORDER BY timestamp
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def query_abnormal_heart_beat_events(
|
|
self,
|
|
user_id: str,
|
|
start_date: str,
|
|
end_date: str,
|
|
) -> list[dict[str, Any]]:
|
|
with self._get_connection() as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT * FROM abnormal_heart_beat_events
|
|
WHERE user_id = ?
|
|
AND substr(start_at, 1, 10) >= ? AND substr(start_at, 1, 10) <= ?
|
|
ORDER BY start_at
|
|
""",
|
|
(user_id, start_date, end_date),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_data_coverage(self, user_id: str) -> list[dict[str, Any]]:
|
|
"""Get data coverage statistics."""
|
|
with self._get_connection() as conn:
|
|
results = []
|
|
|
|
# 日常活动覆盖范围
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
MIN(date) as first_date,
|
|
MAX(date) as last_date,
|
|
COUNT(DISTINCT date) as days_with_data
|
|
FROM daily_activity
|
|
WHERE user_id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if row and row["first_date"]:
|
|
results.append(
|
|
{
|
|
"data_type": "daily_activity",
|
|
**dict(row),
|
|
}
|
|
)
|
|
|
|
# 睡眠覆盖范围
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
MIN(substr(start_at, 1, 10)) as first_date,
|
|
MAX(substr(start_at, 1, 10)) as last_date,
|
|
COUNT(DISTINCT substr(start_at, 1, 10)) as days_with_data
|
|
FROM sleep_sessions
|
|
WHERE user_id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if row and row["first_date"]:
|
|
results.append(
|
|
{
|
|
"data_type": "sleep",
|
|
**dict(row),
|
|
}
|
|
)
|
|
|
|
# 运动记录覆盖范围
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
MIN(substr(start_at, 1, 10)) as first_date,
|
|
MAX(substr(start_at, 1, 10)) as last_date,
|
|
COUNT(DISTINCT substr(start_at, 1, 10)) as days_with_data
|
|
FROM workouts
|
|
WHERE user_id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if row and row["first_date"]:
|
|
results.append(
|
|
{
|
|
"data_type": "workouts",
|
|
**dict(row),
|
|
}
|
|
)
|
|
|
|
# 身体测量覆盖范围
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
MIN(substr(timestamp, 1, 10)) as first_date,
|
|
MAX(substr(timestamp, 1, 10)) as last_date,
|
|
COUNT(DISTINCT substr(timestamp, 1, 10)) as days_with_data
|
|
FROM body_measurements
|
|
WHERE user_id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if row and row["first_date"]:
|
|
results.append(
|
|
{
|
|
"data_type": "body_measurements",
|
|
**dict(row),
|
|
}
|
|
)
|
|
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
MIN(substr(timestamp, 1, 10)) as first_date,
|
|
MAX(substr(timestamp, 1, 10)) as last_date,
|
|
COUNT(DISTINCT substr(timestamp, 1, 10)) as days_with_data
|
|
FROM heart_rate_samples
|
|
WHERE user_id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if row and row["first_date"]:
|
|
results.append(
|
|
{
|
|
"data_type": "heart_rate",
|
|
**dict(row),
|
|
}
|
|
)
|
|
|
|
for data_type, table_name, date_expr in [
|
|
("spo2", "spo2_samples", "timestamp"),
|
|
("stress", "stress_samples", "timestamp"),
|
|
("abnormal_heart_beat", "abnormal_heart_beat_events", "start_at"),
|
|
]:
|
|
row = conn.execute(
|
|
f"""
|
|
SELECT
|
|
MIN(date({date_expr})) as first_date,
|
|
MAX(date({date_expr})) as last_date,
|
|
COUNT(DISTINCT date({date_expr})) as days_with_data
|
|
FROM {table_name}
|
|
WHERE user_id = ?
|
|
""",
|
|
(user_id,),
|
|
).fetchone()
|
|
if row and row["first_date"]:
|
|
results.append({"data_type": data_type, **dict(row)})
|
|
|
|
return results
|