b98cf3c718
用户可切活跃租户,消耗按"计费租户"扣(数据仍落活跃租户/工作区): - User.active_tenant_id(切换持久化)+ Tenant.shared_billing(每租户开关,默认关)。 - 中间件 ActiveTenantForUser:active_tenant_id 若有效(仍是成员)则用之,否则默认租户。 - 计费目标解析 ResolveBillingTenantID:本人是活跃租户 owner 或该租户 shared_billing 开 → 记活跃租户;否则记本人个人租户(各付各的)。SubmitTask 的硬拦截 + 用量都按它走。 - 接口:GET /me/tenants、POST /me/tenant(切换)、PUT /admin/tenants/:id/shared-billing; /tenants/current 改显"可花余额"(计费租户) + billing_shared 标记。 live 验证(demoB 是公司A 成员、个人租户=公司B):切到公司A 后—— shared OFF:任务落公司A 工作区、用量扣公司B(个人),公司A 不变; shared ON:用量扣公司A(共享),公司B 不变;/tenants/current 余额随之在 6.48↔15.92 切换。全过。 前端切换器 + admin 开关见后续。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
369 lines
12 KiB
Go
369 lines
12 KiB
Go
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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|