feat(gateway): 多租户地基·增量1 —— 租户身份/成员/上下文(T4.A/SaaS P1)

按 SAAS_DESIGN.md P1 第一刀(真·多租户 + 桌面端为主):先立租户身份,不动查询。
- store.Tenant / TenantMember 表 + AutoMigrate
- store: CreateTenant / AddMember(幂等) / DefaultTenantForUser / EnsureDefaultTenant(幂等) /
  BackfillDefaultTenants / GetTenant / MemberRole
- 注册即建单人默认租户(owner);启动回填给存量用户补建(幂等)
- middleware.TenantContext(挂 Auth 后)解析当前租户→注入 tenant_id;handler.tenantID(c) 助手
- GET /api/v1/tenants/current 验证端点(租户上下文 + 角色)
- live:存量用户(回填 7 租户)/tenants/current 返回默认租户+owner;新注册自动建租户

下一步(增量2):核心表加 tenant_id + 统一 gorm scope 强制隔离 + 存量行回填 + 重写查询。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-06 17:31:09 +08:00
parent 2f19e322e3
commit 693a8f09e9
8 changed files with 250 additions and 4 deletions
+22
View File
@@ -72,3 +72,25 @@ type GuardrailEvent struct {
}
func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" }
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
type Tenant struct {
BaseModel
Name string `gorm:"size:128"`
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
Status string `gorm:"size:16;default:active"` // active / suspended
}
func (Tenant) TableName() string { return "sundynix_tenant" }
// TenantMember 是用户与租户的成员关系(带角色)。一个用户可属多个租户。
type TenantMember struct {
BaseModel
TenantID string `gorm:"size:64;uniqueIndex:idx_tenant_user;index"`
UserID string `gorm:"size:64;uniqueIndex:idx_tenant_user;index"`
Role string `gorm:"size:16"` // owner / admin / member / viewer / billing_admin
Status string `gorm:"size:16;default:active"` // active / invited / removed
}
func (TenantMember) TableName() string { return "sundynix_tenant_member" }
+1 -1
View File
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
migrateLegacyIntIDs(db)
migrateDocLinkToID(db)
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}); err != nil {
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{}
}
+148
View File
@@ -0,0 +1,148 @@
package store
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 角色/成员状态常量。
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
RoleBillingAdmin = "billing_admin"
)
// CreateTenant 建一个租户。
func (p *Postgres) CreateTenant(ctx context.Context, name, slug, plan string) (*Tenant, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if plan == "" {
plan = "free"
}
t := &Tenant{Name: name, Slug: slug, Plan: plan, Status: "active"}
if err := p.db.WithContext(ctx).Create(t).Error; err != nil {
return nil, err
}
return t, nil
}
// AddMember 添加成员(幂等:已存在则不重复插入)。
func (p *Postgres) AddMember(ctx context.Context, tenantID, userID, role string) error {
if p.db == nil {
return errStoreDisabled
}
m := &TenantMember{TenantID: tenantID, UserID: userID, Role: role, Status: "active"}
// 命中唯一约束(tenant_id,user_id)则忽略,保证幂等。
return p.db.WithContext(ctx).
Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}}, DoNothing: true}).
Create(m).Error
}
// DefaultTenantForUser 返回用户所属的默认租户(当前取其最早加入的 active 成员关系;无则 nil)。
func (p *Postgres) DefaultTenantForUser(ctx context.Context, userID string) (*Tenant, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var m TenantMember
err := p.db.WithContext(ctx).
Where("user_id = ? AND status = ?", userID, "active").
Order("created_at asc").First(&m).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
var t Tenant
if err := p.db.WithContext(ctx).First(&t, "id = ?", m.TenantID).Error; err != nil {
return nil, err
}
return &t, nil
}
// EnsureDefaultTenant 幂等地保证用户有一个默认租户:已有 active 成员关系则返回其默认租户;
// 否则建一个单人租户(owner) 并返回。用于注册与存量回填。
func (p *Postgres) EnsureDefaultTenant(ctx context.Context, userID, name string) (*Tenant, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if t, err := p.DefaultTenantForUser(ctx, userID); err != nil {
return nil, err
} else if t != nil {
return t, nil
}
if name == "" {
name = "个人工作区"
}
t, err := p.CreateTenant(ctx, name, "default-"+userID, "free")
if err != nil {
return nil, err
}
if err := p.AddMember(ctx, t.ID, userID, RoleOwner); err != nil {
return nil, err
}
return t, nil
}
// BackfillDefaultTenants 给所有还没有租户成员关系的存量用户补建默认租户(启动时幂等调用)。
func (p *Postgres) BackfillDefaultTenants(ctx context.Context) (int, error) {
if p.db == nil {
return 0, errStoreDisabled
}
var users []User
// 无 active 成员关系的用户。
if err := p.db.WithContext(ctx).
Where("id NOT IN (?)", p.db.Model(&TenantMember{}).Select("user_id").Where("status = ?", "active")).
Find(&users).Error; err != nil {
return 0, err
}
n := 0
for _, u := range users {
if _, err := p.EnsureDefaultTenant(ctx, u.ID, firstNonEmpty(u.Name, u.Email)); err != nil {
return n, err
}
n++
}
return n, nil
}
// GetTenant 按 id 取租户(不存在返回 nil)。
func (p *Postgres) GetTenant(ctx context.Context, id string) (*Tenant, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var t Tenant
if err := p.db.WithContext(ctx).First(&t, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, err
}
return &t, nil
}
// MemberRole 返回用户在某租户的角色(无成员关系返回空)。
func (p *Postgres) MemberRole(ctx context.Context, tenantID, userID string) string {
if p.db == nil {
return ""
}
var m TenantMember
if err := p.db.WithContext(ctx).
Where("tenant_id = ? AND user_id = ? AND status = ?", tenantID, userID, "active").
First(&m).Error; err != nil {
return ""
}
return m.Role
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}