diff --git a/docs/TODO.md b/docs/TODO.md index 54a7f0b..71c49e6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1158,6 +1158,26 @@ admin 取消各驗到一則推播,乘客自己取消驗到**沒有**推播。 **App 端同批**:`lost_item.created`/`lost_item.updated` 進兩端白名單,收到後**只重讀協尋清單** (推播 data 沒有協尋單本體;乘客端也不走「重讀行程」那條——協尋單變了不代表行程變了)。 +### U5. 一支裝置 token 只能有一位主人(2026-07-30,**隱私外洩**) + +> 接完三批推播後回頭問一句「**推播會不會送錯人**」,查出來的洞。 + +`DeviceTokenRepository.Upsert` 以 `(role, subject_id, token)` 為鍵——**換人登入時舊列會留著**。 +FCM/APNs token 是「這台裝置上的這個 App」的識別,換人登入時它不會變; +而 App 在 **session 失效(401)那條路徑刻意不呼叫註銷**(那支 API 只會再回一次 401, +見 fleet-app 第十一輪)。於是: + +**A 的 token 過期 → B 在同一台手機登入 → A 的行程通知與對話推播全部送到 B 的手機**。 +對話推播的本文帶訊息文字,所以這不只是雜訊,是**把 A 的私訊顯示在 B 的鎖定畫面上**。 + +- [x] `Upsert` 註冊前先刪掉「同一支 token 掛在別人身上」的列(跨角色也算別人)。 +- [x] 不影響一人多裝置:只搬「同一支 token」,其他裝置原封不動。 +- [x] 同一人重複註冊仍是冪等的(App 每次登入都會註冊一次)。 + +**驗收**:新增 `device_token_integration_test.go` 四案(換人移轉/跨角色移轉/重複註冊冪等/ +多裝置不受影響),真 PostGIS 容器跑過。 +**反向確認**:移掉那段刪除,前兩案 FAIL——舊主人身上確實還留著那支 token。 + ### U4. 整條推播鏈路的實跑驗證(2026-07-30,**不需 Firebase 憑證**) > U/U2/U3 的證據原本都只到 service 層(單元+整合測試)。這一節補的是 diff --git a/internal/repository/device_token.go b/internal/repository/device_token.go index ebac5eb..d2a41ec 100644 --- a/internal/repository/device_token.go +++ b/internal/repository/device_token.go @@ -19,8 +19,18 @@ func NewDeviceTokenRepository(db *gorm.DB) *DeviceTokenRepository { } // Upsert 同一 role+subject+token 更新 platform/時間;否則新增。 +// +// **一個 token 同時只能屬於一位主體**:註冊前先清掉「同一支 token 掛在別人身上」的舊列。 +// FCM/APNs token 是「這台裝置上的這個 App」的識別,換人登入時它不會變—— +// 而 App 在 session 失效(401)那條路徑**刻意不呼叫註銷**(那支 API 只會再回一次 401), +// 所以舊使用者的那一列會留著。不清的話,下一位在這台裝置登入的人會收到**前一位**的 +// 行程通知與**對話內容預覽**(推播本文帶訊息文字)——這是隱私外洩,不只是雜訊。 func (r *DeviceTokenRepository) Upsert(role string, subjectID int64, platform, token string) error { now := time.Now() + if err := r.db.Where("token = ? AND NOT (role = ? AND subject_id = ?)", token, role, subjectID). + Delete(&model.DeviceToken{}).Error; err != nil { + return err + } var existing model.DeviceToken err := r.db.Where("role = ? AND subject_id = ? AND token = ?", role, subjectID, token). First(&existing).Error diff --git a/internal/repository/device_token_integration_test.go b/internal/repository/device_token_integration_test.go new file mode 100644 index 0000000..6b5ebdb --- /dev/null +++ b/internal/repository/device_token_integration_test.go @@ -0,0 +1,108 @@ +package repository + +import ( + "testing" + + "line-fleet-dispatch/internal/notify" +) + +// 同一支裝置 token 換人登入後,**舊主人身上的那一列必須消失**。 +// +// 為什麼會有舊列:FCM token 是「這台裝置上的這個 App」的識別,換人登入時它不會變; +// 而 App 在 session 失效(401)那條路徑**刻意不呼叫註銷**(那支 API 只會再回一次 401)。 +// 不清的話,下一位登入的人會收到前一位的行程通知與**對話內容預覽**(推播本文帶訊息文字)。 +func TestDeviceToken_同一token換人登入要移轉(t *testing.T) { + db := newMigratedTestDB(t) + repo := NewDeviceTokenRepository(db) + const tok = "SAME-DEVICE-TOKEN" + + if err := repo.Upsert(notify.RoleCustomer, 1, notify.PlatformFCM, tok); err != nil { + t.Fatalf("第一位註冊失敗:%v", err) + } + if err := repo.Upsert(notify.RoleCustomer, 2, notify.PlatformFCM, tok); err != nil { + t.Fatalf("第二位註冊失敗:%v", err) + } + + first, err := repo.ListBySubject(notify.RoleCustomer, 1) + if err != nil { + t.Fatalf("查第一位裝置失敗:%v", err) + } + if len(first) != 0 { + t.Errorf("前一位主人身上不該還有這支 token(會收到別人的推播):%v", first) + } + + second, err := repo.ListBySubject(notify.RoleCustomer, 2) + if err != nil { + t.Fatalf("查第二位裝置失敗:%v", err) + } + if len(second) != 1 || second[0].Token != tok { + t.Errorf("新主人應拿到這支 token:%v", second) + } +} + +// 跨角色也一樣:同一支手機先登司機、後登乘客(雙 flavor 共機時可能同 token)。 +func TestDeviceToken_跨角色也要移轉(t *testing.T) { + db := newMigratedTestDB(t) + repo := NewDeviceTokenRepository(db) + const tok = "CROSS-ROLE-TOKEN" + + if err := repo.Upsert(notify.RoleDriver, 9, notify.PlatformFCM, tok); err != nil { + t.Fatalf("司機註冊失敗:%v", err) + } + if err := repo.Upsert(notify.RoleCustomer, 9, notify.PlatformFCM, tok); err != nil { + t.Fatalf("乘客註冊失敗:%v", err) + } + + driverDevices, err := repo.ListBySubject(notify.RoleDriver, 9) + if err != nil { + t.Fatalf("查司機裝置失敗:%v", err) + } + if len(driverDevices) != 0 { + t.Errorf("司機身上不該還有這支 token:%v", driverDevices) + } +} + +// 同一位主體重複註冊同一支 token 是冪等的(App 每次登入都會註冊一次)。 +func TestDeviceToken_同一人重複註冊冪等(t *testing.T) { + db := newMigratedTestDB(t) + repo := NewDeviceTokenRepository(db) + const tok = "IDEMPOTENT-TOKEN" + + for i := 0; i < 3; i++ { + if err := repo.Upsert(notify.RoleDriver, 5, notify.PlatformFCM, tok); err != nil { + t.Fatalf("第 %d 次註冊失敗:%v", i+1, err) + } + } + devices, err := repo.ListBySubject(notify.RoleDriver, 5) + if err != nil { + t.Fatalf("查裝置失敗:%v", err) + } + if len(devices) != 1 { + t.Fatalf("重複註冊不該長出多列,得到 %d 列", len(devices)) + } +} + +// 一個人可以有多台裝置——移轉只針對「同一支 token」,不可誤刪他其他裝置。 +func TestDeviceToken_多裝置不受影響(t *testing.T) { + db := newMigratedTestDB(t) + repo := NewDeviceTokenRepository(db) + + if err := repo.Upsert(notify.RoleCustomer, 3, notify.PlatformFCM, "PHONE-A"); err != nil { + t.Fatalf("註冊 A 失敗:%v", err) + } + if err := repo.Upsert(notify.RoleCustomer, 3, notify.PlatformFCM, "PHONE-B"); err != nil { + t.Fatalf("註冊 B 失敗:%v", err) + } + // 另一個人在 PHONE-B 上登入 → 只該搬走 B。 + if err := repo.Upsert(notify.RoleCustomer, 4, notify.PlatformFCM, "PHONE-B"); err != nil { + t.Fatalf("換人註冊 B 失敗:%v", err) + } + + devices, err := repo.ListBySubject(notify.RoleCustomer, 3) + if err != nil { + t.Fatalf("查裝置失敗:%v", err) + } + if len(devices) != 1 || devices[0].Token != "PHONE-A" { + t.Fatalf("只該搬走 PHONE-B,得到 %v", devices) + } +}