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:
ericwyuan
2026-08-24 00:26:26 +08:00
parent 12ef5ca06b
commit c70e7ced80
9 changed files with 789 additions and 11 deletions

View File

@@ -11,7 +11,7 @@ from flask_cors import CORS
import db import db
from config import CORS_ORIGINS, PORT, STATIC_DIR from config import CORS_ORIGINS, PORT, STATIC_DIR
from routes import auth, garmin, health, analysis from routes import auth, garmin, health, analysis, settings
from services import scheduler from services import scheduler
@@ -47,6 +47,7 @@ def create_app():
app.register_blueprint(garmin.bp, url_prefix="/api/garmin") app.register_blueprint(garmin.bp, url_prefix="/api/garmin")
app.register_blueprint(health.bp, url_prefix="/api/health") app.register_blueprint(health.bp, url_prefix="/api/health")
app.register_blueprint(analysis.bp, url_prefix="/api/analysis") app.register_blueprint(analysis.bp, url_prefix="/api/analysis")
app.register_blueprint(settings.bp, url_prefix="/api/settings")
@app.errorhandler(404) @app.errorhandler(404)
def not_found(_e): def not_found(_e):

View File

@@ -156,6 +156,38 @@ CREATE TABLE IF NOT EXISTS garmin_mfa_sessions (
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
-- Per-user profile and preferences.
-- Height/weight/birth date/sex are here rather than on `users` because they
-- are body measurements the owner edits over time, not identity; and because
-- the rating bands and the fitness-age estimate need them, an account without
-- them still works, just with fewer personalised readings.
CREATE TABLE IF NOT EXISTS user_settings (
user_id VARCHAR(64) PRIMARY KEY,
height_cm DOUBLE,
weight_kg DOUBLE,
birth_date DATE,
sex VARCHAR(16),
units VARCHAR(16),
auto_sync INT,
auto_sync_minutes INT,
history_days INT,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Full detail for one activity, exactly as Garmin returned it.
-- The list view stores only the summary columns; opening an activity needs
-- laps, heart-rate zones and the sampled series, which are far too large to
-- carry on every list request. Fetched on demand and kept, so the second
-- visit costs nothing and works offline.
CREATE TABLE IF NOT EXISTS activity_details (
activity_id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
payload MEDIUMTEXT,
fetched_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- One cached LLM answer per user. Generating one takes minutes against a -- One cached LLM answer per user. Generating one takes minutes against a
-- large reasoning model, which is far too slow to sit in a page load, so the -- large reasoning model, which is far too slow to sit in a page load, so the
-- result is stored and reused until the underlying data changes. -- result is stored and reused until the underlying data changes.

View File

@@ -146,5 +146,27 @@ def sync_latest():
@bp.route("/auto-sync", methods=["GET"]) @bp.route("/auto-sync", methods=["GET"])
@require_auth @require_auth
def auto_sync_status(): def auto_sync_status():
"""When the scheduler last ran and when it runs next.""" """When the scheduler last ran, and when this account is next due."""
return jsonify(scheduler.status()) return jsonify(scheduler.status(g.user_id))
@bp.route("/activities/<activity_id>/detail", methods=["GET"])
@require_auth
def activity_detail(activity_id):
"""Everything Garmin holds for one activity: stats, laps, zones, series.
The first open goes out to Garmin and takes a few seconds; after that it
is served from the stored copy. `?refresh=1` forces a refetch.
"""
if not garmin_svc.has_token(g.user_id):
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
refresh = request.args.get("refresh") in ("1", "true", "yes")
try:
return jsonify(
garmin_svc.get_activity_detail(g.user_id, activity_id, refresh=refresh)
)
except garmin_svc.MFARequired as e:
return jsonify({"error": str(e)}), 401
except Exception as e: # noqa: BLE001 - surfaced to the user verbatim
return jsonify({"error": garmin_svc.describe(e)}), 502

View File

@@ -1,8 +1,12 @@
"""Health data routes: summary / steps / heart-rate / sleep / activities.""" """Health data routes: summary / steps / heart-rate / sleep / activities."""
import datetime
from flask import Blueprint, request, g, jsonify from flask import Blueprint, request, g, jsonify
from auth import require_auth from auth import require_auth
from services import health as health_svc from services import health as health_svc
from services import settings as settings_svc
from services import fitness_age
bp = Blueprint("health", __name__) bp = Blueprint("health", __name__)
@@ -46,6 +50,33 @@ def activities():
return jsonify(health_svc.get_activities(g.user_id, s, e)) return jsonify(health_svc.get_activities(g.user_id, s, e))
@bp.route("/fitness-age", methods=["GET"])
@require_auth
def body_age():
"""身体年龄, computed from the profile plus the most recent readings.
VO2max only refreshes after an outdoor run or ride, so the search window
is wide: the last recorded value is still the current one.
"""
profile = settings_svc.get_raw(g.user_id)
start = (datetime.date.today() - datetime.timedelta(days=180)).isoformat()
days = health_svc.get_summary(g.user_id, start, None)
def latest(key):
for day in reversed(days):
if day.get(key):
return day[key]
return None
return jsonify(fitness_age.estimate(
age=settings_svc.age_from(profile["birth_date"]),
sex=profile["sex"],
vo2max=latest("vo2max"),
resting_hr=latest("heartRate"),
bmi=settings_svc.bmi_from(profile["height_cm"], profile["weight_kg"]),
))
@bp.route("/badges", methods=["GET"]) @bp.route("/badges", methods=["GET"])
@require_auth @require_auth
def badges(): def badges():

View File

@@ -0,0 +1,85 @@
"""Profile, units and sync preferences."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from services import settings as settings_svc
from services import fitness_age
bp = Blueprint("settings", __name__)
@bp.route("", methods=["GET"])
@bp.route("/", methods=["GET"])
@require_auth
def get_settings():
return jsonify(settings_svc.get_settings(g.user_id))
@bp.route("", methods=["PUT"])
@bp.route("/", methods=["PUT"])
@require_auth
def put_settings():
try:
return jsonify(settings_svc.save_settings(g.user_id, request.json or {}))
except settings_svc.InvalidSetting as e:
return jsonify({"error": str(e)}), 400
@bp.route("/options", methods=["GET"])
@require_auth
def options():
"""The values the pickers may offer, so the UI never invents one the
backend would reject."""
return jsonify({
"sexes": list(settings_svc.SEXES),
"units": list(settings_svc.UNITS),
"autoSyncMinutes": list(settings_svc.INTERVALS),
"historyDays": list(settings_svc.HISTORY),
})
@bp.route("/rating-basis", methods=["GET"])
@require_auth
def rating_basis():
"""Where every rating in the app comes from.
Surfaced in 设置 because a band that colours a number 偏低 is making a
claim, and the user is entitled to see what that claim rests on.
"""
return jsonify({
"fitnessAge": fitness_age.BASIS,
"bands": BAND_SOURCES,
"note": (
"参考区间用于给数字一个位置感,不是诊断标准。"
"AI 只负责解读这些结果,不参与设定任何阈值。"
),
})
# Kept beside the UI's RANGES table (client/src/lib/ranges.ts). Each entry says
# where that metric's band edges came from — the honest answer for several of
# them is "general-population orientation figures", and it says so.
BAND_SOURCES = [
{"metric": "步数", "bands": "<5k 偏低 · 58k 一般 · 812k 达标 · >12k 优秀",
"source": "步数与死亡率的队列研究(约 8000 步起获益明显12000 步后趋平)"},
{"metric": "静息心率", "bands": "<50 很低 · 5065 正常 · 6575 偏高 · >75 较高",
"source": "健康成人静息心率 60100 bpm 为正常范围,规律运动者常低于 60"},
{"metric": "心率变异性", "bands": "<25 偏低 · 2540 一般 · 4070 良好 · >70 很好",
"source": "夜间 RMSSD 的一般人群分布;个体差异极大,趋势比绝对值更有意义"},
{"metric": "睡眠时长", "bands": "<6h 不足 · 67h 偏少 · 79h 充足 · >9h 偏多",
"source": "美国睡眠医学会 / 睡眠研究会成人 79 小时建议"},
{"metric": "压力", "bands": "025 休息 · 2650 偏低 · 5175 中等 · >75 偏高",
"source": "Garmin 官方压力分级,与手表显示一致"},
{"metric": "身体电量", "bands": "025 很低 · 2650 偏低 · 5175 良好 · >75 充足",
"source": "Garmin 官方 Body Battery 分级"},
{"metric": "血氧", "bands": "<90 偏低 · 9094 略低 · ≥95 正常",
"source": "静息血氧饱和度常用临床参考;腕表光学测量误差较大,仅供趋势参考"},
{"metric": "呼吸频率", "bands": "<12 偏低 · 1220 正常 · >20 偏高",
"source": "成人静息呼吸频率 1220 次/分"},
{"metric": "强度分钟", "bands": "<10 偏少 · 1021 一般 · ≥21 达标",
"source": "WHO 每周 150 分钟中等强度活动,折合每天约 21 分钟"},
{"metric": "训练准备度", "bands": "025 很低 · 2650 偏低 · 5175 就绪 · >75 很好",
"source": "Garmin 官方 Training Readiness 分级"},
{"metric": "爬楼", "bands": "<5 偏少 · 510 达标 · >10 优秀",
"source": "一般性活动量参考,无权威标准"},
]

View File

@@ -0,0 +1,177 @@
"""
身体年龄 (body age) — a deterministic estimate, with its working exposed.
This is NOT Garmin's Fitness Age. Garmin's model is proprietary and cannot be
reproduced; asking a language model to invent a number would produce something
unverifiable that changes between runs while looking authoritative. So the
estimate here is computed from published population reference values, and every
step it took is returned alongside the number for the UI to display.
Method
------
1. Base age from VO2max: the age at which the user's VO2max equals the median
for their sex, interpolated over the reference table below. VO2max is the
single strongest fitness predictor and is what Garmin's own model leans on.
2. Resting-heart-rate adjustment, relative to a 60 bpm reference.
3. BMI adjustment, relative to the healthy 18.524.9 band.
4. Clamped to within 20 years of chronological age — beyond that the
extrapolation says more about the table's edges than about the person.
Reference values are 50th-percentile VO2max (ml/kg/min) by age and sex, from
the widely published ACSM / Cooper Institute cardiorespiratory fitness norms.
They are population averages for healthy adults, not clinical thresholds.
"""
# (age, median VO2max) — men and women tabulated separately because the
# distributions differ by roughly 68 ml/kg/min at every age.
VO2_MEDIAN = {
"male": [(25, 44.0), (35, 41.0), (45, 37.0), (55, 33.0), (65, 29.0)],
"female": [(25, 37.0), (35, 34.0), (45, 31.0), (55, 27.0), (65, 24.0)],
}
RHR_REFERENCE = 60.0 # bpm
RHR_YEARS_PER_10BPM = 2.0
RHR_CAP = 5.0
BMI_LOW, BMI_HIGH = 18.5, 24.9
BMI_YEARS_PER_UNIT = 0.5
BMI_CAP = 5.0
MAX_DEVIATION = 20.0 # years either side of chronological age
AGE_FLOOR, AGE_CEILING = 20.0, 85.0
# Rendered verbatim in 设置 → 评分依据. Kept here, next to the constants it
# describes, so the two cannot drift apart.
BASIS = {
"title": "身体年龄的算法",
"summary": (
"由你的 VO₂max、静息心率、BMI 按公开人群参考值推算,"
"不是 Garmin 的 Fitness Age也不是医学评估。"
),
"steps": [
{
"name": "基准VO₂max 对应年龄",
"detail": "找出你的 VO₂max 相当于同性别人群哪个年龄的中位水平,"
"在参考表上线性插值;高于最年轻一档时按 20 岁计,"
"参考表再往上说明不了更多。",
"source": "ACSM / Cooper Institute 心肺适能人群常模50 百分位)",
},
{
"name": "静息心率修正",
"detail": f"{RHR_REFERENCE:.0f} bpm 为参照,"
f"每高 10 bpm +{RHR_YEARS_PER_10BPM:.0f} 岁,"
f"每低 10 bpm {RHR_YEARS_PER_10BPM:.0f} 岁,"
f"最多 ±{RHR_CAP:.0f} 岁。",
"source": "静息心率与心肺适能、全因死亡率的流行病学关联",
},
{
"name": "BMI 修正",
"detail": f"BMI 在 {BMI_LOW}~{BMI_HIGH} 之间不修正;"
f"每偏离 1 +{BMI_YEARS_PER_UNIT} 岁,最多 +{BMI_CAP:.0f} 岁。",
"source": "WHO 成人 BMI 分类",
},
{
"name": "收敛",
"detail": f"结果限制在实际年龄 ±{MAX_DEVIATION:.0f} 岁以内,"
f"并落在 {AGE_FLOOR:.0f}~{AGE_CEILING:.0f} 岁区间。",
"source": "参考表边界外的外推不可靠",
},
],
"caveat": "仅供长期趋势参考,不能用于诊断。有健康疑问请咨询医生。",
}
def _interpolate_age(vo2, table):
"""The age whose median VO2max equals `vo2`.
Above the youngest reference row the answer is simply "fitter than the
median 25-year-old", and the table cannot say more: extrapolating its slope
there gives absurdities (VO2max 48 reads as an eleven-year-old), so the
result floors instead.
"""
first_age, first_vo2 = table[0]
last_age, last_vo2 = table[-1]
if vo2 >= first_vo2:
return AGE_FLOOR
if vo2 <= last_vo2:
slope = (last_age - table[-2][0]) / (last_vo2 - table[-2][1])
return last_age + (vo2 - last_vo2) * slope
for (age_a, vo2_a), (age_b, vo2_b) in zip(table, table[1:]):
if vo2_b <= vo2 <= vo2_a:
share = (vo2_a - vo2) / (vo2_a - vo2_b)
return age_a + share * (age_b - age_a)
return last_age
def estimate(*, age, sex, vo2max, resting_hr=None, bmi=None):
"""Body age plus the arithmetic that produced it.
Returns None when the inputs cannot support an estimate, so the caller can
tell the user what is missing instead of showing a fabricated number.
"""
missing = []
if age is None:
missing.append("出生日期")
if sex not in VO2_MEDIAN:
missing.append("性别")
if not vo2max:
missing.append("VO₂max需要一次户外跑步或骑行才会生成")
if missing:
return {"value": None, "missing": missing, "basis": BASIS}
table = VO2_MEDIAN[sex]
base = _interpolate_age(float(vo2max), table)
steps = [{
"label": "VO₂max 基准",
"input": f"{float(vo2max):.0f} ml/kg/min",
"years": round(base, 1),
"kind": "base",
}]
total = base
if resting_hr:
delta = (float(resting_hr) - RHR_REFERENCE) / 10.0 * RHR_YEARS_PER_10BPM
delta = max(-RHR_CAP, min(RHR_CAP, delta))
total += delta
steps.append({
"label": "静息心率",
"input": f"{float(resting_hr):.0f} bpm",
"years": round(delta, 1),
"kind": "adjust",
})
if bmi:
value = float(bmi)
if value < BMI_LOW:
off = BMI_LOW - value
elif value > BMI_HIGH:
off = value - BMI_HIGH
else:
off = 0.0
delta = min(BMI_CAP, off * BMI_YEARS_PER_UNIT)
total += delta
steps.append({
"label": "BMI",
"input": f"{value:.1f}",
"years": round(delta, 1),
"kind": "adjust",
})
chronological = float(age)
clamped = max(chronological - MAX_DEVIATION,
min(chronological + MAX_DEVIATION, total))
clamped = max(AGE_FLOOR, min(AGE_CEILING, clamped))
value = int(round(clamped))
return {
"value": value,
"chronologicalAge": int(chronological),
"delta": value - int(chronological),
"steps": steps,
"clamped": abs(clamped - total) > 0.05,
"missing": [],
"basis": BASIS,
}

View File

@@ -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". so passing it a date silently asks for activity number "2026-08-23".
""" """
import datetime import datetime
import json
import os import os
import threading import threading
@@ -415,6 +416,172 @@ def _sync_activities(client, user_id, start_date, end_date):
return stored 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 # 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. # on it — a year takes roughly 20 minutes at ~3s per day.
BACKGROUND_THRESHOLD_DAYS = 14 BACKGROUND_THRESHOLD_DAYS = 14

View File

@@ -23,6 +23,7 @@ import time
from config import DB_TYPE from config import DB_TYPE
from db import execute, query_one, query_all from db import execute, query_one, query_all
from services import garmin as garmin_svc from services import garmin as garmin_svc
from services import settings as settings_svc
JOB_NAME = "garmin_auto_sync" JOB_NAME = "garmin_auto_sync"
@@ -32,6 +33,11 @@ INTERVAL_SECONDS = int(os.environ.get("AUTO_SYNC_INTERVAL_SECONDS") or 3600)
SYNC_DAYS = int(os.environ.get("AUTO_SYNC_DAYS") or 2) SYNC_DAYS = int(os.environ.get("AUTO_SYNC_DAYS") or 2)
ENABLED = (os.environ.get("AUTO_SYNC") or "true").lower() not in ("0", "false", "no") ENABLED = (os.environ.get("AUTO_SYNC") or "true").lower() not in ("0", "false", "no")
# The loop wakes on this cadence; whether an account is actually due is then
# decided per account from its own 同步频率 setting. A single global interval
# would mean one user's choice of 30 minutes silently applied to everyone.
TICK_SECONDS = 300
# A claim older than this is treated as abandoned — the worker holding it died # A claim older than this is treated as abandoned — the worker holding it died
# mid-run, and without expiry the job would never run again. # mid-run, and without expiry the job would never run again.
CLAIM_TIMEOUT_SECONDS = 1800 CLAIM_TIMEOUT_SECONDS = 1800
@@ -99,14 +105,45 @@ def release(name=JOB_NAME, ran=True):
execute("UPDATE job_locks SET claimed_at = NULL WHERE name = ?", [name]) execute("UPDATE job_locks SET claimed_at = NULL WHERE name = ?", [name])
def sync_all_accounts(days=None): def due_at(user_id):
"""Sync every account that has a stored token. Returns a per-user result.""" """When this account may next be synced automatically, or None if never.
None means auto-sync is switched off for them; a time in the past means
they are due now.
"""
prefs = settings_svc.get_raw(user_id)
if not prefs.get("auto_sync"):
return None
minutes = prefs.get("auto_sync_minutes") or (INTERVAL_SECONDS // 60)
last = _parse((garmin_svc.get_sync_status(user_id) or {}).get("lastSyncTime"))
if not last:
return _now() - datetime.timedelta(seconds=1)
return last + datetime.timedelta(minutes=minutes)
def sync_all_accounts(days=None, respect_schedule=False):
"""Sync every account that has a stored token. Returns a per-user result.
`respect_schedule` is what the background loop passes: it skips accounts
that have auto-sync off or that were synced recently enough. A direct call
(a manual "sync everything") leaves it False and syncs unconditionally.
"""
days = days or SYNC_DAYS days = days or SYNC_DAYS
rows = query_all("SELECT user_id FROM garmin_tokens") rows = query_all("SELECT user_id FROM garmin_tokens")
results = [] results = []
for row in rows: for row in rows:
uid = row["user_id"] uid = row["user_id"]
try: try:
if respect_schedule:
due = due_at(uid)
if due is None:
results.append({"user": uid, "status": "skipped",
"reason": "auto-sync off"})
continue
if due > _now():
results.append({"user": uid, "status": "skipped",
"reason": "not due"})
continue
out = garmin_svc.sync_data(uid, {}, days=days) out = garmin_svc.sync_data(uid, {}, days=days)
results.append({"user": uid, "status": out.get("status"), results.append({"user": uid, "status": out.get("status"),
"records": out.get("recordsSynced")}) "records": out.get("recordsSynced")})
@@ -118,16 +155,16 @@ def sync_all_accounts(days=None):
def _loop(): def _loop():
while True: while True:
try: try:
if claim(): if claim(interval=TICK_SECONDS):
try: try:
sync_all_accounts() sync_all_accounts(respect_schedule=True)
finally: finally:
release() release()
except Exception as e: # noqa: BLE001 - the loop must outlive any single failure except Exception as e: # noqa: BLE001 - the loop must outlive any single failure
print(f"[scheduler] tick failed: {e}") print(f"[scheduler] tick failed: {e}")
# Checked more often than the interval so a worker that starts late # Checked more often than the interval so a worker that starts late
# still picks the job up promptly rather than waiting a full hour. # still picks the job up promptly rather than waiting a full hour.
time.sleep(min(300, INTERVAL_SECONDS)) time.sleep(TICK_SECONDS)
def start(): def start():
@@ -144,14 +181,27 @@ def start():
print(f"[scheduler] auto-sync every {INTERVAL_SECONDS}s, {SYNC_DAYS} day(s) back") print(f"[scheduler] auto-sync every {INTERVAL_SECONDS}s, {SYNC_DAYS} day(s) back")
def status(): def status(user_id=None):
"""Scheduler state, and — when a user is named — their own next due time."""
row = query_one("SELECT * FROM job_locks WHERE name = ?", [JOB_NAME]) row = query_one("SELECT * FROM job_locks WHERE name = ?", [JOB_NAME])
last = _parse(row.get("last_run_at")) if row else None last = _parse(row.get("last_run_at")) if row else None
return {
out = {
"enabled": ENABLED, "enabled": ENABLED,
"intervalSeconds": INTERVAL_SECONDS, "intervalSeconds": INTERVAL_SECONDS,
"tickSeconds": TICK_SECONDS,
"days": SYNC_DAYS, "days": SYNC_DAYS,
"lastRunAt": _iso(last) if last else None, "lastRunAt": _iso(last) if last else None,
"nextRunAt": _iso(last + datetime.timedelta(seconds=INTERVAL_SECONDS)) if last else None, "nextRunAt": _iso(last + datetime.timedelta(seconds=TICK_SECONDS)) if last else None,
"running": bool(row and row.get("claimed_at")), "running": bool(row and row.get("claimed_at")),
} }
if user_id:
prefs = settings_svc.get_raw(user_id)
due = due_at(user_id)
out["account"] = {
"autoSync": bool(prefs.get("auto_sync")),
"intervalMinutes": prefs.get("auto_sync_minutes"),
"dueAt": _iso(due) if due else None,
}
return out

View File

@@ -0,0 +1,213 @@
"""
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)