Files
GarminHealthLab/backend/routes/garmin.py
ericwyuan 6a5cfa7806 [阶段7.1] 同步改为后台任务 + 进度上报,支持回补历史
趋势页提供了"一年"档,但库里只有 7 天数据,那一档形同虚设。
实测每天约 2.84 秒(一天要打 5 个端点),回补一年需要约 28 分钟,
远超任何 HTTP 超时能等的时间。

- sync_status 新增 progress_current / progress_total / started_at
- start_sync() 起后台线程并立即返回,sync_data 每 5 天写一次进度
  (写库便宜但不免费,而前端本来就是 2 秒一轮询)
- POST /api/garmin/sync 改为 202 立即返回,接受 days 参数并
  夹在 1..730;进度经 GET /status 轮询
- 一次新同步会清掉上一次的错误,避免旧错误一直挂在界面上

前端:
- 同步页给出 7 / 30 / 90 / 365 天四个选项,日常与首次回补分开
- 进度条显示"第 N / 共 M 天"与预计耗时,并说明可以离开本页
- 页面挂载时若发现正在同步会接着轮询 —— 回补比页面存活时间长,
  刷新后必须能接上进度

tests (+7, 共 299):
- start_sync 在工作完成前就返回,且返回前已把 total 写好
- 进度随同步推进,结束时等于总天数
- days 超范围被夹到 730
- 新同步清除上一次的错误

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 21:31:50 +08:00

122 lines
4.1 KiB
Python

"""Garmin routes: trigger a sync and read sync status."""
from flask import Blueprint, request, g, jsonify
from auth import require_auth
from db import query_one
from services import garmin as garmin_svc
from services import garmin_auth
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 stored Garmin email when only a password is supplied.
if not creds["garminEmail"]:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
if user and user.get("garmin_email"):
creds["garminEmail"] = user["garmin_email"]
# 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,
)
days = request.get_json(silent=True).get("days") if request.is_json else None
try:
days = max(1, min(int(days), 730)) if days else None
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("/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:
user = query_one("SELECT garmin_email FROM users WHERE id = ?", [g.user_id])
garmin_email = (user or {}).get("garmin_email") or ""
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))