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:
@@ -23,6 +23,7 @@ import time
|
||||
from config import DB_TYPE
|
||||
from db import execute, query_one, query_all
|
||||
from services import garmin as garmin_svc
|
||||
from services import settings as settings_svc
|
||||
|
||||
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)
|
||||
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
|
||||
# mid-run, and without expiry the job would never run again.
|
||||
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])
|
||||
|
||||
|
||||
def sync_all_accounts(days=None):
|
||||
"""Sync every account that has a stored token. Returns a per-user result."""
|
||||
def due_at(user_id):
|
||||
"""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
|
||||
rows = query_all("SELECT user_id FROM garmin_tokens")
|
||||
results = []
|
||||
for row in rows:
|
||||
uid = row["user_id"]
|
||||
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)
|
||||
results.append({"user": uid, "status": out.get("status"),
|
||||
"records": out.get("recordsSynced")})
|
||||
@@ -118,16 +155,16 @@ def sync_all_accounts(days=None):
|
||||
def _loop():
|
||||
while True:
|
||||
try:
|
||||
if claim():
|
||||
if claim(interval=TICK_SECONDS):
|
||||
try:
|
||||
sync_all_accounts()
|
||||
sync_all_accounts(respect_schedule=True)
|
||||
finally:
|
||||
release()
|
||||
except Exception as e: # noqa: BLE001 - the loop must outlive any single failure
|
||||
print(f"[scheduler] tick failed: {e}")
|
||||
# Checked more often than the interval so a worker that starts late
|
||||
# still picks the job up promptly rather than waiting a full hour.
|
||||
time.sleep(min(300, INTERVAL_SECONDS))
|
||||
time.sleep(TICK_SECONDS)
|
||||
|
||||
|
||||
def start():
|
||||
@@ -144,14 +181,27 @@ def start():
|
||||
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])
|
||||
last = _parse(row.get("last_run_at")) if row else None
|
||||
return {
|
||||
|
||||
out = {
|
||||
"enabled": ENABLED,
|
||||
"intervalSeconds": INTERVAL_SECONDS,
|
||||
"tickSeconds": TICK_SECONDS,
|
||||
"days": SYNC_DAYS,
|
||||
"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")),
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user