Files
sundynix-agentix/sundynix-gateway/internal/store/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

338 lines
12 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 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
}