Files
GarminHealthLab/backend/services/health.py
ericwyuan f1319a6171 feat: 补齐 Garmin 未同步的数据,并各自配上界面
审计 57 个接口后,把账号里真有数据却从未入库的部分补上。全部走同步模块,
界面只读本地库。

新增数据
- 体重与身体成分(体脂率/肌肉量/体水分/骨量/内脏脂肪/代谢年龄)
- 血压(接口通,账号暂无记录)
- 跑步成绩预测(5 公里 / 10 公里 / 半马 / 全马)
- 爬坡分、饮水量、出汗量 → health_data 新增七列
- 全天曲线:心率 / 压力 / 身体电量 / 呼吸 / 血氧
- 挑战赛(徽章挑战与好友挑战,与一次性的徽章不同,有周期和进度)
- 已配对设备

新增界面
- /body/ 身体成分:体重大数字 + BMI 分级 + 体脂肌肉曲线 + 血压表格
- /race/ 成绩预测:四个距离的预测成绩与配速,以及预测随时间的变化
- /challenges/ 挑战赛:按类型筛选,有目标的显示进度条
- /devices/ 已配对设备
- 每日页新增「全天曲线」,这是存日内采样的主要目的
- 健康页新增「身体成分」分组与「更多」入口,运动页加挑战赛与成绩预测入口

同步开销
- 日内曲线每天五个请求,14 天以内的同步顺带拉,更长的历史交给后台
  「补齐详细数据」,否则一年的同步会多出约 1800 个请求
- 原来的「补齐运动详情」扩展为统一的补齐任务,分阶段上报进度

日内采样抽稀到每天 240 点:手机图表分辨不出更多,只会把行撑大。

全量 446 项测试通过。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-24 04:16:33 +08:00

273 lines
9.6 KiB
Python

"""
Health data service: read endpoints + upsert helpers used by the Garmin sync.
Mirrors the original Node HealthService, including the camelCase JSON mapping.
Upserts use backend-specific SQL because SQLite does not support
`ON DUPLICATE KEY UPDATE` (it uses `ON CONFLICT ... DO UPDATE`).
"""
import datetime
import uuid
from db import execute, query_one, query_all
from config import DB_TYPE
def _range_sql(user_id, start=None, end=None):
params = [user_id]
sql = "WHERE user_id = ?"
if start:
sql += " AND date >= ?"
params.append(start)
if end:
sql += " AND date <= ?"
params.append(end)
return sql, params
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(
f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params
)
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):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, steps FROM health_data {sql} AND steps IS NOT NULL ORDER BY date ASC",
params,
)
return [{"date": r["date"], "steps": r["steps"]} for r in rows]
def get_heart_rate(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, heart_rate, heart_rate_variability FROM health_data {sql} "
"AND heart_rate IS NOT NULL ORDER BY date ASC",
params,
)
return [
{
"date": r["date"],
"heartRate": r["heart_rate"],
"heartRateVariability": r["heart_rate_variability"],
}
for r in rows
]
def get_sleep(user_id, start=None, end=None):
sql, params = _range_sql(user_id, start, end)
rows = query_all(
f"SELECT date, sleep_duration, sleep_quality FROM health_data {sql} "
"AND sleep_duration IS NOT NULL ORDER BY date ASC",
params,
)
return [
{"date": r["date"], "duration": r["sleep_duration"], "quality": r["sleep_quality"]}
for r in rows
]
def get_activities(user_id, start=None, end=None):
"""Activities in a date range.
Filters on start_time, not `date`: the activities table has no `date`
column, so reusing the daily-metrics range clause raised
"Unknown column 'date'". It went unnoticed while the only caller asked for
every activity, which produced no date predicate at all.
"""
params = [user_id]
sql = "WHERE user_id = ?"
if start:
sql += " AND start_time >= ?"
params.append(start)
if end:
# Exclusive upper bound at the next midnight rather than "end
# 23:59:59": SQLite compares these as strings, and the stored
# separator may be 'T' (0x54) or a space (0x20), so a same-day
# 18:30 timestamp sorts *after* an end bound written with a space
# and would be dropped from its own day.
sql += " AND start_time < ?"
params.append(
(datetime.date.fromisoformat(end) + datetime.timedelta(days=1)).isoformat()
)
return query_all(
"SELECT id, activity_type, start_time, end_time, duration, distance, "
"calories, heart_rate_average, heart_rate_max "
f"FROM activities {sql} ORDER BY start_time DESC",
params,
)
# 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",
"hill_score": "hillScore",
"hydration_ml": "hydrationMl",
"hydration_goal_ml": "hydrationGoalMl",
"sweat_loss_ml": "sweatLossMl",
"weight_kg": "weightKg",
"body_fat_pct": "bodyFatPct",
"bmi": "bmi",
"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"] + list(HEALTH_COLUMNS)
values = [hid, user_id, record.get("date")] + [
record.get(key) for key in HEALTH_COLUMNS.values()
]
_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
# stored instead of inserting it again.
aid = str(activity.get("id") or uuid.uuid4())
cols = [
"id", "user_id", "activity_type", "start_time", "end_time",
"duration", "distance", "calories", "heart_rate_average", "heart_rate_max",
]
placeholders = ", ".join(["?"] * len(cols))
vals = [
aid, user_id, activity.get("activityType"), activity.get("startTime"),
activity.get("endTime"), activity.get("duration"), activity.get("distance"),
activity.get("calories"), activity.get("heartRateAverage"),
activity.get("heartRateMax"),
]
execute(
f"INSERT INTO activities ({', '.join(cols)}) VALUES ({placeholders})",
vals,
)
return aid