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
+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 阅读 + 双链)。