DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
4.7 KiB
Go
150 lines
4.7 KiB
Go
// Package user 的 Postgres 持久層實作(DB-on FK 收尾,問題 #1)。
|
||
//
|
||
// PostgresStore 實作與 InMemoryStore 完全相同的 Store interface,讓 main.go 在 dbPool != nil
|
||
// 時無痛切換、OIDC callback 呼叫端一行都不需改。
|
||
//
|
||
// 對齊:
|
||
// - migrations/0001_create_users_models.up.sql(users 表:id UUID PK、email NOT NULL +
|
||
// uq_users_email_lower、name、roles TEXT[] NOT NULL DEFAULT '{}'、created_at/updated_at/deleted_at)
|
||
//
|
||
// pattern 比照 internal/model/postgres_repository.go、internal/device/postgres_repository.go:
|
||
// - 參數化 SQL(無字串拼接使用者輸入)
|
||
// - upsert by id(ON CONFLICT (id) DO UPDATE)保留 created_at
|
||
// - scan helper、nullable 欄位以指標接、NULL → 空字串
|
||
//
|
||
// email 唯一衝突語意(D1-B):
|
||
//
|
||
// upsert 以 id(OIDC sub)為衝突依據。若兩個不同 sub 共用同 email,會撞 uq_users_email_lower
|
||
// (lower(email) 唯一)→ INSERT/UPDATE 回 23505。雛形視為資料異常、原樣回錯(不靜默吞),
|
||
// 由 caller(OIDC callback)log 後回 500。實務上同一 IdP 下 sub↔email 一對一,極少發生。
|
||
package user
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
// PostgresStore 是 User 的 PostgreSQL 持久層實作。
|
||
type PostgresStore struct {
|
||
pool *pgxpool.Pool
|
||
}
|
||
|
||
// NewPostgresStore 建立一個以 pgxpool 為後端的 Store。
|
||
//
|
||
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
|
||
func NewPostgresStore(pool *pgxpool.Pool) *PostgresStore {
|
||
return &PostgresStore{pool: pool}
|
||
}
|
||
|
||
// 編譯時檢查:確保 PostgresStore 實作 Store。
|
||
var _ Store = (*PostgresStore)(nil)
|
||
|
||
// userColumns 是 SELECT 共用欄位清單(順序必須與 scanUser 對齊)。
|
||
const userColumns = `id, email, name, roles, created_at, updated_at, deleted_at`
|
||
|
||
// Upsert 新增或更新 user(upsert by id)。
|
||
//
|
||
// 既存(ON CONFLICT (id))→ 更新 email/name/roles + updated_at,保留 created_at;
|
||
// 不存在 → 新建,created_at = now()。roles 為 nil 時寫空陣列(對齊 NOT NULL DEFAULT '{}')。
|
||
func (s *PostgresStore) Upsert(ctx context.Context, in *User) error {
|
||
if in == nil || in.ID == "" {
|
||
return errors.New("user: Upsert requires non-nil user with ID")
|
||
}
|
||
if in.Email == "" {
|
||
return errors.New("user: Upsert requires non-empty email")
|
||
}
|
||
|
||
// name nullable:空字串寫 NULL(對齊 in-memory zero value 與 scan NULL → 空字串)。
|
||
var nameArg any
|
||
if in.Name != "" {
|
||
nameArg = in.Name
|
||
}
|
||
|
||
// roles NOT NULL DEFAULT '{}':nil 時寫空陣列。
|
||
roles := in.Roles
|
||
if roles == nil {
|
||
roles = []string{}
|
||
}
|
||
|
||
const q = `
|
||
INSERT INTO users (id, email, name, roles, created_at, updated_at)
|
||
VALUES ($1, $2, $3, $4, now(), now())
|
||
ON CONFLICT (id) DO UPDATE SET
|
||
email = EXCLUDED.email,
|
||
name = EXCLUDED.name,
|
||
roles = EXCLUDED.roles,
|
||
updated_at = now()
|
||
-- created_at 不在 UPDATE SET:保留原值(首次 INSERT 的 now())。
|
||
-- deleted_at 不觸碰:upsert 不會「復活」已軟刪 user(雛形無刪 user 路徑,保守不動)。`
|
||
|
||
if _, err := s.pool.Exec(ctx, q, in.ID, in.Email, nameArg, roles); err != nil {
|
||
return fmt.Errorf("user: pg Upsert: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Get 取得單一 user;不存在或已軟刪除回 ErrNotFound。
|
||
func (s *PostgresStore) Get(ctx context.Context, id string) (*User, error) {
|
||
const q = `SELECT ` + userColumns + `
|
||
FROM users
|
||
WHERE id = $1 AND deleted_at IS NULL`
|
||
|
||
row := s.pool.QueryRow(ctx, q, id)
|
||
u, err := scanUser(row)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("user: pg Get: %w", err)
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
// ==========================================================================
|
||
// scan helper
|
||
// ==========================================================================
|
||
|
||
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面。
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
// scanUser 從一列掃出 *User。欄位順序必須與 userColumns 對齊。
|
||
//
|
||
// name nullable → 以 *string 接、NULL 掃成空字串(對齊 in-memory zero value)。
|
||
// roles TEXT[] → []string(pgx decode)。時間欄位正規化為 UTC。
|
||
func scanUser(row rowScanner) (*User, error) {
|
||
var (
|
||
u User
|
||
name *string
|
||
)
|
||
err := row.Scan(
|
||
&u.ID,
|
||
&u.Email,
|
||
&name,
|
||
&u.Roles,
|
||
&u.CreatedAt,
|
||
&u.UpdatedAt,
|
||
&u.DeletedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if name != nil {
|
||
u.Name = *name
|
||
}
|
||
|
||
u.CreatedAt = u.CreatedAt.UTC()
|
||
u.UpdatedAt = u.UpdatedAt.UTC()
|
||
if u.DeletedAt != nil {
|
||
d := u.DeletedAt.UTC()
|
||
u.DeletedAt = &d
|
||
}
|
||
return &u, nil
|
||
}
|