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

9.3 KiB
Raw Blame History

单域名多租户(路径前缀路由)+ 上线收口项 设计

  • 日期: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 解析 → 剥前缀 → 下游路由零改动

嵌入代码形态(脚本地址即租户标识,无需新参数):

<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 封顶配置在位(铁律检查项过一遍)。