feat(gateway): 多租户增量2 —— 租户切换 + 每租户共享计费开关

用户可切活跃租户,消耗按"计费租户"扣(数据仍落活跃租户/工作区):
- 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>
This commit is contained in:
Blizzard
2026-07-08 10:52:17 +08:00
parent aa10037511
commit b98cf3c718
6 changed files with 144 additions and 17 deletions
+74 -2
View File
@@ -264,6 +264,7 @@ type TenantInfo struct {
Plan string `json:"plan"`
Status string `json:"status"`
CreditBalanceMicro int64 `json:"credit_balance_micro"`
SharedBilling bool `json:"shared_billing"`
Members int64 `json:"members"`
}
@@ -274,7 +275,7 @@ func (p *Postgres) ListTenants(ctx context.Context) ([]TenantInfo, error) {
}
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, "+
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
@@ -287,10 +288,81 @@ func (p *Postgres) MyTenants(ctx context.Context, userID string) ([]TenantInfo,
}
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, "+
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
}