Compare commits

...

2 Commits

Author SHA1 Message Date
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
1445043649 dd1d40368d feat(scripts): 新增本地调试与示例启动脚本,并登记进 apps/README
新增 4 个脚本,统一子命令风格(start/stop/restart/status/logs/run):

- scripts/dev-gateway.sh:方式 B,只起 Gateway(:8001),用 backend/.venv 虚拟环境,
  自动加载 .env、释放端口、等待就绪;带 PID/日志文件,可查状态与跟随日志。
- scripts/dev-full.sh:方式 A,复用 serve.sh 守护模式起全量栈(Gateway+前端+nginx),
  补齐 serve.sh 缺失的 status 与 logs;restart 默认跳过依赖安装,统一入口 :2026。
- apps/examples/http-chat/run.sh:自动探测网关(:2026 优先,回退 :8001),
  优先 uv 临时环境带 requests(--no-project,不污染系统),无 uv 时回退 venv+pip。
- apps/examples/embedded-chat/run.sh:自动定位 backend、加载 .env 后用 uv run 运行,
  内嵌 SDK 模式无需起服务。

apps/README.md 增加「本地调试脚本」与「运行示例」两节,说明上述脚本用法。

全部脚本均已本地实跑验证:全量栈三服务 HTTP 200,两个示例多轮对话正常。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:47:30 +08:00
7 changed files with 766 additions and 1 deletions
+37
View File
@@ -23,6 +23,20 @@ apps/ ← 你的应用(消费 deerflow,不反
> 还有第三种:LangGraph SDK`langgraph_sdk.get_client(url=".../api")`graph id `lead_agent`),用于接入 LangGraph 生态工具链。需要的话照 HTTP 示例的鉴权流程拿 cookie 即可。 > 还有第三种:LangGraph SDK`langgraph_sdk.get_client(url=".../api")`graph id `lead_agent`),用于接入 LangGraph 生态工具链。需要的话照 HTTP 示例的鉴权流程拿 cookie 即可。
### 运行示例(每个示例自带 run.sh)
```bash
# ① HTTP 模式:需要先起 Gatewaydev-gateway 或 dev-full 都行)
# run.sh 会自动探测网关地址:优先 :2026,回退 :8001
./apps/examples/http-chat/run.sh
DF_BASE=http://localhost:8001 ./apps/examples/http-chat/run.sh # 也可手动指定
# ② 内嵌模式:不需要起任何服务,run.sh 自动进 backend uv 环境运行
./apps/examples/embedded-chat/run.sh
```
http-chat 的 `run.sh` 优先用 `uv run --no-project --with requests`(临时环境,不污染系统),没有 uv 才回退到本地 `.venv` + pip;可用 `DF_BASE` / `DF_EMAIL` / `DF_PASSWORD` 覆盖。embedded-chat 的 `run.sh` 自动定位 `backend/`、加载 `.env` 后用 `uv run` 启动,依赖 `config.yaml` 里有可用模型。
## 前置:先把 DeerFlow 跑起来 ## 前置:先把 DeerFlow 跑起来
在**仓库根目录** 在**仓库根目录**
@@ -33,6 +47,29 @@ make dev # 起 Gateway(8001) + 前端(3000) + nginx(2026),统一入
确保 `config.yaml` 里至少配了一个可用模型 + API key。 确保 `config.yaml` 里至少配了一个可用模型 + API key。
### 本地调试脚本(推荐)
`make dev` 是前台阻塞运行。日常调试更顺手的是仓库根 `scripts/` 下两个生命周期脚本,子命令统一为 `start / stop / restart / status / logs / run`
| 脚本 | 起什么 | 入口 | 适合 |
|---|---|---|---|
| `scripts/dev-gateway.sh` | 只起 Gateway | `http://localhost:8001` | 调后端 API / 接入示例,起得快 |
| `scripts/dev-full.sh` | Gateway + 前端 + nginx | `http://localhost:2026` | 连前端一起调,完整体验 |
```bash
./scripts/dev-gateway.sh start # 后台启动,等就绪后返回
./scripts/dev-gateway.sh status # PID / 端口 / HTTP 健康检查
./scripts/dev-gateway.sh logs # tail -f 跟随日志(不影响服务)
./scripts/dev-gateway.sh stop
./scripts/dev-full.sh start # 全量栈后台启动(首次装依赖)
SKIP_INSTALL=1 ./scripts/dev-full.sh start # 跳过依赖安装,重启更快
./scripts/dev-full.sh status # 三服务一览
./scripts/dev-full.sh run # 前台运行(= make devgateway 带热重载)
```
环境变量:`PORT=`(换端口)、`NO_RELOAD=1`(关热重载,断点更稳)、`SKIP_INSTALL=1`(全量栈跳过装依赖)。
## 鉴权(HTTP 模式必读) ## 鉴权(HTTP 模式必读)
Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要带会话 cookie: Gateway 是 **fail-closed** 的——除少数公开路径外所有请求都要带会话 cookie:
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# embedded-chat 示例启动脚本
# ------------------------------------------------------------------
# 内嵌 SDK 模式:进程内直接 import deerflow.*,不需要起任何服务。
# 必须在 backend 的 uv 虚拟环境里跑(才能解析 deerflow-harness / app 包),
# 本脚本自动 cd 到 backend 并用 uv run 启动。
#
# 用法:
# ./run.sh
#
# 前提:
# - 已 `cd backend && uv sync`(或跑过任意一个 dev 脚本,venv 已建好)
# - config.yaml 里配好至少一个可用模型 + API key
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
APP="$SCRIPT_DIR/app.py"
BACKEND="$REPO_ROOT/backend"
# ── 前置检查 ──────────────────────────────────────────────────────
command -v uv >/dev/null 2>&1 || { echo "✗ 未找到 uv。安装:curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
[ -f "$REPO_ROOT/config.yaml" ] || echo "⚠ 未找到 $REPO_ROOT/config.yaml —— 没有可用模型会启动失败" >&2
if [ ! -d "$BACKEND/.venv" ]; then
echo "→ 未发现 backend/.venv,执行 uv sync"
(cd "$BACKEND" && uv sync)
fi
# ── 加载 .env(模型 key / 数据库等)──────────────────────────────
if [ -f "$REPO_ROOT/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$REPO_ROOT/.env"
set +a
fi
# ── 在 backend uv 环境里运行(config.yaml 解析依赖运行目录为 backend/)──
echo "→ 在 backend uv 环境中运行 embedded-chat"
cd "$BACKEND"
exec env PYTHONPATH=. uv run python "$APP"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# http-chat 示例启动脚本
# ------------------------------------------------------------------
# - 自动探测网关地址:优先 :2026(nginx 全量栈),回退 :8001(只起 Gateway)
# - 优先用 uv 临时虚拟环境带上 requests--no-project,不污染系统/项目)
# 没有 uv 时回退到本地 .venv + pip
#
# 用法:
# ./run.sh # 自动探测网关并运行
# DF_BASE=http://localhost:8001 ./run.sh # 手动指定网关
# DF_EMAIL=a@b.com DF_PASSWORD=xxxx ./run.sh
#
# 前提:先起好 Gateway
# ../../../scripts/dev-gateway.sh start # → :8001
# ../../../scripts/dev-full.sh start # → :2026
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ── 探测可用网关 ──────────────────────────────────────────────────
_alive() { curl -s -o /dev/null -w "%{http_code}" "$1/api/v1/auth/setup-status" 2>/dev/null | grep -qE "200|429"; }
if [ -z "${DF_BASE:-}" ]; then
if _alive "http://localhost:2026"; then DF_BASE="http://localhost:2026"
elif _alive "http://localhost:8001"; then DF_BASE="http://localhost:8001"
else
echo "✗ 没探测到运行中的网关(:2026 / :8001 都不通)。" >&2
echo " 先启动:scripts/dev-gateway.sh start 或 scripts/dev-full.sh start" >&2
echo " 或手动指定:DF_BASE=http://your-host:port ./run.sh" >&2
exit 1
fi
fi
export DF_BASE
echo "→ 使用网关: $DF_BASE"
# ── 运行:优先 uv,回退 venv+pip ─────────────────────────────────
if command -v uv >/dev/null 2>&1; then
echo "→ uv 临时环境运行(--with requests"
exec uv run --no-project --with "requests>=2.31" python app.py
else
echo "→ 未找到 uv,使用本地 .venv + pip"
if [ ! -d .venv ]; then
python3 -m venv .venv
./.venv/bin/pip install -q -r requirements.txt
fi
exec ./.venv/bin/python app.py
fi
@@ -0,0 +1,308 @@
# 数据库设计 · 落地版(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 级实现进度
+3 -1
View File
@@ -26,7 +26,8 @@ docs/multi-tenant-redesign/
│ ├── adr-spike-langgraph-postgres spikeLangGraph PG 注入能力 │ ├── adr-spike-langgraph-postgres spikeLangGraph PG 注入能力
│ ├── adr-vs-code-audit 审计:ADR vs 现状代码 │ ├── adr-vs-code-audit 审计:ADR vs 现状代码
│ ├── multi-tenant-phase-0-plan Phase-0 时间盒 / 产出物 │ ├── multi-tenant-phase-0-plan Phase-0 时间盒 / 产出物
── workspace-schema-design **Stage 0 schema 锁定版**(不可逆决策点) ── workspace-schema-design **Stage 0 schema 锁定版**(不可逆决策点)
│ └── database-schema-as-built **数据库设计落地版**(对照实现代码的事实参考)
└── 02-rollout/ 落地路线 + 集成轨道 └── 02-rollout/ 落地路线 + 集成轨道
├── phased-rollout-by-scale **Stage 04 主线** 路线图 ├── phased-rollout-by-scale **Stage 04 主线** 路线图
├── stage-0-code-map Stage 0 现状代码地图(行号锚点) ├── stage-0-code-map Stage 0 现状代码地图(行号锚点)
@@ -49,6 +50,7 @@ docs/multi-tenant-redesign/
| spike | [LangGraph PG 注入](./01-redesign/adr-spike-langgraph-postgres.zh-CN.md) | 已结论 | 2026-05-09 | `langgraph-checkpoint-postgres==3.0.5` **不存在 connection_factory**;改走应用层强校验 + 自有表 RLS 的两层模型 | | spike | [LangGraph PG 注入](./01-redesign/adr-spike-langgraph-postgres.zh-CN.md) | 已结论 | 2026-05-09 | `langgraph-checkpoint-postgres==3.0.5` **不存在 connection_factory**;改走应用层强校验 + 自有表 RLS 的两层模型 |
| 审计 | [ADR vs 代码](./01-redesign/adr-vs-code-audit.zh-CN.md) | 已结论 | 2026-05-09 | 代码库 0 处 `tenant`Better Auth 不存在;ObjectStorage / KMS / Postgres 测试夹具全缺;底座先行 §3.5 | | 审计 | [ADR vs 代码](./01-redesign/adr-vs-code-audit.zh-CN.md) | 已结论 | 2026-05-09 | 代码库 0 处 `tenant`Better Auth 不存在;ObjectStorage / KMS / Postgres 测试夹具全缺;底座先行 §3.5 |
| 锁定 | [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) | **Stage 0 锁定版** | 2026-05-10 | `workspace_id` 命名 + 7 项不可逆决策;Stage 0 PR1 动手前必读 | | 锁定 | [workspace-schema-design](./01-redesign/workspace-schema-design.zh-CN.md) | **Stage 0 锁定版** | 2026-05-10 | `workspace_id` 命名 + 7 项不可逆决策;Stage 0 PR1 动手前必读 |
| 参考 | [database-schema-as-built](./01-redesign/database-schema-as-built.zh-CN.md) | **落地版(as-built** | 2026-06-27 | 对照实现代码的 10 张表全字段 / 外键 / 索引 / 迁移参考;与锁定版冲突以本文为准 |
| 计划 | [phase-0-plan](./01-redesign/multi-tenant-phase-0-plan.zh-CN.md) | 计划 | 2026-05-09 | Phase-0 时间盒 3 周;含底座先行(§3.5) | | 计划 | [phase-0-plan](./01-redesign/multi-tenant-phase-0-plan.zh-CN.md) | 计划 | 2026-05-09 | Phase-0 时间盒 3 周;含底座先行(§3.5) |
| 路线 | [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) | **当前主线路线图** | 2026-05-09 | Stage 04 + 触发/退出/时间盒/Go-No-Go | | 路线 | [phased-rollout-by-scale](./02-rollout/phased-rollout-by-scale.zh-CN.md) | **当前主线路线图** | 2026-05-09 | Stage 04 + 触发/退出/时间盒/Go-No-Go |
| 锚点 | [stage-0-code-map](./02-rollout/stage-0-code-map.zh-CN.md) | Stage 0 用 | 2026-05-09 | 当前代码文件:行号锚点 + Stage 0 改动落点 | | 锚点 | [stage-0-code-map](./02-rollout/stage-0-code-map.zh-CN.md) | Stage 0 用 | 2026-05-09 | 当前代码文件:行号锚点 + Stage 0 改动落点 |
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
#
# 本地调试全量栈管理脚本(方式 AGateway + Frontend + Nginx
# ------------------------------------------------------------------
# 复用仓库已有的 scripts/serve.sh(处理 config-upgrade / postgres extras /
# nginx 临时目录 / 依赖同步 / 端口等待),在其守护进程模式之上补齐
# status 和 logs,子命令风格与 scripts/dev-gateway.sh 保持一致。
#
# 服务与端口:
# Gateway localhost:8001 (REST API + agent runtime)
# Frontend localhost:3000 (Next.js)
# Nginx localhost:2026 (统一入口 / 反向代理) ← 浏览器访问这个
#
# 用法:
# ./scripts/dev-full.sh start # 后台启动整套(首次会装依赖)
# ./scripts/dev-full.sh stop # 关闭整套
# ./scripts/dev-full.sh restart # 重启整套
# ./scripts/dev-full.sh status # 三个服务的端口 / 健康检查
# ./scripts/dev-full.sh logs [服务] # 跟随日志,默认三个一起;可指定 gateway|frontend|nginx
# ./scripts/dev-full.sh run # 前台运行(= make devCtrl-C 全停,gateway 带热重载)
#
# 环境变量:
# SKIP_INSTALL=1 跳过依赖安装,重启更快
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SERVE="$REPO_ROOT/scripts/serve.sh"
# 服务清单:名称:端口:日志文件:健康检查路径(空=只查端口)
SERVICES=(
"Gateway:8001:gateway.log:/api/v1/auth/setup-status"
"Frontend:3000:frontend.log:/"
"Nginx:2026:nginx.log:/"
)
# ── 工具函数 ──────────────────────────────────────────────────────
_port_pid() { { lsof -ti tcp:"$1" 2>/dev/null || true; } | head -1; }
_any_running() {
for svc in "${SERVICES[@]}"; do
local port="${svc#*:}"; port="${port%%:*}"
[ -n "$(_port_pid "$port")" ] && return 0
done
return 1
}
_serve_flags() {
# serve.sh 守护模式启动;可选跳过依赖安装
local flags="--dev --daemon"
[ "${SKIP_INSTALL:-0}" = "1" ] && flags="$flags --skip-install"
echo "$flags"
}
# ── 子命令 ────────────────────────────────────────────────────────
cmd_start() {
if _any_running; then
echo "检测到已有服务在运行 —— 如需重启用:$0 restart"
cmd_status || true
return 0
fi
echo "→ 后台启动全量栈(serve.sh $(_serve_flags)"
[ "${SKIP_INSTALL:-0}" = "1" ] || echo " 首次启动会执行 uv sync + pnpm install,可能较慢;重启可加 SKIP_INSTALL=1"
# shellcheck disable=SC2046
bash "$SERVE" $(_serve_flags)
echo
cmd_status || true
}
cmd_stop() {
if ! _any_running; then
echo "未在运行"
return 0
fi
echo "→ 关闭全量栈(serve.sh --stop"
bash "$SERVE" --stop
}
cmd_status() {
local all_up=0
printf "%-10s %-7s %-9s %s\n" "服务" "端口" "状态" "健康"
printf "%-10s %-7s %-9s %s\n" "----" "----" "----" "----"
for svc in "${SERVICES[@]}"; do
local name port log path rest
name="${svc%%:*}"; rest="${svc#*:}"
port="${rest%%:*}"; rest="${rest#*:}"
log="${rest%%:*}"; path="${rest#*:}"
local pid; pid="$(_port_pid "$port")"
if [ -z "$pid" ]; then
printf "%-10s %-7s %-9s %s\n" "$name" "$port" "✗ 停止" "-"
all_up=1
else
local code="-"
if [ -n "$path" ]; then
code="$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port$path" 2>/dev/null || echo 000)"
case "$code" in 200|429|301|302|307) code="✓ HTTP $code";; 000) code="⚠ 无响应";; *) code="⚠ HTTP $code";; esac
fi
printf "%-10s %-7s %-9s %s\n" "$name" "$port" "● 运行 ($pid)" "$code"
fi
done
if [ "$all_up" = "0" ]; then
echo
echo " 🌐 统一入口: http://localhost:2026"
fi
return "$all_up"
}
cmd_logs() {
local target="${1:-}"
cd "$REPO_ROOT"
local files=()
if [ -n "$target" ]; then
local f="logs/${target}.log"
[ -f "$f" ] || { echo "暂无日志:$f(可选 gateway|frontend|nginx" >&2; return 1; }
files=("$f")
else
for svc in "${SERVICES[@]}"; do
local log; log="${svc#*:}"; log="${log#*:}"; log="${log%%:*}"
[ -f "logs/$log" ] && files+=("logs/$log")
done
[ ${#files[@]} -gt 0 ] || { echo "暂无日志文件(logs/ 为空)" >&2; return 1; }
fi
echo "→ 跟随日志(Ctrl-C 退出,不影响服务):${files[*]}"
tail -n 30 -f "${files[@]}"
}
cmd_run() {
if _any_running; then
echo "已有后台实例在运行,先 $0 stop" >&2
exit 1
fi
echo "→ 前台运行全量栈(= make devCtrl-C 全停)"
exec bash "$SERVE" --dev
}
# ── 分发 ──────────────────────────────────────────────────────────
case "${1:-status}" in
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_stop; echo; SKIP_INSTALL="${SKIP_INSTALL:-1}" cmd_start ;;
status|"") cmd_status ;;
logs) shift || true; cmd_logs "${1:-}" ;;
run) cmd_run ;;
-h|--help|help)
sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//' ;;
*)
echo "未知命令: $1" >&2
echo "可用: start | stop | restart | status | logs [服务] | run" >&2
exit 1 ;;
esac
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
#
# 本地调试 Gateway 管理脚本(方式 B:只起 Gateway,端口 8001
# ------------------------------------------------------------------
# - 用 backend/.venv 虚拟环境运行(由 uv 管理)
# - 自动加载仓库根 .env(数据库、模型 key 等)
# - 支持 start / stop / restart / status / logs / run 子命令
#
# 用法:
# ./scripts/dev-gateway.sh start # 后台启动(写 PID + 日志)
# ./scripts/dev-gateway.sh stop # 关闭
# ./scripts/dev-gateway.sh restart # 重启
# ./scripts/dev-gateway.sh status # 查看状态(PID / 端口 / 健康检查)
# ./scripts/dev-gateway.sh logs # 实时跟随日志(Ctrl-C 退出,不影响服务)
# ./scripts/dev-gateway.sh run # 前台运行(断点调试,Ctrl-C 退出)
#
# 环境变量:
# PORT=8002 换端口(默认 8001)
# NO_RELOAD=1 关掉热重载(断点调试更稳)
#
set -euo pipefail
# ── 定位仓库根 ────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PORT="${PORT:-8001}"
PID_FILE="$REPO_ROOT/logs/gateway-dev.pid"
LOG_FILE="$REPO_ROOT/logs/gateway-dev.log"
# ── 工具函数 ──────────────────────────────────────────────────────
_running_pid() {
# 打印存活的服务 PID(优先 PID 文件,回退到端口探测),否则空
if [ -f "$PID_FILE" ]; then
local pid
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "$pid"; return 0
fi
fi
# lsof 在无监听时返回 1,配合 pipefail+set -e 会误终止脚本 → 用 || true 兜底
{ lsof -ti tcp:"$PORT" 2>/dev/null || true; } | head -1
}
_load_env() {
if [ -f "$REPO_ROOT/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$REPO_ROOT/.env"
set +a
else
echo "⚠ 未找到 $REPO_ROOT/.env(数据库/模型 key 可能缺失)" >&2
fi
}
_uvicorn_flags() {
if [ "${NO_RELOAD:-0}" != "1" ]; then
echo "--reload --reload-include=*.yaml --reload-include=.env --reload-exclude=*.pyc --reload-exclude=__pycache__/* --reload-exclude=sandbox/* --reload-exclude=.deer-flow/*"
fi
}
_preflight() {
command -v uv >/dev/null 2>&1 || { echo "✗ 未找到 uv。安装:curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
mkdir -p "$REPO_ROOT/logs"
if [ ! -d "$REPO_ROOT/backend/.venv" ]; then
echo "→ 未发现 backend/.venv,执行 uv sync 创建虚拟环境"
(cd "$REPO_ROOT/backend" && uv sync)
fi
}
_wait_ready() {
# 探测 setup-status,最多 30s;就绪返回 0
for _ in $(seq 1 30); do
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/v1/auth/setup-status" 2>/dev/null | grep -qE "200|429"; then
return 0
fi
sleep 1
done
return 1
}
# ── 子命令 ────────────────────────────────────────────────────────
cmd_start() {
local pid; pid="$(_running_pid)"
if [ -n "$pid" ]; then
echo "已在运行 (PID $pid, 端口 $PORT) —— 如需重启用:$0 restart"
return 0
fi
_preflight
_load_env
echo "→ 后台启动 Gateway @localhost:${PORT}venv: backend/.venv, 热重载: $([ "${NO_RELOAD:-0}" = "1" ] && echo off || echo on)"
# shellcheck disable=SC2086
( cd "$REPO_ROOT/backend" && exec env PYTHONPATH=. uv run uvicorn app.gateway.app:app \
--host 0.0.0.0 --port "$PORT" $(_uvicorn_flags) ) > "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
if _wait_ready; then
echo "✓ 启动成功 (PID $(cat "$PID_FILE"))"
echo " 日志: $0 logs 状态: $0 status 关闭: $0 stop"
else
echo "✗ 30s 内未就绪,最后 20 行日志:" >&2
tail -n 20 "$LOG_FILE" >&2
return 1
fi
}
cmd_stop() {
local pid; pid="$(_running_pid)"
if [ -z "$pid" ]; then
echo "未在运行"
rm -f "$PID_FILE"
return 0
fi
echo "→ 关闭 Gateway (PID $pid)"
# 优雅终止整组进程(uv → uvicorn → reloader 子进程)
kill "$pid" 2>/dev/null || true
for _ in $(seq 1 10); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done
# 兜底:按端口清残留(reload worker 偶尔不随父进程退出)
lsof -ti tcp:"$PORT" 2>/dev/null | xargs kill -9 2>/dev/null || true
rm -f "$PID_FILE"
echo "✓ 已停止,端口 $PORT 释放"
}
cmd_status() {
local pid; pid="$(_running_pid)"
if [ -z "$pid" ]; then
echo "● Gateway: 已停止 (端口 $PORT 空闲)"
return 1
fi
echo "● Gateway: 运行中"
echo " PID: $pid"
echo " 端口: $PORT"
local code
code="$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$PORT/api/v1/auth/setup-status" 2>/dev/null || echo "000")"
case "$code" in
200|429) echo " 健康: ✓ HTTP $code (REST API 响应中)";;
000) echo " 健康: ⚠ 端口占用但 HTTP 无响应(可能仍在启动)";;
*) echo " 健康: ⚠ HTTP $code";;
esac
echo " 日志: $LOG_FILE"
}
cmd_logs() {
[ -f "$LOG_FILE" ] || { echo "暂无日志文件:$LOG_FILE"; return 1; }
echo "→ 跟随日志(Ctrl-C 退出,不影响服务):$LOG_FILE"
tail -n 50 -f "$LOG_FILE"
}
cmd_run() {
# 前台运行:日志直出终端,适合 IDE 断点 / 看实时堆栈
local pid; pid="$(_running_pid)"
[ -n "$pid" ] && { echo "已有后台实例在运行 (PID $pid),先 $0 stop" >&2; exit 1; }
_preflight
_load_env
echo "→ 前台运行 @localhost:${PORT}Ctrl-C 退出)"
cd "$REPO_ROOT/backend"
# shellcheck disable=SC2046,SC2086
exec env PYTHONPATH=. uv run uvicorn app.gateway.app:app \
--host 0.0.0.0 --port "$PORT" $(_uvicorn_flags)
}
# ── 分发 ──────────────────────────────────────────────────────────
case "${1:-status}" in
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_stop; echo; cmd_start ;;
status|"") cmd_status ;;
logs) cmd_logs ;;
run) cmd_run ;;
-h|--help|help)
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//' ;;
*)
echo "未知命令: $1" >&2
echo "可用: start | stop | restart | status | logs | run" >&2
exit 1 ;;
esac