visionA/visionA-backend/internal/model/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

339 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.

// Package model 的 Postgres 持久層實作DB 接入塊 1
//
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface
// 讓 main.go 在 cfg.Database.Enabled() 時無痛切換、handler 與呼叫端一行都不需改。
//
// 對齊:
// - database.md §2.3Model 欄位、§4models 表 schemafaa_object_key / uploaded_at +
// owner/chip/source filter index + soft-delete partial index
// - migrations/0001_create_users_models.up.sqlmodels 表已含全部欄位,塊 1 不需新 migration
//
// 語意對齊 in-memory見 model.go
// - Get / List 略過 deleted_at IS NOT NULL 的紀錄。
// - Save 為 upsert by IDexisting 且未刪除時保留原 created_atin-memory model.go ~line 209
// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。
//
// array 映射pgx v5
// - input_shape INT[] <-> []int32pgx 對 INT[] 預設 decode 成 []int32Model.InputShape 為 []int
// - classes TEXT[] <-> []string
package model
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// PostgresRepository 是 Model 的 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)
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at`
// Get 取得單一 Model不存在或已軟刪除回 ErrNotFound。
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) {
const q = `SELECT ` + modelColumns + `
FROM models
WHERE id = $1 AND deleted_at IS NULL`
row := r.pool.QueryRow(ctx, q, id)
m, err := scanModel(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("model: pg Get: %w", err)
}
return m, nil
}
// List 依 filter 列出未刪除的 Model。
//
// filter 三維OwnerUserID / TargetChip / Source皆為可選空字串表示不過濾該維度。
// 條件以動態 WHERE 拼接(參數化,無字串拼接使用者輸入),對齊 in-memory List 行為。
// 結果以 created_at DESC 排序(最新在前),提供穩定且對前端友善的順序。
func (r *PostgresRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
var (
conds = []string{"deleted_at IS NULL"}
args []any
)
if filter.OwnerUserID != "" {
args = append(args, filter.OwnerUserID)
conds = append(conds, fmt.Sprintf("owner_user_id = $%d", len(args)))
}
if filter.TargetChip != "" {
args = append(args, filter.TargetChip)
conds = append(conds, fmt.Sprintf("target_chip = $%d", len(args)))
}
if filter.Source != "" {
args = append(args, filter.Source)
conds = append(conds, fmt.Sprintf("source = $%d", len(args)))
}
q := `SELECT ` + modelColumns + `
FROM models
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY created_at DESC`
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("model: pg List query: %w", err)
}
defer rows.Close()
out := make([]*Model, 0)
for rows.Next() {
m, scanErr := scanModel(rows)
if scanErr != nil {
return nil, fmt.Errorf("model: pg List scan: %w", scanErr)
}
out = append(out, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("model: pg List rows: %w", err)
}
return out, nil
}
// Save 新增或更新 Modelupsert by id
//
// 語意對齊 in-memorymodel.go ~line 209
// - 既有且未刪除deleted_at IS NULL→ 保留原 created_at
// - 不存在 / 已刪除 → 以傳入 created_atzero 時用 now())為準。
//
// updated_at 一律設為 now()。透過 ON CONFLICT (id) DO UPDATE 的 GREATEST/COALESCE 無法
// 表達「保留原值僅當未刪除」,故 created_at 用 CASE當 conflict 既有列未刪除時保留
// models.created_at否則用 EXCLUDED.created_at。
func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
if m == nil || m.ID == "" {
return errors.New("model: Save requires non-nil model with ID")
}
// created_atzero 時交給 DB now()(用 NULL 觸發 COALESCE
var createdAt any
if !m.CreatedAt.IsZero() {
createdAt = m.CreatedAt.UTC()
} // else: 留 nil → COALESCE($n, now())
// input_shapeModel.InputShape 為 []intDB 為 INT[]。pgx encode []int 可行;
// 為與 decode[]int32對稱、避免型別歧義這裡顯式轉 []int32。
inputShape := toInt32Slice(m.InputShape)
// nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL
// 對齊 in-memory「zero value 即空字串」語意faa_object_key 等查詢端以 != '' 判斷)。
// visibility空字串 → NULL 交給 COALESCE 落 'private'(對齊 DB DEFAULT + in-memory Save
// 已設值PATCH visibility / 呼叫端指定則原樣寫入CHECK constraint 擋非法值。
var visibility any
if m.Visibility != "" {
visibility = string(m.Visibility)
} // else: 留 nil → COALESCE($15, 'private')
const q = `
INSERT INTO models (
id, owner_user_id, name, description, storage_key, file_size,
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12,
$13, $14, COALESCE($15, 'private'), COALESCE($16, now()), now(), $17, $18
)
ON CONFLICT (id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
name = EXCLUDED.name,
description = EXCLUDED.description,
storage_key = EXCLUDED.storage_key,
file_size = EXCLUDED.file_size,
file_checksum = EXCLUDED.file_checksum,
faa_object_key = EXCLUDED.faa_object_key,
target_chip = EXCLUDED.target_chip,
input_shape = EXCLUDED.input_shape,
classes = EXCLUDED.classes,
framework = EXCLUDED.framework,
source = EXCLUDED.source,
source_job_id = EXCLUDED.source_job_id,
visibility = EXCLUDED.visibility,
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
created_at = CASE
WHEN models.deleted_at IS NULL THEN models.created_at
ELSE EXCLUDED.created_at
END,
updated_at = now(),
uploaded_at = EXCLUDED.uploaded_at,
deleted_at = EXCLUDED.deleted_at`
_, err := r.pool.Exec(ctx, q,
m.ID, // $1
m.OwnerUserID, // $2
m.Name, // $3
m.Description, // $4
m.StorageKey, // $5
m.FileSize, // $6
m.FileChecksum, // $7
m.FAAObjectKey, // $8
m.TargetChip, // $9
inputShape, // $10
m.Classes, // $11
m.Framework, // $12
string(m.Source), // $13
nullableUUID(m.SourceJobID), // $14
visibility, // $15
createdAt, // $16
m.UploadedAt, // $17
m.DeletedAt, // $18
)
if err != nil {
return fmt.Errorf("model: pg Save upsert: %w", err)
}
return nil
}
// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。
func (r *PostgresRepository) Delete(ctx context.Context, id string) error {
const q = `UPDATE models
SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`
tag, err := r.pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("model: pg Delete: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ==========================================================================
// scan / 型別 helper
// ==========================================================================
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scanModel 同時服務 Get 與 List。
type rowScanner interface {
Scan(dest ...any) error
}
// scanModel 從一列掃出 *Model。欄位順序必須與 modelColumns 對齊。
//
// nullable 欄位faa_object_key / target_chip / description / framework / file_checksum
// 在 DB 為 NULL 時掃進空字串source_job_idUUID, NULL以 *string 接再轉空字串;
// input_shapeNULL 或空陣列)掃成 nil/空,再轉回 []int。
func scanModel(row rowScanner) (*Model, error) {
var (
m Model
description *string
fileChecksum *string
faaObjectKey *string
targetChip *string
inputShape []int32
framework *string
sourceJobID *string
)
err := row.Scan(
&m.ID,
&m.OwnerUserID,
&m.Name,
&description,
&m.StorageKey,
&m.FileSize,
&fileChecksum,
&faaObjectKey,
&targetChip,
&inputShape,
&m.Classes,
&framework,
&m.Source,
&sourceJobID,
&m.Visibility,
&m.CreatedAt,
&m.UpdatedAt,
&m.UploadedAt,
&m.DeletedAt,
)
if err != nil {
return nil, err
}
m.Description = derefString(description)
m.FileChecksum = derefString(fileChecksum)
m.FAAObjectKey = derefString(faaObjectKey)
m.TargetChip = derefString(targetChip)
m.Framework = derefString(framework)
m.SourceJobID = derefString(sourceJobID)
m.InputShape = toIntSlice(inputShape)
// 正規化時間為 UTC對齊 in-memorytime.Now().UTC())。
m.CreatedAt = m.CreatedAt.UTC()
m.UpdatedAt = m.UpdatedAt.UTC()
if m.UploadedAt != nil {
u := m.UploadedAt.UTC()
m.UploadedAt = &u
}
if m.DeletedAt != nil {
d := m.DeletedAt.UTC()
m.DeletedAt = &d
}
return &m, nil
}
// derefString 解指標字串nil 視為空字串(對齊 in-memory zero value
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
// nullableUUID 把可能為空的 source_job_idUUID 欄位)轉成寫入值:
// 空字串 → nil寫 NULL避免空字串無法 cast 成 UUID 報錯);否則原樣傳入。
func nullableUUID(s string) any {
if s == "" {
return nil
}
return s
}
// toInt32Slice 把 []int 轉 []int32pgx INT[] encode 用。nil 維持 nil寫 NULL
func toInt32Slice(in []int) []int32 {
if in == nil {
return nil
}
out := make([]int32, len(in))
for i, v := range in {
out[i] = int32(v)
}
return out
}
// toIntSlice 把 DB decode 出的 []int32 轉回 []int。nil/空維持 nil對齊 in-memory omitempty
func toIntSlice(in []int32) []int {
if len(in) == 0 {
return nil
}
out := make([]int, len(in))
for i, v := range in {
out[i] = int(v)
}
return out
}