fix(sync): 「全部历史」真的拉全部历史,自动同步不再每次静默失败

四个独立的 bug 叠在一起,表现为「只同步两天、没有进度」:

* 前端 `...(days ? { days } : {})` 把 days=0 当成未传。「全部历史」
  存的就是 0,请求体里根本没有 days,后端退回 7 天默认值。
* scheduler 用 `s[0]` 读 query_one 返回的 dict,抛 KeyError 后被
  per-account 的 except 吞掉。只要用户存过一次设置,每 30 分钟的
  自动同步就一次都没成功过——库里那 2 天全是手动点出来的。
* 增量同步查 `health_daily`(表其实叫 health_data),后台线程直接
  死掉,状态永远卡在 syncing,进度条不动。
* UI 完全不看 /sync 的返回值,rate_limited 时按钮点了没反应;轮询
  结束时又把 rate_limited 归进 else 分支报「同步完成」。

顺带:
* 日循环遇到 429 立即退避并保留已拉到的天数,而不是当成「跳过一天」
  继续往下捶 700 天——这正是之前限流死循环的来源之一。
* 定时循环显式传 SYNC_DAYS。历史范围按 UI 文案只描述手动全量同步,
  让半小时一次的 tick 重拉 730 天必然把限流撞得更深。
* 短同步逐天上报进度(原来每 5 天一次,7 天的同步全程停在 0)。
* /sync 路由重复解析 body,空 body 会 None.get 崩。
* 4 个 StubGarth 缺 configure(),7 个测试在此之前一直是红的。

新增 deploy/push.sh:NAS 只认密码,脚本开一个 ssh 复用连接,密码只
输一次,后面推送 / 重启 / 健康检查全走它。不碰 .env、.venv 和数据库。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-01 08:11:24 +08:00
parent 15fba8c25c
commit 41b7ae82e4
8 changed files with 283 additions and 20 deletions

View File

@@ -218,8 +218,14 @@ function SyncPage() {
setLoading(true);
try {
// 0 means "everything"; the backend caps it at what Garmin will serve.
await apiClient.syncGarminData(settings?.historyDays ?? 3650);
await refresh();
const started = await apiClient.syncGarminData(settings?.historyDays ?? 365);
const s = await refresh();
// A refused start (Garmin is throttling us) used to be swallowed: the
// button did nothing, no progress bar appeared, and no reason was shown.
if (started.status === 'rate_limited' || s?.status === 'rate_limited') {
setError(started.message || s?.lastError || 'Garmin 正在限流,稍后会自动恢复。');
return;
}
beginSyncPolling();
} catch (err: any) {
setError(errorMessage(err, '同步失败'));
@@ -265,8 +271,10 @@ function SyncPage() {
if (!s) { stopPolling(); return; }
if (s.status !== 'syncing') {
stopPolling();
if (s.status === 'error') setError(s.lastError || '同步失败');
else setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
// Only 'idle' means it finished; anything else reported "同步完成"
// while nothing had been synced.
if (s.status === 'idle') setMessage(`同步完成,已更新 ${s.recordsSynced} 天数据`);
else setError(s.lastError || '同步失败');
}
}, POLL_MS);
};
@@ -374,7 +382,9 @@ function SyncPage() {
<section className="sync-card">
<div className="progress-head">
<span>{syncStatus?.stage || '正在同步…'}</span>
<span className="progress-count">{current} / {total} </span>
<span className="progress-count">
{total > 0 ? `${current} / ${total}` : `${current}`}
</span>
</div>
<div
className="progress-bar"

View File

@@ -105,7 +105,7 @@ export interface Activity {
}
export interface SyncStatus {
status: 'idle' | 'syncing' | 'error';
status: 'idle' | 'syncing' | 'error' | 'rate_limited';
lastSyncTime: string | null;
recordsSynced: number;
totalDays: number;
@@ -120,7 +120,7 @@ export interface SyncStatus {
}
export interface SyncResult {
status: 'success' | 'error';
status: 'success' | 'error' | 'rate_limited';
recordsSynced: number;
activitiesSynced?: number;
message: string;
@@ -442,10 +442,15 @@ class ApiClient {
*/
/** Starts a sync in the background; poll getGarminSyncStatus for progress. */
async syncGarminData(days?: number, garminPassword?: string) {
const { data } = await this.client.post<{ status: string; days?: number }>(
const { data } = await this.client.post<{
status: string; days?: number; message?: string; retryAfterSeconds?: number;
}>(
'/garmin/sync',
{
...(days ? { days } : {}),
// `days` must survive being 0 — that is "全部历史", not "unset".
// `days ? …` dropped it, so the backend fell back to its 7-day
// default and a full backfill silently pulled a week.
...(days === undefined || days === null ? {} : { days }),
...(garminPassword ? { garminPassword } : {}),
}
);