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>
83 lines
3.1 KiB
Go
83 lines
3.1 KiB
Go
package handler
|
||
|
||
import (
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||
)
|
||
|
||
// AgentList: GET /api/v1/agents —— 当前活跃工作区(space)的全部编排(共享:同空间成员都可见,含创建人)。
|
||
func (h *Handler) AgentList(c *gin.Context) {
|
||
rows, err := h.db.ListAgents(c.Request.Context(), spaceID(c))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
out := make([]gin.H, 0, len(rows))
|
||
for _, r := range rows {
|
||
creator := r.CreatorName
|
||
if creator == "" {
|
||
creator = r.CreatorMail
|
||
}
|
||
out = append(out, gin.H{"name": r.Name, "graph": r.Graph, "updated_at": r.UpdatedAt,
|
||
"owner": r.Owner, "creator": creator, "mine": r.Owner == userID(c)})
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"agents": out})
|
||
}
|
||
|
||
// AgentSave: POST /api/v1/agents {name, graph} —— 保存/更新编排到当前空间(空间内重名覆盖)。
|
||
// 已由 RequireSpaceRole(member) 门控:viewer 只读进不来。
|
||
func (h *Handler) AgentSave(c *gin.Context) {
|
||
var body struct {
|
||
Name string `json:"name"`
|
||
Graph string `json:"graph"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Graph) == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "name/graph required"})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(body.Name)
|
||
// 覆盖他人建的编排需 admin/owner;member 只能改自己建的(新建不受限)。
|
||
if existing, _ := h.db.GetAgent(c.Request.Context(), spaceID(c), name); existing != nil &&
|
||
existing.Owner != userID(c) && store.RoleRank(h.db.SpaceMemberRole(c.Request.Context(), spaceID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "该编排由他人创建,仅管理员可覆盖"})
|
||
return
|
||
}
|
||
if err := h.db.SaveAgent(c.Request.Context(), spaceID(c), userID(c), name, body.Graph); err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"name": name})
|
||
}
|
||
|
||
// AgentDelete: DELETE /api/v1/agents?name= —— 删除当前空间的一份编排。
|
||
// 删他人建的需 admin/owner;member 只能删自己建的。
|
||
func (h *Handler) AgentDelete(c *gin.Context) {
|
||
name := strings.TrimSpace(c.Query("name"))
|
||
if name == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
|
||
return
|
||
}
|
||
existing, err := h.db.GetAgent(c.Request.Context(), spaceID(c), name)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if existing == nil {
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"}) // 已不存在,幂等
|
||
return
|
||
}
|
||
if existing.Owner != userID(c) && store.RoleRank(h.db.SpaceMemberRole(c.Request.Context(), spaceID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "该编排由他人创建,仅管理员可删除"})
|
||
return
|
||
}
|
||
if err := h.db.DeleteAgent(c.Request.Context(), spaceID(c), name); err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||
}
|