feat(sync-history): 新增同步结果查询(每次自动/手动/立即同步的记录)

后端:
- db.py SCHEMA 新增 sync_history 表(不可变,每次同步尝试一行) + 索引
- garmin.py: _log_sync_history() 在 sync_data 全部出口记录; trigger 区分
  auto(调度器)/manual(同步页开始同步)/quick(设置页立即同步); start_sync
  限流拦截分支同样留档; 写入失败只告警不影响同步
- scheduler.py 自动同步传 trigger="auto"; routes 新增 GET /garmin/sync-history
  按时间倒序返回(默认 50 条,上限 200)

前端:
- api.ts 增加 SyncHistoryItem 类型 + getSyncHistory()
- 新页面 /sync-history/ 同步记录: 卡片列表, 状态(成功/失败/被限流)chip 配色,
  时间本地化(今天/昨天/X月X日), 触发类型标签, 范围与耗时
- 同步页与设置页同步区块均加入口链接
This commit is contained in:
ericwyuan
2026-09-02 20:38:48 +08:00
parent 26ff853f0b
commit 5e6fc01f76
13 changed files with 476 additions and 20 deletions

View File

@@ -87,6 +87,31 @@ CREATE TABLE IF NOT EXISTS sync_status (
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- One immutable row per sync attempt (auto / manual / 立即同步), so the user
-- can see what ran and what happened. sync_status above only keeps the
-- *current* state; it says nothing about a run that finished hours ago.
-- Column is trigger_kind, not trigger: TRIGGER is a MariaDB reserved word and
-- a bare `trigger` column fails with 1064 there (SQLite tolerates it, so a
-- local green suite does not catch it).
CREATE TABLE IF NOT EXISTS sync_history (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
trigger_kind VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL,
days INT DEFAULT 0,
records_synced INT DEFAULT 0,
activities_synced INT DEFAULT 0,
badges_synced INT DEFAULT 0,
personal_records_synced INT DEFAULT 0,
started_at DATETIME NOT NULL,
finished_at DATETIME,
duration_seconds INT DEFAULT 0,
message TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_sync_history_user_time
ON sync_history (user_id, started_at);
-- Garmin OAuth tokens, obtained once through an interactive login.
-- Garmin accounts with two-factor auth cannot be logged into unattended: the
-- library asks for an MFA code on stdin, which a gunicorn worker does not

View File

@@ -170,7 +170,18 @@ def sync_latest():
except (TypeError, ValueError):
window = scheduler.SYNC_DAYS
return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window))
return jsonify(garmin_svc.sync_data(g.user_id, {}, days=window, trigger="quick"))
@bp.route("/sync-history", methods=["GET"])
@require_auth
def sync_history():
"""Every recorded sync attempt, newest first (auto / manual / 立即同步)."""
try:
limit = max(1, min(int(request.args.get("limit", 50)), 200))
except (TypeError, ValueError):
limit = 50
return jsonify({"items": garmin_svc.get_sync_history(g.user_id, limit)})
@bp.route("/auto-sync", methods=["GET"])

View File

@@ -24,6 +24,7 @@ import json
import logging
import os
import threading
import uuid
from db import execute, query_one, query_all
from config import DB_TYPE
@@ -60,6 +61,78 @@ def _set_sync_status(user_id, status, now, last_sync_time=None, **fields):
execute(sql, [user_id, status, last] + list(fields.values()))
def _log_sync_history(user_id, trigger, days, started_at, result):
"""Append one immutable row per sync attempt.
`trigger` names what started it — "auto" (scheduler), "manual" (同步页
开始同步) or "quick" (设置页立即同步) — so the 同步记录 screen can show
the whole picture. A failed or refused sync is as much a record as a
successful one; the user is usually looking at this list because one of
those did not behave. A write failure must never break the sync it is
reporting, so it is logged and swallowed.
"""
try:
finished = datetime.datetime.utcnow().isoformat(timespec="seconds")
r = result or {}
duration = 0
try:
s = datetime.datetime.fromisoformat(str(started_at).replace(" ", "T"))
f = datetime.datetime.fromisoformat(finished)
duration = max(0, int((f - s).total_seconds()))
except (TypeError, ValueError):
pass
execute(
"INSERT INTO sync_history (id, user_id, trigger_kind, status, days, "
"records_synced, activities_synced, badges_synced, "
"personal_records_synced, started_at, finished_at, "
"duration_seconds, message) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
str(uuid.uuid4()), user_id, trigger,
r.get("status") or "error",
days if days is not None else 0,
r.get("recordsSynced") or 0,
r.get("activitiesSynced") or 0,
r.get("badgesSynced") or 0,
r.get("personalRecordsSynced") or 0,
started_at, finished, duration,
(r.get("message") or "")[:500],
],
)
except Exception as e: # noqa: BLE001 - history must never break a sync
logging.getLogger(__name__).warning(
"sync history write failed for %s: %s", user_id, e
)
def get_sync_history(user_id, limit=50):
"""Most recent sync attempts for the user, newest first."""
rows = query_all(
"SELECT trigger_kind, status, days, records_synced, activities_synced, "
"badges_synced, personal_records_synced, started_at, finished_at, "
"duration_seconds, message "
"FROM sync_history WHERE user_id = ? "
"ORDER BY started_at DESC LIMIT ?",
[user_id, limit],
)
return [
{
"triggerKind": r["trigger_kind"],
"status": r["status"],
"days": r["days"],
"recordsSynced": r["records_synced"],
"activitiesSynced": r["activities_synced"],
"badgesSynced": r["badges_synced"],
"personalRecordsSynced": r["personal_records_synced"],
"startedAt": r["started_at"],
"finishedAt": r["finished_at"],
"durationSeconds": r["duration_seconds"],
"message": r["message"],
}
for r in rows
]
def get_sync_status(user_id):
row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id])
# Count distinct days actually stored in the database for this user.
@@ -991,6 +1064,11 @@ def start_sync(user_id, creds, days=None):
records_synced=0, progress_current=0, progress_total=days,
started_at=now, last_error=msg,
)
_log_sync_history(user_id, "manual", days, now, {
"status": "rate_limited",
"recordsSynced": 0,
"message": msg,
})
return {
"status": "rate_limited",
"message": msg,
@@ -1008,15 +1086,24 @@ def start_sync(user_id, creds, days=None):
return {"status": "syncing", "days": days}
def sync_data(user_id, creds, days=None, client=None):
def sync_data(user_id, creds, days=None, client=None, trigger="manual"):
"""Pull the last `days` days from Garmin Connect into the local database.
`client` exists so tests can inject a stub instead of reaching Garmin.
`trigger` ("auto" / "manual" / "quick") names what started this run and is
recorded on the sync_history row every exit path appends.
"""
days = DEFAULT_SYNC_DAYS if days is None else days
if days == 0:
days = MAX_HISTORY_DAYS # 全部历史 → 走到数据尽头为止
now = datetime.datetime.utcnow().isoformat(timespec="seconds")
started = now
def finish(result):
"""Record the attempt, then hand the result to the caller."""
_log_sync_history(user_id, trigger, days, started, result)
return result
until, msg = _rate_limit_block(user_id)
if until:
# A blocked account must issue zero Garmin requests — that is the whole
@@ -1030,12 +1117,12 @@ def sync_data(user_id, creds, days=None, client=None):
last_sync_time=row["last_sync_time"] if row else None,
records_synced=0, last_error=msg,
)
return {
return finish({
"status": "rate_limited",
"recordsSynced": 0,
"message": msg,
"retryAfterSeconds": int((until - datetime.datetime.utcnow()).total_seconds()),
}
})
_set_sync_status(
user_id, "syncing", now,
records_synced=0, progress_current=0, progress_total=days,
@@ -1047,13 +1134,13 @@ def sync_data(user_id, creds, days=None, client=None):
except Exception as e:
message = describe(e)
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
return {
return finish({
"status": "error",
"recordsSynced": 0,
"message": message,
"mfaRequired": isinstance(e, MFARequired),
"lastSyncTime": now,
}
})
today = datetime.date.today()
@@ -1156,7 +1243,7 @@ def sync_data(user_id, creds, days=None, client=None):
progress_current=days_synced, progress_total=days, stage=None,
last_error=message,
)
return {
return finish({
"status": "rate_limited",
"recordsSynced": days_synced,
"message": f"已同步 {days_synced} 天后被 Garmin 限流:{message}",
@@ -1164,7 +1251,7 @@ def sync_data(user_id, creds, days=None, client=None):
int((until - datetime.datetime.utcnow()).total_seconds()) if until else None
),
"lastSyncTime": now,
}
})
activities_synced = 0
_set_sync_status(user_id, "syncing", now, records_synced=days_synced,
@@ -1236,8 +1323,8 @@ def sync_data(user_id, creds, days=None, client=None):
if days_synced == 0 and days_attempted > 0 and len(day_errors) >= days_attempted:
message = "; ".join(day_errors[:3])
_set_sync_status(user_id, "error", now, records_synced=0, last_error=message)
return {"status": "error", "recordsSynced": 0,
"message": f"同步失败:{message}", "lastSyncTime": now}
return finish({"status": "error", "recordsSynced": 0,
"message": f"同步失败:{message}", "lastSyncTime": now})
_set_sync_status(
user_id, "idle", now, records_synced=days_synced,
@@ -1251,7 +1338,7 @@ def sync_data(user_id, creds, days=None, client=None):
)
if day_errors:
message += f"{len(day_errors)} 项跳过)"
return {
return finish({
"status": "success",
"recordsSynced": days_synced,
"activitiesSynced": activities_synced,
@@ -1259,4 +1346,4 @@ def sync_data(user_id, creds, days=None, client=None):
"personalRecordsSynced": records_synced_pr,
"message": message,
"lastSyncTime": now,
}
})

View File

@@ -161,7 +161,7 @@ def sync_all_accounts(days=None, respect_schedule=False):
"retryAfterSeconds": int((blocked - _now()).total_seconds()),
})
continue
out = garmin_svc.sync_data(uid, {}, days=d)
out = garmin_svc.sync_data(uid, {}, days=d, trigger="auto")
results.append({"user": uid, "status": out.get("status"),
"records": out.get("recordsSynced")})
# New data invalidates every stored insight, so queue them all at

View File

@@ -845,3 +845,80 @@ class TestSyncWindow:
assert out["recordsSynced"] == 2, "the days pulled before the 429 are kept"
assert len([c for c in client.calls if c[0] == "summary"]) == 3
assert garmin_svc.rate_limited_until(user["id"]) is not None
class TestSyncHistory:
"""Every sync attempt gets one immutable row, whatever its outcome — the
record is what lets the 同步记录 screen show what ran, when, and whether
it worked. sync_status only keeps the latest state."""
def test_success_records_one_row(self, db, user):
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
assert out["status"] == "success"
rows = garmin_svc.get_sync_history(user["id"])
assert len(rows) == 1
row = rows[0]
assert row["triggerKind"] == "manual"
assert row["status"] == "success"
assert row["days"] == 1
assert row["recordsSynced"] == 1
assert row["startedAt"] and row["finishedAt"]
assert row["durationSeconds"] >= 0
def test_auto_and_quick_triggers_are_tagged(self, db, user):
garmin_svc.sync_data(
user["id"], CREDS, days=1, client=StubClient(), trigger="auto"
)
garmin_svc.sync_data(
user["id"], CREDS, days=1, client=StubClient(), trigger="quick"
)
rows = garmin_svc.get_sync_history(user["id"])
assert [r["triggerKind"] for r in rows] == ["quick", "auto"], "newest first"
def test_newest_first(self, db, user):
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
rows = garmin_svc.get_sync_history(user["id"])
assert len(rows) == 2
assert rows[0]["finishedAt"] >= rows[1]["finishedAt"]
def test_total_failure_is_recorded(self, db, user):
out = garmin_svc.sync_data(
user["id"], CREDS, days=2, client=StubClient(fail_days=[day(0), day(1)])
)
assert out["status"] == "error"
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "error"
def test_a_429_mid_run_is_recorded(self, db, user):
class Throttled(StubClient):
def get_user_summary(self, cdate):
self.calls.append(("summary", cdate))
if cdate == day(1):
raise RuntimeError("too many 429 error responses")
return summary()
garmin_svc.sync_data(user["id"], CREDS, days=2, client=Throttled())
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
garmin_svc._rate_limited_until.clear()
def test_a_refused_sync_is_still_a_record(self, db, user, monkeypatch):
future = datetime.datetime.utcnow() + datetime.timedelta(hours=1)
monkeypatch.setitem(garmin_svc._rate_limited_until, user["id"], future)
out = garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
assert out["status"] == "rate_limited"
assert garmin_svc.get_sync_history(user["id"])[0]["status"] == "rate_limited"
def test_history_is_per_user(self, db, user, make_user):
garmin_svc.sync_data(user["id"], CREDS, days=1, client=StubClient())
other = make_user("other@example.com")
assert garmin_svc.get_sync_history(other["id"]) == []
def test_history_endpoint_requires_auth(self, client):
assert client.get("/api/garmin/sync-history").status_code == 401
def test_history_endpoint_returns_empty_list(self, client, auth):
r = client.get("/api/garmin/sync-history", headers=auth)
assert r.status_code == 200
assert r.get_json() == {"items": []}

View File

@@ -88,7 +88,7 @@ class TestSyncAllAccounts:
seen = []
monkeypatch.setattr(
garmin_svc, "sync_data",
lambda uid, creds, days=None, client=None: seen.append(uid)
lambda uid, creds, days=None, client=None, trigger="manual": seen.append(uid)
or {"status": "success", "recordsSynced": days},
)
@@ -105,7 +105,7 @@ class TestSyncAllAccounts:
garmin_svc.save_token(user["id"], "t1")
garmin_svc.save_token(other["id"], "t2")
def flaky(uid, creds, days=None, client=None):
def flaky(uid, creds, days=None, client=None, trigger="manual"):
if uid == user["id"]:
raise RuntimeError("token expired")
return {"status": "success", "recordsSynced": 2}
@@ -160,7 +160,7 @@ class TestEndpoints:
garmin_svc.save_token(user["id"], "blob")
monkeypatch.setattr(
garmin_svc, "sync_data",
lambda uid, creds, days=None, client=None: {
lambda uid, creds, days=None, client=None, trigger="manual": {
"status": "success", "recordsSynced": days, "message": "ok",
},
)
@@ -173,7 +173,7 @@ class TestEndpoints:
garmin_svc.save_token(user["id"], "blob")
monkeypatch.setattr(
garmin_svc, "sync_data",
lambda uid, creds, days=None, client=None: {
lambda uid, creds, days=None, client=None, trigger="manual": {
"status": "success", "recordsSynced": days,
},
)
@@ -200,8 +200,9 @@ class TestSyncWindowResolution:
garmin_svc.save_token(user["id"], "t1")
seen = {}
def record(uid, creds, days=None, client=None):
def record(uid, creds, days=None, client=None, trigger="manual"):
seen["days"] = days
seen["trigger"] = trigger
return {"status": "success", "recordsSynced": 0}
monkeypatch.setattr(garmin_svc, "sync_data", record)