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

@@ -31,7 +31,9 @@ class TestGetSummary:
assert row["heartRate"] == 70
assert row["heartRateVariability"] == 45
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):
seed_health([{"date": "2026-08-20", "steps": 100}])
@@ -187,3 +189,68 @@ class TestEndpoints:
"/api/health/summary?startDate=2026-08-22", headers=auth
).get_json()
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
)