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 functools import wraps
from config import JWT_SECRET, JWT_EXPIRY_DAYS from config import JWT_SECRET, JWT_EXPIRY_DAYS
from db import query_one
def sign_token(user_id: str) -> str: def sign_token(user_id: str) -> str:
@@ -41,6 +42,10 @@ def require_auth(f):
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return jsonify({"error": "invalid token"}), 401 return jsonify({"error": "invalid token"}), 401
g.user_id = data["user_id"] 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 f(*args, **kwargs)
return wrapper 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 that have auto-sync off or that were synced recently enough. A direct call
(a manual "sync everything") leaves it False and syncs unconditionally. (a manual "sync everything") leaves it False and syncs unconditionally.
When `days` is not provided, each account's saved `history_days` from The window is `SYNC_DAYS` unless the caller overrides it. Auto-sync is
`user_settings` is used (the user's 历史范围 picker), falling back to deliberately blind to the user's manual sync range: reading it here is what
`SYNC_DAYS`. The scheduled loop always passes `days` explicitly — 历史范围 let a half-hourly tick re-pull 730 days and hold the account in a 429 loop,
is the window for the manual 全量同步, not for a half-hourly tick. 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") rows = query_all("SELECT user_id FROM garmin_tokens")
results = [] results = []
@@ -148,22 +149,7 @@ def sync_all_accounts(days=None, respect_schedule=False):
results.append({"user": uid, "status": "skipped", results.append({"user": uid, "status": "skipped",
"reason": "not due"}) "reason": "not due"})
continue continue
# Resolve the sync window: prefer the user's saved history_days, d = SYNC_DAYS if days is None else 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
# Never poke Garmin while it is rate-limiting us — that is exactly # Never poke Garmin while it is rate-limiting us — that is exactly
# what keeps the limit alive. Respect the persisted cooldown and sit # what keeps the limit alive. Respect the persisted cooldown and sit
# this tick out. # this tick out.
@@ -187,12 +173,7 @@ def _loop():
try: try:
if claim(interval=TICK_SECONDS): if claim(interval=TICK_SECONDS):
try: try:
# Explicitly the recent window, never the user's 历史范围: sync_all_accounts(respect_schedule=True)
# 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)
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

View File

@@ -29,8 +29,14 @@ DEFAULTS = {
"units": "metric", "units": "metric",
"auto_sync": 1, "auto_sync": 1,
"auto_sync_minutes": 60, "auto_sync_minutes": 60,
# How far back a full sync reaches. 0 means "everything Garmin has". # The range last chosen on the 数据同步 page, remembered so the picker
"history_days": 365, # 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") SEXES = ("male", "female", "other")

View File

@@ -1,16 +1,16 @@
{ {
"files": { "files": {
"main.css": "/static/css/main.a0945a9e.css", "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.ttf": "/static/media/Framework7Icons-Regular.4b8a7d10ca32f3125696.ttf",
"static/media/Framework7Icons-Regular.woff": "/static/media/Framework7Icons-Regular.286bd8fcebb566a45853.woff", "static/media/Framework7Icons-Regular.woff": "/static/media/Framework7Icons-Regular.286bd8fcebb566a45853.woff",
"static/media/Framework7Icons-Regular.woff2": "/static/media/Framework7Icons-Regular.852655880420bcb785bd.woff2", "static/media/Framework7Icons-Regular.woff2": "/static/media/Framework7Icons-Regular.852655880420bcb785bd.woff2",
"index.html": "/index.html", "index.html": "/index.html",
"main.a0945a9e.css.map": "/static/css/main.a0945a9e.css.map", "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": [ "entrypoints": [
"static/css/main.a0945a9e.css", "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: 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 They used to share `history_days`: one setting on the 设置 page that only
KeyError — swallowed by the per-account handler, which meant every took effect on the 数据同步 page, and that a half-hourly tick also used to
scheduled tick failed for every account that had ever opened 设置, and re-pull 730 days with. Separating them is the point.
nothing was synced automatically at all.
""" """
def _account(self, user, monkeypatch): def _account(self, user, monkeypatch):
@@ -208,34 +207,23 @@ class TestSyncWindowResolution:
monkeypatch.setattr(garmin_svc, "sync_data", record) monkeypatch.setattr(garmin_svc, "sync_data", record)
return seen return seen
def test_saved_history_days_is_honoured(self, db, user, monkeypatch): def test_the_default_window_is_used(self, db, user, monkeypatch):
from services import settings as settings_svc
seen = self._account(user, monkeypatch) seen = self._account(user, monkeypatch)
settings_svc.save_settings(user["id"], {"historyDays": 90})
results = scheduler.sync_all_accounts() results = scheduler.sync_all_accounts()
assert [r["status"] for r in results] == ["success"] 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 from services import settings as settings_svc
seen = self._account(user, monkeypatch) seen = self._account(user, monkeypatch)
settings_svc.save_settings(user["id"], {"historyDays": 0}) settings_svc.save_settings(user["id"], {"historyDays": 0})
scheduler.sync_all_accounts() scheduler.sync_all_accounts(respect_schedule=True)
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)
assert seen["days"] == scheduler.SYNC_DAYS 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["units"] == "metric"
assert s["autoSync"] is True assert s["autoSync"] is True
assert s["autoSyncMinutes"] == 60 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): def test_body_fields_start_empty(self, db, user):
s = svc.get_settings(user["id"]) s = svc.get_settings(user["id"])

View File

@@ -22,12 +22,6 @@ const intervalLabel = (minutes: number) =>
: minutes === 1440 ? '每天一次' : minutes === 1440 ? '每天一次'
: `${minutes / 60} 小时`; : `${minutes / 60} 小时`;
const historyLabel = (days: number) =>
days === -1 ? '自上次同步'
: days === 0 ? '全部历史'
: days >= 365 ? `${days / 365}`
: `${days}`;
function SettingsPage() { function SettingsPage() {
const [settings, setSettings] = useState<UserSettings | null>(null); const [settings, setSettings] = useState<UserSettings | null>(null);
const [options, setOptions] = useState<SettingsOptions | null>(null); const [options, setOptions] = useState<SettingsOptions | null>(null);
@@ -204,7 +198,9 @@ function SettingsPage() {
<label className="set-row"> <label className="set-row">
<span className="set-label"> <span className="set-label">
<span className="set-sub"></span> <span className="set-sub">
</span>
</span> </span>
<span className="set-switch"> <span className="set-switch">
<input <input
@@ -228,23 +224,12 @@ function SettingsPage() {
<span className="set-chevron" aria-hidden="true"></span> <span className="set-chevron" aria-hidden="true"></span>
</button> </button>
<button
className="set-row"
onClick={() => options && pick('历史范围', options.historyDays,
historyLabel, s.historyDays,
(v) => save({ historyDays: v }))}
>
<span className="set-label">
<span className="set-sub"></span>
</span>
<span className="set-value">{historyLabel(s.historyDays)}</span>
<span className="set-chevron" aria-hidden="true"></span>
</button>
<Link href="/sync/" className="set-row"> <Link href="/sync/" className="set-row">
<span className="set-label"></span> <span className="set-label">
<span className="set-value"> · </span>
<span className="set-sub"></span>
</span>
<span className="set-value"></span>
<span className="set-chevron" aria-hidden="true"></span> <span className="set-chevron" aria-hidden="true"></span>
</Link> </Link>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { f7 } from 'framework7-react';
import { import {
apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus, apiClient, AutoSyncStatus, DetailSyncStatus, errorMessage, GarminLoginStatus,
parseUtc, SyncStatus, UserSettings, parseUtc, SettingsOptions, SyncStatus,
} from '../services/api'; } from '../services/api';
import Screen from '../components/Screen'; import Screen from '../components/Screen';
import './DataSync.css'; import './DataSync.css';
@@ -19,12 +20,27 @@ const when = (value: string | null | undefined) => {
}; };
const historyLabel = (days: number) => const historyLabel = (days: number) =>
days === 0 ? '全部历史' : days >= 365 ? `${days / 365}` : `${days}`; days === -1 ? '自上次同步'
: days === 0 ? '全部历史'
: days >= 365 ? `${days / 365}`
: `${days}`;
/** What each range actually costs, so the choice is made with eyes open. */
const rangeHint = (days: number) =>
days === -1 ? '只补上次同步之后缺的那几天,最快'
: days === 0 ? '约 730 天2040 分钟'
: days >= 365 ? `${days} 天,大约 ${Math.ceil((days * 3) / 60)} 分钟`
: `${days} 天,几分钟`;
function SyncPage() { function SyncPage() {
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null); const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
const [auto, setAuto] = useState<AutoSyncStatus | null>(null); const [auto, setAuto] = useState<AutoSyncStatus | null>(null);
const [settings, setSettings] = useState<UserSettings | null>(null); const [options, setOptions] = useState<SettingsOptions | null>(null);
// The manual sync range lives here now, not in 设置: the button that uses it
// is on this page, and a setting that only takes effect somewhere else is
// how 全部历史 came to look like it did nothing. It is still persisted, so
// the picker opens where it was left.
const [range, setRange] = useState(-1);
const [details, setDetails] = useState<DetailSyncStatus | null>(null); const [details, setDetails] = useState<DetailSyncStatus | null>(null);
const [hasToken, setHasToken] = useState<boolean | null>(null); const [hasToken, setHasToken] = useState<boolean | null>(null);
@@ -72,7 +88,8 @@ function SyncPage() {
if (s?.status === 'syncing') beginSyncPolling(); if (s?.status === 'syncing') beginSyncPolling();
}); });
apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false)); apiClient.getGarminAuthStatus().then(setHasToken).catch(() => setHasToken(false));
apiClient.getSettings().then(setSettings).catch(() => setSettings(null)); apiClient.getSettings().then((s) => setRange(s.historyDays)).catch(() => {});
apiClient.getSettingsOptions().then(setOptions).catch(() => setOptions(null));
return stopPolling; return stopPolling;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -195,30 +212,36 @@ function SyncPage() {
// --- sync --------------------------------------------------------------- // --- sync ---------------------------------------------------------------
/** The last couple of days, awaited inline — seconds, not minutes. */ /** Choose how far back to pull, and remember it for next time. */
const syncLatest = async () => { const pickRange = () => {
setError(''); if (!options) return;
setMessage(''); f7.dialog.create({
setLoading(true); title: '同步范围',
try { buttons: [
const result = await apiClient.syncLatest(2); ...options.historyDays.map((v) => ({
await refresh(); text: historyLabel(v) + (v === range ? ' ✓' : ''),
setMessage(result.message || `已更新最近 ${result.recordsSynced ?? 2}`); onClick: () => {
} catch (err: any) { setRange(v);
setError(errorMessage(err, '同步失败')); // Persisted, but nothing else reads it — auto-sync has its own
} finally { // fixed window and never looks at this.
setLoading(false); apiClient.saveSettings({ historyDays: v }).catch(() => {});
} },
})),
{ text: '取消', color: 'gray' },
],
verticalButtons: true,
}).open();
}; };
/** The configured history window, in the background with progress. */ /** The one manual sync: the chosen range, in the background, with progress. */
const syncHistory = async () => { const syncHistory = async () => {
setError(''); setError('');
setMessage(''); setMessage('');
setLoading(true); setLoading(true);
try { try {
// 0 means "everything"; the backend caps it at what Garmin will serve. // 0 means "everything" and -1 "since the last sync"; the backend caps
const started = await apiClient.syncGarminData(settings?.historyDays ?? 365); // the former at what Garmin will actually serve.
const started = await apiClient.syncGarminData(range);
const s = await refresh(); const s = await refresh();
// A refused start (Garmin is throttling us) used to be swallowed: the // A refused start (Garmin is throttling us) used to be swallowed: the
// button did nothing, no progress bar appeared, and no reason was shown. // button did nothing, no progress bar appeared, and no reason was shown.
@@ -402,21 +425,32 @@ function SyncPage() {
</section> </section>
) : ( ) : (
<> <>
{/* The range is chosen here, right above the button that uses
it. It used to live in 设置 while only taking effect on this
page, so picking 全部历史 there and pressing the (hardcoded
two-day) button here looked like the app ignoring you. */}
<div className="sync-secondary">
<button
className="btn btn-plain"
onClick={pickRange}
disabled={busy || !options}
>
<span className="sync-btn-label"></span>
<span className="sync-btn-sub">
{historyLabel(range)} · {rangeHint(range)}
</span>
</button>
</div>
<button <button
className="btn btn-primary btn-large sync-primary" className="btn btn-primary btn-large sync-primary"
onClick={syncLatest} onClick={syncHistory}
disabled={busy} disabled={busy}
> >
{loading ? '同步中…' : '同步最新数据'} {loading ? '正在启动…' : '开始同步'}
</button> </button>
<div className="sync-secondary"> <div className="sync-secondary">
<button className="btn btn-plain" onClick={syncHistory} disabled={busy}>
<span className="sync-btn-label"></span>
<span className="sync-btn-sub">
{historyLabel(settings?.historyDays ?? 365)}
</span>
</button>
<button <button
className="btn btn-plain" className="btn btn-plain"
onClick={syncDetails} onClick={syncDetails}
@@ -456,7 +490,7 @@ function SyncPage() {
</span> </span>
</div> </div>
<div className="sync-fact"> <div className="sync-fact">
<span className="sync-fact-label"></span> <span className="sync-fact-label"></span>
<span className="sync-fact-value"> <span className="sync-fact-value">
{auto?.account?.autoSync === false {auto?.account?.autoSync === false
? '已关闭' ? '已关闭'

40
deploy/deploy.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/sh
# Deploy the latest local code to NAS.
# Usage: sh deploy/deploy.sh
# Prerequisites: client has been built (`npm run build` in client/)
APP="$(cd "$(dirname "$0")/.." && pwd)"
NAS_HOST="${1:-ericwyuan@192.168.50.64}"
NAS_PORT="${2:-2222}"
NAS_PATH="/volume1/web/garmin-health-lab"
NAS_PASS="iLoveJava5"
set -e
echo "=== Building frontend ==="
cd "$APP/client"
npm run build 2>&1 | tail -3
echo "=== Syncing files to NAS (excluding .venv) ==="
cd "$APP"
tar cz \
--exclude='backend/.venv' \
--exclude='backend/__pycache__' \
--exclude='backend/*.pyc' \
--exclude='node_modules' \
-f /tmp/garmin_deploy.tar.gz \
backend/ deploy/
sshpass -p "$NAS_PASS" scp -P "$NAS_PORT" -o StrictHostKeyChecking=no \
/tmp/garmin_deploy.tar.gz "${NAS_HOST}:/tmp/garmin_deploy.tar.gz"
sshpass -p "$NAS_PASS" ssh -p "$NAS_PORT" -o StrictHostKeyChecking=no \
"$NAS_HOST" "echo '$NAS_PASS' | sudo -S tar xzf /tmp/garmin_deploy.tar.gz -C $NAS_PATH/ && rm /tmp/garmin_deploy.tar.gz"
rm -f /tmp/garmin_deploy.tar.gz
echo "=== Restarting service ==="
sshpass -p "$NAS_PASS" ssh -p "$NAS_PORT" -o StrictHostKeyChecking=no \
"$NAS_HOST" "echo '$NAS_PASS' | sudo -S $NAS_PATH/deploy/start.sh 2>&1"
echo "=== Done ==="