Files
GarminHealthLab/backend/routes/garmin.py
ericwyuan 1c80b16325 fix(garmin): 重新绑定走的是同一个被封的登录接口,也得拦住
用户问:限流了,重新输账号密码验证码换个新令牌行不行。

不行,而且是最糟的一种试法。`garth.login()` 和 `refresh_oauth2()` 打的是
同一个 SSO 端点,流程还更重;限流按**账号**计(不是按 IP、按 UA),换设备
换网络都绕不开;而窗口内每次尝试都会把窗口往后推。

而这正是被卡住时第一个会去试的操作,代码里却只有 `_connect` 的刷新有闸门,
重新绑定那条路照发不误。

- start_login 在 sso 冷却窗口内直接拒绝,不建会话行、不碰网络
- 错误信息说清三件事:为什么现在不试、什么时候恢复、换设备没用
- 路由返 429(请求本身没毛病,是该晚点再来)并带 retryAfterSeconds
- 数据端点的 429 不参与拦截,force 可以推翻

前端补上 UI:报错文案早先承诺了「同步页选择强制重试」,但那个按钮不存在。
现在只在被冷却拒绝之后才出现,样式刻意做得不像第二个「开始同步」——它是给
估算失准时的出口,不是随手可点的第二选择。

顺带修一个正要被我引入的 bug:`onClick={syncHistory}` 会把 MouseEvent 当成
force 传进去,等于每次点开始同步都跳过冷却。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 06:20:05 +08:00

251 lines
8.8 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
# `force` overrules the recorded SSO cooldown. That deadline is our own
# 24h guess rather than something Garmin told us, so the user has to be
# able to say "it has cleared, try anyway" — otherwise a wrong estimate
# strands the account for a day, which is the failure that got a blanket
# rate-limit gate removed once before.
if data.get("force"):
garmin_svc.clear_rate_limit(g.user_id)
# 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
try:
session_id = garmin_auth.start_login(
g.user_id, garmin_email, password, force=bool(data.get("force"))
)
except garmin_auth.LoginRateLimited as e:
# 429, not 400: the request was well-formed and the client should
# retry later — and the body says when, and why not now.
return jsonify({
"error": str(e),
"status": "rate_limited",
"retryAfterSeconds": garmin_auth.retry_after_seconds(g.user_id),
}), 429
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))