feat(api): 个人资料/单位/同步偏好 + 运动详情 + 身体年龄
设置 (services/settings.py, routes/settings.py) - user_settings 表:身高/体重/出生日期/性别/单位/自动同步开关/同步频率/历史范围 - GET|PUT /api/settings,GET /api/settings/options(取值由后端给,前端不臆造) - GET /api/settings/rating-basis:把每条参考区间的来源公开出来。 一个把数字标成「偏低」的区间是在下判断,用户有权看到依据。 运动详情 (services/garmin.py) - GET /api/garmin/activities/<id>/detail:概览/分段/心率区间/天气/装备/采样曲线 - 首次打开回源 Garmin 并落库,之后走缓存;?refresh=1 强制刷新 - 采样点在写入时抽稀到 300,手机图表画不了更多,也免得整行撑大 身体年龄 (services/fitness_age.py) - 0.2.8 版 garminconnect 没有 fitnessage 接口,改为本地按公开常模推算: VO₂max 对应年龄为基准,静息心率与 BMI 做有上限的修正 - 返回每一步的中间值,界面照实展示,不做成一个不可追溯的分数 - 高于参考表最年轻一档时按 20 岁计——那里外推会得到「11 岁」这种结果 调度器改为每 5 分钟 tick,是否该同步按各账号自己的频率判断 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ The last two are easy to confuse: `get_activities` takes an offset and a count,
|
||||
so passing it a date silently asks for activity number "2026-08-23".
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
@@ -415,6 +416,172 @@ def _sync_activities(client, user_id, start_date, end_date):
|
||||
return stored
|
||||
|
||||
|
||||
# --- one activity, in full ---------------------------------------------------
|
||||
|
||||
# Garmin will return thousands of samples per activity. A phone chart cannot
|
||||
# draw more than a few hundred usefully, and the payload is stored as a row, so
|
||||
# the series are thinned on the way in rather than on every read.
|
||||
DETAIL_MAX_POINTS = 300
|
||||
|
||||
# Descriptor key -> the name the UI charts by. Anything not listed is dropped:
|
||||
# the full descriptor set runs to dozens of fields, most of them empty.
|
||||
SERIES_KEYS = {
|
||||
"directTimestamp": "timestamp",
|
||||
"sumElapsedDuration": "elapsed",
|
||||
"sumDuration": "duration",
|
||||
"sumDistance": "distance",
|
||||
"directHeartRate": "heartRate",
|
||||
"directSpeed": "speed",
|
||||
"directElevation": "elevation",
|
||||
"directRunCadence": "cadence",
|
||||
"directBikeCadence": "cadence",
|
||||
"directDoubleCadence": "cadence",
|
||||
"directPower": "power",
|
||||
"directAirTemperature": "temperature",
|
||||
}
|
||||
|
||||
|
||||
def _thin(values, limit=DETAIL_MAX_POINTS):
|
||||
"""Evenly sample a list down to `limit` points, keeping first and last."""
|
||||
if len(values) <= limit:
|
||||
return values
|
||||
step = (len(values) - 1) / (limit - 1)
|
||||
return [values[int(round(i * step))] for i in range(limit)]
|
||||
|
||||
|
||||
def _series_from_details(details):
|
||||
"""Turn Garmin's column-store detail payload into per-metric arrays.
|
||||
|
||||
The response is a descriptor list plus rows of parallel values, so every
|
||||
metric has to be read out by the index its descriptor names.
|
||||
"""
|
||||
descriptors = details.get("metricDescriptors") or []
|
||||
rows = details.get("activityDetailMetrics") or []
|
||||
if not descriptors or not rows:
|
||||
return {}
|
||||
|
||||
index = {}
|
||||
for d in descriptors:
|
||||
name = SERIES_KEYS.get(d.get("key"))
|
||||
if name and name not in index:
|
||||
index[name] = d.get("metricsIndex")
|
||||
|
||||
rows = _thin(rows)
|
||||
out = {}
|
||||
for name, position in index.items():
|
||||
if position is None:
|
||||
continue
|
||||
column = []
|
||||
for row in rows:
|
||||
metrics = row.get("metrics") or []
|
||||
column.append(metrics[position] if position < len(metrics) else None)
|
||||
# A column of nothing but nulls is a sensor the watch does not have.
|
||||
if any(v is not None for v in column):
|
||||
out[name] = column
|
||||
return out
|
||||
|
||||
|
||||
def _lap_rows(splits):
|
||||
laps = []
|
||||
for i, lap in enumerate((splits or {}).get("lapDTOs") or [], start=1):
|
||||
laps.append({
|
||||
"index": lap.get("lapIndex") or i,
|
||||
"duration": _num(lap.get("duration")),
|
||||
"movingDuration": _num(lap.get("movingDuration")),
|
||||
"distance": _num(lap.get("distance")),
|
||||
"averageSpeed": _num(lap.get("averageSpeed")),
|
||||
"maxSpeed": _num(lap.get("maxSpeed")),
|
||||
"calories": _num(lap.get("calories")),
|
||||
"averageHR": _num(lap.get("averageHR")),
|
||||
"maxHR": _num(lap.get("maxHR")),
|
||||
"elevationGain": _num(lap.get("elevationGain")),
|
||||
"elevationLoss": _num(lap.get("elevationLoss")),
|
||||
})
|
||||
return laps
|
||||
|
||||
|
||||
def _hr_zones(zones):
|
||||
out = []
|
||||
for z in zones or []:
|
||||
out.append({
|
||||
"zone": z.get("zoneNumber"),
|
||||
"seconds": _num(z.get("secsInZone")) or 0,
|
||||
"lowBoundary": _num(z.get("zoneLowBoundary")),
|
||||
})
|
||||
return sorted(out, key=lambda z: z.get("zone") or 0)
|
||||
|
||||
|
||||
def _build_detail(client, activity_id):
|
||||
"""Assemble everything Garmin knows about one activity.
|
||||
|
||||
Each call is wrapped: a watch without a barometer has no weather, a
|
||||
treadmill run has no gear, and a missing optional endpoint must leave the
|
||||
rest of the page intact rather than fail the request.
|
||||
"""
|
||||
summary = _safe(lambda: client.get_activity_evaluation(activity_id), {}) or {}
|
||||
details = _safe(
|
||||
lambda: client.get_activity_details(activity_id, maxchart=2000, maxpoly=0), {}
|
||||
) or {}
|
||||
|
||||
return {
|
||||
"activityId": str(activity_id),
|
||||
"summary": summary.get("summaryDTO") or {},
|
||||
"activityName": summary.get("activityName"),
|
||||
"activityType": (summary.get("activityTypeDTO") or {}).get("typeKey"),
|
||||
"eventType": (summary.get("eventTypeDTO") or {}).get("typeKey"),
|
||||
"laps": _lap_rows(_safe(lambda: client.get_activity_splits(activity_id), {})),
|
||||
"hrZones": _hr_zones(
|
||||
_safe(lambda: client.get_activity_hr_in_timezones(activity_id), [])
|
||||
),
|
||||
"weather": _safe(lambda: client.get_activity_weather(activity_id), {}) or {},
|
||||
"gear": _safe(lambda: client.get_activity_gear(activity_id), []) or [],
|
||||
"exerciseSets": (
|
||||
_safe(lambda: client.get_activity_exercise_sets(activity_id), {}) or {}
|
||||
).get("exerciseSets") or [],
|
||||
"series": _series_from_details(details),
|
||||
}
|
||||
|
||||
|
||||
def get_activity_detail(user_id, activity_id, creds=None, refresh=False):
|
||||
"""Cached detail for one activity, fetched from Garmin on first open."""
|
||||
activity_id = str(activity_id)
|
||||
|
||||
if not refresh:
|
||||
row = query_one(
|
||||
"SELECT payload FROM activity_details "
|
||||
"WHERE user_id = ? AND activity_id = ?",
|
||||
[user_id, activity_id],
|
||||
)
|
||||
if row and row.get("payload"):
|
||||
try:
|
||||
cached = json.loads(row["payload"])
|
||||
cached["cached"] = True
|
||||
return cached
|
||||
except ValueError:
|
||||
# A truncated row is worth refetching, not worth crashing on.
|
||||
pass
|
||||
|
||||
client = _connect(creds or {}, user_id=user_id)
|
||||
detail = _build_detail(client, activity_id)
|
||||
|
||||
cols = ["activity_id", "user_id", "payload", "fetched_at"]
|
||||
values = [activity_id, user_id, json.dumps(detail, default=str),
|
||||
datetime.datetime.utcnow().isoformat(timespec="seconds")]
|
||||
placeholders = ", ".join(["?"] * len(cols))
|
||||
if DB_TYPE == "mariadb":
|
||||
updates = ", ".join(f"{c}=VALUES({c})" for c in cols if c != "activity_id")
|
||||
sql = (f"INSERT INTO activity_details ({', '.join(cols)}) "
|
||||
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
|
||||
else:
|
||||
updates = ", ".join(f"{c}=excluded.{c}" for c in cols if c != "activity_id")
|
||||
sql = (f"INSERT INTO activity_details ({', '.join(cols)}) VALUES "
|
||||
f"({placeholders}) ON CONFLICT(activity_id) DO UPDATE SET {updates}")
|
||||
execute(sql, values)
|
||||
|
||||
detail["cached"] = False
|
||||
return detail
|
||||
|
||||
|
||||
# Above this many days a sync is long enough that the caller must not block
|
||||
# on it — a year takes roughly 20 minutes at ~3s per day.
|
||||
BACKGROUND_THRESHOLD_DAYS = 14
|
||||
|
||||
Reference in New Issue
Block a user