docs(multi-tenant): 加入汇总索引、跨文档一致性修订、Postgres 切换前移到 Stage 0

* 新增 README.zh-CN.md 汇总索引:ADR 状态表 + Stage 0-4 业务目标 / 技术路径 /
  验证方式 + Stage↔ADR 对照矩阵 + 不可逆决策一览 + 用语映射 + FAQ + 阅读路径
* 新增 workspace-schema-design.zh-CN.md(Stage 0 schema 锁定版)一并入库
* 7 份 ADR 顶部加"代码命名"映射行(tenant_id ↔ workspace_id)
* ADR-002 §1 加分期落地提示,明确 K8s 推迟到 Stage 3
* ADR-005 §5"第 1/2 阶段"补出与 rollout Stage 2/3 的映射
* ADR-007 §4 加 /api/v1/ 反向链接;§8 加 tid → wid 字段名映射
* headless-api §0/§7 把"SaaS + on-prem 双主线"改为"SaaS 主线、schema 兼容 on-prem"
* phased-rollout 去除重复的"Go/No-Go 进入 Stage 2"段

Postgres 切换从 Stage 1 提前到 Stage 0:Stage 0 已要 ALTER 4 张表加
workspace_id,先 SQLite 再 PG 是纯返工;Stage 0 没有生产数据,迁移阻力最小。
同步调整 phased-rollout / headless-api / phase-0-plan / workspace-schema-design /
README 中的时间盒(Stage 0: 3-4→4-5 周;Stage 1: 10-15→8-13 周)、不可逆决策
清单、PR 顺序、轨道前置依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1445043649
2026-05-10 21:39:58 +08:00
parent 9ff790554d
commit 89fe54cc07
12 changed files with 712 additions and 69 deletions
@@ -7,6 +7,7 @@
| 决策者 | CTO + 架构 + 后端 lead |
| 关联 ADR | ADR-004 租户层级、ADR-005 存储拓扑、ADR-006 运行时与渠道 |
| 关联 spike / 审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) · [langgraph-postgres spike](./adr-spike-langgraph-postgres.zh-CN.md) |
| 代码命名 | 本 ADR 写 `tenant_id`,落代码统一读作 `workspace_id`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
@@ -7,6 +7,7 @@
| 决策者 | 安全 + 架构 + SRE |
| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) — 注意:现有 `AioSandboxProvider` 出网/资源/cosign 缺位;K8sSandboxProvider 几乎从零开工(实际工作量大于本 ADR §5 估算) |
| 代码命名 | 本 ADR 写 `tenant_id` / `tenant-{tenant_id}` namespace,落代码统一读作 `workspace_id` / `ws-{workspace_id}`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
@@ -101,6 +102,13 @@ spec:
## 1. 背景
> **分期落地提示**:本 ADR 描述的 K8s + gVisor + NetworkPolicy 全套架构是**目标态**。按 [phased-rollout-by-scale](../02-rollout/phased-rollout-by-scale.zh-CN.md) 实际落地节奏:
> - **Stage 1**:仅做 §3 威胁模型的"出网默认禁 + cgroup CPU/memory 限额",落到现有 `AioSandboxProvider` 上(轻量补丁版)。**不上 K8s**。
> - **Stage 3**:才换 `K8sSandboxProvider`,引入 namespace + gVisor + NetworkPolicy + Pod Security Standard 全套(§5 全文落地)。
> - **Stage 4 / premium**:按合同切 Kata-Firecracker 或独立 nodepool。
>
> 读 §2~§9 时把它当作"Stage 3 完成态"Stage 1 落地时只摘 §3 出网/资源那两行就好。
沙箱是多租户里**爆炸半径最大**的组件:客户的 agent 可以跑任意 bash 命令、读写文件、调用 MCP 工具。如果隔离不够强,一个客户能:
- **读到其他客户的数据**(容器逃逸 / 共享卷误用)
@@ -7,6 +7,7 @@
| 决策者 | 产品 + CTO + 财务 |
| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑、ADR-006 运行时与渠道 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) |
| 代码命名 | 本 ADR 写 `tenant_*` 表 / `tenant_id` 列,落代码统一读作 `workspace_*` / `workspace_id`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
@@ -7,6 +7,7 @@
| 决策者 | 产品 + 后端 lead |
| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) — 现状:`users.token_version` + JWT `ver` claim 已存在;`@require_permission` 装饰器存在但 `owner_check` 是 bool 需扩为 enum`MembershipCache` 30s LRU 全新建 |
| 代码命名 | 本 ADR 写 `tenant_id` / `tenant_memberships`,落代码读作 `workspace_id` / `workspace_memberships`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
@@ -7,6 +7,7 @@
| 决策者 | 架构 + 后端 lead + SRE |
| 关联 ADR | ADR-001 数据隔离、ADR-002 沙箱隔离、ADR-006 运行时与渠道 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) — 现状:`ObjectStorage` Protocol、7 张新表、KMS 抽象**全部不存在**,本 ADR 描述的是从 0 起的设计;phase-0 §3.5 已加"底座先行"骨架要求 |
| 代码命名 | 本 ADR 写 `tenants/{tid}/...` prefix 与 `tenant_*` 表名,落代码统一读作 `workspaces/{wid}/...``workspace_*`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
@@ -289,9 +290,11 @@ storage:
## 5. 落地改造点(实施清单)
按优先级排序,每条对应第 1 阶段或第 2 阶段的一个 PR
按优先级排序,每条对应一个 PR
### 第 1 阶段(必须)
> **分期映射**(与 [phased-rollout-by-scale](../02-rollout/phased-rollout-by-scale.zh-CN.md) 对齐):本 §5 的"第 1 阶段"≈ rollout 的 **Stage 2**KMS + ObjectStorage + RLS 同期落地);"第 2 阶段"≈ rollout 的 **Stage 3**。Stage 0 / Stage 1 仅复用现有本地文件系统,不动 storage 拓扑。
### 第 1 阶段(必须;对应 rollout Stage 2
1. **抽象 `ObjectStorage` 接口 + `LocalObjectStorage` 实现** — 走通端到端,开发/测试用本地目录跑,不阻塞迁移
2. **memory.json 迁库**
@@ -339,7 +342,7 @@ storage:
**估工**:原 ADR-005 列了 1 条 bullet 偏乐观;这块包含 c/d/e/f 四子项,**整体约 M 偏 L**(一周量级),不是 S。
### 第 1 阶段 / 第 2 阶段交界
### 第 1 阶段 / 第 2 阶段交界rollout Stage 2 末 / Stage 3 头)
5. **`S3ObjectStorage` 实现** — 用 aioboto3,覆盖 protocol 全部方法
6. **上传文件改 presigned 直传**
@@ -353,7 +356,7 @@ storage:
- `POST /api/skills/install`:把 .skill 上传到 S3(带 SHA256 metadata),不再解压到本地全局目录
- 启动时按 tenant skill list 从 S3 拉 + 校验 + 解压到 LRU 缓存
### 第 2 阶段(建议)
### 第 2 阶段(建议;对应 rollout Stage 3
9. **退订 GC**tenant 标记 deleted 后,30 天定时任务跑 `delete_prefix(f"tenants/{tid}/")` + DB cascade delete
10. **跨区域复制 / CDN**:按客户分布加 region replica 或 CloudFront / OSS 加速域名
@@ -7,6 +7,7 @@
| 决策者 | 后端 lead + 架构 + 渠道 owner |
| 关联 ADR | ADR-001 数据隔离、ADR-003 LLM Key 与计费、ADR-005 存储拓扑 |
| 关联 spike / 审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) · [langgraph-postgres spike](./adr-spike-langgraph-postgres.zh-CN.md) |
| 代码命名 | 本 ADR 写 `tenant_id` / `TenantMCPCache` / `tenant-{tenant_id}` namespace,落代码统一读作 `workspace_id` / `WorkspaceMCPCache` / `ws-{workspace_id}`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
@@ -7,6 +7,7 @@
| 决策者 | 前端 lead + 后端 lead + 产品 |
| 关联 ADR | ADR-001 数据隔离、ADR-004 RBAC、ADR-006 运行时与渠道 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) |
| 代码命名 | 本 ADR 写 `tenant_id` / JWT `tid`,落代码统一读作 `workspace_id` / JWT `wid`(详 [workspace-schema-design §1, §4](./workspace-schema-design.zh-CN.md) |
---
@@ -107,12 +108,15 @@ ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最
API 路径**不带 slug**
```
/api/... # 业务 APItenant 由 JWT 决定
/api/langgraph/threads/{tid}/runs/stream # LangGraph 兼容
/api/v1/... # 业务 APItenant 由 JWT / API key 决定;Stage 1 起强制带版本
/api/... # 旧路径,Stage 1 起转发到 /api/v1Stage 3 sunset
/api/langgraph/threads/{tid}/runs/stream # LangGraph 兼容(不带版本,跟随上游 SDK 约定)
```
理由:API 是 SDK 调用的,不需要人类可读 URL;slug 只在浏览器导航/分享时有意义。
**`/api/v1/` 引入时机与设计**:详见 [headless-api-track §4](../02-rollout/headless-api-track.zh-CN.md#4-核心设计api-版本化) —— Stage 1 切换 mount prefix、保留旧路径转发并加 `X-API-Deprecated` header。
---
## 5. 后端:Path slug 与 JWT 的交叉校验
@@ -277,14 +281,16 @@ frontend/src/app/
## 8. Auth 改造(基于现有自签 JWT)
> 现状:`app/gateway/auth/jwt.py:14-19` `TokenPayload` 当前是 `{sub, exp, iat, ver}`;前端 cookie name 是 `access_token``users.token_version` 已存在,bump 该列即让所有旧 JWT 失效。
>
> **代码字段名以 [workspace-schema-design §4](./workspace-schema-design.zh-CN.md#4-jwt-tokenpayload--一次到位的字段集) 为准**:本 ADR 写 `tid`、落代码写 `wid`(同义)。Stage 0 PR2 已锁定 `wid` 命名 + Stage 0 一次性加齐 `wid` + `role` 两字段,避免 Stage 2 再 bump `token_version` 导致全用户重登。
多租户化改造:
1. **扩 `TokenPayload`**
1. **扩 `TokenPayload`**(字段名以 workspace-schema-design §4 为准)
```python
class TokenPayload(BaseModel):
sub: str # user_id
tid: str # tenant_id(新增)
tid: str # tenant_id(新增;落代码读作 wid / workspace_id
role: str # owner | admin | member(新增)
exp: int
iat: int
@@ -226,7 +226,7 @@ grep -rn "extensions_config\|skills/public\|skills/custom" backend/
| 底座 | 缺失现状 | 为何阻塞 ADR | Phase-0 内必须产出 |
|---|---|---|---|
| **Postgres 测试夹具**testcontainers + RLS smoke 测试基础设施) | 仓库当前以 SQLite 为默认后端,`tests/` 下无 Postgres fixtureSQLite 不支持 RLS | ADR-001 / 004 / 005 的所有租户隔离测试都要 Postgres | testcontainers 集成 + 至少 1 个 RLS 冒烟测试模板 + CI 跑通 |
| **Postgres 切换 + testcontainers 夹具**(生产 + 测试基础设施一并落) | 仓库当前以 SQLite 为默认后端,`tests/` 下无 Postgres fixtureSQLite 不支持 RLS | ADR-001 / 004 / 005 的所有租户隔离测试都要 PostgresStage 0 ALTER 4 张表如果在 SQLite 上做完再切 PG 是纯返工 | **Stage 0 直接切 Postgres 为生产默认**(Stage 0 没有生产数据,迁移阻力最小)+ testcontainers 集成 + 至少 1 个 RLS 冒烟测试模板policy Stage 2 才启用,但夹具 Stage 0 就位)+ CI 跑通 |
| **ObjectStorage Protocol + 实现** | `backend/packages/harness/deerflow/` 内 grep 不到 `ObjectStorage` 类;当前 memory/uploads/artifacts 全走文件系统 | ADR-005 §2 的三层拓扑、ADR-006 §2.2 的 OAuth 持久化都依赖它 | Protocol 接口 + LocalObjectStorage 骨架(可不实现 S3,留接口) |
| **KMS / Secret Vault 抽象** | 当前没有 secret 加密层;`mcp/oauth.py` 的 token 是明文进程内存 | ADR-003 §4.6 BYO key、ADR-006 §2.2 MCP OAuth、ADR-007 channel binding token 共用 | 抽象接口(envelope encryption pattern+ 本地 dev 实现(明文 fallback + 警告日志),生产实现可推迟 |
@@ -271,7 +271,8 @@ grep -rn "extensions_config\|skills/public\|skills/custom" backend/
| 决策 | 默认值 | 选它的理由 |
|---|---|---|
| 数据隔离 | 行级 + Postgres RLS(仅 DeerFlow 自有表)+ LangGraph 表应用层强校验 | 改造成本低;LangGraph 表无 RLS hookspike 已验证),应用层兜底 |
| **数据库** | Stage 0 起直接切 Postgres 为生产默认;SQLite 仅保留为可选 dev 兜底 | Stage 0 没有生产数据,迁移阻力最小;省 Stage 1 重 ALTER 一遍的返工 |
| 数据隔离 | 行级 + Postgres RLS(仅 DeerFlow 自有表,policy Stage 2 启用)+ LangGraph 表应用层强校验 | 改造成本低;LangGraph 表无 RLS hookspike 已验证),应用层兜底 |
| 沙箱隔离 | K8s namespace + gVisor + NetworkPolicy 默认禁出网 | 强度足够 + 运维可控 |
| LLM Key | 混合:默认平台 key + 限额,premium 切 BYO;悲观预扣防超额 | 体验与成本兼顾 |
| 租户层级 | 二级 RBACowner/admin/member+ JWT/cache 双层 | 为 SSO 和企业销售留口 |
@@ -0,0 +1,306 @@
# Workspace Schema 设计 · Stage 0 锁定版
> 写于 2026-05-10。Stage 0 PR1 动手前的 schema 锁定文档。
>
> **数据库基线**Stage 0 的 PR1 必须**先**完成 [phased-rollout Stage 0](../02-rollout/phased-rollout-by-scale.zh-CN.md#stage-0--workspace-模型立起来--postgres-切换--auth-收紧) 的 PR1-PR2Postgres 接入 + 默认切换),再起 schema PR。下面所有 ALTER 都直接在 Postgres 上跑,**不再走 SQLite → Postgres 二次迁移**。SQLite 仅保留为可选 dev 兜底。
>
> **范围**:仅 Stage 0 必须落地的 schema —— `workspaces` / `workspace_memberships` 两张新表,`users` / `threads_meta` / `runs` / `feedback` 的 ALTER`service_accounts` / `api_keys` / `external_users` 的预建(Stage 0 末,schema only),以及 JWT `TokenPayload` 一次到位的字段集。
>
> **不在范围**:仓储实现细节、ContextVar、路径迁移、路由校验、Stage 1+ 才加的列(`plan` / `allowed_origins` / `custom_domain` 等)。
>
> **配套阅读**
> - [02-rollout/phased-rollout-by-scale.zh-CN.md](../02-rollout/phased-rollout-by-scale.zh-CN.md) Stage 0 必做项
> - [02-rollout/stage-0-code-map.zh-CN.md](../02-rollout/stage-0-code-map.zh-CN.md) 现状代码锚点
> - [adr-001-data-isolation.zh-CN.md](./adr-001-data-isolation.zh-CN.md) §4.1 表结构改造
> - [adr-004-tenant-rbac.zh-CN.md](./adr-004-tenant-rbac.zh-CN.md) §5.1 / §5.2 RBAC + JWT
> - [adr-007-routing-frontend.zh-CN.md](./adr-007-routing-frontend.zh-CN.md) §4 slug 规范、§8 JWT 改造
> - [02-rollout/headless-api-track.zh-CN.md](../02-rollout/headless-api-track.zh-CN.md) §2 service account / API key
---
## 1. 命名约定 · workspace vs tenant
| 维度 | 选择 |
|---|---|
| 数据库列名 | `workspace_id` |
| Python 标识符 | `workspace_id` / `WorkspaceRow` / `_current_workspace` |
| JWT claim | `wid`(紧凑) |
| 用户可见用语 | "Workspace"(团队 workspace 也叫 workspace,不分"个人空间" |
ADR-001 / 004 / 007 原稿写 `tenant_id`——这些 ADR**不重命名**(成本不抵收益),落代码时统一读作 `workspace_id`。本文档与 02-rollout 系列保持 `workspace` 用语一致。
> 推翻条件:拿到强企业客户后真的出现"租户内多 workspace"的层级(tenant > workspace > user),那时再分裂概念。Stage 0/1/2 不预留这层。
---
## 2. 新增表
### 2.1 `workspaces`
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | `String(36)` | PK | UUID v4 字符串。与 `users.id` 类型对齐,跨 DB 可移植(SQLite/Postgres 都用 36 字符 CHAR |
| `name` | `String(64)` | NOT NULL | 显示名(用户首次注册时默认 `<email 前缀>'s Workspace` |
| `slug` | `String(32)` | UNIQUE NOT NULL | URL 标识,正则 `^[a-z0-9](-?[a-z0-9])*$`3-32 字符;DB 存小写 |
| `status` | `String(16)` | NOT NULL default `'active'` | `active` / `suspended` / `deleted`platform admin 暂停/删 workspace |
| `owner_id` | `String(36)` | NOT NULL FK `users.id` | 冗余字段,便于查询;与 `workspace_memberships.role='owner'` 严格一致(事务保证) |
| `created_at` | `DateTime(timezone=True)` | NOT NULL | UTC |
| `updated_at` | `DateTime(timezone=True)` | NOT NULL | UTC,写入自动更新 |
**索引**
- PK: `id`
- UNIQUE: `slug`
- 不加 `(status)` 索引——Stage 0 用户量小,全表扫够用;Stage 1+ 视情况补
**slug 黑名单**(应用层校验,不写进 DB constraint):
```
admin, api, auth, login, signup, accept-invite, pricing, docs, status,
platform, system, health, static, public, favicon.ico, robots.txt,
sitemap.xml, _next, .well-known, settings, billing, onboarding, select-workspace
```
> ADR-007 §4 列了一份基础黑名单;本表是落代码版(含 Next.js 保留路径)。
**Stage 0 不加的列**(决策记录):
| 列 | 推迟到 | 理由 |
|---|---|---|
| `plan` | Stage 1(与 `workspace_quotas.plan` 一起加) | Stage 0 没有付费分层 |
| `allowed_origins` | Stage 1 末(headless API Pattern B | 用到时加 ARRAY/JSON 列代价低 |
| `custom_domain` | Stage 4enterprise | 长尾需求,列加在哪一层都行 |
| `billing_email` | Stage 1Stripe 对接) | 一并加 |
| `settings_json` | 永不加 | 扩展点用专门的 `workspace_settings` 表 + 枚举 key,比 JSON dump 易迁移 |
### 2.2 `workspace_memberships`
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `workspace_id` | `String(36)` | PK / FK `workspaces.id` ON DELETE CASCADE | |
| `user_id` | `String(36)` | PK / FK `users.id` ON DELETE CASCADE | |
| `role` | `String(16)` | NOT NULL | Stage 0 只允许 `owner`Stage 2 起 `owner`/`admin`/`member` |
| `invited_by` | `String(36)` | NULL FK `users.id` ON DELETE SET NULL | Stage 0 暂不写入;Stage 2 invitation 流程才用 |
| `joined_at` | `DateTime(timezone=True)` | NOT NULL | UTC |
**为什么 `role` 用 `String(16)` 不用 DB enum**Postgres enum ALTER 加值需要 `ALTER TYPE ... ADD VALUE`,且不可删;string + 应用层校验 = 后续随便加 `viewer` / `auditor` 等角色不动 DB schema。
**索引**
- PK: `(workspace_id, user_id)` 复合主键
- `idx_workspace_memberships_user`: `(user_id, workspace_id)` —— 倒查索引,用于 `/auth/me` 列出当前 user 所有 workspace
- `idx_one_owner_per_workspace`: UNIQUE on `(workspace_id)` WHERE `role = 'owner'` —— partial unique index
**partial unique 兼容性**
- SQLite **支持** `CREATE UNIQUE INDEX ... WHERE ...`(参 `users.idx_users_oauth_identity` 现有用法)
- Postgres 同样支持
- SQLAlchemy 通过 `Index(..., unique=True, sqlite_where=text(...), postgresql_where=text(...))` 表达;这条索引**保留两套 where 条件等价**
### 2.3 `service_accounts`Stage 0 末加,schema only
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | `String(36)` | PK | |
| `workspace_id` | `String(36)` | NOT NULL FK `workspaces.id` ON DELETE CASCADE | 必属于一个 workspace |
| `name` | `String(64)` | NOT NULL | 业务系统起的标识名 |
| `role` | `String(16)` | NOT NULL default `'member'` | SA 在 workspace 内的 role |
| `identity_mode` | `String(16)` | NOT NULL default `'collapsed'` | `collapsed` / `external_passthrough` / `both` |
| `status` | `String(16)` | NOT NULL default `'active'` | `active` / `suspended` / `revoked` |
| `created_by` | `String(36)` | NOT NULL FK `users.id` ON DELETE RESTRICT | 必须是 workspace owner/admin |
| `created_at` | `DateTime(tz)` | NOT NULL | |
| `updated_at` | `DateTime(tz)` | NOT NULL | |
索引:`idx_service_accounts_workspace`: `(workspace_id, status)`
### 2.4 `api_keys`Stage 0 末加,schema only
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | `String(36)` | PK | |
| `service_account_id` | `String(36)` | NOT NULL FK `service_accounts.id` ON DELETE CASCADE | |
| `key_prefix` | `String(16)` | UNIQUE NOT NULL | 前 16 字符明文(`dfk_live_...`),UI 展示用 |
| `key_hash` | `String(128)` | NOT NULL | 完整 key 的 sha256 hex64 字符)+ 余量 |
| `name` | `String(64)` | NOT NULL | "生产环境 key" |
| `scopes` | `String(1024)` | NOT NULL default `''` | 逗号分隔字符串。Postgres 已是 Stage 0 默认,但保持 `String` 以兼容 SQLite dev 兜底;如果未来确认完全弃用 SQLite,可平滑迁 `text[]` |
| `rate_limit_rpm` | `Integer` | NULL | NULL = 用 workspace plan 默认 |
| `expires_at` | `DateTime(tz)` | NULL | |
| `last_used_at` | `DateTime(tz)` | NULL | |
| `revoked_at` | `DateTime(tz)` | NULL | 软删除标记 |
| `created_at` | `DateTime(tz)` | NOT NULL | |
索引:
- `idx_api_keys_sa`: `(service_account_id)`
- `idx_api_keys_active`: `(key_prefix)` WHERE `revoked_at IS NULL`partial
### 2.5 `external_users`Stage 0 末加,schema only
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | `String(36)` | PK | ghost user idDeerFlow 内部) |
| `workspace_id` | `String(36)` | NOT NULL FK `workspaces.id` ON DELETE CASCADE | |
| `service_account_id` | `String(36)` | NOT NULL FK `service_accounts.id` ON DELETE CASCADE | |
| `external_id` | `String(128)` | NOT NULL | 业务系统传入 ID,原样存 |
| `display_name` | `String(128)` | NULL | |
| `metadata_json` | `JSON` | NOT NULL default `{}` | 业务字段 |
| `created_at` | `DateTime(tz)` | NOT NULL | |
| `last_seen_at` | `DateTime(tz)` | NULL | |
索引:UNIQUE `(service_account_id, external_id)` —— 同一 SA 下 external_id 唯一
---
## 3. ALTER 现有表
### 3.1 `users`
```python
# 新增列
default_workspace_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
comment="登录后默认进入的 workspaceNULL 时强制走 pickeruser 多 workspace 场景)"
)
```
**为什么不加 `current_workspace_id`**:每次登录时从 `default``/select-workspace` 决定,写入 JWT 的 `wid` claimDB 不存"当前激活"状态,避免多设备冲突。
`system_role` 字段保留——它是**平台级** role(`platform_admin` / `user`),与 workspace role 正交(参 ADR-004 §6)。
### 3.2 `threads_meta`
```python
# 新增列(Stage 0 PR3 先 nullable,回填后 ALTER 改 NOT NULL
workspace_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True, # PR3 中段;回填脚本跑完改 NOT NULL
comment="所属 workspace;与 (thread_id) 复合 UNIQUE 防跨 workspace 复用同 ID"
)
# 新增索引(在 __table_args__ 里)
Index("idx_threads_meta_workspace_thread", "workspace_id", "thread_id", unique=True),
Index("idx_threads_meta_workspace_user_updated", "workspace_id", "user_id", "updated_at"),
```
**关键索引说明**
- `(workspace_id, thread_id)` UNIQUE 是 ADR-001 §4.1.1 修订版的"应用层强约束 + DB 兜底"防线
- `(workspace_id, user_id, updated_at)` 覆盖前端 thread list 默认查询模式
- 现有 `user_id` 上的非复合索引可以**保留**(删了某些后台 cleanup 脚本会变慢;不阻塞主路径)
### 3.3 `runs`
```python
workspace_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True, # PR3 中段
)
Index("idx_runs_workspace_created", "workspace_id", "created_at"),
```
### 3.4 `feedback`
```python
workspace_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True,
)
Index("idx_feedback_workspace_run", "workspace_id", "run_id"),
```
### 3.5 `run_events`(待确认)
stage-0-code-map §2 标注 `run_events` 是否 DB 持久化"unverified"。PR3 第一步先 grep 确认;若是 DB 表就比照 `runs``workspace_id`,若是内存队列则跳过。
---
## 4. JWT TokenPayload · 一次到位的字段集
> Stage 0 落 `wid` + `role`**Stage 2 不再 bump**。理由:每次扩字段都要 bump `token_version` 让所有用户重登,churn 体验差;一次加齐两次的份。
```python
# backend/app/gateway/auth/jwt.py
class TokenPayload(BaseModel):
sub: str # user_id(沿用)
wid: str # workspace_idStage 0 新增)
role: str # owner / admin / memberStage 0 新增;1 人 workspace 默认 'owner'
exp: datetime # 沿用
iat: datetime | None = None # 沿用
ver: int = 0 # token_version(沿用;任何 membership 变更 bump
```
**Stage 0 实际填充值**
- `wid` = 用户 `default_workspace_id`(注册时自动建的 1 人 workspace
- `role` = 总是 `'owner'`Stage 0 还没有团队 workspace
**Stage 2 启用时**
- 加入团队 workspace 后 → `role` 真正区分 admin/member
- bump `token_version` 让旧 JWT(仍写 `'owner'`)过期重发
**兼容性**
- 旧 4 字段 token`{sub, exp, iat, ver}`)解码失败时**强制走 `/select-workspace`** 重发新 JWT(参 ADR-007 §11)。Stage 0 部署后 7 天(默认 token TTL)内所有老 token 自然轮替完。
---
## 5. 不可逆决策清单
| 决策 | 选择 | 反悔代价 |
|---|---|---|
| id 类型 | `String(36)` (UUID v4 字符串) | 改 native UUID → 全表 schema rewrite + 所有 FK 重建 + Python `str``UUID` 边界改造 |
| 命名(workspace_id | `workspace_id` | 改 `tenant_id` → 全代码改名 + 所有 ADR 文档同步 |
| slug 字符集 | `^[a-z0-9](-?[a-z0-9])*$` 3-32 | 改 → 老 URL 全失效(v1 还没暴露 slug 路由前改是免费的) |
| memberships PK | `(workspace_id, user_id)` 复合 | 改 surrogate id → migration 脚本要写 dedup 逻辑 |
| TokenPayload 字段 | `sub/wid/role/exp/iat/ver` | 加新字段 → 必 bump `token_version`,全用户重登(Stage 0 一次性加 `wid` + `role`,省一次) |
| `users.default_workspace_id` 而非 `current_workspace_id` | 默认 + JWT 决定当前 | 改成 `current_*` → 多设备语义混乱 |
| FK 删除策略(workspace 删 → memberships/threads CASCADE| CASCADE | 改 RESTRICT → 平台 admin 删 workspace 时手动级联,运营负担大 |
> Stage 0 PR1 合入前,上面这 7 项**全部**要在团队 review 中拍板;任何一项改主意都要 revert PR1 重写。
---
## 6. PR 拆分(Stage 0 内的 5 个 schema PR
> **前置 PR**:本表 PR1 之前必须先完成 [phased-rollout Stage 0 PR1-2](../02-rollout/phased-rollout-by-scale.zh-CN.md#stage-0--workspace-模型立起来--postgres-切换--auth-收紧)Postgres 接入 + testcontainers + 默认 backend 切换。本文档下面的 PR1 = phased-rollout 的 PR3,本文档 PR5 = phased-rollout 的 PR8。
>
> 在 Postgres 已就绪的基础上,schema 改动按下面 5 个 PR 推:
| PR | 范围 | 落本文档的哪些章节 | 状态 |
|---|---|---|---|
| **PR1** | `workspaces` + `workspace_memberships` 表 + 仓储 + 单测 | §2.1 + §2.2 | 设计完,可写 |
| **PR2** | 注册/initialize 流程改造(自动建 1 人 workspace+ JWT 扩 `wid`/`role` + AuthMiddleware ContextVar 注入 | §3.1 (`default_workspace_id`) + §4 | 依赖 PR1 |
| **PR3** | 现有 4 表 ALTER 加 `workspace_id`(直接 Postgres,先 nullable+ 数据回填脚本(legacy_workspace)→ ALTER 改 NOT NULL | §3.2-§3.5 | 依赖 PR1+PR2 |
| **PR4** | 路由层 `(workspace_id, thread_id)` 校验 + `Paths` 切 workspace 维度 + 文件系统迁移脚本 | 不在本文档(仓储/路径设计) | 依赖 PR3 |
| **PR5** | `service_accounts` / `api_keys` / `external_users` schema(不接路径) | §2.3-§2.5 | 与 PR4 并行 |
每个 PR 必须独立可上线、可回滚。PR1 单独合入后系统行为不变(新表无人写入)。
---
## 7. 测试要点(PR1 范围)
仿现有 `tests/test_*.py` 模式:
- `test_workspace_repo.py`
- create / get / update / delete workspace
- slug 唯一性约束
- slug 黑名单校验(应用层)
- status 状态机(active → suspended → deleted
- `test_workspace_membership_repo.py`
- create membership
- 同一 workspace 不能有 2 个 ownerpartial unique index
- CASCADE 删(删 workspace 后 memberships 消失)
- `list_workspaces_by_user(user_id)` 返回正确顺序
- 不写:路由测试(PR4 才有路由)、JWT 测试(PR2 才扩字段)
---
## 8. 推翻条件
整份 schema 设计要重排只在两种情况:
1. **拿到强企业客户必须自定义角色**role 列从 String(16) + 应用层校验 → 完整 RBAC engine`roles` / `permissions` / `role_permissions` 表)。schema **加表**,不改现有列,影响小。
2. **决定改用 native UUID 类型**Postgres 切换时一并):String(36) → `UUID`。需要全表 ALTER + Python 边界改造。建议**不**做,String(36) 在 Postgres 上落地为 `CHAR(36)`,性能差异 < 5%,可接受。
> 上面 §5 七项不可逆决策不在"推翻条件"覆盖范围——那些一旦发布到生产就只能往前走。