[阶段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:
ericwyuan
2026-08-23 20:48:49 +08:00
parent bb774c332b
commit cbbff61082
10 changed files with 696 additions and 96 deletions

View File

@@ -98,6 +98,36 @@ CREATE TABLE IF NOT EXISTS garmin_tokens (
FOREIGN KEY (user_id) REFERENCES users(id) 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. -- Rendezvous for the interactive MFA login.
-- garth asks for the code through a *blocking* callback, so the login parks in -- 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 -- 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()} 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 ------------------------------------------------------------- # --- Public API -------------------------------------------------------------
def init_db(): def init_db():
conn = _connect() conn = _connect()
@@ -237,6 +336,7 @@ def init_db():
if not stmt: if not stmt:
continue continue
cur.execute(_adapt_sql(stmt)) cur.execute(_adapt_sql(stmt))
_migrate(cur)
finally: finally:
_disconnect(conn) _disconnect(conn)

View File

@@ -44,3 +44,16 @@ def sleep():
def activities(): def activities():
s, e = _range() s, e = _range()
return jsonify(health_svc.get_activities(g.user_id, s, e)) return jsonify(health_svc.get_activities(g.user_id, s, e))
@bp.route("/badges", methods=["GET"])
@require_auth
def badges():
"""Earned badges (奖励), most recent first."""
return jsonify(health_svc.get_badges(g.user_id))
@bp.route("/personal-records", methods=["GET"])
@require_auth
def personal_records():
return jsonify(health_svc.get_personal_records(g.user_id))

View File

@@ -361,13 +361,28 @@ def resolve_chain(preferred=None):
# --- prompt construction ---------------------------------------------------- # --- prompt construction ----------------------------------------------------
# Kept deliberately short: every extra column multiplies by the number of
# days sent, and the column names double as the vocabulary the model cites
# back in `basedOn`.
_CSV_COLUMNS = [ _CSV_COLUMNS = [
("date", "date"), ("date", "date"),
("steps", "steps"), ("steps", "steps"),
("distanceMeters", "dist_m"),
("heartRate", "rest_hr"), ("heartRate", "rest_hr"),
("heartRateMax", "max_hr"),
("heartRateVariability", "hrv"), ("heartRateVariability", "hrv"),
("stress", "stress"), ("stress", "stress"),
("stressMax", "stress_max"),
("bodyBatteryHigh", "bb_high"),
("bodyBatteryLow", "bb_low"),
("spo2Avg", "spo2"),
("respirationAvg", "resp"),
("intensityMinutes", "intensity_min"),
("caloriesBurned", "kcal"), ("caloriesBurned", "kcal"),
("activeCalories", "active_kcal"),
("floorsAscended", "floors"),
("trainingReadiness", "readiness"),
("enduranceScore", "endurance"),
] ]
@@ -379,7 +394,10 @@ def build_prompt(summary, activities=None, day_budget=None):
""" """
day_budget = day_budget if day_budget is not None else default_day_budget() day_budget = day_budget if day_budget is not None else default_day_budget()
rows = summary[-day_budget:] if day_budget else summary rows = summary[-day_budget:] if day_budget else summary
header = ",".join(label for _, label in _CSV_COLUMNS) + ",sleep_h,sleep_q" header = (
",".join(label for _, label in _CSV_COLUMNS)
+ ",sleep_h,sleep_q,sleep_deep_s,sleep_rem_s,sleep_awake_s"
)
lines = [header] lines = [header]
for r in rows: for r in rows:
cells = [] cells = []
@@ -387,8 +405,9 @@ def build_prompt(summary, activities=None, day_budget=None):
value = r.get(key) value = r.get(key)
cells.append("" if value is None else str(value)) cells.append("" if value is None else str(value))
sleep = r.get("sleep") or {} sleep = r.get("sleep") or {}
cells.append("" if sleep.get("duration") is None else str(sleep["duration"])) for key in ("duration", "quality", "deepSeconds", "remSeconds", "awakeSeconds"):
cells.append("" if sleep.get("quality") is None else str(sleep["quality"])) value = sleep.get(key)
cells.append("" if value is None else str(value))
lines.append(",".join(cells)) lines.append(",".join(cells))
sections = [ sections = [

View File

@@ -69,6 +69,21 @@ class MFARequired(RuntimeError):
"""Raised when a password login needs a code this process cannot obtain.""" """Raised when a password login needs a code this process cannot obtain."""
# garth sends a browser User-Agent, which its SSO flow needs. The data API
# treats that same UA as a browser hitting it directly and answers every
# request with HTTP 200 and an empty array — no error, just no data. The
# official app's UA (and in fact any non-browser one) returns real data, so
# the header is swapped after login, before any API call.
API_USER_AGENT = "com.garmin.android.apps.connectmobile"
def _use_api_user_agent(client):
try:
client.garth.sess.headers["User-Agent"] = API_USER_AGENT
except AttributeError:
pass # a stubbed client in tests has no session
def _is_cn(): def _is_cn():
# Selects Garmin's China service, a separate backend with separate # Selects Garmin's China service, a separate backend with separate
# accounts. This project tracks an international account. # accounts. This project tracks an international account.
@@ -124,8 +139,11 @@ def _connect(creds, user_id=None):
token = load_token(user_id) if user_id else None token = load_token(user_id) if user_id else None
if token: if token:
client.garth.loads(token) client.garth.loads(token)
# Populates display_name/unit_system and proves the token still works. _use_api_user_agent(client)
# Proves the token still works, and refreshes it if near expiry.
client.garth.refresh_oauth2() client.garth.refresh_oauth2()
# garminconnect builds most of its URLs from display_name, so leaving
# it unset sends every request to ".../None".
client.display_name = client.garth.profile["displayName"] client.display_name = client.garth.profile["displayName"]
return client return client
@@ -140,12 +158,24 @@ def _connect(creds, user_id=None):
# garth's default MFA prompt calls input(); under gunicorn stdin is # garth's default MFA prompt calls input(); under gunicorn stdin is
# closed, so it raises EOFError rather than anything descriptive. # closed, so it raises EOFError rather than anything descriptive.
raise MFARequired( raise MFARequired(
"该 Garmin 账号开启了两步验证,无法在服务端直接登录。" "该 Garmin 账号开启了两步验证。请在「数据同步」页面用密码重新绑定,"
"请在 NAS 上执行一次 `python garmin_login.py` 完成验证并保存令牌" "系统会提示你输入验证码"
) from e ) from e
_use_api_user_agent(client)
return client return client
def describe(e):
"""A message that is never empty.
Some exceptions carry no text at all — a bare `assert` raises
AssertionError with str(e) == "" — and storing that produced a failed
sync whose recorded reason was blank, which is undiagnosable.
"""
text = str(e).strip()
return f"{type(e).__name__}: {text}" if text else type(e).__name__
def _num(*values): def _num(*values):
"""First value that is a usable number.""" """First value that is a usable number."""
for v in values: for v in values:
@@ -154,46 +184,179 @@ def _num(*values):
return None return None
def _extract_daily(client, date_str): def _safe(fn, default=None):
"""One day of metrics, assembled from the endpoints that carry them. """Call an optional endpoint; a metric the device does not record must not
abort the whole day."""
try:
return fn()
except Exception:
return default
Sleep and HRV are separate endpoints in this library — they are not part
of the daily summary — so a sync that only read the summary would record def _first(seq):
every night as "no sleep data". return seq[0] if isinstance(seq, list) and seq else {}
def _to_datetime(*values):
"""Normalise Garmin's several timestamp shapes into an ISO string.
The same payload mixes ISO strings ("2019-10-13T10:10:12.0") with epoch
milliseconds (1570961412000); handing the latter to a DATETIME column is
rejected outright, so a personal record whose only timestamp was numeric
failed the whole batch.
""" """
summary = client.get_user_summary(date_str) or {} for v in values:
if v is None or v == "":
sleep_seconds = None continue
sleep_quality = None if isinstance(v, str):
return v[:26]
if isinstance(v, (int, float)) and not isinstance(v, bool):
# Values past ~1e11 are milliseconds, below that seconds.
seconds = v / 1000 if v > 1e11 else v
try: try:
sleep = (client.get_sleep_data(date_str) or {}).get("dailySleepDTO") or {} return datetime.datetime.utcfromtimestamp(seconds).isoformat(
sleep_seconds = _num(sleep.get("sleepTimeSeconds")) timespec="seconds"
sleep_quality = _num(sleep.get("sleepScores", {}).get("overall", {}).get("value") )
if isinstance(sleep.get("sleepScores"), dict) else None) except (ValueError, OverflowError, OSError):
except Exception: continue
pass # a missing night must not abort the whole day return None
hrv = None
try: def _extract_daily(client, date_str):
hrv_body = client.get_hrv_data(date_str) or {} """Everything Garmin exposes for one day.
summary_block = hrv_body.get("hrvSummary") or {}
hrv = _num(summary_block.get("lastNightAvg"), summary_block.get("weeklyAvg")) The daily summary is the bulk of it, but sleep, HRV, training readiness
except Exception: and endurance each live behind their own endpoint — none of them appear in
pass get_user_summary. Each is fetched defensively so a metric this device does
not record leaves a NULL instead of failing the day.
"""
s = client.get_user_summary(date_str) or {}
sleep_dto = (_safe(lambda: client.get_sleep_data(date_str)) or {}).get(
"dailySleepDTO"
) or {}
scores = sleep_dto.get("sleepScores") if isinstance(
sleep_dto.get("sleepScores"), dict
) else {}
sleep_seconds = _num(sleep_dto.get("sleepTimeSeconds"))
hrv_summary = (_safe(lambda: client.get_hrv_data(date_str)) or {}).get(
"hrvSummary"
) or {}
readiness = _first(_safe(lambda: client.get_training_readiness(date_str), []))
training = _safe(lambda: client.get_training_status(date_str), {}) or {}
vo2 = (training.get("mostRecentVO2Max") or {}).get("generic") or {}
endurance = _safe(lambda: client.get_endurance_score(date_str), {}) or {}
def secs(key):
return _num(sleep_dto.get(key))
return { return {
"date": date_str, "date": date_str,
"steps": _num(summary.get("totalSteps")), # --- activity / energy ---
"heartRate": _num(summary.get("restingHeartRate"), "steps": _num(s.get("totalSteps")),
summary.get("averageHeartRate")), "stepGoal": _num(s.get("dailyStepGoal")),
"heartRateVariability": hrv, "distanceMeters": _num(s.get("totalDistanceMeters")),
"caloriesBurned": _num(s.get("totalKilocalories")),
"activeCalories": _num(s.get("activeKilocalories")),
"bmrCalories": _num(s.get("bmrKilocalories")),
"floorsAscended": _num(s.get("floorsAscended")),
"floorsDescended": _num(s.get("floorsDescended")),
"intensityMinutes": (
(_num(s.get("moderateIntensityMinutes")) or 0)
+ (_num(s.get("vigorousIntensityMinutes")) or 0)
) or None,
"sedentarySeconds": _num(s.get("sedentarySeconds")),
"activeSeconds": _num(s.get("activeSeconds")),
# --- heart / stress ---
"heartRate": _num(s.get("restingHeartRate"), s.get("averageHeartRate")),
"heartRateMax": _num(s.get("maxHeartRate")),
"heartRateMin": _num(s.get("minHeartRate")),
"heartRateVariability": _num(
hrv_summary.get("lastNightAvg"), hrv_summary.get("weeklyAvg")
),
"stress": _num(s.get("averageStressLevel")),
"stressMax": _num(s.get("maxStressLevel")),
# --- body battery ---
"bodyBatteryHigh": _num(s.get("bodyBatteryHighestValue")),
"bodyBatteryLow": _num(s.get("bodyBatteryLowestValue")),
"bodyBatteryCharged": _num(s.get("bodyBatteryChargedValue")),
"bodyBatteryDrained": _num(s.get("bodyBatteryDrainedValue")),
# --- breathing / blood oxygen ---
"spo2Avg": _num(s.get("averageSpo2")),
"spo2Min": _num(s.get("lowestSpo2")),
"respirationAvg": _num(
s.get("avgWakingRespirationValue"), s.get("latestRespirationValue")
),
"respirationMin": _num(s.get("lowestRespirationValue")),
"respirationMax": _num(s.get("highestRespirationValue")),
# --- sleep ---
"sleepDuration": round(sleep_seconds / 3600, 1) if sleep_seconds else None, "sleepDuration": round(sleep_seconds / 3600, 1) if sleep_seconds else None,
"sleepQuality": sleep_quality, "sleepQuality": _num((scores.get("overall") or {}).get("value")),
"stress": _num(summary.get("averageStressLevel")), "sleepDeepSeconds": secs("deepSleepSeconds"),
"caloriesBurned": _num(summary.get("totalKilocalories")), "sleepLightSeconds": secs("lightSleepSeconds"),
"sleepRemSeconds": secs("remSleepSeconds"),
"sleepAwakeSeconds": secs("awakeSleepSeconds"),
"sleepSpo2Avg": secs("averageSpO2Value"),
"sleepRespirationAvg": secs("averageRespirationValue"),
"sleepStressAvg": secs("avgSleepStress"),
# --- training ---
"trainingReadiness": _num(readiness.get("score")),
"vo2max": _num(vo2.get("vo2MaxValue")),
"enduranceScore": _num(endurance.get("overallScore")),
} }
def sync_badges(client, user_id):
"""Earned badges. Keyed by Garmin's badge id, so re-syncing updates."""
badges = _safe(lambda: client.get_earned_badges(), []) or []
stored = 0
for b in badges:
bid = b.get("badgeId")
if bid is None:
continue
health.upsert_badge(user_id, {
"id": str(bid),
"badgeKey": b.get("badgeKey"),
"name": b.get("badgeName"),
"categoryId": _num(b.get("badgeCategoryId")),
"difficultyId": _num(b.get("badgeDifficultyId")),
"earnedDate": _to_datetime(b.get("badgeEarnedDate")),
"earnedCount": _num(b.get("badgeEarnedNumber")),
"points": _num(b.get("badgePoints")),
})
stored += 1
return stored
def sync_personal_records(client, user_id):
records = _safe(lambda: client.get_personal_record(), []) or []
stored = 0
for r in records:
rid = r.get("id")
if rid is None:
continue
health.upsert_personal_record(user_id, {
"id": str(rid),
"typeId": _num(r.get("typeId")),
"activityId": r.get("activityId"),
"activityName": r.get("activityName"),
"activityType": r.get("activityType"),
"value": _num(r.get("value")),
# Prefer the pre-formatted strings; the bare fields are epoch ms.
"achievedAt": _to_datetime(
r.get("prStartTimeLocalFormatted"),
r.get("prStartTimeGmtFormatted"),
r.get("activityStartDateTimeLocalFormatted"),
r.get("prStartTimeLocal"),
r.get("prStartTimeGmt"),
),
})
stored += 1
return stored
def _activity_end(start, duration_seconds): def _activity_end(start, duration_seconds):
if not start or not duration_seconds: if not start or not duration_seconds:
return start return start
@@ -260,7 +423,7 @@ def sync_data(user_id, creds, days=None, client=None):
try: try:
client = client or _connect(creds, user_id) client = client or _connect(creds, user_id)
except Exception as e: except Exception as e:
message = str(e) message = describe(e)
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
return { return {
"status": "error", "status": "error",
@@ -280,7 +443,7 @@ def sync_data(user_id, creds, days=None, client=None):
try: try:
record = _extract_daily(client, date_str) record = _extract_daily(client, date_str)
except Exception as e: except Exception as e:
day_errors.append(f"{date_str}: {e}") day_errors.append(f"{date_str}: {describe(e)}")
continue continue
# A day Garmin has no data for comes back all-None; storing it would # A day Garmin has no data for comes back all-None; storing it would
# create an empty row that the metric endpoints then have to filter. # create an empty row that the metric endpoints then have to filter.
@@ -294,7 +457,20 @@ def sync_data(user_id, creds, days=None, client=None):
client, user_id, start_date, today.isoformat() client, user_id, start_date, today.isoformat()
) )
except Exception as e: except Exception as e:
day_errors.append(f"activities: {e}") day_errors.append(f"activities: {describe(e)}")
# Badges and personal records are account-wide rather than per-day, so
# they are fetched once per sync rather than inside the day loop.
badges_synced = 0
records_synced_pr = 0
try:
badges_synced = sync_badges(client, user_id)
except Exception as e:
day_errors.append(f"badges: {describe(e)}")
try:
records_synced_pr = sync_personal_records(client, user_id)
except Exception as e:
day_errors.append(f"personal_records: {describe(e)}")
# Every single day failing means something systemic (expired session, # Every single day failing means something systemic (expired session,
# API change) — reporting that as a clean success would hide it. # API change) — reporting that as a clean success would hide it.
@@ -308,13 +484,18 @@ def sync_data(user_id, creds, days=None, client=None):
user_id, "idle", now, records_synced=days_synced, user_id, "idle", now, records_synced=days_synced,
last_error="; ".join(day_errors[:3]) if day_errors else None, last_error="; ".join(day_errors[:3]) if day_errors else None,
) )
message = f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录" message = (
f"同步完成,更新 {days_synced} 天数据、{activities_synced} 条运动记录、"
f"{badges_synced} 个奖励、{records_synced_pr} 项个人纪录"
)
if day_errors: if day_errors:
message += f"{len(day_errors)} 跳过)" message += f"{len(day_errors)} 跳过)"
return { return {
"status": "success", "status": "success",
"recordsSynced": days_synced, "recordsSynced": days_synced,
"activitiesSynced": activities_synced, "activitiesSynced": activities_synced,
"badgesSynced": badges_synced,
"personalRecordsSynced": records_synced_pr,
"message": message, "message": message,
"lastSyncTime": now, "lastSyncTime": now,
} }

View File

@@ -24,29 +24,35 @@ def _range_sql(user_id, start=None, end=None):
def get_summary(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) sql, params = _range_sql(user_id, start, end)
columns = ", ".join(HEALTH_COLUMNS)
rows = query_all( rows = query_all(
"SELECT date, steps, heart_rate, heart_rate_variability, " f"SELECT date, {columns} FROM health_data {sql} ORDER BY date ASC", params
"sleep_duration, sleep_quality, stress, calories_burned "
f"FROM health_data {sql} ORDER BY date ASC",
params,
) )
return [
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"] = (
{ {
"date": r["date"], "duration": r.get("sleep_duration"),
"steps": r["steps"], "quality": r.get("sleep_quality"),
"heartRate": r["heart_rate"], "deepSeconds": r.get("sleep_deep_seconds"),
"heartRateVariability": r["heart_rate_variability"], "lightSeconds": r.get("sleep_light_seconds"),
"sleep": ( "remSeconds": r.get("sleep_rem_seconds"),
{"duration": r["sleep_duration"], "quality": r["sleep_quality"]} "awakeSeconds": r.get("sleep_awake_seconds"),
if r["sleep_duration"] is not None
else None
),
"stress": r["stress"],
"caloriesBurned": r["calories_burned"],
} }
for r in rows if r.get("sleep_duration") is not None
] else None
)
out.append(day)
return out
def get_steps(user_id, start=None, end=None): def get_steps(user_id, start=None, end=None):
@@ -99,40 +105,121 @@ def get_activities(user_id, start=None, end=None):
return rows 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): def upsert_health_daily(user_id, record):
hid = f"{user_id}-{record['date']}" hid = f"{user_id}-{record['date']}"
cols = [ cols = ["id", "user_id", "date"] + list(HEALTH_COLUMNS)
"id", "user_id", "date", "steps", "heart_rate", values = [hid, user_id, record.get("date")] + [
"heart_rate_variability", "blood_pressure_systolic", record.get(key) for key in HEALTH_COLUMNS.values()
"blood_pressure_diastolic", "sleep_duration", "sleep_quality",
"stress", "calories_burned",
] ]
placeholders = ", ".join(["?"] * len(cols)) _upsert("health_data", ("user_id", "date"), cols, values)
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)
return hid 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): def insert_activity(user_id, activity):
# Prefer Garmin's own activity id when the caller has one: it is stable # 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 # across syncs, which is what lets a re-synced window skip what is already

View File

@@ -117,7 +117,8 @@ class TestHappyPath:
assert row["stress"] == 33 assert row["stress"] == 33
assert row["caloriesBurned"] == 2450 assert row["caloriesBurned"] == 2450
assert row["heartRateVariability"] == 52 assert row["heartRateVariability"] == 52
assert row["sleep"] == {"duration": 8.0, "quality": 91} assert row["sleep"]["duration"] == 8.0
assert row["sleep"]["quality"] == 91
def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user): def test_sleep_and_hrv_come_from_their_own_endpoints(self, db, user):
"""Regression: both live outside get_user_summary. Reading only the """Regression: both live outside get_user_summary. Reading only the
@@ -408,3 +409,121 @@ class TestAuthStatusEndpoint:
garmin_svc.save_token(user["id"], "blob", "g@example.com") garmin_svc.save_token(user["id"], "blob", "g@example.com")
r = client.post("/api/garmin/sync", headers=auth, json={}) r = client.post("/api/garmin/sync", headers=auth, json={})
assert r.status_code != 400 assert r.status_code != 400
class TestApiUserAgent:
"""Regression: garth keeps its browser User-Agent after login, and the
Garmin data API answers that UA with HTTP 200 and an empty array — every
endpoint silently returns nothing."""
def test_a_browser_user_agent_is_not_used_for_the_api(self):
assert "Mozilla" not in garmin_svc.API_USER_AGENT
assert "iPhone" not in garmin_svc.API_USER_AGENT
def test_header_is_swapped_after_loading_a_token(self, db, user):
garmin_svc.save_token(user["id"], "blob", "g@example.com")
headers = {"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_5)"}
class StubSess:
def __init__(self): self.headers = headers
class StubGarth:
profile = {"displayName": "Tester"}
def __init__(self): self.sess = StubSess()
def loads(self, s): pass
def refresh_oauth2(self): pass
class StubGarmin:
def __init__(self, *a, **k): self.garth = StubGarth()
monkey = pytest.MonkeyPatch()
monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
try:
garmin_svc._connect({}, user["id"])
finally:
monkey.undo()
assert headers["User-Agent"] == garmin_svc.API_USER_AGENT
def test_swap_is_harmless_on_a_client_without_a_session(self):
class Bare:
garth = object()
garmin_svc._use_api_user_agent(Bare()) # must not raise
def test_display_name_is_populated(self, db, user):
"""garminconnect builds URLs from display_name; unset sends every
request to '.../None'."""
class StubGarth:
profile = {"displayName": "Tester"}
sess = type("S", (), {"headers": {}})()
def loads(self, s): pass
def refresh_oauth2(self): pass
class StubGarmin:
def __init__(self, *a, **k): self.garth = StubGarth()
garmin_svc.save_token(user["id"], "blob", "g@example.com")
monkey = pytest.MonkeyPatch()
monkey.setattr(garmin_svc, "_import_garmin", lambda: StubGarmin)
try:
client = garmin_svc._connect({}, user["id"])
finally:
monkey.undo()
assert client.display_name == "Tester"
class TestErrorMessagesAreNeverEmpty:
"""Regression: a bare `assert` inside garth raised AssertionError with an
empty str(), which was stored as the sync's reason — a failed sync with a
blank explanation cannot be diagnosed."""
def test_exception_without_text_still_describes_itself(self):
assert garmin_svc.describe(AssertionError()) == "AssertionError"
def test_exception_with_text_keeps_it(self):
assert "boom" in garmin_svc.describe(RuntimeError("boom"))
assert "RuntimeError" in garmin_svc.describe(RuntimeError("boom"))
def test_whitespace_only_text_is_treated_as_empty(self):
assert garmin_svc.describe(ValueError(" ")) == "ValueError"
def test_connect_failure_records_a_non_empty_reason(self, db, user, monkeypatch):
def boom(_creds, _uid=None):
raise AssertionError() # no message at all
monkeypatch.setattr(garmin_svc, "_connect", boom)
out = garmin_svc.sync_data(user["id"], CREDS, days=1)
assert out["status"] == "error"
assert out["message"].strip()
assert garmin_svc.get_sync_status(user["id"])["lastError"].strip()
class TestTimestampNormalisation:
"""Regression: Garmin mixes ISO strings and epoch milliseconds in one
payload. Writing the numeric form to a DATETIME column is rejected, which
failed the entire personal-records batch."""
def test_iso_string_passes_through(self):
assert garmin_svc._to_datetime("2019-10-13T10:10:12.0").startswith(
"2019-10-13T10:10:12"
)
def test_epoch_milliseconds_are_converted(self):
assert garmin_svc._to_datetime(1570961412000).startswith("2019-10-13")
def test_epoch_seconds_are_converted(self):
assert garmin_svc._to_datetime(1570961412).startswith("2019-10-13")
def test_first_usable_value_wins(self):
assert garmin_svc._to_datetime(None, "", "2020-01-01T00:00:00") == (
"2020-01-01T00:00:00"
)
def test_all_empty_gives_none(self):
assert garmin_svc._to_datetime(None, "") is None
def test_nonsense_value_does_not_raise(self):
assert garmin_svc._to_datetime(float("inf")) is None

View File

@@ -31,7 +31,9 @@ class TestGetSummary:
assert row["heartRate"] == 70 assert row["heartRate"] == 70
assert row["heartRateVariability"] == 45 assert row["heartRateVariability"] == 45
assert row["caloriesBurned"] == 260 assert row["caloriesBurned"] == 260
assert row["sleep"] == {"duration": 6, "quality": 80} # Sleep carries stage detail too; the core two must be right.
assert row["sleep"]["duration"] == 6
assert row["sleep"]["quality"] == 80
def test_sleep_is_none_when_absent(self, seed_health, user): def test_sleep_is_none_when_absent(self, seed_health, user):
seed_health([{"date": "2026-08-20", "steps": 100}]) seed_health([{"date": "2026-08-20", "steps": 100}])
@@ -187,3 +189,68 @@ class TestEndpoints:
"/api/health/summary?startDate=2026-08-22", headers=auth "/api/health/summary?startDate=2026-08-22", headers=auth
).get_json() ).get_json()
assert len(body) == 1 assert len(body) == 1
class TestBadgesAndRecords:
"""Badges ("奖励") and personal records are account-wide, not per-day."""
BADGE = {
"id": "1822", "badgeKey": "sleep_30_days", "name": "Sleep Savant",
"categoryId": 3, "difficultyId": 2, "earnedDate": "2026-08-01T10:00:00",
"earnedCount": 1, "points": 5,
}
def test_badge_round_trips(self, db, user):
health_svc.upsert_badge(user["id"], self.BADGE)
rows = health_svc.get_badges(user["id"])
assert len(rows) == 1
assert rows[0]["name"] == "Sleep Savant"
assert rows[0]["badge_key"] == "sleep_30_days"
def test_resync_updates_rather_than_duplicating(self, db, user):
health_svc.upsert_badge(user["id"], self.BADGE)
health_svc.upsert_badge(user["id"], {**self.BADGE, "earnedCount": 2})
rows = health_svc.get_badges(user["id"])
assert len(rows) == 1
assert rows[0]["earned_count"] == 2
def test_badges_are_per_user(self, db, user, client):
health_svc.upsert_badge(user["id"], self.BADGE)
other = client.post(
"/api/auth/register",
json={"email": "b@example.com", "garminEmail": "bg@example.com",
"garminPassword": "pw123456"},
).get_json()
assert health_svc.get_badges(other["id"]) == []
def test_two_users_may_hold_the_same_badge_id(self, db, user, client):
"""The key is (user, badge), so the same Garmin badge on two accounts
must not collide."""
other = client.post(
"/api/auth/register",
json={"email": "c@example.com", "garminEmail": "cg@example.com",
"garminPassword": "pw123456"},
).get_json()
health_svc.upsert_badge(user["id"], self.BADGE)
health_svc.upsert_badge(other["id"], self.BADGE)
assert len(health_svc.get_badges(user["id"])) == 1
assert len(health_svc.get_badges(other["id"])) == 1
def test_personal_record_round_trips(self, db, user):
health_svc.upsert_personal_record(user["id"], {
"id": "2538883970", "typeId": 1, "activityId": "17446848459",
"activityName": "晨跑", "activityType": "running",
"value": 1234.5, "achievedAt": "2026-08-01T07:00:00",
})
rows = health_svc.get_personal_records(user["id"])
assert len(rows) == 1 and rows[0]["activity_name"] == "晨跑"
def test_endpoints_require_auth(self, client):
assert client.get("/api/health/badges").status_code == 401
assert client.get("/api/health/personal-records").status_code == 401
def test_endpoints_return_lists(self, client, auth):
assert isinstance(client.get("/api/health/badges", headers=auth).get_json(), list)
assert isinstance(
client.get("/api/health/personal-records", headers=auth).get_json(), list
)

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { import {
apiClient, errorMessage, GarminLoginStatus, SyncStatus, apiClient, errorMessage, GarminLoginStatus, parseUtc, SyncStatus,
} from '../services/api'; } from '../services/api';
import './DataSync.css'; import './DataSync.css';
@@ -183,9 +183,8 @@ function DataSync() {
<div className="status-item"> <div className="status-item">
<span className="label"></span> <span className="label"></span>
<span className="value"> <span className="value">
{syncStatus.lastSyncTime {parseUtc(syncStatus.lastSyncTime)?.toLocaleString('zh-CN')
? new Date(syncStatus.lastSyncTime).toLocaleString('zh-CN') ?? '从未同步'}
: '从未同步'}
</span> </span>
</div> </div>
<div className="status-item"> <div className="status-item">

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
apiClient, errorMessage, AiRecommendations, ModelInfo, apiClient, errorMessage, AiRecommendations, ModelInfo, parseUtc,
} from '../services/api'; } from '../services/api';
import './Recommendations.css'; import './Recommendations.css';
@@ -112,7 +112,7 @@ function Recommendations() {
<span className="fallback"> <span className="fallback">
· ·
{meta.generatedAt && {meta.generatedAt &&
`,生成于 ${new Date(meta.generatedAt + 'Z').toLocaleString('zh-CN')}`} `,生成于 ${parseUtc(meta.generatedAt)?.toLocaleString('zh-CN')}`}
</span> </span>
)} )}
{meta.fallbackFrom && meta.fallbackFrom.length > 0 && ( {meta.fallbackFrom && meta.fallbackFrom.length > 0 && (

View File

@@ -85,6 +85,21 @@ export interface TrendPoint {
value: number; value: number;
} }
/**
* Parse a timestamp the backend wrote with `datetime.utcnow()` — i.e. UTC but
* with no offset in the string. JavaScript reads such a value as *local* time,
* which showed sync times eight hours in the past here.
*/
export function parseUtc(value: string | null | undefined): Date | null {
if (!value) return null;
const normalised = value.replace(' ', 'T');
const withZone = /[Zz]|[+-]\d{2}:?\d{2}$/.test(normalised)
? normalised
: `${normalised}Z`;
const date = new Date(withZone);
return Number.isNaN(date.getTime()) ? null : date;
}
/** Pull a human-readable message out of an axios error. */ /** Pull a human-readable message out of an axios error. */
export function errorMessage(err: any, fallback = '请求失败'): string { export function errorMessage(err: any, fallback = '请求失败'): string {
return err?.response?.data?.error || err?.message || fallback; return err?.response?.data?.error || err?.message || fallback;