diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 0800413..2afa248 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -115,7 +115,18 @@ def login(): if not garmin_email: return jsonify({"error": "缺少 Garmin 邮箱"}), 400 - session_id = garmin_auth.start_login(g.user_id, garmin_email, password) + 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 diff --git a/backend/services/garmin_auth.py b/backend/services/garmin_auth.py index 07a8078..7a09cc6 100644 --- a/backend/services/garmin_auth.py +++ b/backend/services/garmin_auth.py @@ -129,8 +129,47 @@ def _run_login(session_id, user_id, garmin_email, password, is_cn, import_garmin _set(session_id, "failed", error=error, code=None) -def start_login(user_id, garmin_email, password, import_garmin=None, is_cn=None): - """Kick off a login in the background. Returns the session id.""" +class LoginRateLimited(Exception): + """Refused before contacting Garmin, because a login 429 is still active.""" + + +def retry_after_seconds(user_id): + """Seconds until the login cooldown lapses, or None.""" + blocked = garmin_svc.sso_cooldown(user_id) + if not blocked: + return None + return max(0, int((blocked - datetime.datetime.utcnow()).total_seconds())) + + +def start_login(user_id, garmin_email, password, import_garmin=None, + is_cn=None, force=False): + """Kick off a login in the background. Returns the session id. + + Refuses while a login 429 is still in its window. Re-binding is the + obvious thing to try when syncing is blocked — "just get a fresh token" — + but it goes through `garth.login()`, which is the *same* SSO endpoint that + is doing the blocking, by a heavier path than the token refresh. The limit + is keyed to the account, so a new password entry, a new device or a new + network reaches the same wall, and each attempt pushes the window out. + + `force` overrules the recorded deadline, which is this app's own 24h guess + rather than anything Garmin stated. + """ + if force: + garmin_svc.clear_rate_limit(user_id) + else: + blocked = garmin_svc.sso_cooldown(user_id) + if blocked: + minutes = int( + (blocked - datetime.datetime.utcnow()).total_seconds() // 60 + ) + raise LoginRateLimited( + "Garmin 正在限制该账号的登录请求。重新绑定走的是同一个登录接口," + f"现在重试只会延长封锁。预计 {blocked.isoformat(timespec='minutes')} " + f"UTC 之后恢复(约 {minutes} 分钟)。" + "限流按账号计算,换设备或换网络都绕不开。" + ) + _cleanup(user_id) session_id = str(uuid.uuid4()) diff --git a/backend/tests/test_garmin_mfa.py b/backend/tests/test_garmin_mfa.py index e2a9017..2a21a40 100644 --- a/backend/tests/test_garmin_mfa.py +++ b/backend/tests/test_garmin_mfa.py @@ -276,3 +276,52 @@ class TestEndpoints: assert wait_status(sid, "done", "failed") == "done" assert client.get("/api/garmin/auth-status", headers=auth).get_json()[ "hasToken"] is True + + +class TestRebindingIsGatedToo: + """"Just re-enter the password and get a fresh token" is the obvious thing + to try when syncing is blocked — and it is the worst thing to try. + + `garth.login()` is the same SSO endpoint that is doing the blocking, by a + heavier path than the token refresh, and the limit is keyed to the account + so a new device or network reaches the same wall. + """ + + def test_login_is_refused_inside_the_window(self, db, user): + garmin_svc._note_rate_limit(user["id"], "sso") + with pytest.raises(garmin_auth.LoginRateLimited): + garmin_auth.start_login(user["id"], "a@example.com", "pw") + + def test_no_session_row_is_created_by_a_refusal(self, db, user): + garmin_svc._note_rate_limit(user["id"], "sso") + with pytest.raises(garmin_auth.LoginRateLimited): + garmin_auth.start_login(user["id"], "a@example.com", "pw") + rows = db.query_all( + "SELECT id FROM garmin_mfa_sessions WHERE user_id = ?", [user["id"]]) + assert rows == [] + + def test_a_data_429_does_not_block_rebinding(self, db, user, monkeypatch): + started = [] + monkeypatch.setattr(garmin_auth.threading, "Thread", + lambda **kw: type("T", (), {"start": lambda s: started.append(1)})()) + garmin_svc._note_rate_limit(user["id"], "data") + garmin_auth.start_login(user["id"], "a@example.com", "pw") + assert started == [1] + + def test_force_overrules_the_estimate(self, db, user, monkeypatch): + started = [] + monkeypatch.setattr(garmin_auth.threading, "Thread", + lambda **kw: type("T", (), {"start": lambda s: started.append(1)})()) + garmin_svc._note_rate_limit(user["id"], "sso") + garmin_auth.start_login(user["id"], "a@example.com", "pw", force=True) + assert started == [1] + + def test_the_endpoint_answers_429_with_a_retry_hint(self, client, auth, db, user): + garmin_svc._note_rate_limit(user["id"], "sso") + resp = client.post("/api/garmin/login", + json={"garminPassword": "pw", "garminEmail": "a@example.com"}, + headers=auth) + assert resp.status_code == 429 + body = resp.get_json() + assert body["retryAfterSeconds"] > 0 + assert "延长封锁" in body["error"] diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index 63e9855..a8dec57 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -322,3 +322,23 @@ .sync-records-link:active { background: var(--surface-0); } + +/* Shown only after the cooldown refused a sync. Deliberately plain and set + apart from the primary actions: it exists as a way out of a wrong estimate, + not as a second 同步 button — pressing it inside Garmin's real window is + what extends the lockout. */ +.sync-force { + display: block; + width: auto; + margin-top: 0.6rem; + padding: 0.4rem 0.8rem; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary); + background: var(--surface-1); + border: 1px solid var(--border-strong); + border-radius: 999px; + cursor: pointer; +} + +.sync-force:disabled { color: var(--text-muted); cursor: default; } diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index d8860b6..d5f5b6b 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -54,6 +54,10 @@ function SyncPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + /* Whether the last attempt was refused by the recorded cooldown. Gates the + 强制重试 button: offering it unconditionally would invite the very thing + the cooldown prevents. */ + const [blocked, setBlocked] = useState(false); const [message, setMessage] = useState(''); const pollRef = useRef(null); @@ -233,20 +237,26 @@ function SyncPage() { }).open(); }; - /** The one manual sync: the chosen range, in the background, with progress. */ - const syncHistory = async () => { + /** The one manual sync: the chosen range, in the background, with progress. + * + * `force` discards the recorded cooldown first. Offered only after a + * refusal, and never as the default: the cooldown exists because trying + * again inside Garmin's login window is what extends it. */ + const syncHistory = async (force?: boolean) => { setError(''); setMessage(''); + setBlocked(false); setLoading(true); try { // 0 means "everything" and -1 "since the last sync"; the backend caps // the former at what Garmin will actually serve. - const started = await apiClient.syncGarminData(range); + const started = await apiClient.syncGarminData(range, undefined, force); const s = await refresh(); // A refused start (Garmin is throttling us) used to be swallowed: the // button did nothing, no progress bar appeared, and no reason was shown. if (started.status === 'rate_limited' || s?.status === 'rate_limited') { setError(started.message || s?.lastError || 'Garmin 正在限流,稍后会自动恢复。'); + setBlocked(true); return; } beginSyncPolling(); @@ -311,7 +321,20 @@ function SyncPage() { return ( - {error &&
{error}
} + {error && ( +
+ {error} + {blocked && ( + + )} +
+ )} {message &&
{message}
} {/* Step 1 — link the Garmin account, once. */} @@ -445,7 +468,10 @@ function SyncPage() {