Files
sundynix-agentix/sundynix-gateway/internal/store/tenant.go
T
Blizzard 693a8f09e9 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>
2026-07-06 17:31:09 +08:00

149 lines
4.0 KiB
Go

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
}