refactor(sync): 手动同步与自动同步彻底分开

「历史范围」原本放在设置页,却只对同步页的一个按钮起作用;而同步页最
显眼的主按钮「同步最新数据」写死 2 天,根本不看这个设置。选了「全部
历史」再点主按钮,表现就是应用无视你 —— 这正是反复出现的「只同步下来
两天」。

现在两条链路各管各的:

* 自动同步:只在设置页配置(开关 + 频率),窗口固定 SYNC_DAYS,不再
  读 history_days。措辞也改成「拉取最近几天」,不再暗示会补历史。
* 手动同步:范围就在同步页当场选,紧挨着用它的按钮,并标出每个范围的
  实际代价(自上次同步 / 7 天 / … / 全部历史约 730 天、20-40 分钟)。
  两个按钮合成一个「开始同步」,写死 2 天的那个删掉。

history_days 保留为「上次手动选的范围」,只有同步页读它;默认值改成
-1(自上次同步),对日常使用是正确的起点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 09:20:52 +08:00
parent 4d55fb4776
commit 526ece7d14
13 changed files with 156 additions and 116 deletions

View File

@@ -10,6 +10,7 @@ from flask import request, g, jsonify
from functools import wraps
from config import JWT_SECRET, JWT_EXPIRY_DAYS
from db import query_one
def sign_token(user_id: str) -> str:
@@ -41,6 +42,10 @@ def require_auth(f):
except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401
g.user_id = data["user_id"]
# Ensure the user record still exists in the database (it may have been
# deleted by a database reset). If not, ask the user to re-login.
if not query_one("SELECT 1 FROM users WHERE id = ?", [g.user_id]):
return jsonify({"error": "user not found — please re-login"}), 401
return f(*args, **kwargs)
return wrapper

View File

@@ -128,10 +128,11 @@ def sync_all_accounts(days=None, respect_schedule=False):
that have auto-sync off or that were synced recently enough. A direct call
(a manual "sync everything") leaves it False and syncs unconditionally.
When `days` is not provided, each account's saved `history_days` from
`user_settings` is used (the user's 历史范围 picker), falling back to
`SYNC_DAYS`. The scheduled loop always passes `days` explicitly — 历史范围
is the window for the manual 全量同步, not for a half-hourly tick.
The window is `SYNC_DAYS` unless the caller overrides it. Auto-sync is
deliberately blind to the user's manual sync range: reading it here is what
let a half-hourly tick re-pull 730 days and hold the account in a 429 loop,
and it also made one setting mean two different things in two places.
Anything the user chooses by hand goes through `/garmin/sync` instead.
"""
rows = query_all("SELECT user_id FROM garmin_tokens")
results = []
@@ -148,22 +149,7 @@ def sync_all_accounts(days=None, respect_schedule=False):
results.append({"user": uid, "status": "skipped",
"reason": "not due"})
continue
# Resolve the sync window: prefer the user's saved history_days,
# then the caller override, then the global default.
if days is None:
# query_one returns a dict, so `s[0]` raised KeyError — caught
# by the per-account handler below, which meant every single
# scheduled tick failed for every account and nothing was ever
# synced automatically.
s = query_one(
"SELECT history_days FROM user_settings WHERE user_id = ?", (uid,)
)
user_days = s.get("history_days") if s else None
if user_days == 0:
user_days = 730 # 全部历史 → 最大范围
d = user_days if user_days is not None else SYNC_DAYS
else:
d = days
d = SYNC_DAYS if days is None else days
# Never poke Garmin while it is rate-limiting us — that is exactly
# what keeps the limit alive. Respect the persisted cooldown and sit
# this tick out.
@@ -187,12 +173,7 @@ def _loop():
try:
if claim(interval=TICK_SECONDS):
try:
# Explicitly the recent window, never the user's 历史范围:
# that setting describes the manual 全量同步. Letting a
# half-hourly tick re-pull "全部历史" meant 730 days x ~7
# Garmin calls every 30 minutes, which is precisely what
# kept the account in a 429 loop.
sync_all_accounts(days=SYNC_DAYS, respect_schedule=True)
sync_all_accounts(respect_schedule=True)
finally:
release()
except Exception as e: # noqa: BLE001 - the loop must outlive any single failure

View File

@@ -29,8 +29,14 @@ DEFAULTS = {
"units": "metric",
"auto_sync": 1,
"auto_sync_minutes": 60,
# How far back a full sync reaches. 0 means "everything Garmin has".
"history_days": 365,
# 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")

View File

@@ -1,16 +1,16 @@
{
"files": {
"main.css": "/static/css/main.a0945a9e.css",
"main.js": "/static/js/main.3efa2734.js",
"main.js": "/static/js/main.56f8eb9b.js",
"static/media/Framework7Icons-Regular.ttf": "/static/media/Framework7Icons-Regular.4b8a7d10ca32f3125696.ttf",
"static/media/Framework7Icons-Regular.woff": "/static/media/Framework7Icons-Regular.286bd8fcebb566a45853.woff",
"static/media/Framework7Icons-Regular.woff2": "/static/media/Framework7Icons-Regular.852655880420bcb785bd.woff2",
"index.html": "/index.html",
"main.a0945a9e.css.map": "/static/css/main.a0945a9e.css.map",
"main.3efa2734.js.map": "/static/js/main.3efa2734.js.map"
"main.56f8eb9b.js.map": "/static/js/main.56f8eb9b.js.map"
},
"entrypoints": [
"static/css/main.a0945a9e.css",
"static/js/main.3efa2734.js"
"static/js/main.56f8eb9b.js"
]
}

View File

@@ -1 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="佳明健康数据分析平台"><title>Garmin Health Lab - 佳明健康数据分析</title><script defer="defer" src="/static/js/main.3efa2734.js"></script><link href="/static/css/main.a0945a9e.css" rel="stylesheet"></head><body><div id="root"></div></body></html>
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="佳明健康数据分析平台"><title>Garmin Health Lab - 佳明健康数据分析</title><script defer="defer" src="/static/js/main.56f8eb9b.js"></script><link href="/static/css/main.a0945a9e.css" rel="stylesheet"></head><body><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

View File

@@ -187,12 +187,11 @@ class TestEndpoints:
class TestSyncWindowResolution:
"""Which window each account is synced over.
"""Auto-sync must be blind to the manual sync range.
`query_one` returns a dict, so reading the saved 历史范围 as `s[0]` raised
KeyError — swallowed by the per-account handler, which meant every
scheduled tick failed for every account that had ever opened 设置, and
nothing was synced automatically at all.
They used to share `history_days`: one setting on the 设置 page that only
took effect on the 数据同步 page, and that a half-hourly tick also used to
re-pull 730 days with. Separating them is the point.
"""
def _account(self, user, monkeypatch):
@@ -208,34 +207,23 @@ class TestSyncWindowResolution:
monkeypatch.setattr(garmin_svc, "sync_data", record)
return seen
def test_saved_history_days_is_honoured(self, db, user, monkeypatch):
from services import settings as settings_svc
def test_the_default_window_is_used(self, db, user, monkeypatch):
seen = self._account(user, monkeypatch)
settings_svc.save_settings(user["id"], {"historyDays": 90})
results = scheduler.sync_all_accounts()
assert [r["status"] for r in results] == ["success"]
assert seen["days"] == 90
assert seen["days"] == scheduler.SYNC_DAYS
def test_full_history_becomes_the_maximum(self, db, user, monkeypatch):
def test_the_manual_range_is_ignored(self, db, user, monkeypatch):
"""全部历史 on the sync page must not turn every tick into 730 days."""
from services import settings as settings_svc
seen = self._account(user, monkeypatch)
settings_svc.save_settings(user["id"], {"historyDays": 0})
scheduler.sync_all_accounts()
assert seen["days"] == 730
def test_the_scheduled_tick_ignores_the_history_setting(
self, db, user, monkeypatch
):
"""历史范围 is the window for the manual 全量同步. A half-hourly tick
re-pulling 全部历史 is what kept the account in a 429 loop."""
from services import settings as settings_svc
seen = self._account(user, monkeypatch)
settings_svc.save_settings(user["id"], {"historyDays": 0, "autoSync": True})
scheduler.sync_all_accounts(days=scheduler.SYNC_DAYS, respect_schedule=True)
scheduler.sync_all_accounts(respect_schedule=True)
assert seen["days"] == scheduler.SYNC_DAYS
def test_an_explicit_override_still_wins(self, db, user, monkeypatch):
seen = self._account(user, monkeypatch)
scheduler.sync_all_accounts(days=30)
assert seen["days"] == 30

View File

@@ -19,7 +19,8 @@ class TestDefaults:
assert s["units"] == "metric"
assert s["autoSync"] is True
assert s["autoSyncMinutes"] == 60
assert s["historyDays"] == 365
# 自上次同步: the manual sync page opens on the incremental option.
assert s["historyDays"] == -1
def test_body_fields_start_empty(self, db, user):
s = svc.get_settings(user["id"])