# 单域名多租户(路径前缀路由)+ 上线收口项 设计 - 日期: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 形态校验先行**:校验在 **`raw_path` 对应段**上做 ASCII 直判(段须为未编码的 `^[a-z0-9-]+$`,与租户创建 `SLUG_RE` 一致,tenant_routes.py:65 同款正则常量提取共用)——`/t/%61uto/` 解码后虽是合法 slug,但原始段含编码即拒(**raw_path 直判为准**;解码后含 `%` 的拒绝路线不充分——`%61` 解码为字母后 `%` 不复存在,故不可依赖解码形态判编码)。不匹配(含 `..`、大小写不符、任何编码形态)直接走 Host 解析(共享域名下即 404),**不做任何剥离**。 2. **路径改写**:**仅当路径为 `/t//` 前缀形态(带尾斜杠)才剥离**;`/t/`(无尾斜杠)视为无前缀、照走 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/`);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//` → `/t/`;`/` → ""),改写自身 `/