Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md
T
1445043649 45efba50e4 docs(db): 补充数据库设计落地版(as-built)并登记进 README
新增 docs/multi-tenant-redesign/01-redesign/database-schema-as-built.zh-CN.md:
对照实现代码(persistence/*/model.py + base.py/engine.py + alembic 0001-0003)
生成的事实参考,补齐之前只有「锁定版」(动手前决策)而缺失的落地 schema 文档。

涵盖:
- 持久化层总览:memory/sqlite/postgres 三后端、create_all vs Alembic、
  Postgres 库自愈、SQLite WAL、JSON ensure_ascii=False、partial index 双 where 兼容
- LangGraph checkpointer/store 表不归 ORM 管的说明
- ER 图(mermaid)+ 10 张表全字段参考(users/workspaces/workspace_memberships/
  threads_meta/runs/run_events/feedback/service_accounts/api_keys/external_users)
- 外键与删除策略矩阵(CASCADE/RESTRICT/SET NULL)
- 迁移历史 0001-0003(含 0002→0003 两段式上线:可空列→回填→锁 NOT NULL)
- 与锁定版的差异(run_events 已确认为 DB 表、runs token 分项列、status 状态机等)

README.zh-CN.md 的「文档总图」与「状态表」登记该文档,并注明
与锁定版冲突时以落地版为准。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:55:01 +08:00

309 lines
18 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.
# 数据库设计 · 落地版(as-built)
> 写于 2026-06-27。**对照实现代码生成**,反映 Stage 0 PR1PR8 合入后的真实 schema。
>
> 与 [workspace-schema-design.zh-CN.md](./workspace-schema-design.zh-CN.md) 的关系:那份是 **Stage 0 动手前的锁定版**(决策 + 不可逆点),本文是 **落地后的事实参考**。两者冲突时**以代码与本文为准**(锁定版里标 "待确认 / schema only" 的项,这里给出最终结果,例如 `run_events` 已确认为 DB 表并带 `workspace_id`)。
>
> **真源**`backend/packages/harness/deerflow/persistence/`
> - 表定义:各子目录 `*/model.py`(如 `user/model.py`、`api_key/model.py`+ `models/run_event.py`
> - 基类 / 引擎:`base.py` / `engine.py`
> - 迁移:`migrations/versions/0001..0003`
---
## 1. 持久化层总览
### 1.1 后端与建表
| 维度 | 说明 |
|---|---|
| 引擎 | 异步 SQLAlchemy`create_async_engine`),见 `engine.py` |
| 后端三选一 | `memory`(不建引擎,仓储回退内存实现)/ `sqlite`aiosqlite/ `postgres`asyncpg)。Stage 0 生产默认 **postgres** |
| 建表方式 | **dev**:启动时 `Base.metadata.create_all()` 自动建表(缺表即补,不改已存在的表)。**生产**:用 Alembic 迁移(`migrations/versions/` |
| Postgres 自愈 | 目标库不存在时(报 `does not exist`),自动连到 `postgres` 维护库 `CREATE DATABASE` 后重建引擎重试(`_auto_create_postgres_db` |
| SQLite 加固 | 每条连接启用 `PRAGMA journal_mode=WAL` + `synchronous=NORMAL` + `foreign_keys=ON` |
| JSON 序列化 | 自定义 `json.dumps(..., ensure_ascii=False)`,中文不转义 |
| 连接池 | postgres`pool_size`(默认 5+ `pool_pre_ping=True` |
> ⚠️ `create_all` 只**新建缺失的表****不会 ALTER 已存在的表**。给已有表加列/改约束必须走 Alembic 迁移;dev 下想偷懒可删库重建。
### 1.2 不归 ORM 管的表
LangGraph 的 **checkpointer**`checkpoints*`)与 **store**`store` / `store_migrations`)由 LangGraph 自己 `setup()` 建表,**不在** `Base.metadata` 里(见 `runtime/checkpointer/``runtime/store/`)。它们与本文的业务表**共用同一个 Postgres 库**,但生命周期、迁移各自独立。多租户隔离对这些表走"应用层强校验 + `UNIQUE(workspace_id, thread_id)` 兜底"(见 [ADR-001](./adr-001-data-isolation.zh-CN.md) / [spike-langgraph-postgres](./adr-spike-langgraph-postgres.zh-CN.md))。
### 1.3 通用约定
- **主键 id**:业务实体用 `String(36)`UUID v4 字符串),跨 SQLite/Postgres 可移植(Postgres 落 `CHAR(36)`,性能差异可忽略)。
- **时间**:一律 `DateTime(timezone=True)`,应用层写 `datetime.now(UTC)``updated_at` 在写入时自动更新。
- **枚举**:状态/角色用 `String(16)` + 应用层校验,**不用 DB enum**Postgres enum 加值要 `ALTER TYPE`、不可删,扩展成本高)。
- **JSON 列**:用 SQLAlchemy 可移植 `JSON` 类型(Postgres 落 `json`),默认 `{}`
- **partial unique / partial index**:同时声明 `sqlite_where` + `postgresql_where` 两套等价条件,双后端兼容。
---
## 2. 实体关系总览
```mermaid
erDiagram
users ||--o{ workspace_memberships : "成员"
workspaces ||--o{ workspace_memberships : "包含"
users ||--o| workspaces : "owner_id (RESTRICT)"
workspaces ||--o| users : "default_workspace_id (SET NULL)"
workspaces ||--o{ threads_meta : "wid (CASCADE)"
workspaces ||--o{ runs : "wid (CASCADE)"
workspaces ||--o{ run_events : "wid (CASCADE)"
workspaces ||--o{ feedback : "wid (CASCADE)"
workspaces ||--o{ service_accounts : "wid (CASCADE)"
service_accounts ||--o{ api_keys : "CASCADE"
service_accounts ||--o{ external_users : "CASCADE"
workspaces ||--o{ external_users : "wid (CASCADE)"
threads_meta ||--o{ runs : "thread_id (逻辑)"
runs ||--o{ run_events : "run_id (逻辑)"
runs ||--o{ feedback : "run_id (逻辑)"
```
**两条主线**
1. **租户骨架**`users``workspaces`(多对多经 `workspace_memberships`)。workspace 是隔离粒度单位,每个用户注册自动建 1 人 workspace。
2. **业务数据**`threads_meta``runs``run_events` / `feedback`,全部挂 `workspace_id`(行级隔离),workspace 删除时级联清空。
3. **Headless 接入**Stage 0 末预建 schema):`service_accounts``api_keys`(鉴权凭证)+ `external_users`passthrough 终端身份)。
> `threads_meta.thread_id` / `runs.run_id` 与下游是**逻辑关联**(无 DB 外键,因 thread/run id 也被 LangGraph 表使用);workspace 外键才是物理约束。
---
## 3. 表参考
> 列约定:所有 `created_at`/`updated_at` 为 `DateTime(tz)` NOT NULL,下表不再逐行重复说明。
### 3.1 `users` — 用户账户(本地密码 + OAuth)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | String(36) | PK | UUID |
| `email` | String(320) | UNIQUE NOT NULL,索引 | 登录邮箱 |
| `password_hash` | String(128) | NULL | OAuth-only 用户为 NULL |
| `system_role` | String(16) | NOT NULL default `"user"` | 平台级角色 `admin`/`user`(与 workspace role 正交) |
| `oauth_provider` | String(32) | NULL | google/github… |
| `oauth_id` | String(128) | NULL | 提供商内用户 ID |
| `needs_setup` | Boolean | NOT NULL default `False` | 首次设置标记 |
| `token_version` | Integer | NOT NULL default `0` | 自增即吊销该用户所有旧 JWT |
| `default_workspace_id` | String(36) | NULLFK `workspaces.id` **SET NULL** | 登录默认进入的 workspaceNULL 走 picker |
| `created_at` | DateTime(tz) | NOT NULL | |
**索引**`idx_users_oauth_identity` UNIQUE `(oauth_provider, oauth_id)`,仅当两者均非 NULLpartial)。
### 3.2 `workspaces` — 工作空间(多租户隔离单位)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | String(36) | PK | UUID |
| `name` | String(64) | NOT NULL | 显示名 |
| `slug` | String(32) | UNIQUE NOT NULL | URL 标识 `^[a-z0-9](-?[a-z0-9])*$`332 字符 |
| `status` | String(16) | NOT NULL default `"active"` | `active`/`suspended`/`deleted` |
| `owner_id` | String(36) | NOT NULLFK `users.id` **RESTRICT** | 冗余 owner;删 owner 被阻拦 |
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
> slug 黑名单(`admin`/`api`/`auth`/`_next`/… 见锁定版 §2.1)走应用层校验,不入 DB 约束。
### 3.3 `workspace_memberships` — 成员(RBAC
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `workspace_id` | String(36) | **复合 PK**FK `workspaces.id` **CASCADE** | |
| `user_id` | String(36) | **复合 PK**FK `users.id` **CASCADE** | |
| `role` | String(16) | NOT NULL | Stage 0 仅 `owner`Stage 2 起 `admin`/`member` |
| `invited_by` | String(36) | NULLFK `users.id` **SET NULL** | Stage 2 invitation 才写 |
| `joined_at` | DateTime(tz) | NOT NULL | |
**索引**
- 复合 PK `(workspace_id, user_id)`
- `idx_workspace_memberships_user` `(user_id, workspace_id)` — 倒查"某 user 的所有 workspace"
- `idx_one_owner_per_workspace` UNIQUE `(workspace_id)` WHERE `role='owner'`partial)— 每 workspace 恰好 1 owner
### 3.4 `threads_meta` — 会话元数据
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `thread_id` | String(64) | PK | LangGraph thread_id |
| `assistant_id` | String(128) | NULL,索引 | 自定义 agent 名;NULL=默认 lead agent |
| `user_id` | String(64) | NULL,索引 | 所有者;NULL=历史无主 |
| `workspace_id` | String(36) | NOT NULL0003 后),FK `workspaces.id` **CASCADE** | |
| `display_name` | String(256) | NULL | 自动标题或用户改名 |
| `status` | String(20) | NOT NULL default `"idle"` | `idle`/`busy` |
| `metadata_json` | JSON | NOT NULL default `{}` | |
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
**索引**
- `idx_threads_meta_workspace_user_updated` `(workspace_id, user_id, updated_at)` — 前端 thread list 默认查询
- `idx_threads_meta_workspace_thread` UNIQUE `(workspace_id, thread_id)`0003 加)— 防跨 workspace 复用同 thread_id
### 3.5 `runs` — 单次 agent 运行(含 token 指标)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `run_id` | String(64) | PK | |
| `thread_id` | String(64) | NOT NULL,索引 | 所属会话 |
| `assistant_id` | String(128) | NULL | |
| `user_id` | String(64) | NULL,索引 | |
| `workspace_id` | String(36) | NOT NULL0003 后),FK `workspaces.id` **CASCADE** | |
| `status` | String(20) | NOT NULL default `"pending"` | `pending`/`running`/`success`/`error`/`timeout`/`interrupted` |
| `model_name` | String(128) | NULL | |
| `multitask_strategy` | String(20) | NOT NULL default `"reject"` | `reject`/`interrupt`/`rollback`/`enqueue` |
| `metadata_json` / `kwargs_json` | JSON | NOT NULL default `{}` | 运行级元数据 / 提交参数 |
| `error` | Text | NULL | 失败错误文本 |
| `message_count` | Integer | NOT NULL default `0` | |
| `first_human_message` / `last_ai_message` | Text | NULL | 文本预览 |
| `total_input_tokens` / `total_output_tokens` / `total_tokens` | Integer | NOT NULL default `0` | 累计 token |
| `llm_call_count` | Integer | NOT NULL default `0` | |
| `lead_agent_tokens` / `subagent_tokens` / `middleware_tokens` | Integer | NOT NULL default `0` | 分项 token(主 agent / 子 agent / 中间件) |
| `follow_up_to_run_id` | String(64) | NULL | 续接的上一次运行(重新生成/继续) |
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
**索引**`ix_runs_thread_status` `(thread_id, status)`
### 3.6 `run_events` — 运行事件流(回放 + 审计真源)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | Integer | PK autoincrement | |
| `thread_id` | String(64) | NOT NULL | |
| `run_id` | String(64) | NOT NULL,索引 | |
| `user_id` | String(64) | NULL,索引 | |
| `workspace_id` | String(36) | NOT NULL0003 后),FK `workspaces.id` **CASCADE** | |
| `event_type` | String(32) | NOT NULL | 子类型(`ai_message_chunk`/`tool_call`…) |
| `category` | String(16) | NOT NULL | `message`/`trace`/`lifecycle` |
| `content` | Text | NOT NULL default `""` | 事件文本 |
| `event_metadata` | JSON | NOT NULL default `{}` | |
| `seq` | Integer | NOT NULL | thread 内全局递增序号 |
| `created_at` | DateTime(tz) | NOT NULL | |
**索引**
- `uq_events_thread_seq` UNIQUE `(thread_id, seq)`
- `ix_events_thread_cat_seq` `(thread_id, category, seq)`
- `ix_events_run` `(thread_id, run_id, seq)`
### 3.7 `feedback` — 运行反馈(赞/踩 + 评论)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `feedback_id` | String(64) | PK | |
| `run_id` | String(64) | NOT NULL,索引 | |
| `thread_id` | String(64) | NOT NULL,索引 | |
| `user_id` | String(64) | NULL,索引 | |
| `workspace_id` | String(36) | NOT NULL0003 后),FK `workspaces.id` **CASCADE** | |
| `message_id` | String(64) | NULL | NULL=针对整次运行而非单条消息 |
| `rating` | Integer | NOT NULL | +1 赞 / -1 踩 |
| `comment` | Text | NULL | |
| `created_at` | DateTime(tz) | NOT NULL | |
**索引**`uq_feedback_thread_run_user` UNIQUE `(thread_id, run_id, user_id)` — 一人对一次运行只一条反馈。
### 3.8 `service_accounts` — 服务账号(headless 非人身份)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | String(36) | PK | |
| `workspace_id` | String(36) | NOT NULLFK `workspaces.id` **CASCADE**,索引 | |
| `name` | String(64) | NOT NULL | |
| `role` | String(16) | NOT NULL default `"member"` | |
| `identity_mode` | String(16) | NOT NULL default `"collapsed"` | `collapsed`/`external_passthrough`/`both` |
| `status` | String(16) | NOT NULL default `"active"` | `active`/`suspended`/`deleted` |
| `created_by` | String(36) | NOT NULLFK `users.id` **RESTRICT** | 创建者(owner/admin |
| `created_at` / `updated_at` | DateTime(tz) | NOT NULL | |
**索引**`idx_service_accounts_workspace` `(workspace_id, status)`
### 3.9 `api_keys` — API Keyheadless 凭证)
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | String(36) | PK | |
| `service_account_id` | String(36) | NOT NULLFK `service_accounts.id` **CASCADE**,索引 | |
| `key_prefix` | String(16) | UNIQUE NOT NULL | 公开 prefix`dfk_live_…`),可打日志 |
| `key_hash` | String(128) | NOT NULL | 完整 token 的 sha-256 hex |
| `name` | String(64) | NOT NULL | 标签(同 SA 内不强制唯一) |
| `scopes` | String(1024) | NOT NULL default `""` | 逗号分隔(`threads:read,threads:write` |
| `rate_limit_rpm` | Integer | NULL | NULL=走 SA 默认 |
| `expires_at` / `last_used_at` / `revoked_at` | DateTime(tz) | NULL | `revoked_at` 非空=软删,不删行 |
| `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)— 鉴权热路径只扫活跃 key
### 3.10 `external_users` — 终端用户身份(passthrough
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | String(36) | PK | ghost user id |
| `workspace_id` | String(36) | NOT NULLFK `workspaces.id` **CASCADE** | 冗余存,加速跨 SA 的 workspace 查询 |
| `service_account_id` | String(36) | NOT NULLFK `service_accounts.id` **CASCADE** | |
| `external_id` | String(128) | NOT NULL | 调用方传入的 `X-External-User-Id`,原样存 |
| `display_name` | String(128) | NULL | 仅 admin UI 展示 |
| `metadata_json` | JSON | NOT NULL default `{}` | plan tier / region / tag |
| `created_at` | DateTime(tz) | NOT NULL | 首见时间 |
| `last_seen_at` | DateTime(tz) | NULL | |
**索引**`uq_external_users_sa_external` UNIQUE `(service_account_id, external_id)` — 同一 SA 下 external_id 唯一(鉴权中间件按此 upsert)。
---
## 4. 外键与删除策略一览
| 子表 | 外键列 | 指向 | ON DELETE | 含义 |
|---|---|---|---|---|
| users | default_workspace_id | workspaces.id | **SET NULL** | 默认 workspace 没了就清空,用户仍在 |
| workspaces | owner_id | users.id | **RESTRICT** | 不能直接删 owner,需先转移 |
| workspace_memberships | workspace_id | workspaces.id | **CASCADE** | 删 workspace → 成员清空 |
| workspace_memberships | user_id | users.id | **CASCADE** | 删 user → 其成员关系清空 |
| workspace_memberships | invited_by | users.id | **SET NULL** | 删邀请人,保留成员关系 |
| threads_meta / runs / run_events / feedback | workspace_id | workspaces.id | **CASCADE** | 删 workspace → 业务数据全清 |
| service_accounts | workspace_id | workspaces.id | **CASCADE** | 删 workspace → SA 清空 |
| service_accounts | created_by | users.id | **RESTRICT** | 不能删 SA 创建者 |
| api_keys | service_account_id | service_accounts.id | **CASCADE** | 删 SA → key 清空 |
| external_users | workspace_id | workspaces.id | **CASCADE** | |
| external_users | service_account_id | service_accounts.id | **CASCADE** | |
**心智模型**:删 workspace = 整租户级联清空(业务数据 + SA + key + 外部身份);user 作为他人的 owner/creator 受 RESTRICT 保护,不能"误删带塌一片"。
---
## 5. 迁移历史(Alembic
> dev 用 `create_all` 直接建到最新;生产/已有库用迁移逐步推进。`migrations/versions/`
| 版本 | 依赖 | 变更 | 要点 |
|---|---|---|---|
| **0001** `users_default_workspace` | — | `users``default_workspace_id`nullable+ FK→`workspaces.id` SET NULL | 无需回填(nullable |
| **0002** `business_tables_workspace` | 0001 | `threads_meta`/`runs`/`feedback`/`run_events` 各加 `workspace_id`**nullable**+ FK CASCADE`threads_meta``(workspace_id,user_id,updated_at)` 索引 | 先 nullable,留给 `scripts/backfill_workspace_id.py` 回填 |
| **0003** `business_tables_workspace_not_null` | 0002 | 4 张表 `workspace_id`**NOT NULL**`threads_meta``(workspace_id,thread_id)` UNIQUE | **升级前校验**:任一表仍有 `workspace_id IS NULL` 则拒绝升级,逼先跑回填脚本 |
**两段式上线**(0002→0003)是为了零停机:先加可空列 → 后台回填 → 校验通过再锁 NOT NULL,避免大表 ALTER 长锁与脏数据静默写入。
---
## 6. 与设计锁定版的差异 / 落地补充
| 项 | 锁定版 | 落地实际 |
|---|---|---|
| `run_events.workspace_id` | "待确认是否 DB 表" | **已确认**为 DB 表,比照 runs 加 `workspace_id` + CASCADE,并入 0002/0003 |
| `runs` token 指标列 | 未在 schema 文档列出 | 实际有完整一组:`total_*_tokens` / `llm_call_count` / `lead_agent_tokens` / `subagent_tokens` / `middleware_tokens` |
| `service_accounts.status` | `active/suspended/revoked` | 落地为 `active/suspended/deleted`(与 workspace 状态机一致) |
| `api_keys.scopes` 默认 | `''` | 一致(`String(1024)`,逗号分隔) |
| 索引名 | 设计期未定名 | 见各表"索引"小节(如 `ix_runs_thread_status``uq_events_thread_seq` |
> 不可逆决策(id 类型 / 命名 / slug / 复合 PK / TokenPayload / FK 删除策略)均按锁定版 §5 执行,未变。
---
## 7. 配套阅读
- [workspace-schema-design.zh-CN.md](./workspace-schema-design.zh-CN.md) — Stage 0 schema 锁定版(决策依据 + 不可逆点 + JWT TokenPayload
- [adr-001-data-isolation.zh-CN.md](./adr-001-data-isolation.zh-CN.md) — 行级 `workspace_id` + Postgres RLS + LangGraph 表两层模型
- [adr-004-tenant-rbac.zh-CN.md](./adr-004-tenant-rbac.zh-CN.md) — RBAC + JWT 设计
- [adr-spike-langgraph-postgres.zh-CN.md](./adr-spike-langgraph-postgres.zh-CN.md) — 为何 LangGraph 表不归 ORM 管
- [03-impl/STATUS.md](../03-impl/STATUS.md) + `03-impl/pr8-headless-api-schema.md` — PR 级实现进度