// 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 }