Files
GarminHealthLab/backend/services/settings.py
ericwyuan 92ea33b800 feat(sync): 全部历史真的是全部,不再截断在两年
730 天是个凭空写死的上限。账号有七年数据的人选「全部历史」,拿到的是
最近两年,而且没有任何提示说剩下的被丢掉了。

* 全部历史现在一直回溯到账号最早的数据:连续 EMPTY_RUN_STOP(120) 天
  完全没有内容就停,所以既不会截断,也不会去问手表存在之前的年份。
  MAX_HISTORY_DAYS(3650) 只是兜底,可用环境变量覆盖。
* 已经存过的日期跳过(最近 3 天除外,它们还在写入中)。这让多年的
  回填变成可续传的:撞上限流停下来,冷却过后再点一次就从断点继续,
  而不是每次都从今天重新爬。
* 选择器补上 3 年 / 5 年。
* 前端把每个范围的实际代价写出来,并说明中断可续。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:30:03 +08:00

221 lines
7.4 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,
# The range last chosen on the 数据同步 page, remembered so the picker
# opens where the user left it. -1 is 自上次同步 and 0 is 全部历史.
#
# This is a *manual* sync setting and nothing else reads it — auto-sync
# has its own fixed window (scheduler.SYNC_DAYS). It used to live in 设置
# as 历史范围 while only taking effect on another page, which is exactly
# how "全部历史" ended up looking like it did nothing.
"history_days": -1,
}
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)
# 0 is 全部历史 (walk back to the start of the account) and -1 自上次同步.
HISTORY = (7, 30, 90, 180, 365, 730, 1095, 1825, 0, -1)
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)