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

97 lines
9.3 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 形态校验先行**:校验在 **`raw_path` 对应段**上做 ASCII 直判(段须为未编码的 `^[a-z0-9-]+$`,与租户创建 `SLUG_RE` 一致,tenant_routes.py:65 同款正则常量提取共用)——`/t/%61uto/` 解码后虽是合法 slug,但原始段含编码即拒(**raw_path 直判为准**;解码后含 `%` 的拒绝路线不充分——`%61` 解码为字母后 `%` 不复存在,故不可依赖解码形态判编码)。不匹配(含 `..`、大小写不符、任何编码形态)直接走 Host 解析(共享域名下即 404),**不做任何剥离**。
2. **路径改写**:**仅当路径为 `/t/<slug>/` 前缀形态(带尾斜杠)才剥离**;`/t/<slug>`(无尾斜杠)视为无前缀、照走 Host 解析(共享域名下 404)。剥离时 `scope["path"]` 去前缀;`scope["raw_path"]` **按剥离后的解码 path 重新编码重算**(不按长度截断——编码形态下解码/原始长度不一致);http 与 websocket 两类 scope 同构处理。
3. **orig_path 留痕**:原始 path 存 `scope["state"]["orig_path"]`(排障/日志对账用;uvicorn 访问日志显示剥离后路径为已知性状)。
4. **解析与错误语义**:slug 经 registry 公开方法 `resolve_by_slug`(见 §5;中间件不触私有 `_by_slug`)——未知 slug → http 404 / ws close(1008);停用 → 503 暂停页 / ws close(1013)(与 resolve_site 同款语义);站点 = 租户**首个站点**为默认(`?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`(既有 test_placeholder_static / test_theme_static 同款 grep 式静态断言)钉住。
- **chat.html**:新增 15 行内联引导脚本——按 `location.pathname` 推导 basePath(`/t/<slug>/` → `/t/<slug>`;`/` → ""),改写自身 `<link>/<script>/<img>` 的绝对引用前缀并定义 `window.__BASE_PATH__`;`static/js/chat.js` 的 brand fetch / avatar / WS 消费该变量(缺省 "" 兼容)。**新建** `tests/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(可信代理语义——取**末段**)
登录端点的限流 IP 提取:`X-Forwarded-For` **末段**(无 XFF **或 XFF 为空串/空白**回退 `request.client.host`——空串末段不得入键,否则全部此类请求共享一个空键)。**只进限流键**(认证语义、日志、其他端点不动)。
为什么是末段而非首段(评审纠偏,安全关键):部署模板用 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`——该语义为**客户端自带 XFF 保留在前、反代把真实来源 `$remote_addr` 追加在尾**。若取首段,攻击者每次请求换一个伪造首段即得全新限流键(零失败历史)→ 无限爆破,比现状更糟。取末段:单跳反代(客户端 → OpenResty → 应用,当前部署形态)下末段=反代亲见的真实 IP,**不可伪造**;将来若加 CDN(客户端 → CDN → OpenResty),末段退化为 CDN IP → 键退化为按用户名——即现状行为,故障安全。
**与引擎 `_server_ip`(取首段)的刻意不对称**:聊天面取首段是上游 ai-kefu 传承的"CDN 在前"拓扑假设,服务的是地理推测/数据质量(伪造无害);登录限流是安全控件,取末段。两者拓扑假设不同,**不许互相"统一"**——注释双向注记。
测试项相应为:末段入键 / 无 XFF 回退 client.host / 伪造首段不影响(换首段仍同键计数)/ 单段直连形态。
## 7. limiter 周期清扫(内存上限铁律合规)
- 登录限流器(platform/auth.py LoginLimiter)与聊天限流桶(engine/services/rate_limit.py `_buckets`):注册**定期清扫**(lifespan 启动 asyncio 任务,每 300s 全量清过期键/空键;停用即取消)。实现走各模块自有数据结构私有方法,不新增共享抽象(YAGNI)。**WS 连接计数不列入**——ConnectionLimiter 的 release() 已自清零计数键,无时间戳结构可清扫(评审核实)。
- 验收注记: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 回退 client.host/伪造首段仍同键计数(防绕过)/单段直连形态。
- 清扫:过期键回收、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 封顶配置在位(铁律检查项过一遍)。