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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user