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