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>
136 lines
4.8 KiB
Go
136 lines
4.8 KiB
Go
// 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-B):Member Center 的 OIDC sub 確認是 UUID/GUID 格式,故 sub 可直接
|
||
// 當 users.id 主鍵 — 不需另開 oidc_sub 欄位、不需新 migration。callback 驗完 id_token 後
|
||
// 呼叫 Store.Upsert 把 user 落 DB(DB-on 時),in-memory 模式也呼叫對齊行為。
|
||
//
|
||
// 對齊 migrations/0001_create_users_models.up.sql 的 users 表 schema:
|
||
// - id UUID PK(D1-B 下即 OIDC sub)
|
||
// - email TEXT NOT NULL(uq_users_email_lower:lower(email) 唯一)
|
||
// - name TEXT(nullable)
|
||
// - 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-B:ID 即 OIDC sub(Member Center sub 為 UUID 格式,可直接當 PK)。
|
||
type User struct {
|
||
ID string `json:"id"` // = OIDC sub(UUID)
|
||
Email string `json:"email"` // NOT NULL;upsert 必給
|
||
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 持久層介面。
|
||
//
|
||
// 兩個實作:InMemoryStore(local-dev fallback / 單元測試)+ PostgresStore(DB-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 NULL);caller 須確保 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 = id(OIDC sub)
|
||
}
|
||
|
||
// NewInMemoryStore 建立一個空的記憶體 Store。
|
||
func NewInMemoryStore() *InMemoryStore {
|
||
return &InMemoryStore{users: make(map[string]*User)}
|
||
}
|
||
|
||
// Upsert 新增或更新 user(by 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,避免外部後續修改影響 store(in-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)
|