[阶段6] 同步 Garmin 全量数据:31 项日指标 + 奖励 + 个人纪录
原来每天只存 7 个指标,而 get_user_summary 一次就返回 60+ 字段, 另有睡眠分期、训练准备度、耐力分等独立端点从未被调用。 db.py: - health_data 新增 31 列(距离/活动卡路里/基础代谢/爬楼/强度分钟/ 久坐时长/最高最低心率/最大压力/身体电量四项/血氧/呼吸/ 睡眠深浅REM清醒分期/睡眠血氧/睡眠呼吸/睡眠压力/训练准备度/ VO2max/耐力分) - 新增 badges 与 personal_records 两张表,均以 (user_id, garmin_id) 为主键,重复同步更新而非累积 - 新增增量迁移: CREATE TABLE IF NOT EXISTS 对已存在的表不生效, 新列必须显式 ALTER,否则生产库上永远不会出现。按列名比对后 逐个补齐,SQLite 与 MariaDB 都幂等 services/garmin.py: - _extract_daily 改为汇总 user_summary + sleep + hrv + training_readiness + training_status + endurance_score 五个端点 - 每个可选端点用 _safe 包裹:某项设备不记录时留 NULL,不影响当天其余数据 - 新增 sync_badges / sync_personal_records(账号级,每次同步取一次) fix(garmin): 个人纪录整批写入失败 - Garmin 在同一份数据里混用 ISO 字符串和 Unix 毫秒时间戳, prStartTimeGmt 是 1570961412000,写进 DATETIME 列被 MariaDB 以 1292 拒绝,导致 11 项个人纪录一条都没存进去 - 新增 _to_datetime 统一处理 ISO / 毫秒 / 秒三种形状,并优先取 Garmin 自己提供的 *Formatted 字段 services/ai.py: - 送给模型的 CSV 从 7 列扩到 23 列,纳入身体电量、血氧、呼吸、 训练准备度、耐力分和睡眠分期 接口: GET /api/health/badges、/api/health/personal-records tests (+13, 共 292): - 徽章/纪录的往返、重复同步不累积、按用户隔离 - 两个用户可持有同一个 Garmin 徽章 id 而不冲突 - 时间戳三种形状的归一化及无效值不抛异常 NAS 实测: 7 天数据每天 31 项指标、65 个奖励、11 项个人纪录 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
100
backend/db.py
100
backend/db.py
@@ -98,6 +98,36 @@ CREATE TABLE IF NOT EXISTS garmin_tokens (
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Badges earned on Garmin Connect ("奖励"). Keyed by Garmin's own badge id so
|
||||
-- a re-sync updates rather than duplicates.
|
||||
CREATE TABLE IF NOT EXISTS badges (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
badge_key VARCHAR(128),
|
||||
name VARCHAR(255),
|
||||
category_id INT,
|
||||
difficulty_id INT,
|
||||
earned_date DATETIME,
|
||||
earned_count INT,
|
||||
points INT,
|
||||
PRIMARY KEY (user_id, id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Personal records (个人纪录), e.g. fastest 5k, longest run.
|
||||
CREATE TABLE IF NOT EXISTS personal_records (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
type_id INT,
|
||||
activity_id VARCHAR(64),
|
||||
activity_name VARCHAR(255),
|
||||
activity_type VARCHAR(64),
|
||||
value DOUBLE,
|
||||
achieved_at DATETIME,
|
||||
PRIMARY KEY (user_id, id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Rendezvous for the interactive MFA login.
|
||||
-- garth asks for the code through a *blocking* callback, so the login parks in
|
||||
-- a background thread while the code arrives in a separate HTTP request that
|
||||
@@ -227,6 +257,75 @@ def _row_to_dict(row):
|
||||
return {k: _serialize(row[k]) for k in row.keys()}
|
||||
|
||||
|
||||
# Columns added after the first release. `CREATE TABLE IF NOT EXISTS` does
|
||||
# nothing to a table that already exists, so new metrics need an explicit
|
||||
# additive migration or they silently never appear in production.
|
||||
MIGRATIONS = {
|
||||
"health_data": [
|
||||
# activity / energy
|
||||
("distance_meters", "DOUBLE"),
|
||||
("active_calories", "DOUBLE"),
|
||||
("bmr_calories", "DOUBLE"),
|
||||
("floors_ascended", "DOUBLE"),
|
||||
("floors_descended", "DOUBLE"),
|
||||
("intensity_minutes", "INT"),
|
||||
("step_goal", "INT"),
|
||||
("sedentary_seconds", "INT"),
|
||||
("active_seconds", "INT"),
|
||||
# heart / stress
|
||||
("heart_rate_max", "INT"),
|
||||
("heart_rate_min", "INT"),
|
||||
("stress_max", "INT"),
|
||||
# body battery
|
||||
("body_battery_high", "INT"),
|
||||
("body_battery_low", "INT"),
|
||||
("body_battery_charged", "INT"),
|
||||
("body_battery_drained", "INT"),
|
||||
# breathing / blood oxygen
|
||||
("spo2_avg", "DOUBLE"),
|
||||
("spo2_min", "INT"),
|
||||
("respiration_avg", "DOUBLE"),
|
||||
("respiration_min", "DOUBLE"),
|
||||
("respiration_max", "DOUBLE"),
|
||||
# sleep detail
|
||||
("sleep_deep_seconds", "INT"),
|
||||
("sleep_light_seconds", "INT"),
|
||||
("sleep_rem_seconds", "INT"),
|
||||
("sleep_awake_seconds", "INT"),
|
||||
("sleep_spo2_avg", "DOUBLE"),
|
||||
("sleep_respiration_avg", "DOUBLE"),
|
||||
("sleep_stress_avg", "DOUBLE"),
|
||||
# training
|
||||
("training_readiness", "INT"),
|
||||
("vo2max", "DOUBLE"),
|
||||
("endurance_score", "INT"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _existing_columns(cur, table):
|
||||
if DB_TYPE == "mariadb":
|
||||
cur.execute(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
|
||||
[table],
|
||||
)
|
||||
return {r["COLUMN_NAME"] if isinstance(r, dict) else r[0] for r in cur.fetchall()}
|
||||
cur.execute(f"PRAGMA table_info({table})")
|
||||
return {row[1] for row in cur.fetchall()}
|
||||
|
||||
|
||||
def _migrate(cur):
|
||||
for table, columns in MIGRATIONS.items():
|
||||
present = _existing_columns(cur, table)
|
||||
for name, coltype in columns:
|
||||
if name in present:
|
||||
continue
|
||||
# SQLite has no "ADD COLUMN IF NOT EXISTS"; the membership check
|
||||
# above is what keeps this idempotent on both backends.
|
||||
cur.execute(f"ALTER TABLE {table} ADD COLUMN {name} {coltype}")
|
||||
|
||||
|
||||
# --- Public API -------------------------------------------------------------
|
||||
def init_db():
|
||||
conn = _connect()
|
||||
@@ -237,6 +336,7 @@ def init_db():
|
||||
if not stmt:
|
||||
continue
|
||||
cur.execute(_adapt_sql(stmt))
|
||||
_migrate(cur)
|
||||
finally:
|
||||
_disconnect(conn)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user