visionA/visionA-backend/internal/device/postgres_repository.go
jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
B 設備管理(feature-device-mgmt-tdd):
- POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋
  + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪)
- error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409)
- 不需 migration(registered_at 欄/index/讀寫已在 0005)

C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE):
- migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表
- canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用
- GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email)
  / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬
- tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審)

reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:29:50 +08:00

469 lines
19 KiB
Go
Raw Permalink 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"
"time"
"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 對齊)。
//
// A' 模型migration 0005新增 4 欄,接在原 13 欄之後agent_id / agent_local_device_id /
// registered_at / is_representative。順序改動時 scanDevice 的 Scan 目標順序須同步WP-B B1
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,
agent_id, agent_local_device_id, registered_at, is_representative`
// 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 過濾)。
//
// serial 空字串Save 把空 serial 寫成 SQL NULL見 SaveTx故空 serial 查詢需用
// serial_number IS NULL 比對(等號比較永不命中 NULL。非空 serial 走參數化 = $2。
// 此分支讓 PG 與 in-memoryd.SerialNumber == serial空查空語意一致。
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
return r.GetBySerialTx(ctx, r.pool, ownerUserID, serial)
}
// GetBySerialTx 與 GetBySerial 相同語意,但在傳入的 Querierpool 或 tx上執行。
//
// WP-B B3Mi#2 lost-update 收斂exchange 復用真 USB device 時,把「查 serial → 復用/建」
// 都放進同一交易(先前 GetBySerial 走 pool、與 SaveTx 之間有 race window。tx 內查詢讓
// 「查到既有 → 復用」與後續寫入在同一快照下序列化,撞 partial unique 的機率降到只剩跨 tx
// 的並發配對(實務上同一 agent 序列配對,可接受),且撞到時整筆 rollback 不產生重複。
//
// 空 serial 分支WP-0 Mi#4 收斂):以序號查詢對「空序號」無業務意義(呼叫端一律帶已正規化
// 的非空 serial。空 serial 直接回 ErrNotFound——不再掃「serial IS NULL」的多筆 representative
// / 佔位 device那些不是「以序號識別的實體 USB」撈出來也無意義且無 ORDER BY 為非決定性)。
func (r *PostgresRepository) GetBySerialTx(ctx context.Context, q db.Querier, ownerUserID, serial string) (*Device, error) {
if serial == "" {
// 空 serial 無法以序號識別實體 USBMi#4直接回 ErrNotFound避免非決定性多筆掃描。
return nil, ErrNotFound
}
const sql = `SELECT ` + deviceColumns + `
FROM devices
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
row := q.QueryRow(ctx, sql, 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 的所有未刪除、**非 representative** 的 deviceWP-B B4以 created_at
// DESC 排序(最新在前)。
//
// is_representative=false filterA' 模型ADR-018 §2.5 / migration-0005-spec §4.3
//
// representative device 是「agent 連線佔位」(綁 session_tokens不是真實 USB不該出現在
// 使用者的裝置清單。遷移後所有舊 device 都被標為 representative0005 data migration若不
// filterUI 會把這些佔位 device 當 USB 顯示。故 List 只回真 USB deviceis_representative=false
// 精確查詢Get / GetBySerialTx / GetRepresentativeByAgentTx不受此 filter 影響——它們是
// by-id / by-serial / by-agent 的通用查詢,用途包含查 representative 本身。
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 AND is_representative = false
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 {
return r.SaveTx(ctx, r.pool, d)
}
// SaveTx 與 Save 相同的 upsert 語意,但在傳入的 Querierpool 或 tx上執行。
//
// 用於 pairing exchange 自建 device 時,與 session token 建立在同一交易內pairing 收尾問題 #2
// 「建 device + 建 session token」整筆原子——任一步失敗整筆 rollback避免建了 device 卻沒建成
// session token 的中間態。q 可為 *pgxpool.Pool自動 commit或 pgx.Tx隨外層交易
func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device) error {
if d == nil || d.ID == "" {
return errors.New("device: Save requires non-nil device with ID")
}
// remote_status / status 補預設值offline / unknown避免空字串寫進「有預設」的
// NOT NULL 欄位devices.remote_status / status 為 NOT NULL DEFAULT後語意混淆。
// 此補預設行為與 InMemoryRepository.Save 一致(見 device.go兩個實作對空狀態的
// 處理刻意對齊,讓同一筆空狀態 device 不論走 PG 或 in-memory 都讀回 offline / unknown。
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())
// serial_number空字串寫成 SQL NULL而非 '')。
//
// 為什麼devices.serial_number 是 nullable TEXTpartial unique index
// uq_devices_owner_serial_active (owner_user_id, serial_number) WHERE deleted_at IS NULL
// 對「非 NULL」值才強制唯一。SQL 規範下每個 NULL 互不相等,故多筆「無序號」的
// device同 owner不互撞 unique但空字串 '' 是個確定值,同 owner 多筆 '' 會撞。
//
// 語意:實體裝置一定帶非空 serial防重複註冊照舊生效雲端 pairing exchange
// 自建的 device 沒有真實序號,正確表示是「無序號」(NULL) 而非 ''。如此同一 owner
// 多次 exchange 各建一筆 serial=NULL 的 distinct device、不撞 unique。
//
// 未來 local-tool 上報真實 serial 時,直接把這個 NULL 更新成真值即可,無 reconciliation
// 成本(若先前捏一個假 serial 佔位,反而要額外處理覆蓋)。
var serialNumber any
if d.SerialNumber != "" {
serialNumber = d.SerialNumber
} // else: 留 nil → 寫入 SQL NULL
// A' 新欄migration 0005WP-B B2
// - agent_id空字串寫 SQL NULLagent_id 是 nullable UUID FK→agents空字串無法 cast
// UUID 會炸,且「未掛 agent」的語意正是 NULL——遷移前舊資料 / 尚未歸入 agent 的 device
// - agent_local_device_id空字串寫 NULLnullable TEXT對齊 serial_number 慣例)。
// - registered_at*time.Time nil → SQL NULLpgx 直接處理),非 nil 寫值(註冊軸)。
// - is_representativeNOT NULL BOOLEANbool 直接寫zero value false = 真 USB / 未指定)。
var agentID any
if d.AgentID != "" {
agentID = d.AgentID
} // else: 留 nil → SQL NULL
var agentLocalDeviceID any
if d.AgentLocalDeviceID != "" {
agentLocalDeviceID = d.AgentLocalDeviceID
} // else: 留 nil → SQL NULL
const sql = `
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,
agent_id, agent_local_device_id, registered_at, is_representative
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9,
COALESCE($10, now()), now(), $11, $12,
$13, $14, $15, $16
)
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,
agent_id = EXCLUDED.agent_id,
agent_local_device_id = EXCLUDED.agent_local_device_id,
registered_at = EXCLUDED.registered_at,
is_representative = EXCLUDED.is_representative`
_, err := q.Exec(ctx, sql,
d.ID, // $1
d.OwnerUserID, // $2
d.Name, // $3
d.DeviceType, // $4
serialNumber, // $5
remoteStatus, // $6
d.LastSeenAt, // $7
d.LastConnectedAt, // $8
status, // $9
createdAt, // $10
d.PairedAt, // $11
d.DeletedAt, // $12
agentID, // $13
agentLocalDeviceID, // $14
d.RegisteredAt, // $15
d.IsRepresentative, // $16
)
if err != nil {
return fmt.Errorf("device: pg Save upsert: %w", err)
}
return nil
}
// SetRegistered 設定 / 清除 registered_at註冊軸單欄 UPDATEfeature-device-mgmt-tdd §3.3)。
//
// 精準單欄 UPDATE不走 Save 的全欄 upsert避免「Get→改欄→Save 回去」的讀寫競態面:
//
// UPDATE devices SET registered_at = $2, updated_at = now()
// WHERE id = $1 AND deleted_at IS NULL AND is_representative = false
//
// WHERE 的 deleted_at IS NULL + is_representative = false 是縱深第三層(配合 handler 的
// owner / representative / already-registered 檢查):對不存在 / 已軟刪 / representative 的
// 列 RowsAffected()==0 → 回 ErrNotFound。
//
// - registerat != nilhandler 已先擋 already-registered這裡不重複判
// - unregisterat == nil清成 NULL對已 NULL 的列 UPDATE 到相同值 RowsAffected 仍為 1
// WHERE 命中),語意上「取消一個未註冊的 = 已達成目標」冪等TDD §4.1)。
//
// 絕不軟刪、不呼叫 DeviceUnpairer、不碰 tokenTDD §1.2 紅線)。
func (r *PostgresRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error {
const sql = `UPDATE devices
SET registered_at = $2, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL AND is_representative = false`
tag, err := r.pool.Exec(ctx, sql, id, at)
if err != nil {
return fmt.Errorf("device: pg SetRegistered: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// GetRepresentativeByAgentTx 取得某 agent 的 representative deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound在傳入 Querier / tx 上執行)。
//
// A' 語意ADR-018 §2.4):一 agent 一顆 representative device綁 session_tokens。exchange
// 復用 agent 時用它找既有 representative 復用(避免每次配對長出一顆新 representative
// 一 agent 最多一顆 representative仍加 ORDER BY created_at + LIMIT 1 保決定性。
func (r *PostgresRepository) GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*Device, error) {
const sql = `SELECT ` + deviceColumns + `
FROM devices
WHERE agent_id = $1 AND is_representative = true AND deleted_at IS NULL
ORDER BY created_at ASC
LIMIT 1`
row := q.QueryRow(ctx, sql, agentID)
d, err := scanDevice(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("device: pg GetRepresentativeByAgent: %w", err)
}
return d, 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 / agent_id / agent_local_device_id在 DB
// 為 NULL 時掃進空字串(對齊 in-memory zero valuenullable TIMESTAMPTZlast_seen_at /
// last_connected_at / paired_at / deleted_at / registered_at以 *time.Time 接NULL → nil。
// is_representative 為 NOT NULL BOOLEANDEFAULT false直接掃進 bool。
//
// A' 新欄migration 0005WP-B B1agent_id / agent_local_device_id 以 nullable *string
// 接(遷移前舊資料 / 真 USB 未掛 agent 時為 NULLregistered_at 以 *time.Time 接NULL=未註冊)。
func scanDevice(row rowScanner) (*Device, error) {
var (
d Device
deviceType *string
serialNumber *string
agentID *string
agentLocalDeviceID *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,
&agentID,
&agentLocalDeviceID,
&d.RegisteredAt,
&d.IsRepresentative,
)
if err != nil {
return nil, err
}
d.DeviceType = derefString(deviceType)
d.SerialNumber = derefString(serialNumber)
d.AgentID = derefString(agentID)
d.AgentLocalDeviceID = derefString(agentLocalDeviceID)
// 正規化時間為 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
}
if d.RegisteredAt != nil {
t := d.RegisteredAt.UTC()
d.RegisteredAt = &t
}
return &d, nil
}
// derefString 解指標字串nil 視為空字串(對齊 in-memory zero value
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}