Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/workspace-schema-design.zh-CN.md
T
1445043649 89fe54cc07 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>
2026-05-10 21:39:58 +08:00

307 lines
16 KiB
Markdown
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.
# 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 七项不可逆决策不在"推翻条件"覆盖范围——那些一旦发布到生产就只能往前走。