visionA/visionA-backend/internal/user/postgres_store.go
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

150 lines
4.7 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 user 的 Postgres 持久層實作DB-on FK 收尾,問題 #1
//
// PostgresStore 實作與 InMemoryStore 完全相同的 Store interface讓 main.go 在 dbPool != nil
// 時無痛切換、OIDC callback 呼叫端一行都不需改。
//
// 對齊:
// - migrations/0001_create_users_models.up.sqlusers 表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 idON CONFLICT (id) DO UPDATE保留 created_at
// - scan helper、nullable 欄位以指標接、NULL → 空字串
//
// email 唯一衝突語意D1-B
//
// upsert 以 idOIDC sub為衝突依據。若兩個不同 sub 共用同 email會撞 uq_users_email_lower
// lower(email) 唯一)→ INSERT/UPDATE 回 23505。雛形視為資料異常、原樣回錯不靜默吞
// 由 callerOIDC callbacklog 後回 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 新增或更新 userupsert 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[] → []stringpgx 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
}