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