visionA/visionA-backend/internal/device/postgres_repository.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

306 lines
10 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.

// Package device 的 Postgres 持久層實作DB 接入塊 2
//
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface
// 讓 main.go 在 dbPool != nil 時無痛切換、handler 與呼叫端一行都不需改。
//
// 對齊:
// - database.md §2.2Device 雙狀態欄位 + paired_at、§4devices 表 schema
// partial unique index uq_devices_owner_serial_active + owner/remote_status filter index
// - migrations/0002_create_devices.up.sqldevices 表含全部欄位)
//
// 語意對齊 in-memory見 device.go
// - Get / GetBySerial / List 略過 deleted_at IS NOT NULL 的紀錄。
// - GetBySerial 以 (owner_user_id, serial_number) 查未刪除紀錄。
// - Save 為 upsert by IDexisting 且未刪除時保留原 created_atin-memory device.go ~line 187
// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。
//
// partial unique × soft-delete 語意(塊 2 子任務 2.3
//
// devices 的 (owner_user_id, serial_number) 唯一性只對「未刪除」紀錄成立
// migration 0002 的 uq_devices_owner_serial_active WHERE deleted_at IS NULL
// 因此:
// - 同 owner 同 serial 同時存在「兩筆未刪除」→ INSERT 第二筆會撞 unique23505
// - 但若先把第一筆 soft-deletedeleted_at IS NOT NULL它就退出 partial index 的
// 管轄範圍,同 (owner, serial) 可再 INSERT 一筆新的(新 id而不違反 unique。
// 這正是「已刪除 serial 可重新註冊」的決策落地。
// 注意Save 是 upsert by **id**,而非 by (owner, serial)。重註冊走「新 id」路徑、
// 不會 ON CONFLICT (id) 命中舊列;舊列保持 soft-deleted新列為一筆全新紀錄。
package device
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/db"
)
// PostgresRepository 是 Device 的 PostgreSQL 持久層實作。
type PostgresRepository struct {
pool *pgxpool.Pool
}
// NewPostgresRepository 建立一個以 pgxpool 為後端的 Repository。
//
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{pool: pool}
}
// 編譯時檢查:確保 PostgresRepository 實作 Repository。
var _ Repository = (*PostgresRepository)(nil)
// deviceColumns 是 SELECT 共用的欄位清單(順序必須與 scanDevice 對齊)。
const deviceColumns = `id, owner_user_id, name, device_type, serial_number,
remote_status, last_seen_at, last_connected_at, status,
created_at, updated_at, paired_at, deleted_at`
// Get 取得單一 device不存在或已軟刪除回 ErrNotFound。
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error) {
const q = `SELECT ` + deviceColumns + `
FROM devices
WHERE id = $1 AND deleted_at IS NULL`
row := r.pool.QueryRow(ctx, q, id)
d, err := scanDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("device: pg Get: %w", err)
}
return d, nil
}
// GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。
//
// 對齊 in-memory同一個 serial 在不同 owner 下不互相干擾owner 過濾)。
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
const q = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
row := r.pool.QueryRow(ctx, q, ownerUserID, serial)
d, err := scanDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("device: pg GetBySerial: %w", err)
}
return d, nil
}
// List 列出某 owner 的所有未刪除 device以 created_at DESC 排序(最新在前)。
func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*Device, error) {
const q = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC`
rows, err := r.pool.Query(ctx, q, ownerUserID)
if err != nil {
return nil, fmt.Errorf("device: pg List query: %w", err)
}
defer rows.Close()
out := make([]*Device, 0)
for rows.Next() {
d, scanErr := scanDevice(rows)
if scanErr != nil {
return nil, fmt.Errorf("device: pg List scan: %w", scanErr)
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("device: pg List rows: %w", err)
}
return out, nil
}
// Save 新增或更新 deviceupsert by id
//
// 語意對齊 in-memorydevice.go ~line 187
// - 既有且未刪除deleted_at IS NULL→ 保留原 created_at
// - 不存在 / 已刪除(復活)→ 以傳入 created_atzero 時用 now())為準。
//
// updated_at 一律設為 now()。created_at 用 CASE當 conflict 既有列未刪除時保留
// devices.created_at否則用 EXCLUDED.created_at。
//
// 重註冊(已 soft-delete 的 serial走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT
// partial unique 因舊列已 deleted 不阻擋(見 package 註解)。
func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
if d == nil || d.ID == "" {
return errors.New("device: Save requires non-nil device with ID")
}
// remote_status / status 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。
remoteStatus := d.RemoteStatus
if remoteStatus == "" {
remoteStatus = RemoteStatusOffline
}
status := d.Status
if status == "" {
status = USBStatusUnknown
}
// created_atzero 時交給 DB now()(用 NULL 觸發 COALESCE
var createdAt any
if !d.CreatedAt.IsZero() {
createdAt = d.CreatedAt.UTC()
} // else: 留 nil → COALESCE($n, now())
const q = `
INSERT INTO devices (
id, owner_user_id, name, device_type, serial_number,
remote_status, last_seen_at, last_connected_at, status,
created_at, updated_at, paired_at, deleted_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
COALESCE($10, now()), now(), $11, $12
)
ON CONFLICT (id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
name = EXCLUDED.name,
device_type = EXCLUDED.device_type,
serial_number = EXCLUDED.serial_number,
remote_status = EXCLUDED.remote_status,
last_seen_at = EXCLUDED.last_seen_at,
last_connected_at = EXCLUDED.last_connected_at,
status = EXCLUDED.status,
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)則用新值。
created_at = CASE
WHEN devices.deleted_at IS NULL THEN devices.created_at
ELSE EXCLUDED.created_at
END,
updated_at = now(),
paired_at = EXCLUDED.paired_at,
deleted_at = EXCLUDED.deleted_at`
_, err := r.pool.Exec(ctx, q,
d.ID, // $1
d.OwnerUserID, // $2
d.Name, // $3
d.DeviceType, // $4
d.SerialNumber, // $5
remoteStatus, // $6
d.LastSeenAt, // $7
d.LastConnectedAt, // $8
status, // $9
createdAt, // $10
d.PairedAt, // $11
d.DeletedAt, // $12
)
if err != nil {
return fmt.Errorf("device: pg Save upsert: %w", err)
}
return nil
}
// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。
//
// 直接在 pool 上跑(自動 commit。若需與 token cascade 撤銷在同一交易內,請改用 DeleteTx。
func (r *PostgresRepository) Delete(ctx context.Context, id string) error {
return r.DeleteTx(ctx, r.pool, id)
}
// DeleteTx 與 Delete 相同的軟刪除語意,但在傳入的 Querierpool 或 tx上執行。
//
// 塊 5.2 cascade 撤銷unpair 流程在 db.WithTx 內先呼叫本方法軟刪 device再於同一 tx 對
// pairing_tokens / session_tokens 撤銷——任一步失敗整筆 rollbackdevice 不會「已刪但 token 沒撤」。
//
// q 可為 *pgxpool.Pool自動 commit或 pgx.Tx隨外層交易。語意與 Delete 一致:
// 已刪除或不存在回 ErrNotFound呼叫端可 errors.Is 比對)。
func (r *PostgresRepository) DeleteTx(ctx context.Context, q db.Querier, id string) error {
const sql = `UPDATE devices
SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`
tag, err := q.Exec(ctx, sql, id)
if err != nil {
return fmt.Errorf("device: pg Delete: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ==========================================================================
// scan helper
// ==========================================================================
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scanDevice 同時服務 Get 與 List。
type rowScanner interface {
Scan(dest ...any) error
}
// scanDevice 從一列掃出 *Device。欄位順序必須與 deviceColumns 對齊。
//
// nullable TEXT 欄位device_type / serial_number在 DB 為 NULL 時掃進空字串(對齊
// in-memory zero valuenullable TIMESTAMPTZlast_seen_at / last_connected_at /
// paired_at / deleted_at以 *time.Time 接NULL → nil。
func scanDevice(row rowScanner) (*Device, error) {
var (
d Device
deviceType *string
serialNumber *string
)
err := row.Scan(
&d.ID,
&d.OwnerUserID,
&d.Name,
&deviceType,
&serialNumber,
&d.RemoteStatus,
&d.LastSeenAt,
&d.LastConnectedAt,
&d.Status,
&d.CreatedAt,
&d.UpdatedAt,
&d.PairedAt,
&d.DeletedAt,
)
if err != nil {
return nil, err
}
d.DeviceType = derefString(deviceType)
d.SerialNumber = derefString(serialNumber)
// 正規化時間為 UTC對齊 in-memorytime.Now().UTC())。
d.CreatedAt = d.CreatedAt.UTC()
d.UpdatedAt = d.UpdatedAt.UTC()
if d.LastSeenAt != nil {
t := d.LastSeenAt.UTC()
d.LastSeenAt = &t
}
if d.LastConnectedAt != nil {
t := d.LastConnectedAt.UTC()
d.LastConnectedAt = &t
}
if d.PairedAt != nil {
t := d.PairedAt.UTC()
d.PairedAt = &t
}
if d.DeletedAt != nil {
t := d.DeletedAt.UTC()
d.DeletedAt = &t
}
return &d, nil
}
// derefString 解指標字串nil 視為空字串(對齊 in-memory zero value
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}