feat(space): 全员空间(tenant-kind) —— 整租户共享的"开关",自动纳入全部成员
补齐 Space 三类型的最后一种:tenant-kind「全员空间」= 用户最初想要的"共享工作区开关"。 与 project 空间的本质区别:自动纳入全租户成员(含之后新加入的),而非手动逐个邀请。 后端: - EnableTenantSpace(幂等):建 kind=tenant 空间 + 把全部 active 租户成员按租户角色纳入 (owner→owner…billing_admin→viewer);tenantSpaceID 查询;spaceRoleFromTenant 角色映射 - AddMemberByEmail 加租户成员时 autoJoinTenantSpace:若已启用全员空间则自动补纳新成员 - SpaceCreate handler:kind=tenant 路由到 EnableTenantSpace,且须租户 admin/owner(inline 403) 桌面端: - 空间切换器加"启用全员空间"入口(Globe 图标):仅租户 admin/owner 且尚未启用时显示, 带二次确认;启用后该按钮自动隐藏(hasTenantSpace),全员空间进切换器 验证(gateway+docker):member 启用 403 / owner 启用纳入全部现有成员(owner·member·viewer 按租户角色映射) / 新加租户成员自动纳入 / 幂等(再启用返回同一个不重复建) / viewer 在全员空间 入库 403(只读) / owner 建库 200;浏览器实机:启用后全员空间进切换器 + 启用按钮消失 至此 Space 三类型(个人/项目/全员)全部落地。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -57,20 +57,32 @@ func (h *Handler) SpaceCurrent(c *gin.Context) {
|
||||
|
||||
// SpaceCreate: POST /api/v1/spaces {name, kind} —— 在活跃租户建一个工作区(建者成为 owner)。
|
||||
// 已由 RequireTenantRole(member) 门控:租户只读 viewer 进不来。
|
||||
// kind=tenant(全员空间)走 EnableTenantSpace(幂等纳入全部成员),且须租户 admin。
|
||||
func (h *Handler) SpaceCreate(c *gin.Context) {
|
||||
var b struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" {
|
||||
_ = c.ShouldBindJSON(&b)
|
||||
if b.Kind == store.SpaceKindTenant {
|
||||
// 启用全员空间:整租户级动作,须租户 admin/owner。
|
||||
if store.RoleRank(h.db.MemberRole(c.Request.Context(), tenantID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "启用全员空间需要租户管理员权限"})
|
||||
return
|
||||
}
|
||||
s, err := h.db.EnableTenantSpace(c.Request.Context(), tenantID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"id": s.ID, "name": s.Name, "kind": s.Kind})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(b.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
|
||||
return
|
||||
}
|
||||
kind := b.Kind
|
||||
if kind != store.SpaceKindProject && kind != store.SpaceKindTenant {
|
||||
kind = store.SpaceKindProject // 只允许经此建 project/tenant;personal 由系统建
|
||||
}
|
||||
s, err := h.db.CreateSpace(c.Request.Context(), tenantID(c), b.Name, kind, userID(c))
|
||||
s, err := h.db.CreateSpace(c.Request.Context(), tenantID(c), b.Name, store.SpaceKindProject, userID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -207,6 +207,70 @@ func (p *Postgres) CreateSpace(ctx context.Context, tenantID, name, kind, creato
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ---- 全员空间(tenant-kind:整租户共享,自动纳入全部成员)----
|
||||
|
||||
// tenantSpaceID 返回某租户的全员空间 id(无则空 = 未启用)。
|
||||
func (p *Postgres) tenantSpaceID(ctx context.Context, tenantID string) string {
|
||||
var s Space
|
||||
if err := p.db.WithContext(ctx).Select("id").
|
||||
Where("tenant_id = ? AND kind = ? AND deleted_at IS NULL AND archived = false", tenantID, SpaceKindTenant).
|
||||
Order("created_at asc").First(&s).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
// spaceRoleFromTenant 把租户角色映射为空间角色:billing_admin(财务只读)→viewer,其余照搬。
|
||||
func spaceRoleFromTenant(role string) string {
|
||||
if role == RoleBillingAdmin {
|
||||
return RoleViewer
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// EnableTenantSpace 幂等启用某租户的全员空间:已启用则返回;否则建 kind=tenant 空间 +
|
||||
// 把当前全部 active 租户成员按其租户角色纳入(owner→owner…billing_admin→viewer)。
|
||||
// 新加入租户的成员由 AddMemberByEmail 自动补纳(见其实现)。
|
||||
func (p *Postgres) EnableTenantSpace(ctx context.Context, tenantID string) (*Space, error) {
|
||||
if p.db == nil {
|
||||
return nil, errStoreDisabled
|
||||
}
|
||||
if id := p.tenantSpaceID(ctx, tenantID); id != "" {
|
||||
return p.GetSpace(ctx, id)
|
||||
}
|
||||
members, err := p.ListMembers(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
creator := ""
|
||||
for _, m := range members {
|
||||
if m.Role == RoleOwner {
|
||||
creator = m.UserID
|
||||
break
|
||||
}
|
||||
}
|
||||
s := &Space{TenantID: tenantID, Name: "全员空间", Kind: SpaceKindTenant, Creator: creator}
|
||||
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range members {
|
||||
if m.Status != "active" {
|
||||
continue
|
||||
}
|
||||
if err := p.addSpaceMember(ctx, s.ID, m.UserID, spaceRoleFromTenant(m.Role)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// autoJoinTenantSpace 若某租户已启用全员空间,把用户按其租户角色纳入(AddMemberByEmail 调用)。
|
||||
func (p *Postgres) autoJoinTenantSpace(ctx context.Context, tenantID, userID, tenantRole string) {
|
||||
if sid := p.tenantSpaceID(ctx, tenantID); sid != "" {
|
||||
_ = p.addSpaceMember(ctx, sid, userID, spaceRoleFromTenant(tenantRole))
|
||||
}
|
||||
}
|
||||
|
||||
// ArchiveSpace 归档空间(软;个人空间不可归档)。
|
||||
func (p *Postgres) ArchiveSpace(ctx context.Context, spaceID string) error {
|
||||
if p.db == nil {
|
||||
|
||||
@@ -241,6 +241,8 @@ func (p *Postgres) AddMemberByEmail(ctx context.Context, tenantID, email, role s
|
||||
}).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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user