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:
Blizzard
2026-07-13 13:55:29 +08:00
parent a8c0bb42a7
commit 9dab25a5f0
5 changed files with 112 additions and 11 deletions
+18 -6
View File
@@ -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/tenantpersonal 由系统建
}
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