Files
sundynix-agentix/sundynix-gateway/internal/handler/space.go
T
Blizzard addaa1b34f feat(space): 共享工作区增量3a —— Agent 编排按 Space 共享(纯 PG 打样)
引入 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>
2026-07-13 10:51:33 +08:00

173 lines
5.7 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 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/tenantpersonal 由系统建
}
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"})
}