把 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>
306 lines
10 KiB
Go
306 lines
10 KiB
Go
// Package device 的 Postgres 持久層實作(DB 接入塊 2)。
|
||
//
|
||
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface,
|
||
// 讓 main.go 在 dbPool != nil 時無痛切換、handler 與呼叫端一行都不需改。
|
||
//
|
||
// 對齊:
|
||
// - database.md §2.2(Device 雙狀態欄位 + paired_at)、§4(devices 表 schema:
|
||
// partial unique index uq_devices_owner_serial_active + owner/remote_status filter index)
|
||
// - migrations/0002_create_devices.up.sql(devices 表含全部欄位)
|
||
//
|
||
// 語意對齊 in-memory(見 device.go):
|
||
// - Get / GetBySerial / List 略過 deleted_at IS NOT NULL 的紀錄。
|
||
// - GetBySerial 以 (owner_user_id, serial_number) 查未刪除紀錄。
|
||
// - Save 為 upsert by ID;existing 且未刪除時保留原 created_at(in-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 第二筆會撞 unique(23505)。
|
||
// - 但若先把第一筆 soft-delete(deleted_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 新增或更新 device(upsert by id)。
|
||
//
|
||
// 語意對齊 in-memory(device.go ~line 187):
|
||
// - 既有且未刪除(deleted_at IS NULL)→ 保留原 created_at;
|
||
// - 不存在 / 已刪除(復活)→ 以傳入 created_at(zero 時用 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_at:zero 時交給 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 相同的軟刪除語意,但在傳入的 Querier(pool 或 tx)上執行。
|
||
//
|
||
// 塊 5.2 cascade 撤銷:unpair 流程在 db.WithTx 內先呼叫本方法軟刪 device,再於同一 tx 對
|
||
// pairing_tokens / session_tokens 撤銷——任一步失敗整筆 rollback,device 不會「已刪但 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 value);nullable TIMESTAMPTZ(last_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-memory(time.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
|
||
}
|