Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-007-routing-frontend.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

382 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.
# ADR-007 · URL 路由与前端租户化
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft · 2026-05-09 据审计修订 §1 / §8 / §10 / §11 / §12Better Auth 假设作废)|
| 决策日期 | TBD |
| 决策者 | 前端 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) |
---
## 1. 背景
ADR-001 ~ 006 锁定了数据/沙箱/Key/RBAC/存储/运行时——但客户最先看到的是**浏览器地址栏长什么样**。多租户产品形态决定了 URL 形态、cookie scope、登录跳转、auth 改造方式。
当前 DeerFlow`frontend/src/`):
- nginx 把 `/api/*` → Gateway 8001、`/api/langgraph/*` → 同 Gateway(重写)
- 前端**没有 Better Auth**——auth 走后端自签 JWTGateway `app/gateway/auth/jwt.py` 签发 → `access_token` cookieHttpOnly)→ 前端 `core/auth/server.ts:25-26` 读 cookie 调 `/auth/me`
- JWT payload 当前结构:`{sub, exp, iat, ver}``auth/jwt.py:14-19`),**无 tid/role**
- `users.token_version` 列已存在 + JWT `ver` claim 已用作失效(`persistence/user/model.py:49`
- CSRF 双重 cookie 已实现(`csrf_middleware.py` + 前端 `core/api/api-client.ts:20-32`
- 没有租户概念,单 host 单工作区
- LangGraph SDK client 在 `core/api/api-client.ts` 单例,所有 thread 操作共用一个 SDK 实例
多租户后必须回答:
1. URL 怎么标记 tenant
2. 多 tenant 切换时 SDK 单例怎么处理?
3. cookie 怎么 scope(避免跨 tenant session 串)?
4. JWT 怎么扩展才能带 tenant_id 和 role
> 审计纠正:原稿假设"前端用 Better Auth 走 cookie session"。`frontend/package.json` 实际不依赖 `better-auth`grep 命中 0 次);前端 auth 是后端自签 JWT + `access_token` cookie 直通——所有"Better Auth 改造"段落都改为"扩展现有 `auth/jwt.py` `TokenPayload`",工作量更小。
---
## 2. 决策
**采用 path-based slug + 顶层 `TenantProvider` + 切换时强制刷新**
| 维度 | 决策 |
|---|---|
| URL 形态 | `/{tenant_slug}/...`(如 `/acme/threads/abc-123` |
| 子域名(如 `acme.deerflow.app` | 推迟到 v2,通过 `tenants.custom_domain` 列预留 |
| Tenant 解析点 | nginx 不解析;后端 `AuthMiddleware` 从 path + JWT 双向交叉校验 |
| Cookie scope | `Path=/`、不绑 tenant;通过 JWT 内 `tid` 区分 |
| SDK 单例 | 全局单例,但切租户时 `await invalidate()` + 强制 reload |
| Auth | 沿用现有 `app/gateway/auth/jwt.py` 自签 JWT;扩 `TokenPayload``tid` + `role` 字段、bump `ver` 触发旧 token 失效 |
---
## 3. 备选方案与拒绝理由
### A. 子域名(`acme.deerflow.app`)作为默认
**拒绝(默认)。** 几个硬伤:
- **本地开发劝退**:每个开发者要起 `*.localtest.me` 之类的通配 DNSDocker compose 的 nginx 也要改
- **TLS 证书**:通配证书或 ACME 动态签证;自部署客户卡这一步
- **`access_token` cookie 跨子域**:要走 `Domain=.deerflow.app`scope 太宽,租户隔离反而变弱
- **CSRF 双重 cookie**:当前 csrf_middleware 假定同源;跨子域要改写
**保留**作为 enterprise plan 的"自定义域名"功能(vanity domain),通过 `tenants.custom_domain` 解析回平台 tenant,但不作为默认。
### B. Header `X-Tenant-Id`(无 URL 标记)
**拒绝。** 浏览器分享一个 thread URL 别人打不开(缺 header),UX 灾难;爬虫/SEO 也无法索引租户公开内容。
### C. URL 不带 tenant,全靠 session
**拒绝。** 用户多 tenant 切换后,浏览器 history 不可区分;同一个 URL 在不同会话里显示不同内容,BUG 报告噩梦。
---
## 4. URL 形态规范
```
公开(不带租户):
/ → 营销页
/login → 登录页
/signup
/accept-invite/{token}
/pricing
租户内:
/{slug}/ → 租户首页(threads 列表)
/{slug}/threads/{tid}
/{slug}/skills
/{slug}/mcp
/{slug}/memory
/{slug}/settings → 租户设置(owner/admin
/{slug}/settings/billing
平台 adminsystem_role=platform_admin):
/admin/tenants
/admin/usage
/admin/audit
```
**slug 约束**
- `^[a-z0-9](-?[a-z0-9])*$`3-32 字符
- 保留 slug 黑名单:`admin``api``auth``login``signup``pricing``docs``status``accept-invite``platform`
- 大小写归一化:DB 存小写
- 切换 slug`tenants``slug_history` 表,30 天内老 slug 重定向到新 slug,过后 410
API 路径**不带 slug**
```
/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 的交叉校验
`AuthMiddleware` 当前从 cookie 读 JWT,注入 `user_id` 到 ContextVar。多租户后改:
```python
async def dispatch(request, call_next):
if _is_public(request.url.path):
return await call_next(request)
payload = await verify_jwt_from_cookie(request)
jwt_tid: str = payload["tid"]
role: str = payload["role"]
# 1. API 调用:tenant 完全靠 JWT
if request.url.path.startswith("/api/"):
active_tid = jwt_tid
# 2. 页面导航:从 path 解析 slug → 反查 tenant_id
else:
slug = _extract_slug(request.url.path)
if slug is None:
active_tid = jwt_tid
else:
tenant = await tenant_repo.get_by_slug(slug)
if tenant is None:
raise HTTPException(404, "Tenant not found")
# JWT tid 与 URL slug 不一致 → 强制重定向到正确 slug 或拒绝
if tenant.id != jwt_tid:
# 校验 user 是否是该 tenant 的成员
membership = await membership_repo.get(tenant.id, payload["sub"])
if membership is None:
raise HTTPException(403, "Not a member of this tenant")
# 是成员但 JWT 没切过来 → 重定向到 /switch-tenant
return RedirectResponse(f"/auth/switch-tenant?to={slug}&next={request.url.path}")
active_tid = tenant.id
set_current_user(...)
set_current_tenant(active_tid, role)
return await call_next(request)
```
**关键**page 路由用 path slug 校验,API 路由用 JWT tid——两条路只在登录时由 `/auth/switch-tenant` 触发同步。
---
## 6. 前端:TenantProvider + SDK 重建
### 6.1 顶层 Provider
```tsx
// frontend/src/core/tenant/provider.tsx
"use client";
export function TenantProvider({ children, tenantId, slug, role }: Props) {
const value = useMemo(() => ({ tenantId, slug, role }), [tenantId, slug, role]);
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
}
export function useTenant() {
const ctx = useContext(TenantContext);
if (!ctx) throw new Error("useTenant() outside TenantProvider");
return ctx;
}
```
挂载点:`app/(tenant)/[slug]/layout.tsx`
```tsx
export default async function TenantLayout({ params, children }) {
const { slug } = await params;
const session = await getSession();
const tenant = await fetchTenantBySlug(slug);
if (!tenant) notFound();
if (session.tid !== tenant.id) {
// 同上:要么是路径错了,要么是切租户没同步
redirect(`/auth/switch-tenant?to=${slug}`);
}
return (
<TenantProvider tenantId={tenant.id} slug={slug} role={session.role}>
{children}
</TenantProvider>
);
}
```
### 6.2 LangGraph SDK 实例与 tenant 绑定
当前 `core/api/langgraph-client.ts` 是模块级单例。改造:**SDK 实例**保持单例(HTTP client 不需要重建),但**所有调用 wrapper**强制读 `useTenant()`,把 `tenantId` 作为对话 metadata 传递(实际 tenant 鉴权在后端 JWT,前端传只是为了请求溯源):
```ts
// useThreadStream.ts
export function useThreadStream(threadId: string) {
const { tenantId } = useTenant();
return useStream<ThreadState>(threadId, {
apiUrl: "/api/langgraph",
metadata: { tenant_id: tenantId }, // 仅用于日志/追踪,不替代鉴权
});
}
```
**租户切换时的清理**
- 切换前用 `cancelAllStreams()` 关掉所有打开的 SSE
- 调用 `POST /api/auth/switch-tenant`(后端重发 JWT 新 cookie
- 拿到 200 后 `window.location.assign(/{newSlug}/)` 强制硬刷新
**为什么硬刷新**
- React state 里有大量缓存的 thread / skill / mcp 配置,按租户全洗一遍代码量大
- LangGraph SDK 内部维护 EventSource 连接池,强制重建最干净
- 一次 nav 1-2 秒可接受,远比 in-memory 切租户的边界 bug 划算
### 6.3 租户切换 UI
顶栏组件 `<TenantSwitcher>`
- 列出 user 的所有 membership(来自 `/api/auth/me` 返回的 `tenants[]`
- 当前激活租户高亮 + 显著色块(避免误操作)
- 点击切换 → 上面 6.2 的硬刷新流程
### 6.4 路由组与 Server Components
```
frontend/src/app/
├── (marketing)/ # 公开:/, /pricing
├── (auth)/ # 登录注册:/login, /signup, /accept-invite
├── (admin)/ # 平台 admin/admin/...
└── (tenant)/[slug]/ # 租户内:/{slug}/...
├── layout.tsx # TenantProvider 注入
├── page.tsx # threads 列表
├── threads/[tid]/page.tsx
├── skills/page.tsx
├── settings/page.tsx
└── ...
```
`layout.tsx` 内的 `fetchTenantBySlug` 走 Server Component → 直连后端,缓存 60s(用 React `cache()`)。
---
## 7. Cookie 与会话
| 维度 | 决策 |
|---|---|
| Session cookie name | `access_token`(沿用现状,HttpOnly |
| Path scope | `/`(不绑 tenant slug |
| Domain | 平台主域(不跨子域) |
| SameSite | `Lax`(默认) |
| HttpOnly | 是 |
| Secure | 是(生产) |
| 切换 tenant | 后端**重签**新 JWT,覆写同一 cookie;不清旧 cookie |
| 跨设备登录 | session 多设备 OK;切 tenant 不强制其他设备退出 |
**为什么 cookie 不绑 slug**:用户从 `/acme/...` 切到 `/bigco/...` 时如果 cookie path 不同,会出现"两个 cookie 同时存在浏览器但前端选错一个"的边界 case。统一 path=/ + JWT 内 `tid` 单一来源最干净。
**CSRF**:现有双重 cookie CSRF`csrf_middleware.py`)保持,CSRF token 不需要按 tenant 区分。
---
## 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`**(字段名以 workspace-schema-design §4 为准):
```python
class TokenPayload(BaseModel):
sub: str # user_id
tid: str # tenant_id(新增;落代码读作 wid / workspace_id
role: str # owner | admin | member(新增)
exp: int
iat: int
ver: int # bump 即让所有旧 token 失效(沿用)
```
`ver` 字段已经在用——任何 membership 变更(加入/退出/role 调整)都 bump `users.token_version`,下次请求 `access_token` 校验失败强制重新登录。
2. **签发流程**:用户登录成功 → 检查 `tenant_memberships` 数量
- 0 个:跳到 `/onboarding/create-tenant`(新用户首次登录)
- 1 个:直接签 `{tid=该 tenant.id, role=membership.role}`,跳 `/{slug}/`
- 多个:跳到 `/select-tenant` 让用户选;选后签对应 JWT,落 `users.default_tenant_id`
3. **切换 tenant**`POST /api/auth/switch-tenant` → 校验 membership → 重签 JWT 覆写 `access_token` cookie → 客户端硬刷新(§6.2)
4. **SSOv2)**:当前自签 JWT 模型可以直接配 SAML / OIDC provider,把外部 IdP 的 user/group 映射到 platform user + membership;不依赖 Better Auth,自由度更高
5. **Invitation 流程**`/accept-invite/{token}` 路径下点击 → 校验 invitation → 自动 attach membership + bump 用户 `token_version` → 跳到 `/{new_slug}/`
**为什么不引入 Better Auth**:现有 JWT 实现已经有 `token_version` 失效机制 + cookie HttpOnly + CSRF 双重 cookie,扩 2 个字段比引入新 auth 框架的破坏面小得多。引入 Better Auth 反而要重写 `auth_middleware.py` + 前端 `core/auth/` + 所有 server actions 调用——工作量多 1 倍。
---
## 9. 自定义域名(v2 预留)
`tenants` 表加 `custom_domain VARCHAR(253) UNIQUE NULL`。客户配置 CNAME 后:
1. 客户在 settings 里填域名
2. 平台调 ACME 签证(per-domain+ Caddy/nginx 动态 vhost
3. 请求来时 nginx 看 Host 头:
- 是平台主域 → 走 path slug 解析
- 是 custom_domain → 反查 tenant_id 直接注入
不在 v1 范围。
---
## 10. 落地改造清单
| 模块 | 改动 | 估工 |
|---|---|---|
| `tenants.slug` + `slug_history` 表 | DB 迁移 | S |
| `AuthMiddleware` path slug 解析 + 交叉校验 | 后端 | M |
| `/api/auth/switch-tenant` 路由 | 后端 | S |
| `/api/auth/me` 返回 tenant 列表 | 后端 | S |
| `auth/jwt.py` `TokenPayload` 扩 `tid/role` 字段 + 签发流程 | 后端 | S |
| 前端 `app/(tenant)/[slug]/layout.tsx` + Provider | 前端 | M |
| 前端路由全部按 `(tenant)/[slug]/` 重组 | 前端 | L |
| `useTenant()` hook + 所有 API 调用接入 | 前端 | M |
| `<TenantSwitcher>` 组件 | 前端 | S |
| 租户首登 onboarding `/onboarding/create-tenant` | 前端 + 后端 | M |
| Tenant picker 页面 `/select-tenant` | 前端 | S |
| 平台 admin `/admin/...` 路由 + 鉴权 | 前端 + 后端 | M |
| 硬刷新切换流 + cancelAllStreams | 前端 | S |
合计:约 8 人周(前端 5 + 后端 3)。
---
## 11. 风险与缓解
| 风险 | 缓解 |
|---|---|
| slug 冲突(保留字 / 已注册) | 注册流程强制校验黑名单;冲突时返显建议 slug |
| 浏览器分享 URL 给非成员看 | 后端 403,前端展示"申请加入"按钮 |
| 切换 tenant 时 streams 没断干净导致看到上租户的 events | hard reload 兜底;E2E 测试 stream cancellation |
| `TokenPayload` 字段升级导致旧 cookie 校验失败 | 加 fallback:旧 4 字段 token 视为"无 tenant 上下文",强制走 `/select-tenant` 重发 |
| 自定义域名灰区(DNS / TLS) | v2 才做,v1 不实现 |
| `default_tenant_id` 被删除(成员被踢) | 登录时 fallback 到 memberships 第一个;都没了引导建租户 |
| SEO 收录租户页 | 默认 `noindex`,租户开关启用公开页 |
---
## 12. 推翻条件
- v2 决定走子域名优先 → §2 决策切到子域名 + 兼容老 path 形态 6 个月
- 单页应用改成多页 / SSR 完整迁移 → 前端层重写,路由组结构会变
- 决定改用 Better Auth / Auth.js 等成熟框架 → §8 重写为框架接入路径,但当前评估收益不抵迁移成本
---
## 13. 默认假设
| 项 | 默认 |
|---|---|
| URL 形态 | `/{slug}/...`slug 3-32 字符小写 |
| 自定义域名 | v2 才支持,v1 不开 |
| Cookie path | `/` |
| Cookie domain | 平台主域,不跨子域 |
| 切换 tenant | 硬刷新(`window.location.assign` |
| Tenant picker | 1 个 membership 时跳过 |
| API 路径 | 不带 slugtenant 由 JWT 决定) |
| SEO | 默认 `noindex`,可按租户开 |