Files
ai-kefu-admin/docs/superpowers/specs/2026-10-19-shared-domain-routing-design.md
T
2026-10-19 15:10:13 +08:00

91 lines
7.8 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.
# 单域名多租户(路径前缀路由)+ 上线收口项 设计
- 日期:2026-10-19
- 状态:待评审
- 范围:后端路由层 + 静态面前缀自推导 + 平台管理面小改 + 登录限流 XFF + limiter 清扫
- 前置:配置中心优化已合入 main(6d6d8a8)
## 1. 背景与目标
现状租户按域名(Host)解析,一租户至少一个域名——给大量网站挂客服时 DNS/证书/反代条目线性增长,不适合运营。目标:**一个共享域名承载任意多租户**,租户标识在 URL 路径中;Host 模式原样保留,两种共存。
上线收口项(随本 spec 一并实施):
1. **登录限流 XFF**:反代形态下 `client.host` 恒为 127.0.0.1,登录限流键退化为纯用户名,多 IP 爆破同一账号不受 IP 维度限制——解析 XFF 首段入键;
2. **limiter 清扫**(日志/内存上限铁律的进程内面):登录限流器与聊天限流桶的过期键永不回收,周期清扫。
## 2. 方案总览
```
Host 模式(不动):chat.xxx.com → registry.resolve_site(host)
路径模式(新增): kefu.xxx.com/t/<slug>/... → 按 slug 解析 → 剥前缀 → 下游路由零改动
```
嵌入代码形态(脚本地址即租户标识,无需新参数):
```html
<script src="https://kefu.xxx.com/t/<slug>/static/widget.js" async></script>
```
## 3. 路由层设计(TenantHostMiddleware 扩展)
优先级:**平台豁免 → 路径前缀解析 → Host 解析**。
1. **slug 形态校验先行**:前缀段须 `^[a-z0-9-]+$`(与租户创建 `SLUG_RE` 一致,tenant_routes.py:65 同款正则常量提取共用);不匹配(含 `..`、编码畸形、大小写不符)直接走 Host 解析(共享域名下即 404),**不做任何剥离**。
2. **路径改写**:匹配 `/t/<slug>/` 或精确 `/t/<slug>`(后者无尾斜杠 → 不剥离,走 Host 解析 → 共享域名 404,语义记录在案);剥离时同步改写 `scope["path"]` 与 `scope["raw_path"]`(bytes,存在才改);http 与 websocket 两类 scope 同构处理。
3. **orig_path 留痕**:原始 path 存 `scope["state"]["orig_path"]`(排障/日志对账用;uvicorn 访问日志显示剥离后路径为已知性状)。
4. **解析与错误语义**:slug → `registry._by_slug` 查租户(缺停用判断与 resolve_site 同款:未知 slug → http 404 / ws close(1008);停用 → 503 暂停页 / ws close(1013));站点 = 租户**首个站点**为默认(`?site=` 覆盖链路既有),`state["site"]` 同样写入。
5. **平台豁免优先级在前**:`/health`、`/api/platform/`、`/admin` 不受影响;`/t/x/api/platform/...` 剥离后命中平台路由属 by design(认证守护,与未知 Host 下 admin 面可达同级)。
6. 已知边界(记录不修):StaticFiles 目录 308 的 Location 不带前缀(挂件/页面只引用具体文件,不触发)。
## 4. 静态面前缀自推导
- **widget.js**:`var basePath = new URL(script.src).pathname` 剥掉尾部 `/static/widget.js...`(得 "" 或 `/t/<slug>`);brand / avatar(含 DEFAULT_AVATAR_PATH)/ WS 三类端点构造统一 `wsBase + basePath + 原路径`。Host 模式 basePath="" 零变化;`tests/test_widget_static.py` 风格静态断言钉住。
- **chat.html**:新增 15 行内联引导脚本——按 `location.pathname` 推导 basePath(`/t/<slug>/` → `/t/<slug>`;`/` → ""),改写自身 `<link>/<script>/<img>` 的绝对引用前缀并定义 `window.__BASE_PATH__`;`static/js/chat.js` 的 brand fetch / avatar / WS 消费该变量(缺省 "" 兼容)。`test_chat_static.py` 同款守卫。
## 5. 平台管理面
- **registry**:新增 `resolve_by_slug(slug) -> tuple[TenantRuntime, Site]`(含停用判断,默认站点 = 站点表首项;空站点表的租户按 UnknownTenantError 处理——路径模式无从选站点)。
- **创建向导**:域名步骤放宽为可跳过(后端 `domains: list[str]` 本就 default 空;前端向导与「租户详情无域名」展示同步);无域名租户 = 纯路径模式。
- **平台设置**:推荐键新增 `shared_widget_domain`(共享挂件域名,运营填一次)。
- **嵌入代码生成器**(配置中心品牌页):双形态——租户有域名 → 域名形态 + 共享域名形态(`shared_widget_domain` 已配置时)都给;无域名 → 仅共享形态;都无 → 引导文案(去平台设置配共享域名 / 去租户详情绑域名)。`data-site` 等参数两形态通用。
## 6. 登录限流 XFF(可信代理语义)
与聊天面 `_server_ip`(XFF 服务端优先、取首段)同款提取:登录端点把 `request.headers.get("x-forwarded-for")` 首段作为限流 IP,无 XFF 回退 `request.client.host`。**只进限流键**(认证语义、日志、其他端点不动)。部署注记:直连形态(无反代)行为不变;反代形态下 XFF 由我方 1Panel 反代附加,客户端伪造的 XFF 会被反代追加在尾部、首段仍为真实来源(OpenResty `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` 语义——部署模板已有则复用,没有则在模板补此头)。
## 7. limiter 周期清扫(内存上限铁律合规)
- 登录限流器(platform/auth.py LoginLimiter)与聊天限流桶(engine/services/rate_limit.py `_buckets`)、WS 连接计数:注册**定期清扫**(lifespan 启动 asyncio 任务,每 300s 全量清过期键/空键;停用即取消)。实现走各模块自有数据结构私有方法,不新增共享抽象(YAGNI)。
- 验收注记:compose 已有 `mem_limit: 768m` 与日志 `json-file 10m×3` 封顶(铁律的容器面已合规,本条补齐进程内面)。
## 8. 部署形态(本 spec 落地后)
- 共享域名(如 `kefu.hnxinyouhe.com`):一次 DNS + 一张证书 + 一条 1Panel 反代(透传 Host 头、WS 开关、超时 3600s——模板 `deploy/openresty-all-kefu.conf.template` 增补该域名 server 块与 XFF 头注记)。
- 新租户接入 = 后台建租户(可零域名)→ 复制嵌入代码 → 完事,零 DNS 操作。
- `docs/deploy-production.md` 增补「共享域名模式」一节(含 `/t/<slug>/` 地址形态与无尾斜杠语义)。
## 9. 测试策略
- 中间件:slug 形态校验(畸形/编码/大小写/无尾斜杠)、path+raw_path 双改写、orig_path、未知/停用语义(http+ws 双 scope)、Host 模式回归(既有 test_host_routing 全绿)、平台豁免优先级、`/t/x/static/…` 全链(chat 页/静态/public/chat REST/WS)。
- registry:resolve_by_slug(默认首站点/site 覆盖/停用/空站点表)。
- 静态守卫:widget.js basePath 构造、chat.html 引导脚本、chat.js __BASE_PATH__ 消费(grep 式静态测试既有风格)。
- 登录限流:XFF 首段入键/无 XFF 回退/伪造 XFF 追加形态(反代追加语义模拟)。
- 清扫:过期键回收、lifespan 启停。
- 前端 vitest:嵌入代码生成器双形态 model 纯函数。
- 基线:pytest 632+2skip 与 vitest 182 不回归,新增只增不减。
## 10. 非目标
- 不做 RAG 知识库(等首个真实新租户知识量再定);
- 不做挂件防盗嵌白名单(演进项);
- 不动 Host 模式任何行为;不做多 worker(单进程硬约束不变);
- 不做 `/t/<slug>` 无尾斜杠 308 重定向(404 语义即可)。
## 11. 验收标准
1. 全量 pytest + vitest 绿;两模式共存回归(chat./chat2. 本地 hosts 走查不受影响);
2. 共享域名模拟走查:`/t/<slug>/` 打开聊天页、挂件加载、WS 对话、试金石、限流隔离(同 IP 双租户互不干扰);
3. 嵌入代码:无域名租户在 `shared_widget_domain` 配置后可生成可用嵌入代码;
4. 登录限流:反代形态(XFF)下按真实 IP 计数;清扫任务可见过期键回收;
5. 日志/内存上限:compose 封顶配置在位(铁律检查项过一遍)。