Files
GarminHealthLab/backend/services/settings.py
ericwyuan c70e7ced80 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>
2026-08-24 00:26:26 +08:00

214 lines
7.0 KiB
Python

"""
Per-user profile and preferences.
Two unrelated things share one table because they share a lifetime and an
edit screen: body measurements (height, weight, birth date, sex) that the
rating bands and the fitness-age estimate read, and preferences (units, how
often to sync, how far back to pull).
Every value is optional. An account with an empty profile still works — it
just falls back to general-population bands instead of personalised ones.
"""
import datetime
from db import execute, query_one
from config import DB_TYPE
# Only these are accepted from a request body; anything else is ignored rather
# than rejected, so an older client posting an unknown field still saves.
FIELDS = (
"height_cm", "weight_kg", "birth_date", "sex", "units",
"auto_sync", "auto_sync_minutes", "history_days",
)
DEFAULTS = {
"height_cm": None,
"weight_kg": None,
"birth_date": None,
"sex": None,
"units": "metric",
"auto_sync": 1,
"auto_sync_minutes": 60,
# How far back a full sync reaches. 0 means "everything Garmin has".
"history_days": 365,
}
SEXES = ("male", "female", "other")
UNITS = ("metric", "imperial")
# Offered in the UI as a picker; anything else is snapped to the nearest.
INTERVALS = (30, 60, 180, 360, 720, 1440)
HISTORY = (7, 30, 90, 180, 365, 730, 0)
CAMEL = {
"height_cm": "heightCm",
"weight_kg": "weightKg",
"birth_date": "birthDate",
"sex": "sex",
"units": "units",
"auto_sync": "autoSync",
"auto_sync_minutes": "autoSyncMinutes",
"history_days": "historyDays",
}
class InvalidSetting(ValueError):
"""A value the UI should not have sent; the message is user-facing."""
def _number(value, low, high, label):
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
raise InvalidSetting(f"{label}必须是数字")
if not low <= n <= high:
raise InvalidSetting(f"{label}应在 {low:g}~{high:g} 之间")
return n
def _date(value, label):
if not value:
return None
text = str(value)[:10]
try:
d = datetime.date.fromisoformat(text)
except ValueError:
raise InvalidSetting(f"{label}格式应为 YYYY-MM-DD")
today = datetime.date.today()
if not 0 < (today - d).days < 365 * 120:
raise InvalidSetting(f"{label}看起来不对")
return text
def _choice(value, allowed, label, default=None):
if value is None or value == "":
return default
if value not in allowed:
raise InvalidSetting(f"{label}只能是 {'/'.join(map(str, allowed))}")
return value
def _snap(value, allowed, default):
"""Nearest allowed number rather than an error.
The interval and history controls are pickers, so an off-list value means
a stale client, not a user mistake — snapping keeps them working.
"""
if value is None or value == "":
return default
try:
n = int(value)
except (TypeError, ValueError):
return default
if n in allowed:
return n
if n <= 0:
return 0 if 0 in allowed else default
return min((a for a in allowed if a > 0), key=lambda a: abs(a - n))
def clean(patch):
"""Validate an incoming patch into storable columns."""
out = {}
if "heightCm" in patch or "height_cm" in patch:
out["height_cm"] = _number(
patch.get("heightCm", patch.get("height_cm")), 80, 250, "身高"
)
if "weightKg" in patch or "weight_kg" in patch:
out["weight_kg"] = _number(
patch.get("weightKg", patch.get("weight_kg")), 25, 300, "体重"
)
if "birthDate" in patch or "birth_date" in patch:
out["birth_date"] = _date(
patch.get("birthDate", patch.get("birth_date")), "出生日期"
)
if "sex" in patch:
out["sex"] = _choice(patch.get("sex"), SEXES, "性别")
if "units" in patch:
out["units"] = _choice(patch.get("units"), UNITS, "单位", "metric")
if "autoSync" in patch or "auto_sync" in patch:
out["auto_sync"] = 1 if patch.get("autoSync", patch.get("auto_sync")) else 0
if "autoSyncMinutes" in patch or "auto_sync_minutes" in patch:
out["auto_sync_minutes"] = _snap(
patch.get("autoSyncMinutes", patch.get("auto_sync_minutes")),
INTERVALS, DEFAULTS["auto_sync_minutes"],
)
if "historyDays" in patch or "history_days" in patch:
out["history_days"] = _snap(
patch.get("historyDays", patch.get("history_days")),
HISTORY, DEFAULTS["history_days"],
)
return out
def get_raw(user_id):
"""Settings as column names, with defaults filled in for missing rows."""
row = query_one("SELECT * FROM user_settings WHERE user_id = ?", [user_id])
merged = dict(DEFAULTS)
if row:
for key in FIELDS:
value = row.get(key)
if value is not None:
merged[key] = value
# DATE comes back as a date object from MariaDB and a string from SQLite.
if merged["birth_date"] is not None:
merged["birth_date"] = str(merged["birth_date"])[:10]
return merged
def get_settings(user_id):
"""Settings in the camelCase the UI consumes, plus derived age."""
raw = get_raw(user_id)
out = {CAMEL[k]: raw[k] for k in FIELDS}
out["autoSync"] = bool(raw["auto_sync"])
out["age"] = age_from(raw["birth_date"])
out["bmi"] = bmi_from(raw["height_cm"], raw["weight_kg"])
return out
def save_settings(user_id, patch):
changes = clean(patch)
if not changes:
return get_settings(user_id)
current = get_raw(user_id)
current.update(changes)
current["updated_at"] = datetime.datetime.utcnow().isoformat(timespec="seconds")
cols = ["user_id"] + list(FIELDS) + ["updated_at"]
values = [user_id] + [current[k] for k in FIELDS] + [current["updated_at"]]
placeholders = ", ".join(["?"] * len(cols))
updatable = [c for c in cols if c != "user_id"]
if DB_TYPE == "mariadb":
updates = ", ".join(f"{c}=VALUES({c})" for c in updatable)
sql = (f"INSERT INTO user_settings ({', '.join(cols)}) "
f"VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}")
else:
updates = ", ".join(f"{c}=excluded.{c}" for c in updatable)
sql = (f"INSERT INTO user_settings ({', '.join(cols)}) "
f"VALUES ({placeholders}) ON CONFLICT(user_id) DO UPDATE SET {updates}")
execute(sql, values)
return get_settings(user_id)
def age_from(birth_date):
if not birth_date:
return None
try:
born = datetime.date.fromisoformat(str(birth_date)[:10])
except ValueError:
return None
today = datetime.date.today()
# Subtract a year when this year's birthday has not happened yet.
return today.year - born.year - (
(today.month, today.day) < (born.month, born.day)
)
def bmi_from(height_cm, weight_kg):
if not height_cm or not weight_kg:
return None
metres = float(height_cm) / 100
return round(float(weight_kg) / (metres * metres), 1)