visionA/visionA-backend/internal/auth/postgres_pairing_store.go
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

324 lines
11 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.

// PostgresPairingStore 是 PairingStore 的 PostgreSQL 持久層實作DB 接入塊 3
//
// 與 InMemoryPairingStore 實作相同的 PairingStore interface讓 main.go 在 dbPool != nil
// 時無痛切換、handler 與呼叫端internal/api/pairing.go一行都不需改。
//
// 對齊:
// - database.md §2.4PairingToken struct、§4pairing_tokens 表 schema
// - migrations/0003_create_token_tables.up.sqlpairing_tokens 表)
//
// ── 關鍵改動plaintext → token_hash 當 PKdatabase.md 結尾提醒、塊 3 子任務 3.3)──
//
// in-memory 版以 plaintext token 當 map keyPostgres 版改以 token_hashHashToken(plaintext)
// 當 PKDB 永不存明文 tokensecurity.md §1.3)。
//
// 所有「以 plaintext 查詢」的方法Validate / MarkUsed / Revoke一律先 HashToken(plaintext)
// 再以 hash 比對。呼叫端統一傳 plaintext 進來(已 grep 確認internal/api/pairing.go 的
// Validate / MarkUsed / Revoke 全部傳 plaintext 的 vAc_ tokenstore 內部統一 hash
// 不會有「漏 hash 某個呼叫端」的問題。
//
// 語意對齊 in-memory見 inmemory_pairing_store.go
// - Validate 的狀態優先序revoked → used → expired與 in-memory 完全一致)。
// - MarkUsed 一次性 + 冪等:未使用 → 寫 used_at + device_id已使用 → no-op 回 nil不覆寫 device_id
// 不存在 → ErrInvalidToken。DB 層用 `WHERE used_at IS NULL` 達成兩併發只一筆實際標記。
// - Revoke 冪等:未撤銷 → 寫 revoked_at已撤銷 → no-op nil不存在 → ErrInvalidToken。
// - CleanupExpiredDELETE 所有 expires_at < now 的列,回刪除數。
package auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/db"
)
// PostgresPairingStore 是 pairing token 的 PostgreSQL 持久層實作。
type PostgresPairingStore struct {
pool *pgxpool.Pool
}
// NewPostgresPairingStore 建立一個以 pgxpool 為後端的 PairingStore。
//
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
func NewPostgresPairingStore(pool *pgxpool.Pool) *PostgresPairingStore {
return &PostgresPairingStore{pool: pool}
}
// 編譯時檢查:確保 PostgresPairingStore 實作 PairingStore。
var _ PairingStore = (*PostgresPairingStore)(nil)
// pairingColumns 是 SELECT 共用欄位清單(順序必須與 scanPairingToken 對齊)。
//
// 注意DB 不存 plaintext故掃出的 PairingToken.Plaintext 永遠為空字串(符合預期,
// 呼叫端在 Validate 後只用 UserID / DeviceID / TokenHash不依賴 Plaintext
const pairingColumns = `token_hash, user_id, device_id, kind,
created_at, expires_at, used_at, revoked_at`
// Create 產生並保存一個新 pairing token。
//
// ttl <= 0 時 ExpiresAt 保持 NULL永不過期測試用
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用DB 不存)。
func (s *PostgresPairingStore) Create(
ctx context.Context, userID string, ttl time.Duration,
) (string, *PairingToken, error) {
plaintext, err := GeneratePairingToken()
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
info := &PairingToken{
Plaintext: plaintext,
TokenHash: HashToken(plaintext),
UserID: userID,
Kind: KindPairing,
CreatedAt: now,
}
var expiresAt any // nil → DB NULL
if ttl > 0 {
expires := now.Add(ttl)
info.ExpiresAt = &expires
expiresAt = expires
}
const q = `INSERT INTO pairing_tokens
(token_hash, user_id, kind, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5)`
if _, err := s.pool.Exec(ctx, q,
info.TokenHash, info.UserID, string(info.Kind), info.CreatedAt, expiresAt,
); err != nil {
return "", nil, fmt.Errorf("auth: pg pairing Create: %w", err)
}
return plaintext, info, nil
}
// Validate 檢查 token 是否存在且可用(未撤銷、未使用、未過期)。
//
// 接收 plaintext內部 HashToken 後查詢。狀態優先序對齊 in-memory
// revoked → used → expired。不存在回 ErrInvalidToken。
func (s *PostgresPairingStore) Validate(ctx context.Context, token string) (*PairingToken, error) {
hash := HashToken(token)
const q = `SELECT ` + pairingColumns + `
FROM pairing_tokens WHERE token_hash = $1`
row := s.pool.QueryRow(ctx, q, hash)
info, err := scanPairingToken(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("auth: pg pairing Validate: %w", err)
}
if info.IsRevoked() {
return nil, ErrTokenRevoked
}
if info.IsUsed() {
return nil, ErrTokenUsed
}
if info.IsExpired(time.Now().UTC()) {
return nil, ErrTokenExpired
}
return info, nil
}
// MarkUsed 標記一次性 token 為已使用並綁定 deviceID。
//
// 一次性 + 冪等語意DB 層):
// - 以 `WHERE token_hash = $ AND used_at IS NULL` UPDATE兩併發只一筆 RowsAffected = 1
// DB 行鎖保證),是「真正標記成功」的那一個。
// - RowsAffected = 0 時需區分「已使用(冪等 no-op回 nil、不覆寫 device_id」與
// 「不存在ErrInvalidToken」→ 再查一次存在性。
//
// deviceID 可為空字串(雛形 pairing 尚未綁 device 時),對齊 in-memory。
func (s *PostgresPairingStore) MarkUsed(ctx context.Context, token, deviceID string) error {
hash := HashToken(token)
// device_id 為 UUID 欄位且 nullable空字串無法寫進 UUID 欄位,轉成 NULL。
var deviceArg any
if deviceID != "" {
deviceArg = deviceID
}
const q = `UPDATE pairing_tokens
SET used_at = now(), device_id = $2
WHERE token_hash = $1 AND used_at IS NULL`
tag, err := s.pool.Exec(ctx, q, hash, deviceArg)
if err != nil {
return fmt.Errorf("auth: pg pairing MarkUsed: %w", err)
}
if tag.RowsAffected() == 1 {
return nil // 本呼叫為實際標記成功者
}
// RowsAffected == 0可能已使用冪等或不存在。查存在性以區分。
exists, err := s.tokenExists(ctx, hash)
if err != nil {
return fmt.Errorf("auth: pg pairing MarkUsed exists check: %w", err)
}
if !exists {
return ErrInvalidToken
}
return nil // 已使用 → 冪等 no-op
}
// Revoke 撤銷一個 token之後 Validate 回 ErrTokenRevoked
//
// 冪等:已撤銷 → no-op nil不存在 → ErrInvalidToken。
func (s *PostgresPairingStore) Revoke(ctx context.Context, token string) error {
hash := HashToken(token)
const q = `UPDATE pairing_tokens
SET revoked_at = now()
WHERE token_hash = $1 AND revoked_at IS NULL`
tag, err := s.pool.Exec(ctx, q, hash)
if err != nil {
return fmt.Errorf("auth: pg pairing Revoke: %w", err)
}
if tag.RowsAffected() == 1 {
return nil
}
exists, err := s.tokenExists(ctx, hash)
if err != nil {
return fmt.Errorf("auth: pg pairing Revoke exists check: %w", err)
}
if !exists {
return ErrInvalidToken
}
return nil // 已撤銷 → 冪等 no-op
}
// List 回傳指定 user 的所有 pairing token含已使用 / 撤銷created_at DESC。
//
// 注意:回傳的 token Plaintext 為空DB 不存明文caller 不應依賴 Plaintext。
func (s *PostgresPairingStore) List(ctx context.Context, userID string) ([]*PairingToken, error) {
const q = `SELECT ` + pairingColumns + `
FROM pairing_tokens WHERE user_id = $1
ORDER BY created_at DESC`
rows, err := s.pool.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("auth: pg pairing List query: %w", err)
}
defer rows.Close()
out := make([]*PairingToken, 0)
for rows.Next() {
info, scanErr := scanPairingToken(rows)
if scanErr != nil {
return nil, fmt.Errorf("auth: pg pairing List scan: %w", scanErr)
}
out = append(out, info)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("auth: pg pairing List rows: %w", err)
}
return out, nil
}
// CleanupExpired 移除所有已過 expires_at 的 token回傳移除數量。
//
// expires_at IS NULL永不過期不會被刪。
func (s *PostgresPairingStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
const q = `DELETE FROM pairing_tokens
WHERE expires_at IS NOT NULL AND expires_at < $1`
tag, err := s.pool.Exec(ctx, q, now.UTC())
if err != nil {
return 0, fmt.Errorf("auth: pg pairing CleanupExpired: %w", err)
}
return int(tag.RowsAffected()), nil
}
// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 pairing tokencascade 撤銷,塊 5.2)。
//
// 在傳入的 Querierpool 或 tx上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1
// AND revoked_at IS NULL`database.md §6。回傳實際撤銷的列數觀測用無撤銷對象回 0、不報錯
//
// 對齊 database.md §6刪 device 時對 pairing_tokens + session_tokens 各跑一次此類 UPDATE
// 須在同一 tx 內。本方法只負責 pairing 表那一半device 軟刪與 session 撤銷由 cascade 協調者
// 在同一個 db.WithTx 內串起。
//
// 注意pairing token 的 device_id 只有在 MarkUsed 綁定後才有值;未綁定的 tokendevice_id IS NULL
// 不屬於任何 device自然不會被本查詢撤銷符合語意。
func (s *PostgresPairingStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
if deviceID == "" {
return 0, nil // 無 device 對象(對齊 in-memory空 deviceID 不撤任何 token
}
const sql = `UPDATE pairing_tokens
SET revoked_at = now()
WHERE device_id = $1 AND revoked_at IS NULL`
tag, err := q.Exec(ctx, sql, deviceID)
if err != nil {
return 0, fmt.Errorf("auth: pg pairing RevokeByDevice: %w", err)
}
return int(tag.RowsAffected()), nil
}
// tokenExists 查指定 hash 的 pairing token 是否存在(不論狀態)。
func (s *PostgresPairingStore) tokenExists(ctx context.Context, hash string) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM pairing_tokens WHERE token_hash = $1)`, hash,
).Scan(&exists)
return exists, err
}
// scanPairingToken 從一列掃出 *PairingToken。欄位順序必須與 pairingColumns 對齊。
//
// device_id 為 nullable UUIDMarkUsed 前為 NULL→ 以 *string 接、NULL 掃成空字串
// (對齊 in-memory zero value。時間欄位正規化為 UTC。Plaintext 留空DB 不存)。
func scanPairingToken(row rowScanner) (*PairingToken, error) {
var (
t PairingToken
deviceID *string
kind string
)
err := row.Scan(
&t.TokenHash,
&t.UserID,
&deviceID,
&kind,
&t.CreatedAt,
&t.ExpiresAt,
&t.UsedAt,
&t.RevokedAt,
)
if err != nil {
return nil, err
}
t.Kind = TokenKind(kind)
if deviceID != nil {
t.DeviceID = *deviceID
}
t.CreatedAt = t.CreatedAt.UTC()
t.ExpiresAt = utcPtr(t.ExpiresAt)
t.UsedAt = utcPtr(t.UsedAt)
t.RevokedAt = utcPtr(t.RevokedAt)
return &t, nil
}
// ==========================================================================
// shared scan helperspairing + session token 共用)
// ==========================================================================
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scan helper 同時服務單列查詢與 List。
type rowScanner interface {
Scan(dest ...any) error
}
// utcPtr 將 nullable 時間指標正規化為 UTCnil 維持 nil
func utcPtr(p *time.Time) *time.Time {
if p == nil {
return nil
}
u := p.UTC()
return &u
}