addaa1b34f
引入 Space 中间容器(租户>Space>成员),资源作用域从 owner 改为 space_id,
让"个人私有/项目临时组队/整租户共享"出自同一模型(设计见 SPACE_DESIGN.md)。
先在纯 PG 的 Agent 上打样,零存储风险,验证协作+RBAC+切换 UX。
后端:
- 新表 Space{tenant_id,name,kind,creator,archived} + SpaceMember{space_id,user_id,role}
(如 Tenant 般不 isTenantScoped);User.ActiveSpaceID;Agent 作用域 owner→space_id,
owner 降级为创建人(供 UI 显示 / 删他人鉴权)
- store/space.go:个人空间幂等/活跃空间解析/切换/列表/建/成员CRUD/归档
- 迁移顺序坑:结构体只放非唯一 index,MigrateAgentSpaces 回填 space_id 后再建唯一
索引 idx_agent_sn + DROP 旧 idx_agent_on(否则存量空 space_id 撞车);启动序4步幂等
- 中间件 SpaceContext(注入 space_id) + RequireSpaceRole(照 RequireTenantRole)
- handler/space.go 空间端点 + 路由;agent.go 改空间作用域(删/覆盖他人需 admin)
- 计费零改动(Space 与 ResolveBillingTenantID 正交)
桌面端:
- api.ts space 接口;顶栏 SpaceSwitcher(含新建项目空间);StudioView 随空间切换
重拉编排 + viewer 禁保存;Agent 列表显示创建人 + 按 mine 控删除
验证:中间件6门控单测 + DB迁移(13个人空间/9 Agent全re-key/索引换新) + 后端HTTP全
场景(member见他人编排/删他人403、viewer存403、非成员切空间400+隔离、owner删他人200)
+ 浏览器实机(切换器3空间/Studio空间编排随切换隔离刷新/创建人显示/console无错)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
173 lines
5.7 KiB
Go
173 lines
5.7 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||
)
|
||
|
||
// SpacesList: GET /api/v1/me/spaces —— 当前活跃租户内我所属的工作区(供切换器)+ 当前活跃空间 id。
|
||
func (h *Handler) SpacesList(c *gin.Context) {
|
||
rows, err := h.db.MySpaces(c.Request.Context(), tenantID(c), userID(c))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"spaces": rows, "active": spaceID(c)})
|
||
}
|
||
|
||
// SwitchSpace: POST /api/v1/me/space {space_id} —— 切换当前活跃工作区(须为其成员)。
|
||
func (h *Handler) SwitchSpace(c *gin.Context) {
|
||
var b struct {
|
||
SpaceID string `json:"space_id"`
|
||
}
|
||
if err := c.ShouldBindJSON(&b); err != nil || b.SpaceID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "space_id 必填"})
|
||
return
|
||
}
|
||
if err := h.db.SetActiveSpace(c.Request.Context(), userID(c), b.SpaceID); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok", "active": b.SpaceID})
|
||
}
|
||
|
||
// SpaceCurrent: GET /api/v1/spaces/current —— 当前活跃工作区上下文 + 我在其中的角色。
|
||
func (h *Handler) SpaceCurrent(c *gin.Context) {
|
||
sid := spaceID(c)
|
||
if sid == "" {
|
||
c.JSON(http.StatusOK, gin.H{"space": nil})
|
||
return
|
||
}
|
||
s, _ := h.db.GetSpace(c.Request.Context(), sid)
|
||
if s == nil {
|
||
c.JSON(http.StatusOK, gin.H{"space": nil})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"space": gin.H{"id": s.ID, "name": s.Name, "kind": s.Kind, "creator": s.Creator, "archived": s.Archived},
|
||
"role": h.db.SpaceMemberRole(c.Request.Context(), sid, userID(c)),
|
||
})
|
||
}
|
||
|
||
// SpaceCreate: POST /api/v1/spaces {name, kind} —— 在活跃租户建一个工作区(建者成为 owner)。
|
||
// 已由 RequireTenantRole(member) 门控:租户只读 viewer 进不来。
|
||
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.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))
|
||
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})
|
||
}
|
||
|
||
// spaceAdminOr403 校验当前用户在指定空间是 admin/owner,否则写 403 并返回 false。
|
||
func (h *Handler) spaceAdminOr403(c *gin.Context, sid string) bool {
|
||
if store.RoleRank(h.db.SpaceMemberRole(c.Request.Context(), sid, userID(c))) < store.RoleRank(store.RoleAdmin) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "需要空间管理员权限"})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// SpaceMembers: GET /api/v1/spaces/:id/members —— 空间成员列表(须为该空间成员)。
|
||
func (h *Handler) SpaceMembers(c *gin.Context) {
|
||
sid := c.Param("id")
|
||
if h.db.SpaceMemberRole(c.Request.Context(), sid, userID(c)) == "" {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "你不是该空间的成员"})
|
||
return
|
||
}
|
||
rows, err := h.db.ListSpaceMembers(c.Request.Context(), sid)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"members": rows})
|
||
}
|
||
|
||
// SpaceAddMember: POST /api/v1/spaces/:id/members {email, role} —— 拉租户成员进空间(须空间 admin)。
|
||
func (h *Handler) SpaceAddMember(c *gin.Context) {
|
||
sid := c.Param("id")
|
||
if !h.spaceAdminOr403(c, sid) {
|
||
return
|
||
}
|
||
var b struct {
|
||
Email string `json:"email"`
|
||
Role string `json:"role"`
|
||
}
|
||
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Email) == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "email 必填"})
|
||
return
|
||
}
|
||
if b.Role == "" {
|
||
b.Role = store.RoleMember
|
||
}
|
||
m, err := h.db.AddSpaceMemberByEmail(c.Request.Context(), sid, b.Email, b.Role)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"member": m})
|
||
}
|
||
|
||
// SpaceSetMemberRole: PUT /api/v1/spaces/:id/members/:uid {role} —— 改空间成员角色(须空间 admin)。
|
||
func (h *Handler) SpaceSetMemberRole(c *gin.Context) {
|
||
sid := c.Param("id")
|
||
if !h.spaceAdminOr403(c, sid) {
|
||
return
|
||
}
|
||
var b struct {
|
||
Role string `json:"role"`
|
||
}
|
||
if err := c.ShouldBindJSON(&b); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := h.db.SetSpaceMemberRole(c.Request.Context(), sid, c.Param("uid"), b.Role); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
}
|
||
|
||
// SpaceRemoveMember: DELETE /api/v1/spaces/:id/members/:uid —— 移除空间成员(须空间 admin)。
|
||
func (h *Handler) SpaceRemoveMember(c *gin.Context) {
|
||
sid := c.Param("id")
|
||
if !h.spaceAdminOr403(c, sid) {
|
||
return
|
||
}
|
||
if err := h.db.RemoveSpaceMember(c.Request.Context(), sid, c.Param("uid")); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
}
|
||
|
||
// SpaceArchive: POST /api/v1/spaces/:id/archive —— 归档空间(须空间 admin;个人空间不可)。
|
||
func (h *Handler) SpaceArchive(c *gin.Context) {
|
||
sid := c.Param("id")
|
||
if !h.spaceAdminOr403(c, sid) {
|
||
return
|
||
}
|
||
if err := h.db.ArchiveSpace(c.Request.Context(), sid); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
}
|