jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

158 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// unpair.go — device unpair 的 cascade 撤銷協調者DB 接入塊 5.2)。
//
// 「刪 device → 同時撤銷該 device 名下所有 pairing + session token」是一個跨 store 的一致性
// 操作database.md §6。為了讓 handlerdevices.go 的 unpair維持薄、同時支援 Postgres
// 交易)與 in-memorylocal-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.ErrNotFoundhandler 轉 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.PostgresSessionTokenStoremain.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.ErrNotFoundWithTx 會 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
}