# 单域名多租户(路径前缀路由)+ 上线收口项 设计 - 日期: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 解析 → 剥前缀 → 下游路由零改动 ``` 嵌入代码形态(脚本地址即租户标识,无需新参数): ```html ``` ## 3. 路由层设计(TenantHostMiddleware 扩展) 优先级:**平台豁免 → 路径前缀解析 → Host 解析**。 1. **slug 形态校验先行**:前缀段须 `^[a-z0-9-]+$`(与租户创建 `SLUG_RE` 一致,tenant_routes.py:65 同款正则常量提取共用);不匹配(含 `..`、编码畸形、大小写不符)直接走 Host 解析(共享域名下即 404),**不做任何剥离**。 2. **路径改写**:匹配 `/t//` 或精确 `/t/`(后者无尾斜杠 → 不剥离,走 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/`);brand / avatar(含 DEFAULT_AVATAR_PATH)/ WS 三类端点构造统一 `wsBase + basePath + 原路径`。Host 模式 basePath="" 零变化;`tests/test_widget_static.py` 风格静态断言钉住。 - **chat.html**:新增 15 行内联引导脚本——按 `location.pathname` 推导 basePath(`/t//` → `/t/`;`/` → ""),改写自身 `/