Files
GarminHealthLab/backend/services/settings.py
ericwyuan 15fba8c25c feat: NAS 部署 + auth-hub 认证 + 同步逻辑修复
部署:
- 后端 Python/Flask,数据层可插拔 SQLite/MariaDB
- NAS 部署路径 /volume1/web/garmin-health-lab,端口 8124
- Gunicorn 生产服务器 (2 workers / 4 threads)
- 开机自启脚本 deploy/S99garmin.sh
- frp 隧道甲骨文 8124 → NAS 8124,外网访问
- 前端构建产物纳入版本管理 (backend/static/)

认证:
- auth-hub OAuth2/OIDC 统一登录接入
- 新建 NAS 专用 client,注册内外网回调地址
- 前端 LoginPage 支持回调路由 /auth/callback
- 数据同步页增加 Garmin 邮箱输入框

同步逻辑修复:
- 自动同步调度器读取用户 history_days 设置,不再固定 2 天
- 前端 0(全部历史)不再被 || 吞掉,改为 ?? 处理
- 后端路由和 sync_data 中 0 不再被当成 falsy 回退默认值
- sync_data 和调度器中 0 → 730 天(全部历史=最大范围)
- 已同步天数显示数据库实际总天数 (totalDays)
- 历史范围新增「自上次同步」增量选项 (days=-1)
- 后端 settings.py HISTORY 添加 -1 值

项目文档:
- 创建 PROGRESS.md 跟踪项目进度
2026-09-01 07:39:09 +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, -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)