From db7f1820300b8463b2d14d3593b523d6b0de46f5 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 13 Sep 2026 07:20:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(garmin):=20=E9=80=80=E5=87=BA=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=BC=9A=E6=8A=8A=E9=82=AE=E7=AE=B1=E5=92=8C=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E4=B8=80=E8=B5=B7=E6=B0=B8=E4=B9=85=E5=88=A0=E6=8E=89?= =?UTF-8?q?=EF=BC=8CSSO=20=E8=B4=A6=E5=8F=B7=E6=97=A0=E5=A4=84=E8=A1=A5?= =?UTF-8?q?=E6=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户刚才绑定失败:`Request failed with status code 400`。查下来是 `delete_token()`("退出 Garmin 账号"背后的函数)的注释说"邮箱留在 user 表, 下次只需要密码",但代码只有 `DELETE FROM garmin_tokens`——而 `garmin_email` 唯一的落脚点就是这张表。`users.garmin_email` 是本地密码登录时代的遗留列, `get_remembered_email` 里写得很清楚:auth-hub 接管之后,没有任何绑定路径再 往那张表写过东西。也就是说现在**所有账号**(auth-hub SSO)退出一次,等于 永久忘记邮箱——注释描述的"安全网"对这些账号从来没生效过。 - `delete_token` 删除前把 `garmin_tokens.garmin_email` 复制一份到 `users.garmin_email`,注释里承诺的行为终于是真的 - 顺带修了一条原有测试掩盖真相的问题:`test_disconnecting_drops_the_ current_binding_but_not_the_legacy_value` 预先在 users 表塞了一条 legacy 邮箱,退出后自然能读到——从来没测过 SSO 账号真正会遇到的情况(legacy 列 本来就是空的)。新增两条测试覆盖这个和"连续退出两次不会把邮箱也搞丢" - 生产账号的邮箱已经从退出前的 dump 备份里手工恢复,不用重新绑定就能补上 Co-Authored-By: Claude Sonnet 5 --- backend/services/garmin.py | 18 +++++++++++++++--- backend/tests/test_garmin_sync.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/backend/services/garmin.py b/backend/services/garmin.py index 808ac9c..668a4a5 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -496,10 +496,22 @@ def delete_token(user_id): """Forget the stored Garmin OAuth token. The next sync or login will have to re-authenticate and mint a fresh token. - garmin_email on the user record is left in place so re-login only needs the - password. The cached client — built from the old token's session — is dropped - in the same step so a stale session can't keep being reused. + garmin_email is meant to survive this — the UI's own "只需一次" promise is + that a disconnect still leaves the address remembered — but for every + auth-hub-era account (i.e. all of them now) that address lives *only* on + the row this deletes: `users.garmin_email` is a legacy column nothing + since auth-hub has ever written to (see `get_remembered_email`). Without + copying it forward first, "退出 Garmin 账号" quietly erases the one thing + it promised to keep, and the next bind attempt 400s on a blank email field + with no visible connection to the disconnect that caused it. + + The cached client — built from the old token's session — is dropped in + the same step so a stale session can't keep being reused. """ + row = query_one("SELECT garmin_email FROM garmin_tokens WHERE user_id = ?", [user_id]) + email = (row or {}).get("garmin_email") + if email: + execute("UPDATE users SET garmin_email = ? WHERE id = ?", [email, user_id]) execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id]) forget_client(user_id) diff --git a/backend/tests/test_garmin_sync.py b/backend/tests/test_garmin_sync.py index 3c3f363..0340cac 100644 --- a/backend/tests/test_garmin_sync.py +++ b/backend/tests/test_garmin_sync.py @@ -338,16 +338,41 @@ class TestRememberedEmail: ) assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com" - def test_disconnecting_drops_the_current_binding_but_not_the_legacy_value( + def test_disconnecting_remembers_the_most_recent_email_not_a_stale_legacy_one( self, db, user ): + """The current binding's email is copied forward on disconnect (see + `delete_token`), so it wins over whatever older address happened to be + sitting in the legacy column — the account last used `current@…`, and + that is what the next bind attempt should be offered.""" db.execute( "UPDATE users SET garmin_email = ? WHERE id = ?", ["legacy@example.com", user["id"]], ) garmin_svc.save_token(user["id"], "tok", "current@example.com") garmin_svc.delete_token(user["id"]) - assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com" + assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com" + + def test_disconnecting_an_auth_hub_account_still_remembers_the_email( + self, db, user + ): + """The case the test above does not cover, and the one that actually + broke: an auth-hub account has no pre-existing legacy row — the only + copy of the email is the one `delete_token` is about to remove. Without + copying it forward first, 退出 Garmin 账号 silently breaks its own + "只需一次" promise, and the next bind attempt 400s on a blank email + with no visible link back to the disconnect that caused it. + """ + assert garmin_svc.get_remembered_email(user["id"]) == "" + garmin_svc.save_token(user["id"], "tok", "current@example.com") + garmin_svc.delete_token(user["id"]) + assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com" + + def test_disconnecting_twice_does_not_forget_the_email(self, db, user): + garmin_svc.save_token(user["id"], "tok", "current@example.com") + garmin_svc.delete_token(user["id"]) + garmin_svc.delete_token(user["id"]) # no token row left to read from + assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com" class TestMfaHandling: