[阶段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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user