// unpair.go — device unpair 的 cascade 撤銷協調者(DB 接入塊 5.2)。 // // 「刪 device → 同時撤銷該 device 名下所有 pairing + session token」是一個跨 store 的一致性 // 操作(database.md §6)。為了讓 handler(devices.go 的 unpair)維持薄、同時支援 Postgres(真 // 交易)與 in-memory(local-dev fallback)兩種後端,這裡抽出 DeviceUnpairer 介面: // // - Postgres 後端:pgDeviceUnpairer 用 db.WithTx 把「device 軟刪 + 兩張 token 表撤銷」包成 // 單一交易——任一步失敗整筆 rollback,杜絕「device 已刪但 token 沒撤」的中間狀態。 // - in-memory 後端:memDeviceUnpairer 依序呼叫(無交易),行為一致(刪 device 後 token 也撤), // 對齊「DB 未啟用時 cascade 在 in-memory 也成立」的約束。 // // 兩者由 main.go 依 dbPool 是否非 nil 擇一注入 Deps.DeviceUnpairer。為 nil 時 unpair handler // fallback 到「只軟刪 device(不 cascade)」的舊行為(見 devices.go),確保最小骨架仍可啟動。 package api import ( "context" "fmt" "log/slog" "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/db" "visiona-backend/internal/device" ) // UnpairResult 回報 cascade 撤銷的結果(觀測用:撤了幾個 token)。 type UnpairResult struct { PairingRevoked int // 本次撤銷的 pairing token 數 SessionRevoked int // 本次撤銷的 session token 數 } // DeviceUnpairer 將「軟刪 device + cascade 撤銷其 token」包成一個原子(Postgres)或 // 一致(in-memory)操作。 // // Unpair 語意:device 不存在 / 已刪除回 device.ErrNotFound(handler 轉 404);其餘 DB 錯誤 // 原樣回傳(handler 經 errors.go 映射成 503 / 500,不洩漏 raw error)。 type DeviceUnpairer interface { Unpair(ctx context.Context, deviceID string) (UnpairResult, error) } // ── Postgres 後端介面(避免 api 直接綁 concrete type,方便測試注入) ────────────── // pgDeviceDeleter 是 device 在 tx 內軟刪的能力(由 device.PostgresRepository 滿足)。 type pgDeviceDeleter interface { DeleteTx(ctx context.Context, q db.Querier, id string) error } // pgTokenRevoker 是「在 tx 內撤銷某 device 名下未撤銷 token」的能力 // (由 auth.PostgresPairingStore / auth.PostgresSessionTokenStore 滿足)。 type pgTokenRevoker interface { RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) } // pgDeviceUnpairer 用單一 pgx 交易完成 device 軟刪 + 兩張 token 表 cascade 撤銷。 type pgDeviceUnpairer struct { pool *pgxpool.Pool devices pgDeviceDeleter pairingTok pgTokenRevoker sessionTok pgTokenRevoker log *slog.Logger } // NewPostgresDeviceUnpairer 建立 Postgres 後端的 cascade unpair 協調者。 // // 三個依賴分別來自 device.PostgresRepository / auth.PostgresPairingStore / // auth.PostgresSessionTokenStore(main.go 注入)。pool 用來開交易。 func NewPostgresDeviceUnpairer( pool *pgxpool.Pool, devices pgDeviceDeleter, pairingTok pgTokenRevoker, sessionTok pgTokenRevoker, log *slog.Logger, ) DeviceUnpairer { return &pgDeviceUnpairer{ pool: pool, devices: devices, pairingTok: pairingTok, sessionTok: sessionTok, log: logOrDefault(log), } } // Unpair 在單一交易內:軟刪 device → 撤 pairing token → 撤 session token。 // // 順序:device 軟刪先做——若 device 不存在 / 已刪除(ErrNotFound)就提早回,交易內什麼都沒改、 // rollback 無副作用。device 軟刪成功後才撤兩張 token 表;任一步失敗整筆 rollback。 func (u *pgDeviceUnpairer) Unpair(ctx context.Context, deviceID string) (UnpairResult, error) { var res UnpairResult err := db.WithTx(ctx, u.pool, func(q db.Querier) error { if delErr := u.devices.DeleteTx(ctx, q, deviceID); delErr != nil { return delErr // 含 device.ErrNotFound;WithTx 會 rollback(此時尚無變更) } pRevoked, pErr := u.pairingTok.RevokeByDeviceTx(ctx, q, deviceID) if pErr != nil { return fmt.Errorf("cascade revoke pairing tokens: %w", pErr) } sRevoked, sErr := u.sessionTok.RevokeByDeviceTx(ctx, q, deviceID) if sErr != nil { return fmt.Errorf("cascade revoke session tokens: %w", sErr) } res.PairingRevoked = pRevoked res.SessionRevoked = sRevoked return nil }) if err != nil { return UnpairResult{}, err } return res, nil } // ── in-memory 後端介面 ──────────────────────────────────────────────────────── // memTokenRevoker 是 in-memory store「撤銷某 device 名下未撤銷 token」的能力 // (由 auth.InMemoryPairingStore / auth.InMemorySessionTokenStore 滿足)。 type memTokenRevoker interface { RevokeByDevice(ctx context.Context, deviceID string) (int, error) } // memDeviceUnpairer 依序(非交易)完成 device 軟刪 + token cascade 撤銷。 // // in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致 // (刪 device 後 token 也撤)。device 軟刪失敗(ErrNotFound)提早回、不撤 token。 type memDeviceUnpairer struct { devices device.Repository pairingTok memTokenRevoker sessionTok memTokenRevoker } // NewInMemoryDeviceUnpairer 建立 in-memory 後端的 cascade unpair 協調者。 func NewInMemoryDeviceUnpairer( devices device.Repository, pairingTok memTokenRevoker, sessionTok memTokenRevoker, ) DeviceUnpairer { return &memDeviceUnpairer{ devices: devices, pairingTok: pairingTok, sessionTok: sessionTok, } } // Unpair 軟刪 device 後 cascade 撤銷其 token(依序,非交易)。 func (u *memDeviceUnpairer) Unpair(ctx context.Context, deviceID string) (UnpairResult, error) { if err := u.devices.Delete(ctx, deviceID); err != nil { return UnpairResult{}, err // 含 device.ErrNotFound } pRevoked, err := u.pairingTok.RevokeByDevice(ctx, deviceID) if err != nil { return UnpairResult{}, fmt.Errorf("cascade revoke pairing tokens: %w", err) } sRevoked, err := u.sessionTok.RevokeByDevice(ctx, deviceID) if err != nil { return UnpairResult{}, fmt.Errorf("cascade revoke session tokens: %w", err) } return UnpairResult{PairingRevoked: pRevoked, SessionRevoked: sRevoked}, nil }