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