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>
This commit is contained in:
Blizzard
2026-07-13 10:51:33 +08:00
parent bf5be08e96
commit addaa1b34f
16 changed files with 1077 additions and 46 deletions
+10
View File
@@ -54,6 +54,16 @@ func main() {
if err := db.BackfillRowTenants(context.Background()); err != nil {
log.Printf("[startup] 存量行 tenant_id 回填失败: %v", err)
}
// 增量3 共享工作区:给每个租户成员补个人空间,再把存量 Agent re-key 到其个人空间(幂等;顺序:
// 空间先建、Agent 后迁——迁移内先回填 space_id 再建唯一索引,避免存量空 space_id 撞车)。
if n, err := db.BackfillPersonalSpaces(context.Background()); err != nil {
log.Printf("[startup] 个人空间回填失败: %v", err)
} else if n > 0 {
log.Printf("[startup] 为 %d 个租户成员补建了个人空间", n)
}
if err := db.MigrateAgentSpaces(context.Background()); err != nil {
log.Printf("[startup] Agent 空间作用域迁移失败: %v", err)
}
cache := store.OpenRedis(redisAddr) // CacheDB: Session / Rate Limit(连不上则降级)
defer cache.Close()
bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流
+37 -8
View File
@@ -5,23 +5,31 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// AgentList: GET /api/v1/agents —— 当前用户保存的全部 Agent 编排(owner 隔离,最近在前)。
// AgentList: GET /api/v1/agents —— 当前活跃工作区(space)的全部编排(共享:同空间成员都可见,含创建人)。
func (h *Handler) AgentList(c *gin.Context) {
rows, err := h.db.ListAgents(c.Request.Context(), userID(c))
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 {
out = append(out, gin.H{"name": r.Name, "graph": r.Graph, "updated_at": r.UpdatedAt})
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} —— 保存/更新一份编排(graph 为 {nodes,edges} JSON)。
// AgentSave: POST /api/v1/agents {name, graph} —— 保存/更新编排到当前空间(空间内重名覆盖)。
// 已由 RequireSpaceRole(member) 门控:viewer 只读进不来。
func (h *Handler) AgentSave(c *gin.Context) {
var body struct {
Name string `json:"name"`
@@ -31,21 +39,42 @@ func (h *Handler) AgentSave(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name/graph required"})
return
}
if err := h.db.SaveAgent(c.Request.Context(), userID(c), strings.TrimSpace(body.Name), body.Graph); err != nil {
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": strings.TrimSpace(body.Name)})
c.JSON(http.StatusOK, gin.H{"name": name})
}
// AgentDelete: DELETE /api/v1/agents?name= —— 删除一份编排。
// AgentDelete: DELETE /api/v1/agents?name= —— 删除当前空间的一份编排。
// 删他人建的需 admin/ownermember 只能删自己建的。
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
}
if err := h.db.DeleteAgent(c.Request.Context(), userID(c), name); err != nil {
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
}
+172
View File
@@ -0,0 +1,172 @@
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"})
}
@@ -532,6 +532,16 @@ func tenantID(c *gin.Context) string {
return ""
}
// spaceID 取当前请求的活跃工作区标识 —— 由 SpaceContext 中间件注入(共享工作区作用域用;未解析返回空)。
func spaceID(c *gin.Context) string {
if v, ok := c.Get("space_id"); ok {
if s, _ := v.(string); s != "" {
return s
}
}
return ""
}
// TenantCurrent: GET /api/v1/tenants/current —— 当前用户的租户上下文 + 角色(前端 + 联调验证)。
func (h *Handler) TenantCurrent(c *gin.Context) {
tid := tenantID(c)
@@ -0,0 +1,58 @@
package middleware
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
// CtxSpaceID 是当前活跃工作区(Space) id 在 gin.Context 的键(增量3 共享工作区)。
const CtxSpaceID = "space_id"
// SpaceContext 解析当前用户在活跃租户内的活跃工作区并注入 space_id。
// 须挂在 Auth + TenantContext 之后(依赖 uid 与 tenant_id);未登录/无租户则跳过。
// 活跃空间无效(跨租户/已归档/非成员)时由 ActiveSpaceForUser 兜底回退个人空间。
func SpaceContext(db *store.Postgres) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(CtxUserID)
id, _ := uid.(string)
tid, _ := c.Get(CtxTenantID)
tenant, _ := tid.(string)
if id != "" && tenant != "" {
if s, err := db.ActiveSpaceForUser(c.Request.Context(), tenant, id); err == nil && s != nil {
c.Set(CtxSpaceID, s.ID)
}
}
c.Next()
}
}
// SpaceRoleResolver 解析用户在某空间的角色(*store.Postgres 天然满足)。抽接口便于单测门控。
type SpaceRoleResolver interface {
SpaceMemberRole(ctx context.Context, spaceID, userID string) string
}
// RequireSpaceRole 要求当前用户在活跃空间中的角色不低于 minRole(按 store.RoleRank 阶梯)。
// 须挂在 RequireAuth + SpaceContext 之后。非成员/无空间 → rank 0 → 拦下。
// 用于共享工作区的写操作门控(建/存/删编排要 ≥member;viewer 只读)。
func RequireSpaceRole(db SpaceRoleResolver, minRole string) gin.HandlerFunc {
min := store.RoleRank(minRole)
return func(c *gin.Context) {
uid, _ := c.Get(CtxUserID)
id, _ := uid.(string)
if id == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "需要登录"})
return
}
sid, _ := c.Get(CtxSpaceID)
space, _ := sid.(string)
if store.RoleRank(db.SpaceMemberRole(c.Request.Context(), space, id)) < min {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "当前空间角色为只读,无权执行该操作"})
return
}
c.Next()
}
}
@@ -0,0 +1,60 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/sundynix-gateway/internal/store"
)
type fakeSpaceRole struct{ role string }
func (f fakeSpaceRole) SpaceMemberRole(context.Context, string, string) string { return f.role }
func spaceGateEngine(uid, space, memberRole, minRole string) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
if uid != "" {
c.Set(CtxUserID, uid)
}
if space != "" {
c.Set(CtxSpaceID, space)
}
c.Next()
})
r.POST("/agents", RequireSpaceRole(fakeSpaceRole{role: memberRole}, minRole),
func(c *gin.Context) { c.String(http.StatusOK, "saved") })
return r
}
func TestRequireSpaceRole_Gate(t *testing.T) {
cases := []struct {
name string
uid string
space string
memberRole string
wantCode int
}{
{"owner 放行", "u1", "s1", store.RoleOwner, http.StatusOK},
{"admin 放行", "u1", "s1", store.RoleAdmin, http.StatusOK},
{"member 放行", "u1", "s1", store.RoleMember, http.StatusOK},
{"viewer 拦下(空间只读)", "u1", "s1", store.RoleViewer, http.StatusForbidden},
{"非空间成员 拦下", "u1", "s1", "", http.StatusForbidden},
{"未登录 401", "", "s1", store.RoleOwner, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := spaceGateEngine(tc.uid, tc.space, tc.memberRole, store.RoleMember)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/agents", nil))
if w.Code != tc.wantCode {
t.Errorf("状态码=%d, 期望 %dbody=%s", w.Code, tc.wantCode, w.Body.String())
}
})
}
}
+15 -3
View File
@@ -27,6 +27,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
r.Use(cors()) // 桌面端/浏览器跨源访问
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)——须在限流前,供按用户限流
r.Use(middleware.TenantContext(db)) // 多租户:注入当前 tenant_id(已登录才解析;须在 Auth 之后)
r.Use(middleware.SpaceContext(db)) // 共享工作区:注入当前 space_id(须在 TenantContext 之后)
r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累)
r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event
@@ -81,9 +82,20 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
p.GET("/kb/links", h.KbLinks) // 某库双链
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
p.GET("/agents", h.AgentList) // 的编排列表(owner 隔离
p.POST("/agents", h.AgentSave) // 保存/更新编排
p.DELETE("/agents", h.AgentDelete) // 删除编排
p.GET("/agents", h.AgentList) // 当前空间的编排列表(共享工作区,含创建人
p.POST("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentSave) // 保存/更新编排:空间只读 viewer 拦下
p.DELETE("/agents", middleware.RequireSpaceRole(db, store.RoleMember), h.AgentDelete) // 删除编排:viewer 拦下(删他人另需 admin,见 handler
// 共享工作区(Space):切换 / 列表 / 建 / 成员管理(增量3)
p.GET("/me/spaces", h.SpacesList) // 活跃租户内我所属的空间(供切换)
p.POST("/me/space", h.SwitchSpace) // 切换活跃空间
p.GET("/spaces/current", h.SpaceCurrent) // 当前空间上下文 + 我的角色
p.POST("/spaces", middleware.RequireTenantRole(db, store.RoleMember), h.SpaceCreate) // 建空间:租户只读 viewer 拦下
p.GET("/spaces/:id/members", h.SpaceMembers) // 空间成员列表
p.POST("/spaces/:id/members", h.SpaceAddMember) // 拉人进空间(handler 内校验空间 admin
p.PUT("/spaces/:id/members/:uid", h.SpaceSetMemberRole) // 改空间成员角色
p.DELETE("/spaces/:id/members/:uid", h.SpaceRemoveMember) // 移除空间成员
p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间
p.POST("/reports", h.GenerateReport) // 报告生成
p.GET("/billing", h.Billing)
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
+51 -17
View File
@@ -49,46 +49,80 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
}
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON,按 owner 隔离)。
// 表名 sundynix_agent。(owner,name) 唯一 —— 同一用户下编排名不重复。
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON)。
// 表名 sundynix_agent。增量3:作用域从 owner 改为 space_id——(space_id,name) 唯一,
// 同一工作区内编排名不重复;owner 降级为"创建人"归属(谁建的,供 UI 显示 / 删他人鉴权)。
// 唯一索引 idx_agent_sn(space_id,name) 不在结构体标记里建(否则 AutoMigrate 早于回填、存量 space_id 空会撞车),
// 而由启动迁移 MigrateAgentSpaces 在回填 space_id 后显式建;旧 idx_agent_on(owner,name) 同步 DROP。
type Agent struct {
BaseModel
TenantID string `gorm:"size:64;index"` // 多租户作用域(tenant 插件自动填/过滤)
Owner string `gorm:"size:64;uniqueIndex:idx_agent_on"`
Name string `gorm:"size:128;uniqueIndex:idx_agent_on"`
TenantID string `gorm:"size:64;index"` // 多租户作用域(tenant 插件自动填/过滤,防越权写他租
SpaceID string `gorm:"size:64;index"` // 所属工作区(共享单位;唯一约束见 MigrateAgentSpaces
Owner string `gorm:"size:64;index"` // 创建人 user.id(归属,非隔离键)
Name string `gorm:"size:128"`
Graph string `gorm:"type:text"` // {nodes,edges} 的 JSON(含布局)
}
func (Agent) TableName() string { return "sundynix_agent" }
func (Agent) isTenantScoped() {}
// ListAgents 返回某 owner 的全部编排(最近更新在前)。
func (p *Postgres) ListAgents(ctx context.Context, owner string) ([]Agent, error) {
// AgentInfo 是编排列表视图(含创建人邮箱/名字,供共享工作区显示"谁建的")。
type AgentInfo struct {
Name string `json:"name"`
Graph string `json:"graph"`
Owner string `json:"owner"` // 创建人 user.id
CreatorName string `json:"creator_name"`
CreatorMail string `json:"creator_mail"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListAgents 返回某工作区(space)的全部编排(最近更新在前,join 创建人名字)。
// tenant 插件仍会按 ctx 租户过滤(防越权);空间是主作用域。
func (p *Postgres) ListAgents(ctx context.Context, spaceID string) ([]AgentInfo, error) {
if p.db == nil {
return nil, nil
}
var rows []Agent
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("updated_at desc").Find(&rows).Error
return rows, err
var out []AgentInfo
err := p.db.WithContext(ctx).Table("sundynix_agent a").
Select("a.name, a.graph, a.owner, u.name as creator_name, u.email as creator_mail, a.updated_at").
Joins("LEFT JOIN sundynix_user u ON u.id = a.owner").
Where("a.space_id = ? AND a.deleted_at IS NULL", spaceID).
Order("a.updated_at desc").Scan(&out).Error
return out, err
}
// SaveAgent 新建/更新一份编排(owner+name 唯一,重名覆盖图与更新时间)。
func (p *Postgres) SaveAgent(ctx context.Context, owner, name, graph string) error {
// SaveAgent 新建/更新一份编排(space_id+name 唯一,重名覆盖图与更新时间owner 首建时记创建人,覆盖不改)。
func (p *Postgres) SaveAgent(ctx context.Context, spaceID, creator, name, graph string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
Columns: []clause.Column{{Name: "space_id"}, {Name: "name"}},
DoUpdates: clause.Assignments(map[string]any{"graph": graph, "updated_at": time.Now()}),
}).Create(&Agent{Owner: owner, Name: name, Graph: graph}).Error
}).Create(&Agent{SpaceID: spaceID, Owner: creator, Name: name, Graph: graph}).Error
}
// DeleteAgent 删除某 owner 的一份编排
func (p *Postgres) DeleteAgent(ctx context.Context, owner, name string) error {
// GetAgent 取某工作区内一份编排(含 owner,供删他人鉴权判定)
func (p *Postgres) GetAgent(ctx context.Context, spaceID, name string) (*Agent, error) {
if p.db == nil {
return nil, nil
}
var a Agent
if err := p.db.WithContext(ctx).Where("space_id = ? AND name = ?", spaceID, name).First(&a).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &a, nil
}
// DeleteAgent 删除某工作区的一份编排。
func (p *Postgres) DeleteAgent(ctx context.Context, spaceID, name string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).Delete(&Agent{}).Error
return p.db.WithContext(ctx).Where("space_id = ? AND name = ?", spaceID, name).Delete(&Agent{}).Error
}
// Doc 是入库的一份文件/笔记 —— 文件主表(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
@@ -12,6 +12,7 @@ type User struct {
Name string `gorm:"size:64"`
PasswordHash string `gorm:"size:255" json:"-"` // bcrypt;绝不出 JSON
ActiveTenantID string `gorm:"size:64" json:"-"` // 当前活跃租户(多租户切换;空=用默认)
ActiveSpaceID string `gorm:"size:64" json:"-"` // 当前活跃工作区(Space)(增量3;空/失效=用活跃租户的个人空间)
}
// Task 是一次提交的 Agent 编排任务(DSL)。
+1 -1
View File
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
migrateLegacyIntIDs(db)
migrateDocLinkToID(db)
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}); err != nil {
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &Space{}, &SpaceMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}, &Setting{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{}
}
+337
View File
@@ -0,0 +1,337 @@
package store
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Space 是一个「工作区/协作空间」——共享的资源容器(增量3,见 SPACE_DESIGN.md)。
// 资源(Agent/KB…)挂在 Space 上而非 user 上,让"个人私有 / 项目临时组队 / 整租户共享"三种场景
// 出自同一模型。Space 属单一租户(隔离/计费边界仍在租户)。
// 与 Tenant/TenantMember 一样是"基础设施表",不标 isTenantScopedtenant 过滤在查询里显式做)。
type Space struct {
BaseModel
TenantID string `gorm:"size:64;index"`
Name string `gorm:"size:128"`
Kind string `gorm:"size:16;index"` // personal / project / tenant
Creator string `gorm:"size:64;index"` // 建者 user.id
Archived bool // 软归档(临时空间完事即归档,从切换器隐藏)
}
func (Space) TableName() string { return "sundynix_space" }
// SpaceMember 是空间成员关系(谁在这个空间 + 空间内角色)。复用租户角色语义(RoleRank 阶梯)。
type SpaceMember struct {
BaseModel
SpaceID string `gorm:"size:64;uniqueIndex:idx_space_user;index"`
UserID string `gorm:"size:64;uniqueIndex:idx_space_user;index"`
Role string `gorm:"size:16"`
Status string `gorm:"size:16"` // active / removed
}
func (SpaceMember) TableName() string { return "sundynix_space_member" }
// 空间类型常量。
const (
SpaceKindPersonal = "personal"
SpaceKindProject = "project"
SpaceKindTenant = "tenant"
)
// ---- 基础查询 ----
// GetSpace 按 id 取空间(不存在返回 nil)。
func (p *Postgres) GetSpace(ctx context.Context, id string) (*Space, error) {
if p.db == nil {
return nil, errStoreDisabled
}
var s Space
if err := p.db.WithContext(ctx).First(&s, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &s, nil
}
// SpaceMemberRole 返回用户在某空间的角色(无 active 成员关系返回空)。
func (p *Postgres) SpaceMemberRole(ctx context.Context, spaceID, userID string) string {
if p.db == nil {
return ""
}
var m SpaceMember
if err := p.db.WithContext(ctx).
Where("space_id = ? AND user_id = ? AND status = ?", spaceID, userID, "active").
First(&m).Error; err != nil {
return ""
}
return m.Role
}
// addSpaceMember 幂等加成员(命中唯一 (space_id,user_id) 则改角色并置 active,复活已移除者)。
func (p *Postgres) addSpaceMember(ctx context.Context, spaceID, userID, role string) error {
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "space_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{"role": role, "status": "active", "updated_at": time.Now()}),
}).Create(&SpaceMember{SpaceID: spaceID, UserID: userID, Role: role, Status: "active"}).Error
}
// ---- 个人空间(迁移地基)----
// personalSpaceID 返回用户在某租户的个人空间 id(无则空)。
func (p *Postgres) personalSpaceID(ctx context.Context, tenantID, userID string) string {
var s Space
if err := p.db.WithContext(ctx).Select("id").
Where("tenant_id = ? AND creator = ? AND kind = ?", tenantID, userID, SpaceKindPersonal).
Order("created_at asc").First(&s).Error; err != nil {
return ""
}
return s.ID
}
// EnsurePersonalSpace 幂等保证用户在某租户有个人空间(无则建 + 建者为 owner 成员)。
func (p *Postgres) EnsurePersonalSpace(ctx context.Context, tenantID, userID string) (*Space, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if id := p.personalSpaceID(ctx, tenantID, userID); id != "" {
return p.GetSpace(ctx, id)
}
s := &Space{TenantID: tenantID, Name: "个人空间", Kind: SpaceKindPersonal, Creator: userID}
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
return nil, err
}
if err := p.addSpaceMember(ctx, s.ID, userID, RoleOwner); err != nil {
return nil, err
}
return s, nil
}
// ---- 活跃空间解析 / 切换 ----
// ActiveSpaceForUser 解析用户在某租户的活跃空间:user.active_space_id 若有效(存在、属该租户、且用户是
// active 成员)→ 用之;否则回退该租户的个人空间(并保证其存在)。SpaceContext 中间件每请求调用。
func (p *Postgres) ActiveSpaceForUser(ctx context.Context, tenantID, userID string) (*Space, error) {
def, err := p.EnsurePersonalSpace(ctx, tenantID, userID)
if err != nil {
return nil, err
}
var u User
if e := p.db.WithContext(ctx).Select("active_space_id").First(&u, "id = ?", userID).Error; e == nil {
if u.ActiveSpaceID != "" && u.ActiveSpaceID != def.ID {
if s, _ := p.GetSpace(ctx, u.ActiveSpaceID); s != nil && s.TenantID == tenantID && !s.Archived &&
p.SpaceMemberRole(ctx, s.ID, userID) != "" {
return s, nil
}
}
}
return def, nil
}
// SetActiveSpace 切换用户活跃空间(须为其 active 成员)。
func (p *Postgres) SetActiveSpace(ctx context.Context, userID, spaceID string) error {
if p.db == nil {
return errStoreDisabled
}
if p.SpaceMemberRole(ctx, spaceID, userID) == "" {
return errors.New("你不是该空间的成员")
}
return p.db.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Update("active_space_id", spaceID).Error
}
// ---- 空间列表 / 创建 ----
// SpaceInfo 是空间列表一行(含当前用户角色 + 成员数)。
type SpaceInfo struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Creator string `json:"creator"`
Archived bool `json:"archived"`
Role string `json:"role"` // 当前用户在此空间的角色
Members int64 `json:"members"` // active 成员数
}
// MySpaces 返回某用户在某租户内所属(active 成员)的空间(供切换器;含角色 + 成员数)。
func (p *Postgres) MySpaces(ctx context.Context, tenantID, userID string) ([]SpaceInfo, error) {
if p.db == nil {
return nil, nil
}
var out []SpaceInfo
err := p.db.WithContext(ctx).Table("sundynix_space s").
Select("s.id, s.tenant_id, s.name, s.kind, s.creator, s.archived, m.role, "+
"(SELECT count(*) FROM sundynix_space_member m2 WHERE m2.space_id = s.id AND m2.status = 'active') as members").
Joins("JOIN sundynix_space_member m ON m.space_id = s.id").
Where("m.user_id = ? AND m.status = 'active' AND s.tenant_id = ? AND s.deleted_at IS NULL", userID, tenantID).
Order("s.kind = 'personal' desc, s.created_at asc").Scan(&out).Error
return out, err
}
// CreateSpace 在某租户建一个空间(建者自动成为 owner 成员)。kind 空则 project。
func (p *Postgres) CreateSpace(ctx context.Context, tenantID, name, kind, creator string) (*Space, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if kind == "" {
kind = SpaceKindProject
}
if strings.TrimSpace(name) == "" {
return nil, errors.New("空间名必填")
}
s := &Space{TenantID: tenantID, Name: strings.TrimSpace(name), Kind: kind, Creator: creator}
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
return nil, err
}
if err := p.addSpaceMember(ctx, s.ID, creator, RoleOwner); err != nil {
return nil, err
}
return s, nil
}
// ArchiveSpace 归档空间(软;个人空间不可归档)。
func (p *Postgres) ArchiveSpace(ctx context.Context, spaceID string) error {
if p.db == nil {
return errStoreDisabled
}
s, err := p.GetSpace(ctx, spaceID)
if err != nil || s == nil {
return errors.New("空间不存在")
}
if s.Kind == SpaceKindPersonal {
return errors.New("个人空间不可归档")
}
return p.db.WithContext(ctx).Model(&Space{}).Where("id = ?", spaceID).Update("archived", true).Error
}
// ---- 空间成员 CRUD(照 TenantMember 套路)----
// ListSpaceMembers 返回某空间成员(含邮箱/名字/角色),排除已移除。
func (p *Postgres) ListSpaceMembers(ctx context.Context, spaceID string) ([]MemberInfo, error) {
if p.db == nil {
return nil, nil
}
var out []MemberInfo
err := p.db.WithContext(ctx).Table("sundynix_space_member m").
Select("m.user_id, u.email, u.name, m.role, m.status, m.created_at as joined_at").
Joins("JOIN sundynix_user u ON u.id = m.user_id").
Where("m.space_id = ? AND m.status <> ?", spaceID, "removed").
Order("m.created_at asc").Scan(&out).Error
return out, err
}
// AddSpaceMemberByEmail 按邮箱把已注册用户加入空间(幂等;须对方先注册)。
// 调用方应先确保该用户是租户成员(空间成员应是租户成员的子集)。
func (p *Postgres) AddSpaceMemberByEmail(ctx context.Context, spaceID, email, role string) (*MemberInfo, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if !ValidRole(role) {
return nil, errors.New("非法角色")
}
u, err := p.GetUserByEmail(ctx, strings.ToLower(strings.TrimSpace(email)))
if errors.Is(err, gorm.ErrRecordNotFound) || u == nil {
return nil, errors.New("该邮箱尚未注册,请对方先注册后再加入")
}
if err != nil {
return nil, err
}
s, err := p.GetSpace(ctx, spaceID)
if err != nil || s == nil {
return nil, errors.New("空间不存在")
}
if p.MemberRole(ctx, s.TenantID, u.ID) == "" {
return nil, errors.New("对方不是本租户成员,请先加入租户")
}
if err := p.addSpaceMember(ctx, spaceID, u.ID, role); err != nil {
return nil, err
}
return &MemberInfo{UserID: u.ID, Email: u.Email, Name: u.Name, Role: role, Status: "active"}, nil
}
// SetSpaceMemberRole 改空间成员角色(不能改/授 owner)。
func (p *Postgres) SetSpaceMemberRole(ctx context.Context, spaceID, userID, role string) error {
if p.db == nil {
return errStoreDisabled
}
if !ValidRole(role) {
return errors.New("非法角色")
}
if p.SpaceMemberRole(ctx, spaceID, userID) == RoleOwner {
return errors.New("不能改 owner 的角色")
}
if role == RoleOwner {
return errors.New("不能直接授予 owner")
}
return p.db.WithContext(ctx).Model(&SpaceMember{}).
Where("space_id = ? AND user_id = ?", spaceID, userID).Update("role", role).Error
}
// RemoveSpaceMember 软移除空间成员(不能移除 owner)。
func (p *Postgres) RemoveSpaceMember(ctx context.Context, spaceID, userID string) error {
if p.db == nil {
return errStoreDisabled
}
if p.SpaceMemberRole(ctx, spaceID, userID) == RoleOwner {
return errors.New("不能移除 owner")
}
return p.db.WithContext(ctx).Model(&SpaceMember{}).
Where("space_id = ? AND user_id = ?", spaceID, userID).Update("status", "removed").Error
}
// ---- 迁移回填(启动时幂等调用)----
// BackfillPersonalSpaces 给每个 active 租户成员补建个人空间(幂等)。返回补建数。
func (p *Postgres) BackfillPersonalSpaces(ctx context.Context) (int, error) {
if p.db == nil {
return 0, errStoreDisabled
}
var members []TenantMember
if err := p.db.WithContext(ctx).Where("status = ?", "active").Find(&members).Error; err != nil {
return 0, err
}
n := 0
for _, m := range members {
if p.personalSpaceID(ctx, m.TenantID, m.UserID) != "" {
continue
}
if _, err := p.EnsurePersonalSpace(ctx, m.TenantID, m.UserID); err != nil {
return n, err
}
n++
}
return n, nil
}
// MigrateAgentSpaces 把存量 Agent 的 space_id 回填为其 (tenant_id, owner) 个人空间,
// 然后 DROP 旧唯一索引 idx_agent_on(owner,name) 并建新唯一索引 idx_agent_sn(space_id,name)。
// 顺序关键:先回填再建唯一索引,否则存量 space_id 空会撞车。全程幂等。
func (p *Postgres) MigrateAgentSpaces(ctx context.Context) error {
if p.db == nil {
return errStoreDisabled
}
// 回填 space_id:按 agent.tenant_id + agent.owner 命中个人空间。
sub := "SELECT id FROM sundynix_space s WHERE s.tenant_id = a.tenant_id AND s.creator = a.owner AND s.kind = 'personal' ORDER BY s.created_at ASC LIMIT 1"
if err := p.db.WithContext(ctx).Exec(
"UPDATE sundynix_agent a SET space_id = ("+sub+") WHERE (a.space_id IS NULL OR a.space_id = '') AND a.owner <> ''",
).Error; err != nil {
return err
}
// 旧唯一索引 DROPAutoMigrate 不删;留着会过约束"同人跨空间不能重名")。
if err := p.db.WithContext(ctx).Exec("DROP INDEX IF EXISTS idx_agent_on").Error; err != nil {
return err
}
// 新唯一索引(回填后建,安全)。
if err := p.db.WithContext(ctx).Exec(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sn ON sundynix_agent(space_id, name)",
).Error; err != nil {
return err
}
return nil
}