[阶段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

@@ -117,7 +117,8 @@ class TestHappyPath:
assert row["stress"] == 33
assert row["caloriesBurned"] == 2450
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):
"""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")
r = client.post("/api/garmin/sync", headers=auth, json={})
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