[阶段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:
@@ -24,29 +24,35 @@ def _range_sql(user_id, start=None, end=None):
|
||||
|
||||
|
||||
def get_summary(user_id, start=None, end=None):
|
||||
"""Every stored metric for each day, in the camelCase the UI and the AI
|
||||
prompt consume."""
|
||||
sql, params = _range_sql(user_id, start, end)
|
||||
columns = ", ".join(HEALTH_COLUMNS)
|
||||
rows = query_all(
|
||||
"SELECT date, steps, heart_rate, heart_rate_variability, "
|
||||
"sleep_duration, sleep_quality, stress, calories_burned "
|
||||
f"FROM health_data {sql} ORDER BY date ASC",
|
||||
params,
|
||||
f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params
|
||||
)
|
||||
return [
|
||||
{
|
||||
"date": r["date"],
|
||||
"steps": r["steps"],
|
||||
"heartRate": r["heart_rate"],
|
||||
"heartRateVariability": r["heart_rate_variability"],
|
||||
"sleep": (
|
||||
{"duration": r["sleep_duration"], "quality": r["sleep_quality"]}
|
||||
if r["sleep_duration"] is not None
|
||||
else None
|
||||
),
|
||||
"stress": r["stress"],
|
||||
"caloriesBurned": r["calories_burned"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
day = {"date": r["date"]}
|
||||
for column, key in HEALTH_COLUMNS.items():
|
||||
day[key] = r.get(column)
|
||||
# Sleep stays nested for backwards compatibility with the UI and the
|
||||
# existing recommendation rules.
|
||||
day["sleep"] = (
|
||||
{
|
||||
"duration": r.get("sleep_duration"),
|
||||
"quality": r.get("sleep_quality"),
|
||||
"deepSeconds": r.get("sleep_deep_seconds"),
|
||||
"lightSeconds": r.get("sleep_light_seconds"),
|
||||
"remSeconds": r.get("sleep_rem_seconds"),
|
||||
"awakeSeconds": r.get("sleep_awake_seconds"),
|
||||
}
|
||||
if r.get("sleep_duration") is not None
|
||||
else None
|
||||
)
|
||||
out.append(day)
|
||||
return out
|
||||
|
||||
|
||||
def get_steps(user_id, start=None, end=None):
|
||||
@@ -99,40 +105,121 @@ def get_activities(user_id, start=None, end=None):
|
||||
return rows
|
||||
|
||||
|
||||
# Column name -> key in the record dict produced by the Garmin extractor.
|
||||
# Keeping the mapping in one place means adding a metric touches this table
|
||||
# and the extractor, and nothing else.
|
||||
HEALTH_COLUMNS = {
|
||||
"steps": "steps",
|
||||
"step_goal": "stepGoal",
|
||||
"distance_meters": "distanceMeters",
|
||||
"calories_burned": "caloriesBurned",
|
||||
"active_calories": "activeCalories",
|
||||
"bmr_calories": "bmrCalories",
|
||||
"floors_ascended": "floorsAscended",
|
||||
"floors_descended": "floorsDescended",
|
||||
"intensity_minutes": "intensityMinutes",
|
||||
"sedentary_seconds": "sedentarySeconds",
|
||||
"active_seconds": "activeSeconds",
|
||||
"heart_rate": "heartRate",
|
||||
"heart_rate_max": "heartRateMax",
|
||||
"heart_rate_min": "heartRateMin",
|
||||
"heart_rate_variability": "heartRateVariability",
|
||||
"stress": "stress",
|
||||
"stress_max": "stressMax",
|
||||
"body_battery_high": "bodyBatteryHigh",
|
||||
"body_battery_low": "bodyBatteryLow",
|
||||
"body_battery_charged": "bodyBatteryCharged",
|
||||
"body_battery_drained": "bodyBatteryDrained",
|
||||
"spo2_avg": "spo2Avg",
|
||||
"spo2_min": "spo2Min",
|
||||
"respiration_avg": "respirationAvg",
|
||||
"respiration_min": "respirationMin",
|
||||
"respiration_max": "respirationMax",
|
||||
"sleep_duration": "sleepDuration",
|
||||
"sleep_quality": "sleepQuality",
|
||||
"sleep_deep_seconds": "sleepDeepSeconds",
|
||||
"sleep_light_seconds": "sleepLightSeconds",
|
||||
"sleep_rem_seconds": "sleepRemSeconds",
|
||||
"sleep_awake_seconds": "sleepAwakeSeconds",
|
||||
"sleep_spo2_avg": "sleepSpo2Avg",
|
||||
"sleep_respiration_avg": "sleepRespirationAvg",
|
||||
"sleep_stress_avg": "sleepStressAvg",
|
||||
"training_readiness": "trainingReadiness",
|
||||
"vo2max": "vo2max",
|
||||
"endurance_score": "enduranceScore",
|
||||
"blood_pressure_systolic": "bloodPressureSystolic",
|
||||
"blood_pressure_diastolic": "bloodPressureDiastolic",
|
||||
}
|
||||
|
||||
|
||||
def _upsert(table, key_cols, cols, values):
|
||||
"""INSERT ... ON CONFLICT/DUPLICATE UPDATE, written for both backends."""
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
updatable = [c for c in cols if c not in key_cols]
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
|
||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}")
|
||||
else:
|
||||
conflict = ", ".join(key_cols)
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
|
||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT({conflict}) DO UPDATE SET {updates}")
|
||||
execute(sql, values)
|
||||
|
||||
|
||||
def upsert_health_daily(user_id, record):
|
||||
hid = f"{user_id}-{record['date']}"
|
||||
cols = [
|
||||
"id", "user_id", "date", "steps", "heart_rate",
|
||||
"heart_rate_variability", "blood_pressure_systolic",
|
||||
"blood_pressure_diastolic", "sleep_duration", "sleep_quality",
|
||||
"stress", "calories_burned",
|
||||
cols = ["id", "user_id", "date"] + list(HEALTH_COLUMNS)
|
||||
values = [hid, user_id, record.get("date")] + [
|
||||
record.get(key) for key in HEALTH_COLUMNS.values()
|
||||
]
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
vals = [
|
||||
hid, user_id, record.get("date"), record.get("steps"),
|
||||
record.get("heartRate"), record.get("heartRateVariability"),
|
||||
record.get("bloodPressureSystolic"), record.get("bloodPressureDiastolic"),
|
||||
record.get("sleepDuration"), record.get("sleepQuality"),
|
||||
record.get("stress"), record.get("caloriesBurned"),
|
||||
]
|
||||
if DB_TYPE == "mariadb":
|
||||
update_cols = [c for c in cols if c not in ("id", "user_id")]
|
||||
updates = ", ".join([f"{c}=VALUES({c})" for c in update_cols])
|
||||
sql = (
|
||||
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON DUPLICATE KEY UPDATE {updates}, updated_at=CURRENT_TIMESTAMP"
|
||||
)
|
||||
else:
|
||||
update_cols = [c for c in cols if c not in ("id", "user_id")]
|
||||
updates = ", ".join([f"{c}=excluded.{c}" for c in update_cols])
|
||||
sql = (
|
||||
f"INSERT INTO health_data ({', '.join(cols)}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT(user_id, date) DO UPDATE SET {updates}, updated_at=CURRENT_TIMESTAMP"
|
||||
)
|
||||
execute(sql, vals)
|
||||
_upsert("health_data", ("user_id", "date"), cols, values)
|
||||
return hid
|
||||
|
||||
|
||||
def upsert_badge(user_id, badge):
|
||||
cols = ["id", "user_id", "badge_key", "name", "category_id",
|
||||
"difficulty_id", "earned_date", "earned_count", "points"]
|
||||
values = [
|
||||
badge["id"], user_id, badge.get("badgeKey"), badge.get("name"),
|
||||
badge.get("categoryId"), badge.get("difficultyId"),
|
||||
badge.get("earnedDate"), badge.get("earnedCount"), badge.get("points"),
|
||||
]
|
||||
_upsert("badges", ("user_id", "id"), cols, values)
|
||||
return badge["id"]
|
||||
|
||||
|
||||
def upsert_personal_record(user_id, record):
|
||||
cols = ["id", "user_id", "type_id", "activity_id", "activity_name",
|
||||
"activity_type", "value", "achieved_at"]
|
||||
values = [
|
||||
record["id"], user_id, record.get("typeId"), record.get("activityId"),
|
||||
record.get("activityName"), record.get("activityType"),
|
||||
record.get("value"), record.get("achievedAt"),
|
||||
]
|
||||
_upsert("personal_records", ("user_id", "id"), cols, values)
|
||||
return record["id"]
|
||||
|
||||
|
||||
def get_badges(user_id):
|
||||
return query_all(
|
||||
"SELECT id, badge_key, name, category_id, difficulty_id, earned_date, "
|
||||
"earned_count, points FROM badges WHERE user_id = ? "
|
||||
"ORDER BY earned_date DESC",
|
||||
[user_id],
|
||||
)
|
||||
|
||||
|
||||
def get_personal_records(user_id):
|
||||
return query_all(
|
||||
"SELECT id, type_id, activity_id, activity_name, activity_type, value, "
|
||||
"achieved_at FROM personal_records WHERE user_id = ? "
|
||||
"ORDER BY achieved_at DESC",
|
||||
[user_id],
|
||||
)
|
||||
|
||||
|
||||
def insert_activity(user_id, activity):
|
||||
# Prefer Garmin's own activity id when the caller has one: it is stable
|
||||
# across syncs, which is what lets a re-synced window skip what is already
|
||||
|
||||
Reference in New Issue
Block a user