Files
sundynix-agentix/sundynix-gateway/internal/store/tenant.go
T

406 lines
14 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 store
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ValidRole 校验角色取值合法。
func ValidRole(role string) bool {
switch role {
case RoleOwner, RoleAdmin, RoleMember, RoleViewer, RoleBillingAdmin:
return true
}
return false
}
// 角色/成员状态常量。
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleViewer = "viewer"
RoleBillingAdmin = "billing_admin"
)
// RoleRank 返回角色在「操作/管理」阶梯上的等级(越大权越高),供 RequireTenantRole 门控。
// 语义:viewer 与 billing_admin 均为只读(1)——billing_admin 是「财务只读」角色,管账不跑任务,
// 故不能提交(烧租户积分)member 可操作(2)admin(3)/owner(4) 递增管理权。
// 未知角色 / 非成员(空串)记 0,一律拦下。当前唯一接入点:SubmitTask 要求 ≥ member(挡 viewer/billing_admin 烧租户积分)。
func RoleRank(role string) int {
switch role {
case RoleOwner:
return 4
case RoleAdmin:
return 3
case RoleMember:
return 2
case RoleViewer, RoleBillingAdmin:
return 1
}
return 0
}
// 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)。
// tenantIDForOwner 返回某 user 的默认(最早 active)租户 id;无则空串。
// 供异步路径(入库 / 评测回写,无请求 ctx 租户)在写入时补 tenant_id。
func (p *Postgres) tenantIDForOwner(ctx context.Context, owner string) string {
if p.db == nil || owner == "" {
return ""
}
var m TenantMember
if err := p.db.WithContext(ctx).Select("tenant_id").
Where("user_id = ? AND status = ?", owner, "active").
Order("created_at asc").First(&m).Error; err != nil {
return ""
}
return m.TenantID
}
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
}
// ---- 成员管理(多成员租户)----
// MemberInfo 是一条成员视图(join user 拿邮箱/名字)。
type MemberInfo struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Status string `json:"status"`
JoinedAt time.Time `json:"joined_at"`
}
// ListMembers 返回某租户的成员(含邮箱/名字/角色),排除已移除。
func (p *Postgres) ListMembers(ctx context.Context, tenantID string) ([]MemberInfo, error) {
if p.db == nil {
return nil, nil
}
var out []MemberInfo
err := p.db.WithContext(ctx).Table("sundynix_tenant_member m").
Select("m.user_id, u.email, u.name, m.role, m.status, m.created_at as joined_at").
Joins("JOIN sundynix_user u ON u.id = m.user_id").
Where("m.tenant_id = ? AND m.status <> ?", tenantID, "removed").
Order("m.created_at asc").Scan(&out).Error
return out, err
}
// AddMemberByEmail 按邮箱把已注册用户加入租户(幂等:已在则改其角色并置 active,含"复活"已移除者)。
// 用户不存在返回错误(本阶段不做外发邀请邮件,须对方先注册)。
func (p *Postgres) AddMemberByEmail(ctx context.Context, tenantID, email, role string) (*MemberInfo, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if !ValidRole(role) {
return nil, errors.New("非法角色")
}
// 注册时邮箱按小写存,这里也归一化,避免大小写导致查不到。
u, err := p.GetUserByEmail(ctx, strings.ToLower(strings.TrimSpace(email)))
if errors.Is(err, gorm.ErrRecordNotFound) || u == nil {
return nil, errors.New("该邮箱尚未注册,请对方先注册后再加入")
}
if err != nil {
return nil, err
}
if err := p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{"role": role, "status": "active", "updated_at": time.Now()}),
}).Create(&TenantMember{TenantID: tenantID, UserID: u.ID, Role: role, Status: "active"}).Error; err != nil {
return nil, err
}
// 若该租户已启用全员空间,新成员自动纳入(全员空间的语义:全租户成员皆在内)。
p.autoJoinTenantSpace(ctx, tenantID, u.ID, role)
return &MemberInfo{UserID: u.ID, Email: u.Email, Name: u.Name, Role: role, Status: "active"}, nil
}
// SetMemberRole 改成员角色。不允许把 owner 降级(须先转让 owner,超出本阶段)。
func (p *Postgres) SetMemberRole(ctx context.Context, tenantID, userID, role string) error {
if p.db == nil {
return errStoreDisabled
}
if !ValidRole(role) {
return errors.New("非法角色")
}
if p.MemberRole(ctx, tenantID, userID) == RoleOwner {
return errors.New("不能改 owner 的角色")
}
if role == RoleOwner {
return errors.New("不能直接授予 owner(须转让)")
}
return p.db.WithContext(ctx).Model(&TenantMember{}).
Where("tenant_id = ? AND user_id = ?", tenantID, userID).Update("role", role).Error
}
// RemoveMember 软移除成员(status=removed)。不能移除 owner。
func (p *Postgres) RemoveMember(ctx context.Context, tenantID, userID string) error {
if p.db == nil {
return errStoreDisabled
}
if p.MemberRole(ctx, tenantID, userID) == RoleOwner {
return errors.New("不能移除 owner")
}
return p.db.WithContext(ctx).Model(&TenantMember{}).
Where("tenant_id = ? AND user_id = ?", tenantID, userID).Update("status", "removed").Error
}
// TenantInfo 是管理端租户目录一行(含成员数 + 余额)。
type TenantInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Plan string `json:"plan"`
Status string `json:"status"`
CreditBalanceMicro int64 `json:"credit_balance_micro"`
SharedBilling bool `json:"shared_billing"`
Members int64 `json:"members"`
}
// ListTenants 返回全部租户(管理端目录:含 active 成员数 + 积分余额)。
func (p *Postgres) ListTenants(ctx context.Context) ([]TenantInfo, error) {
if p.db == nil {
return nil, nil
}
var out []TenantInfo
err := p.db.WithContext(ctx).Table("sundynix_tenant t").
Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, t.shared_billing, "+
"(SELECT count(*) FROM sundynix_tenant_member m WHERE m.tenant_id = t.id AND m.status = 'active') as members").
Where("t.deleted_at IS NULL").Order("t.created_at asc").Scan(&out).Error
return out, err
}
// MyTenants 返回某用户所属(active)的全部租户(供多租户切换)。
func (p *Postgres) MyTenants(ctx context.Context, userID string) ([]TenantInfo, error) {
if p.db == nil {
return nil, nil
}
var out []TenantInfo
err := p.db.WithContext(ctx).Table("sundynix_tenant t").
Select("t.id, t.name, t.slug, t.plan, t.status, t.credit_balance_micro, t.shared_billing, "+
"(SELECT count(*) FROM sundynix_tenant_member m2 WHERE m2.tenant_id = t.id AND m2.status = 'active') as members").
Joins("JOIN sundynix_tenant_member m ON m.tenant_id = t.id").
Where("m.user_id = ? AND m.status = 'active' AND t.deleted_at IS NULL", userID).
Order("t.created_at asc").Scan(&out).Error
return out, err
}
// ---- 租户切换 + 共享计费(增量2)----
// PersonalTenantForUser 返回用户自己的个人租户(最早加入且角色 owner 的租户 = 注册默认租户)。
func (p *Postgres) PersonalTenantForUser(ctx context.Context, userID string) string {
if p.db == nil {
return ""
}
var m TenantMember
if err := p.db.WithContext(ctx).Select("tenant_id").
Where("user_id = ? AND status = 'active' AND role = ?", userID, RoleOwner).
Order("created_at asc").First(&m).Error; err != nil {
return ""
}
return m.TenantID
}
// ActiveTenantForUser 解析用户当前活跃租户:user.active_tenant_id 若已设且仍是 active 成员 → 用之;
// 否则回退默认租户(并保证默认租户存在)。中间件每请求调用。
func (p *Postgres) ActiveTenantForUser(ctx context.Context, userID string) (*Tenant, error) {
def, err := p.EnsureDefaultTenant(ctx, userID, "")
if err != nil {
return nil, err
}
var u User
if e := p.db.WithContext(ctx).Select("active_tenant_id").First(&u, "id = ?", userID).Error; e == nil {
if u.ActiveTenantID != "" && u.ActiveTenantID != def.ID && p.MemberRole(ctx, u.ActiveTenantID, userID) != "" {
if t, _ := p.GetTenant(ctx, u.ActiveTenantID); t != nil {
return t, nil
}
}
}
return def, nil
}
// SetActiveTenant 切换用户活跃租户(须为其 active 成员)。
func (p *Postgres) SetActiveTenant(ctx context.Context, userID, tenantID string) error {
if p.db == nil {
return errStoreDisabled
}
if p.MemberRole(ctx, tenantID, userID) == "" {
return errors.New("你不是该租户的成员")
}
return p.db.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Update("active_tenant_id", tenantID).Error
}
// SetSharedBilling 设某租户共享计费开关。
func (p *Postgres) SetSharedBilling(ctx context.Context, tenantID string, on bool) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("shared_billing", on).Error
}
// ResolveBillingTenantID 决定"这次消耗记谁的池子":本人是 active 租户 owner,或 active 开了
// shared_billing → 记 active 租户;否则记本人个人租户(各付各的)。
func (p *Postgres) ResolveBillingTenantID(ctx context.Context, userID, activeTenantID string) string {
if activeTenantID == "" {
return ""
}
if p.MemberRole(ctx, activeTenantID, userID) == RoleOwner {
return activeTenantID
}
if t, _ := p.GetTenant(ctx, activeTenantID); t != nil && t.SharedBilling {
return activeTenantID
}
if personal := p.PersonalTenantForUser(ctx, userID); personal != "" {
return personal
}
return activeTenantID
}
// SetTenantPlan 更改租户方案等级。
func (p *Postgres) SetTenantPlan(ctx context.Context, tenantID string, plan string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("plan", plan).Error
}
// SetTenantStatus 更改租户状态(active / suspended)。
func (p *Postgres) SetTenantStatus(ctx context.Context, tenantID string, status string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Model(&Tenant{}).Where("id = ?", tenantID).Update("status", status).Error
}