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

136 lines
4.8 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 定義 User domain model 與 Store 介面。
//
// 背景DB-on FK 收尾,問題 #1
//
// OIDC callback 驗 id_token 成功後只寫 cookie session從不寫 users 表。DB-on有 FK
// 真人登入後任何「帶 owner_user_id FK」的寫入上傳 model → models.owner_user_id、配對 →
// devices.owner_user_id、發 pairing token → pairing_tokens.user_id都會 FK violation。
// in-memory 模式因為不檢查 FK 而藏住此問題。
//
// 修法(使用者拍板 D1-BMember Center 的 OIDC sub 確認是 UUID/GUID 格式,故 sub 可直接
// 當 users.id 主鍵 — 不需另開 oidc_sub 欄位、不需新 migration。callback 驗完 id_token 後
// 呼叫 Store.Upsert 把 user 落 DBDB-on 時in-memory 模式也呼叫對齊行為。
//
// 對齊 migrations/0001_create_users_models.up.sql 的 users 表 schema
// - id UUID PKD1-B 下即 OIDC sub
// - email TEXT NOT NULLuq_users_email_lowerlower(email) 唯一)
// - name TEXTnullable
// - roles TEXT[] NOT NULL DEFAULT '{}'
// - created_at / updated_at / deleted_at
//
// 雛形範圍刻意只含 OIDC callback provision 需要的最小欄位id / email / name / roles
// password_hash / org_id 等欄位由 DB DEFAULT 處理,本 store 不觸碰。
package user
import (
"context"
"errors"
"sync"
"time"
)
// ErrNotFound 表示指定 ID 的 User 不存在(或已軟刪除)。
var ErrNotFound = errors.New("user: not found")
// User 對應 migrations/0001 的 users 表(取 OIDC provision 所需欄位)。
//
// D1-BID 即 OIDC subMember Center sub 為 UUID 格式,可直接當 PK
type User struct {
ID string `json:"id"` // = OIDC subUUID
Email string `json:"email"` // NOT NULLupsert 必給
Name string `json:"name,omitempty"` // nullable
Roles []string `json:"roles,omitempty"` // TEXT[] NOT NULL DEFAULT '{}'
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
// Store 是 User 持久層介面。
//
// 兩個實作InMemoryStorelocal-dev fallback / 單元測試)+ PostgresStoreDB-on
// main.go 依 dbPool 是否非 nil 擇一注入OIDC callback 一行不需改地切換。
type Store interface {
// Upsert 確保此 user 存在insert 或更新 email/name/roles
//
// 語意:
// - 以 ID= OIDC sub為主鍵衝突依據ON CONFLICT (id))。
// - 既存 → 更新 email / name / roles + updated_at保留 created_at。
// - 不存在 → 新建created_at = now()。
// - in 的 Email 不可為空users.email NOT NULLcaller 須確保 OIDC email claim 存在。
Upsert(ctx context.Context, in *User) error
// Get 取得單一 user不存在或已軟刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*User, error)
}
// ==========================================================================
// InMemoryStore
// ==========================================================================
// InMemoryStore 是 local-dev fallback / 單元測試用的記憶體實作。
//
// 對齊 PostgresStore 的 Upsert 語意:既存保留 CreatedAt、更新 email/name/roles + UpdatedAt。
type InMemoryStore struct {
mu sync.RWMutex
users map[string]*User // key = idOIDC sub
}
// NewInMemoryStore 建立一個空的記憶體 Store。
func NewInMemoryStore() *InMemoryStore {
return &InMemoryStore{users: make(map[string]*User)}
}
// Upsert 新增或更新 userby ID保留既有 CreatedAt。
func (s *InMemoryStore) 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")
}
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
cp := *in
cp.Roles = cloneRoles(in.Roles)
if existing, ok := s.users[in.ID]; ok && existing.DeletedAt == nil {
cp.CreatedAt = existing.CreatedAt // 保留原 CreatedAt
} else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now
}
cp.UpdatedAt = now
cp.DeletedAt = nil
s.users[in.ID] = &cp
return nil
}
// Get 取得單一 user。
func (s *InMemoryStore) Get(ctx context.Context, id string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.users[id]
if !ok || u.DeletedAt != nil {
return nil, ErrNotFound
}
cp := *u
cp.Roles = cloneRoles(u.Roles)
return &cp, nil
}
// cloneRoles 複製 roles slice避免外部後續修改影響 storein-memory copy 語意)。
func cloneRoles(in []string) []string {
if in == nil {
return nil
}
out := make([]string, len(in))
copy(out, in)
return out
}
// 編譯時檢查:確保 InMemoryStore 實作 Store。
var _ Store = (*InMemoryStore)(nil)