后端: - 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日), 触发类型标签, 范围与耗时 - 同步页与设置页同步区块均加入口链接
232 lines
8.0 KiB
Python
232 lines
8.0 KiB
Python
"""Garmin routes: bind a Garmin account, trigger a sync, read sync status.
|
|
|
|
Deliberately independent of the `users` identity: this blueprint reads and
|
|
writes only `garmin_tokens` and the sync-status tables, keyed by `g.user_id`.
|
|
Web login (routes/auth.py, via auth-hub) establishes who `g.user_id` is;
|
|
whether that account has a Garmin binding at all is this blueprint's
|
|
business alone, and the two are meant to be operable independently — see
|
|
services/garmin.py's `get_remembered_email` for the one deliberate,
|
|
backward-compatible read of the legacy `users.garmin_email` column.
|
|
"""
|
|
from flask import Blueprint, request, g, jsonify
|
|
|
|
from auth import require_auth
|
|
from services import garmin as garmin_svc
|
|
from services import garmin_auth
|
|
from services import scheduler
|
|
|
|
bp = Blueprint("garmin", __name__)
|
|
|
|
|
|
@bp.route("/sync", methods=["POST"])
|
|
@require_auth
|
|
def sync():
|
|
data = request.get_json(silent=True) or {}
|
|
creds = {
|
|
"garminEmail": (data.get("garminEmail") or "").strip(),
|
|
"garminPassword": data.get("garminPassword") or "",
|
|
}
|
|
# Fall back to the remembered Garmin email when only a password is supplied.
|
|
if not creds["garminEmail"]:
|
|
creds["garminEmail"] = garmin_svc.get_remembered_email(g.user_id)
|
|
|
|
# With stored OAuth tokens no password is needed at all. Without them the
|
|
# plaintext password must come in the body, because only a hash is kept.
|
|
if not creds["garminPassword"] and not garmin_svc.has_token(g.user_id):
|
|
return (
|
|
jsonify({
|
|
"status": "error",
|
|
"recordsSynced": 0,
|
|
"message": "需要 Garmin 密码以执行同步,请在请求体中提供 garminPassword"
|
|
"(密码仅作哈希存储,无法还原)。",
|
|
}),
|
|
400,
|
|
)
|
|
|
|
# `data` is the body already parsed above; re-parsing here used to blow up
|
|
# on a body-less POST (`None.get`).
|
|
days = data.get("days")
|
|
try:
|
|
if days is not None:
|
|
days = int(days)
|
|
if days == -1:
|
|
days = -1 # 自上次同步(增量)
|
|
elif days == 0:
|
|
days = 0 # 全部历史 — the service walks back to the real end
|
|
else:
|
|
days = max(1, min(days, garmin_svc.MAX_HISTORY_DAYS))
|
|
except (TypeError, ValueError):
|
|
days = None
|
|
|
|
# Always run in the background: even a week takes ~20s, and a full
|
|
# backfill runs for many minutes. Progress is polled via /status.
|
|
result = garmin_svc.start_sync(g.user_id, creds, days)
|
|
return jsonify(result), 202
|
|
|
|
|
|
@bp.route("/auth-status", methods=["GET"])
|
|
@require_auth
|
|
def auth_status():
|
|
"""Whether a stored token exists, so the UI knows to ask for a password."""
|
|
return jsonify({"hasToken": garmin_svc.has_token(g.user_id)})
|
|
|
|
|
|
@bp.route("/disconnect", methods=["POST"])
|
|
@require_auth
|
|
def disconnect():
|
|
"""Drop the stored Garmin token so the next sync must re-authenticate.
|
|
|
|
Deletes the OAuth token (the UI calls this a "退出 Garmin 账号"). garmin_email
|
|
stays on the user record, so re-login only needs the password again. Already
|
|
synced health data is untouched.
|
|
"""
|
|
garmin_svc.delete_token(g.user_id)
|
|
return jsonify({
|
|
"ok": True,
|
|
"message": "已退出 Garmin 账号,已保存的授权令牌已删除,下次同步需重新登录获取新令牌。",
|
|
})
|
|
|
|
|
|
@bp.route("/login", methods=["POST"])
|
|
@require_auth
|
|
def login():
|
|
"""Begin an interactive Garmin login.
|
|
|
|
Returns immediately with a session id; the login continues in the
|
|
background and parks if Garmin asks for a two-factor code. Poll
|
|
/login-status and post the code to /mfa.
|
|
"""
|
|
data = request.get_json(silent=True) or {}
|
|
password = data.get("garminPassword") or ""
|
|
if not password:
|
|
return jsonify({"error": "请提供 Garmin 密码"}), 400
|
|
|
|
garmin_email = (data.get("garminEmail") or "").strip()
|
|
if not garmin_email:
|
|
garmin_email = garmin_svc.get_remembered_email(g.user_id)
|
|
if not garmin_email:
|
|
return jsonify({"error": "缺少 Garmin 邮箱"}), 400
|
|
|
|
session_id = garmin_auth.start_login(g.user_id, garmin_email, password)
|
|
return jsonify({"session": session_id, "status": "starting"}), 202
|
|
|
|
|
|
@bp.route("/login-status", methods=["GET"])
|
|
@require_auth
|
|
def login_status():
|
|
session_id = request.args.get("session") or ""
|
|
row = garmin_auth.get_session(session_id, g.user_id)
|
|
if not row:
|
|
return jsonify({"error": "登录会话不存在或已过期"}), 404
|
|
return jsonify({
|
|
"session": row["id"],
|
|
"status": row["status"],
|
|
"error": row["error"],
|
|
})
|
|
|
|
|
|
@bp.route("/mfa", methods=["POST"])
|
|
@require_auth
|
|
def submit_mfa():
|
|
data = request.get_json(silent=True) or {}
|
|
session_id = (data.get("session") or "").strip()
|
|
code = (data.get("code") or "").strip()
|
|
if not session_id or not code:
|
|
return jsonify({"error": "session 与 code 均为必填"}), 400
|
|
|
|
ok, message = garmin_auth.submit_code(session_id, g.user_id, code)
|
|
return jsonify({"ok": ok, "message": message}), (200 if ok else 400)
|
|
|
|
|
|
@bp.route("/login", methods=["DELETE"])
|
|
@require_auth
|
|
def cancel_login():
|
|
session_id = request.args.get("session") or ""
|
|
garmin_auth.cancel(session_id, g.user_id)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@bp.route("/status", methods=["GET"])
|
|
@require_auth
|
|
def status():
|
|
return jsonify(garmin_svc.get_sync_status(g.user_id))
|
|
|
|
|
|
@bp.route("/sync-latest", methods=["POST"])
|
|
@require_auth
|
|
def sync_latest():
|
|
"""Pull just the last couple of days.
|
|
|
|
Separate from /sync because it is fast enough to wait for (a few seconds
|
|
rather than minutes), so the UI can report the result directly instead of
|
|
handing back a job to poll.
|
|
"""
|
|
if not garmin_svc.has_token(g.user_id):
|
|
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
|
|
|
|
days = request.get_json(silent=True) or {}
|
|
try:
|
|
window = max(1, min(int(days.get("days", scheduler.SYNC_DAYS)), 7))
|
|
except (TypeError, ValueError):
|
|
window = scheduler.SYNC_DAYS
|
|
|
|
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"])
|
|
@require_auth
|
|
def auto_sync_status():
|
|
"""When the scheduler last ran, and when this account is next due."""
|
|
return jsonify(scheduler.status(g.user_id))
|
|
|
|
|
|
@bp.route("/activities/<activity_id>/detail", methods=["GET"])
|
|
@require_auth
|
|
def activity_detail(activity_id):
|
|
"""Everything stored for one activity: stats, laps, zones, series.
|
|
|
|
A local read. Detail is fetched during the sync rather than when the user
|
|
taps an activity — seven Garmin calls on the critical path of a tap was
|
|
slow on a good connection and a timeout on a bad one.
|
|
"""
|
|
detail = garmin_svc.read_activity_detail(g.user_id, activity_id)
|
|
if detail is None:
|
|
return jsonify({
|
|
"error": "这条运动的详细数据还没同步到本机,去「同步」页拉一次即可。",
|
|
"needsSync": True,
|
|
}), 404
|
|
return jsonify(detail)
|
|
|
|
|
|
@bp.route("/sync-details", methods=["POST"])
|
|
@require_auth
|
|
def sync_details():
|
|
"""Backfill activity details and daily curves for existing history."""
|
|
if not garmin_svc.has_token(g.user_id):
|
|
return jsonify({"error": "尚未绑定 Garmin 账号"}), 400
|
|
|
|
body = request.get_json(silent=True) or {}
|
|
try:
|
|
limit = int(body["limit"]) if body.get("limit") else None
|
|
except (TypeError, ValueError):
|
|
limit = None
|
|
|
|
return jsonify(garmin_svc.start_backfill(g.user_id, limit)), 202
|
|
|
|
|
|
@bp.route("/sync-details", methods=["GET"])
|
|
@require_auth
|
|
def sync_details_status():
|
|
return jsonify(garmin_svc.backfill_status(g.user_id))
|