把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
6.3 KiB
Go
158 lines
6.3 KiB
Go
// 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
|
||
}
|