Files
sundynix-agentix/sundynix-gateway/internal/store/tenant.go
T
Blizzard ce7cca657e fix(backend): 补齐三处漏洞——暂停租户拦截/金额不符落审计终态/邀请码列表滤失效
P0 暂停租户是空开关:admin 能设 suspended,但 preflight 只查预算+余额、不看
租户 status → 暂停后照样能提交烧积分。加 TenantSuspended 校验(活跃租户 + 分叉时
的计费租户都拦),403 拒绝。

P1 金额不符只刷日志:回调/查单判了不符却没落审计、订单永远卡 pending 被补偿定时器
每轮重扫刷屏。加 disputed 终态 + MarkOrderDisputed(CAS 只挂一次) + 审计(首次写一次);
disputed 不在 pending 扫描内,停止无限重扫。admin /orders?status=disputed 可查。

P1 邀请码列表混入失效码:ListInvites 只按 status=active 过滤,过期/满员的码仍显示为
有效、误导邀请人。有效列表加 expires_at>now 且 used<max 过滤(RedeemInvite 本就会拒,
这里修的是展示一致性)。

三处均带 store 单测(TenantSuspended/MarkOrderDisputed CAS/ListInvites 过滤)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:20:34 +08:00

417 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
}
// TenantSuspended 报告租户是否被暂停(轻查询,供提交前门控)。查不到/出错按未暂停处理,
// 宁可放行也不误封(暂停是显式管控动作,缺数据时不该凭空拦人)。
func (p *Postgres) TenantSuspended(ctx context.Context, tenantID string) bool {
if p.db == nil || tenantID == "" {
return false
}
var status string
p.db.WithContext(WithoutTenant(ctx)).Model(&Tenant{}).
Where("id = ?", tenantID).Select("status").Scan(&status)
return status == "suspended"
}
// 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
}