* 新增 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>
16 KiB
Workspace Schema 设计 · Stage 0 锁定版
写于 2026-05-10。Stage 0 PR1 动手前的 schema 锁定文档。
数据库基线:Stage 0 的 PR1 必须先完成 phased-rollout Stage 0 的 PR1-PR2(Postgres 接入 + 默认切换),再起 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),以及 JWTTokenPayload一次到位的字段集。不在范围:仓储实现细节、ContextVar、路径迁移、路由校验、Stage 1+ 才加的列(
plan/allowed_origins/custom_domain等)。配套阅读:
- 02-rollout/phased-rollout-by-scale.zh-CN.md Stage 0 必做项
- 02-rollout/stage-0-code-map.zh-CN.md 现状代码锚点
- adr-001-data-isolation.zh-CN.md §4.1 表结构改造
- adr-004-tenant-rbac.zh-CN.md §5.1 / §5.2 RBAC + JWT
- adr-007-routing-frontend.zh-CN.md §4 slug 规范、§8 JWT 改造
- 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 4(enterprise) | 长尾需求,列加在哪一层都行 |
billing_email |
Stage 1(Stripe 对接) | 一并加 |
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 所有 workspaceidx_one_owner_per_workspace: UNIQUE on(workspace_id)WHERErole = '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 hex(64 字符)+ 余量 |
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)WHERErevoked_at IS NULL(partial)
2.5 external_users(Stage 0 末加,schema only)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
String(36) |
PK | ghost user id(DeerFlow 内部) |
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
# 新增列
default_workspace_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
comment="登录后默认进入的 workspace;NULL 时强制走 picker(user 多 workspace 场景)"
)
为什么不加 current_workspace_id:每次登录时从 default 或 /select-workspace 决定,写入 JWT 的 wid claim;DB 不存"当前激活"状态,避免多设备冲突。
system_role 字段保留——它是平台级 role(platform_admin / user),与 workspace role 正交(参 ADR-004 §6)。
3.2 threads_meta
# 新增列(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
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
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。理由:每次扩字段都要 bumptoken_version让所有用户重登,churn 体验差;一次加齐两次的份。
# backend/app/gateway/auth/jwt.py
class TokenPayload(BaseModel):
sub: str # user_id(沿用)
wid: str # workspace_id(Stage 0 新增)
role: str # owner / admin / member(Stage 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: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 个 owner(partial unique index)
- CASCADE 删(删 workspace 后 memberships 消失)
list_workspaces_by_user(user_id)返回正确顺序
- 不写:路由测试(PR4 才有路由)、JWT 测试(PR2 才扩字段)
8. 推翻条件
整份 schema 设计要重排只在两种情况:
- 拿到强企业客户必须自定义角色:role 列从 String(16) + 应用层校验 → 完整 RBAC engine(
roles/permissions/role_permissions表)。schema 加表,不改现有列,影响小。 - 决定改用 native UUID 类型(Postgres 切换时一并):String(36) →
UUID。需要全表 ALTER + Python 边界改造。建议不做,String(36) 在 Postgres 上落地为CHAR(36),性能差异 < 5%,可接受。
上面 §5 七项不可逆决策不在"推翻条件"覆盖范围——那些一旦发布到生产就只能往前走。