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:
@@ -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 阅读 + 双链)。
|
||||
|
||||
Reference in New Issue
Block a user