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>
This commit is contained in:
ericwyuan
2026-08-24 04:16:33 +08:00
parent 34940cc387
commit f1319a6171
19 changed files with 1520 additions and 29 deletions

View File

@@ -27,6 +27,7 @@ import threading
from db import execute, query_one, query_all
from config import DB_TYPE
from services import health
from services import garmin_extras as extras
# How many days back a sync reaches.
DEFAULT_SYNC_DAYS = int(os.environ.get("GARMIN_SYNC_DAYS") or 7)
@@ -647,47 +648,82 @@ def sync_activity_details(client, user_id, limit=None, on_progress=None):
return stored
_detail_progress = {}
_backfill_progress = {}
def detail_sync_status(user_id):
"""Progress of the detail backfill for this account."""
return _detail_progress.get(user_id) or {"running": False, "done": 0, "total": 0}
def backfill_status(user_id):
"""Progress of the historical backfill for this account."""
return _backfill_progress.get(user_id) or {
"running": False, "stage": None, "done": 0, "total": 0, "error": None,
}
def start_detail_sync(user_id, limit=None):
"""Backfill activity details in the background.
def _set_backfill(user_id, **fields):
state = dict(_backfill_progress.get(user_id) or {})
state.update(fields)
_backfill_progress[user_id] = state
Each activity costs several Garmin calls, so 170 of them run for minutes —
far too long to hold a request open. The UI polls instead.
def days_missing_series(user_id, limit=None):
"""Days that have a health row but no within-day curves stored."""
rows = query_all(
"SELECT h.date FROM health_data h "
"LEFT JOIN daily_series s ON s.user_id = h.user_id AND s.date = h.date "
"WHERE h.user_id = ? AND s.date IS NULL "
"GROUP BY h.date ORDER BY h.date DESC",
[user_id],
)
dates = [str(r["date"])[:10] for r in rows]
return dates[:limit] if limit else dates
def start_backfill(user_id, limit=None):
"""Fill in everything the per-day sync leaves out, in the background.
Two long jobs share one runner because they share a cause — an account
whose history predates these features — and because the user should press
one button, not two. Each activity costs several Garmin calls and each day
of curves costs five, so this runs for minutes; the UI polls.
"""
state = _detail_progress.get(user_id)
state = _backfill_progress.get(user_id)
if state and state.get("running"):
return state
_detail_progress[user_id] = {"running": True, "done": 0, "total": 0, "error": None}
_set_backfill(user_id, running=True, stage="启动中", done=0, total=0, error=None)
def run():
try:
client = _connect({}, user_id=user_id)
def progress(done, total):
_detail_progress[user_id] = {
"running": True, "done": done, "total": total, "error": None,
}
_set_backfill(user_id, stage="运动详情", done=0, total=0)
sync_activity_details(
client, user_id, limit,
on_progress=lambda d, n: _set_backfill(
user_id, stage="运动详情", done=d, total=n),
)
stored = sync_activity_details(client, user_id, limit, on_progress=progress)
_detail_progress[user_id] = {
"running": False, "done": stored,
"total": _detail_progress[user_id].get("total", stored), "error": None,
}
dates = days_missing_series(user_id, limit)
_set_backfill(user_id, stage="每日曲线", done=0, total=len(dates))
for i, date in enumerate(dates):
try:
extras.sync_daily_series(client, user_id, date)
except Exception: # noqa: BLE001 - one day must not stop the rest
pass
_set_backfill(user_id, stage="每日曲线", done=i + 1,
total=len(dates))
_set_backfill(user_id, running=False, stage="完成", error=None)
except Exception as e: # noqa: BLE001 - reported through the status endpoint
_detail_progress[user_id] = {
"running": False, "done": 0, "total": 0, "error": describe(e),
}
_set_backfill(user_id, running=False, stage=None, error=describe(e))
threading.Thread(target=run, daemon=True, name=f"detail-sync-{user_id}").start()
return _detail_progress[user_id]
threading.Thread(target=run, daemon=True, name=f"backfill-{user_id}").start()
return backfill_status(user_id)
# Up to this many days, a sync also pulls each day's within-day curves inline.
# Beyond it the curves are left to the background backfill: five extra calls
# per day would turn a year's sync into an hour.
SERIES_INLINE_DAYS = 14
# Above this many days a sync is long enough that the caller must not block
@@ -749,6 +785,7 @@ def sync_data(user_id, creds, days=None, client=None):
date_str = (today - datetime.timedelta(days=i)).isoformat()
try:
record = _extract_daily(client, date_str)
record.update(extras.daily_extras(client, date_str))
except Exception as e:
day_errors.append(f"{date_str}: {describe(e)}")
continue
@@ -757,6 +794,14 @@ def sync_data(user_id, creds, days=None, client=None):
if any(record[k] is not None for k in record if k != "date"):
health.upsert_health_daily(user_id, record)
days_synced += 1
# Within-day curves for short syncs only. A year-long backfill
# would add five calls per day on top of everything else; those
# days are filled by start_backfill instead.
if days <= SERIES_INLINE_DAYS:
try:
extras.sync_daily_series(client, user_id, date_str)
except Exception as e: # noqa: BLE001
day_errors.append(f"{date_str} series: {describe(e)}")
# Reported every few days rather than every day: the write is cheap
# but not free, and the UI polls on a 2s cadence anyway.
@@ -783,6 +828,26 @@ def sync_data(user_id, creds, days=None, client=None):
except Exception as e:
day_errors.append(f"activity_details: {describe(e)}")
# Everything else Garmin holds: body composition, blood pressure, race
# predictions, challenges and devices. Account-wide, so once per sync.
extra_counts = {}
for name, call in (
("bodyComposition",
lambda: extras.sync_body_composition(client, user_id, start_date,
today.isoformat())),
("bloodPressure",
lambda: extras.sync_blood_pressure(client, user_id, start_date,
today.isoformat())),
("racePredictions",
lambda: extras.sync_race_predictions(client, user_id)),
("challenges", lambda: extras.sync_challenges(client, user_id)),
("devices", lambda: extras.sync_devices(client, user_id)),
):
try:
extra_counts[name] = call()
except Exception as e: # noqa: BLE001 - one section must not fail the sync
day_errors.append(f"{name}: {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