Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 層(單元+整合測試)。這一節補的是
Expand Down
10 changes: 10 additions & 0 deletions internal/repository/device_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions internal/repository/device_token_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading