# 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。**