feat(gateway): 多成员租户·成员管理后端 —— 租户目录 + 成员增改删
真·多租户地基(此前只有单人默认租户 + admin 页是 mock): - store:ListTenants(含成员数+余额) / MyTenants / ListMembers(join user) / AddMemberByEmail(邮箱归一化小写、幂等 upsert、复活已移除、未注册报友好错) / SetMemberRole(禁改 owner、禁直接授 owner) / RemoveMember(软移除、禁移除 owner) / ValidRole。 - admin 接口:GET/POST /admin/tenants、GET/POST /admin/tenants/:id/members、 PUT/DELETE /admin/tenants/:id/members/:uid。 live 验证:加 demoB 入公司A→member、列表、改角色→admin、移除 owner 被拒、移除 member,全过。 admin 前端接线 + 租户切换 见后续。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,11 +2,23 @@ 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"
|
||||
@@ -161,3 +173,124 @@ func firstNonEmpty(a, b string) string {
|
||||
}
|
||||
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"`
|
||||
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, "+
|
||||
"(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, "+
|
||||
"(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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user