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>
183 lines
9.9 KiB
Markdown
183 lines
9.9 KiB
Markdown
# sundynix-agentix · 共享工作区设计(Space 抽象)
|
||
|
||
> 版本:v1 | 日期:2026-07-13
|
||
> 定位:`SAAS_DESIGN.md` **P1 增量3(资源共享 / 共享工作区)** 的落地设计。
|
||
> 前提(已确认):**核心要灵活、不写死**——不做"整租户一刀切共享",要支持"针对某项目临时组队贡献"。
|
||
> 基线事实:KB/Agent/Doc 已带 `tenant_id` 且受 gorm 租户插件过滤(见 `store/tenant_scope.go`),
|
||
> 但仍按 `owner`(userID) 锁到个人;存储作用域键是 `owner/name`。
|
||
> 相关:`SAAS_DESIGN.md`(多租户脊柱)、RBAC 落地(`store.RoleRank` + `middleware.RequireTenantRole`)。
|
||
|
||
---
|
||
|
||
## 0. 一句话设计
|
||
|
||
**资源不再直接绑 `user` 或 `tenant`,而是绑一个中间层「Space(工作区/项目空间)」——一个可大可小、可临时可长期的协作容器。**
|
||
个人私有、项目临时组队、整租户共享三种场景,全从同一个 Space 模型里长出来。作用域键从 `owner/name` 换成 `space_id/name`,
|
||
就这一处把"锁"从人解到空间,彻底不写死。
|
||
|
||
---
|
||
|
||
## 1. 为什么不能"整租户一刀切"
|
||
|
||
用户的核心诉求:**灵活**。评估过的两个"简单"方案都不行:
|
||
|
||
| 方案 | 问题 | 结论 |
|
||
|---|---|---|
|
||
| **A. 整租户统一共享**(资源 owner→tenant) | 只有"全私有 / 全租户"两态,没有中间态;一个用户无法"私有一批 + 项目X共享一批 + 项目Y共享一批" | ❌ 死板,且将来想灵活要**再迁一次** |
|
||
| **B. 每资源加 shared 标记 + ACL** | 无天然分组,"一队人围着一个项目协作这 5 个库"很难表达;per-resource ACL 迅速失控 | ❌ 乱 |
|
||
| **C. Space 中间容器**(本方案) | 一个抽象覆盖三种场景;匹配"一队人围着一个项目贡献"的心智;一次迁到正确容器、永不返工 | ✅ 采纳 |
|
||
|
||
这是成熟产品的通用解法:Notion teamspace、Slack channel、Linear team、GitHub repo+协作者。
|
||
|
||
---
|
||
|
||
## 2. 核心抽象:租户 > Space > 成员
|
||
|
||
**层级**:`Tenant`(计费/隔离边界) → `Space`(共享容器) → `SpaceMember`(谁在里面)。
|
||
资源(KB/Agent/Doc/Run)挂在 Space 上,不再挂在 user 上。
|
||
|
||
**三种场景 → 一个模型**:
|
||
|
||
| 场景 | Space 表达 | kind |
|
||
|---|---|---|
|
||
| 个人私有(= 现状) | 每人**自动一个个人 Space**(1 成员 = 自己),新资源默认落这里 | `personal` |
|
||
| **针对项目临时组队贡献** | 任意成员**建个 Space**,拉租户里几个人进来,共建 KB/编排,完事**归档** | `project` |
|
||
| **整租户共享("开关")** | 一个成员 = 全租户的**"全员 Space"**(开关开 = 自动建它、自动纳入全部成员) | `tenant` |
|
||
|
||
**"临时"是免费的**:临时团队 = 建 `project` Space → 拉人 → 贡献 → 归档(软删,可选自动过期)。临时只是生命周期属性,不是另一套机制。
|
||
**grain 提醒**:共享绑 **Space(项目)**,不要绑单次 task(task 是一次执行、跑完即逝);一个 Space 可关联多次 task 运行。
|
||
|
||
---
|
||
|
||
## 3. 现状盘点(改造起点,已核实)
|
||
|
||
三层"锁到个人",尽管 tenant_id 已存在:
|
||
|
||
| 锁 | 现状(file 参考) | 改造 |
|
||
|---|---|---|
|
||
| ① 查询显式 `WHERE owner=?` | `ListKB/ListAgents/ListVault/GetDocByID…`(`store/model.go`、`store/tenant.go`)在插件的 tenant 过滤之上又按 userID 过滤 | 去掉 owner 过滤,改按 `space_id`;`owner` 降级为"创建人"归属 |
|
||
| ② 唯一索引 `(owner,name)` | `KB.idx_kb_owner_name` / `Agent.idx_agent_on` / `Doc.idx_doc_okn`(`store/model.go`) | 改 `(space_id,name)`——名字**按空间唯一** |
|
||
| ③ 存储作用域键 `owner/name` | `scopedKB = userID+"/"+name`(`handler/kb.go:38`),作 mcp-go 工具 `kb` 参数 | 改 `space_id+"/"+name` |
|
||
|
||
**关键有利事实**(决定迁移不重):
|
||
- **Milvus 用标量字段 `kb`(VarChar) 过滤**(`kb == "owner/name"`,`rag/milvus.go:112/207`),**不是物理分区** → re-key = 改字段值 / 从 MinIO 原文重灌,不用重建分区。
|
||
- **MinIO 对象键不透明**(`owner/kb/doc`,存进 `Doc.ObjectKey`,按 key 精确取,`handler/kb.go:154`)→ **不用迁**:老对象照常可读,新对象走新键。
|
||
- **Agent 纯 PG**(Graph JSON,`store/model.go` `ListAgents/SaveAgent/DeleteAgent`,零 Milvus/Neo4j/MinIO 依赖)→ **可先在 Agent 上把整套 Space 打样,零存储风险**。
|
||
- **偏好记忆(`recall_user_memory`/`remember_user_fact`)保持个人**,不进 Space(个人偏好共享无意义)。
|
||
|
||
---
|
||
|
||
## 4. 数据模型
|
||
|
||
**+2 张表**:
|
||
|
||
```
|
||
Space {
|
||
id snowflake
|
||
tenant_id string // 归属租户(隔离/计费边界仍在租户)
|
||
name string
|
||
kind string // personal / project / tenant
|
||
creator string // user.id
|
||
archived bool // 软归档(临时空间完事即归档)
|
||
}
|
||
// 唯一:(tenant_id, kind='personal', creator) 保证每人每租户仅一个个人空间
|
||
|
||
SpaceMember {
|
||
space_id string
|
||
user_id string
|
||
role string // 复用租户角色语义:owner/admin/member/viewer(见 §5)
|
||
}
|
||
// 唯一:(space_id, user_id)
|
||
```
|
||
|
||
**资源改造**(KB / Agent / Doc / DocLink / Task-Run):
|
||
- 加 `space_id string`(`index`);保留 `tenant_id`(防御纵深 + 跨租户查询仍被插件挡);保留 `owner` 作**创建人归属**。
|
||
- 唯一索引 `(owner,name)` → `(space_id,name)`。
|
||
- 作用域键 `owner/name` → `space_id/name`。
|
||
|
||
**注**:租户隔离仍靠 `tenant_id`(Space 属租户,`space.tenant_id` 兜底);主作用域切到 `space_id`。
|
||
|
||
---
|
||
|
||
## 5. 权限(接已落地的 RBAC)
|
||
|
||
每个 Space 有自己的成员角色,直接复用 `store.RoleRank` 阶梯(owner>admin>member>viewer/billing_admin):
|
||
|
||
| 动作 | viewer | member | admin/owner |
|
||
|---|:--:|:--:|:--:|
|
||
| 看 / 搜索空间内资源 | ✅ | ✅ | ✅ |
|
||
| 建 KB/Agent、入库、改**自己建的** | ❌ | ✅ | ✅ |
|
||
| 改 / 删**别人建的** | ❌ | ❌ | ✅ |
|
||
| 管理空间成员(拉人/改角色/移除) | ❌ | ❌ | ✅ |
|
||
|
||
- 新增中间件 `RequireSpaceRole(db, minRole)`(照 `RequireTenantRole` 套路,从 ctx 取 space_id + user 判 `SpaceMember.role`)。
|
||
- **这才让 owner/admin/member/viewer 五个角色真正有区别**——RBAC 落地时留的"架子"在此点亮。
|
||
|
||
---
|
||
|
||
## 6. 迁移方案
|
||
|
||
**一次性迁到正确容器(个人空间),个人租户单成员无冲突**:
|
||
|
||
1. **建个人空间**:给每个用户在其每个所属租户建一个 `personal` Space(幂等,启动回填)。
|
||
2. **资源 re-key**:存量 KB/Agent/Doc/DocLink 的 `space_id` 回填为其 owner 在**对应租户**的个人空间 id;唯一索引 `(owner,name)`→`(space_id,name)`(AutoMigrate 不删旧索引,需显式 migration)。
|
||
3. **存储层**:
|
||
- Milvus:`kb` 字段 `owner/name`→`space_id/name`(从 MinIO 原文重灌,原文安全兜底)。
|
||
- Neo4j:`MATCH (n) WHERE n.kb STARTS WITH 'owner/' SET n.kb = ...` 批量 Cypher。
|
||
- MinIO:**不动**(不透明键)。
|
||
4. **命名冲突**:`(space_id,name)` 上唯一约束前,多成员租户里两人同名资源需先改名(个人空间单成员天然无冲突,只有后续"移进共享空间"时才判重)。
|
||
|
||
**"把个人资源共享进项目"= 一等操作**:改资源 `space_id` + re-key 存储 `kb` 字段(单资源,非全量)→ 天然支持"默认私有 + 显式共享"。
|
||
|
||
---
|
||
|
||
## 7. 分层落地(先 Agent 打样,再 KB)
|
||
|
||
### 阶段 3a:Agent 编排共享 —— 纯 PG,轻,先行 ✅ 推荐起点
|
||
- 建 `Space`/`SpaceMember` 表 + `Agent.space_id`;个人空间自动建 + 存量 Agent re-key。
|
||
- `ListAgents/SaveAgent/DeleteAgent` 去 owner 过滤、改按 space_id;唯一 `(space_id,name)`;owner 存创建人。
|
||
- 空间管理接口:建 project 空间 / 拉人(邮箱,复用 `AddMemberByEmail` 思路)/ 改角色 / 移除 / 归档。
|
||
- `RequireSpaceRole` 门控 + 前端**空间切换器**(顶栏,类似租户切换器)+ StudioView 按空间角色 readOnly。
|
||
- **一周内端到端验证协作 + 权限 + 切换 UX,零存储风险。**
|
||
|
||
### 阶段 3b:KB 知识库共享 —— 存储 re-key,随后
|
||
- 把同一套 `space_id` 作用域推到 KB/Doc/DocLink + `scopedKB` 改 `space_id/name`。
|
||
- Milvus 重灌 / Neo4j 批量 SET / MinIO 不迁 / 存量数据迁移 + 命名冲突处理(§6)。
|
||
- 模式已被 3a 验证,风险最低。
|
||
|
||
---
|
||
|
||
## 8. 计费衔接(无新工作)
|
||
|
||
- 运行共享 Agent / 搜共享 KB 的**计费目标不变**:仍由 `ResolveBillingTenantID(user, activeTenant)` 按活跃租户 + `shared_billing` 决定记谁的池子(`SAAS_DESIGN.md` 增量2)。
|
||
- Space 只管"看得见/改得动谁的资源",**不改"谁付钱"**——两个维度正交,互不干扰。
|
||
|
||
---
|
||
|
||
## 9. 开放问题(待定,别提前写死)
|
||
|
||
| 议题 | 倾向 | 说明 |
|
||
|---|---|---|
|
||
| **默认落哪** | 个人空间 | 新资源默认私有,要共享才移/复制进项目空间 |
|
||
| **删别人建的** | admin/owner 才行 | member 只能删自己建的(§5) |
|
||
| **Space 嵌套** | v1 不做 | 扁平:租户下平铺 Space;有需求再说 |
|
||
| **临时空间自动过期** | v1 手动归档 | 先给 `archived` + 手动归档;自动过期(TTL)后置 |
|
||
| **跨租户 Space** | 不做 | Space 严格属单租户,隔离边界不破 |
|
||
| **个人偏好记忆** | 保持个人 | 不进 Space |
|
||
|
||
---
|
||
|
||
## 10. 明确不做(防过度设计)
|
||
|
||
- ❌ 不做 per-resource ACL(用 Space 分组替代)。
|
||
- ❌ v1 不做嵌套空间 / 跨租户空间 / 空间模板市场。
|
||
- ❌ 不动 MinIO 对象键、不重建 Milvus 分区(现状标量字段 + 不透明键让迁移可控)。
|
||
- ❌ 不碰计费目标解析(正交,复用增量2)。
|
||
|
||
---
|
||
|
||
## 附:一句话给未来的自己
|
||
|
||
**Space 相对"整租户统一"的额外成本 ≈ 两张小表 + 空间成员管理 + 切换器 UI;最重的存储 re-key 两方案一样。
|
||
用对容器几乎免费,还省掉二次迁移。先拿纯 PG 的 Agent 打样跑通协作+权限+切换,再上 KB 存储 re-key。**
|