feat: Host 中间件租户路由(纯ASGI覆盖WS/未知404/停用503暂停页/health豁免)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
"""路由取当前租户运行时——Task 5b→7 的过渡兜底。
|
||||
"""路由取当前租户运行时——纯访问器(Task 7)。
|
||||
|
||||
Task 7 的 Host 中间件上线前,没有人为请求挂 ``state.tenant``。本助手的
|
||||
兜底规则 = state 里有租户用 state 的(中间件灰度接入即生效);没有则用
|
||||
``app.state.default_runtime``(lifespan 预热的默认租户 jiancai),API 测试
|
||||
与直连(无 Host 语义)在 5b→7 过渡期不断。
|
||||
|
||||
已知过渡限制(记录在案,Task 7 一并解决):默认租户经 registry_reload
|
||||
热切换后 default_runtime 仍指向旧 runtime——过渡期不修,Task 7 中间件
|
||||
按请求 Host 重新 resolve 后本模块整体删除。
|
||||
TenantHostMiddleware(纯 ASGI,http 与 websocket 双覆盖)保证凡到达路由
|
||||
的请求 ``state.tenant`` 必已就位;唯一例外是平台级豁免路径(/health),
|
||||
而它不访问租户资源。本模块不再有任何兜底——state 无租户即 raise,把
|
||||
"中间件没挂上/被绕过"这类装配错误炸在第一现场而不是静默串库。
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -20,13 +16,10 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def get_runtime(request_or_ws: Request | WebSocket) -> "TenantRuntime":
|
||||
"""state.tenant 优先,缺省落 app.state.default_runtime(Task 7 删)。"""
|
||||
"""读 state.tenant(中间件写入);缺失即装配错误,清晰失败。"""
|
||||
runtime = getattr(request_or_ws.state, "tenant", None)
|
||||
if runtime is not None:
|
||||
return runtime
|
||||
runtime = getattr(request_or_ws.app.state, "default_runtime", None)
|
||||
if runtime is None or runtime.session_factory is None:
|
||||
if runtime is None:
|
||||
raise RuntimeError(
|
||||
"默认租户运行时未就绪——lifespan 未运行、未预热 default_runtime,"
|
||||
"或 registry_reload 已 dispose 旧 runtime(过渡期限制,Task 7 解决)")
|
||||
"请求 state 里没有租户运行时——TenantHostMiddleware 未覆盖该"
|
||||
"请求(中间件未挂载,或豁免路径访问了租户资源)")
|
||||
return runtime
|
||||
|
||||
+9
-10
@@ -6,6 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.engine.config import STATIC_DIR
|
||||
from app.platform.host_middleware import TenantHostMiddleware
|
||||
from app.platform.registry import TenantRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,9 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
SEED_DIR = BASE_DIR / "seed_profiles"
|
||||
TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db
|
||||
# 过渡兜底(Task 5b→7,中间件上线后随 routes/runtime.py 一并删除):
|
||||
# Host 中间件落地前请求 state 里没有租户,一律落默认租户 jiancai
|
||||
DEFAULT_TENANT_HOST = "chat2.agent.hnxinyouhe.com"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -29,15 +27,10 @@ async def lifespan(app: FastAPI):
|
||||
app.state.registry = registry
|
||||
|
||||
try:
|
||||
# 预热默认租户(过渡兜底读 app.state.default_runtime)。engine 建在
|
||||
# 本 lifespan 的事件循环里——连接与 loop 绑定,别处另起 asyncio.run
|
||||
# 复用同一 engine 会跨 loop 死锁(测试直连造数走 conftest.async_db)。
|
||||
# 预热/加载保持 fail-fast:默认租户起不来 = 过渡期全站无租户。
|
||||
app.state.default_runtime = await registry.resolve(DEFAULT_TENANT_HOST)
|
||||
|
||||
# 询盘推送(设计 8.2): 清扫崩溃遗留的 stale pending → failed。逐租户、
|
||||
# 用该租户自己的 session_factory;db 文件不存在的租户跳过——全新库
|
||||
# 无遗留行,保持 engine 懒建纪律(预热的默认租户文件已建,照常扫)。
|
||||
# 无遗留行,保持 engine 懒建纪律(租户 engine 由 Host 中间件按请求
|
||||
# 懒建,启动期只有已有库文件的租户会被扫描)。
|
||||
# 清扫失败只记日志继续:一个租户的坏库不炸整个平台启动(其余租户
|
||||
# 照常服务),该租户下轮重启还会重扫。
|
||||
from app.engine.services.lead_push_service import sweep_stale_pending
|
||||
@@ -80,6 +73,12 @@ async def static_no_cache(request, call_next):
|
||||
return resp
|
||||
|
||||
|
||||
# 租户路由中间件(Task 7): 最后 add = 最外层——租户解析先于一切用户中间件。
|
||||
# 纯 ASGI 类(http+websocket 双覆盖),registry 经 scope["app"] 延迟取,
|
||||
# 因此可在 lifespan 挂 Registry 之前于模块级注册。
|
||||
app.add_middleware(TenantHostMiddleware)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""TenantHostMiddleware——按 Host 头解析租户的纯 ASGI 中间件(Task 7)。
|
||||
|
||||
为什么必须是纯 ASGI 类而不是 ``@app.middleware("http")``(前序审查实证过
|
||||
的坑): 那条路走 BaseHTTPMiddleware,只跑 http scope——WebSocket 请求会
|
||||
整个绕过租户解析落进 routes/runtime.py 的兜底,租户 A 的 WS 流量静默写进
|
||||
租户 B 的库(串库不崩溃,极难发现)。本类自己分发 http 与 websocket 两种
|
||||
scope type,两条路都先过租户解析。
|
||||
|
||||
行为:
|
||||
- scope 非 http/websocket(lifespan 等)原样放行;
|
||||
- path 精确等于 ``/health`` 豁免(部署健康检查 ``curl 127.0.0.1:8018``
|
||||
/health,Host 是 IP:端口必然未知;精确匹配,``/healthxxx`` 不豁免);
|
||||
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
|
||||
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013);
|
||||
- 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
|
||||
与 scope["state"] 共享同一 dict(setdefault 保证),routes 的 get_runtime
|
||||
即读到本中间件写入的 runtime。
|
||||
|
||||
Registry 延迟取: ``add_middleware`` 实例化时 lifespan 还没跑、
|
||||
``app.state.registry`` 未挂,构造期拿不到——从 ``scope["app"].state``
|
||||
逐请求取(Starlette 在中间件栈外层保证 scope 带 app)。
|
||||
"""
|
||||
from starlette.responses import HTMLResponse, PlainTextResponse
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
# 归一复用 registry 的私有实现——同一包内单一事实源,中间件与域名索引
|
||||
# 的建法永不漂移(resolve_site 内部也会再归一次,双保险无害)
|
||||
from app.platform.registry import (
|
||||
TenantDisabledError,
|
||||
UnknownTenantError,
|
||||
_normalize_host,
|
||||
)
|
||||
|
||||
# 中性"服务暂停"页: 不暴露租户 slug/域名/停用原因(可能是欠费等敏感信息)
|
||||
PAUSE_PAGE = (
|
||||
"<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
|
||||
"<title>服务暂停</title></head>"
|
||||
"<body style=\"font-family:system-ui,sans-serif;text-align:center;"
|
||||
"padding:4rem 1rem;color:#333;\">"
|
||||
"<p style=\"font-size:1.1rem;\">服务暂停维护中,请稍后再试。</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _host_from_scope(scope: Scope) -> str:
|
||||
"""ASGI headers(小写 bytes 键值对)里的 host → str 并归一。"""
|
||||
for key, value in scope.get("headers") or []:
|
||||
if key == b"host":
|
||||
return _normalize_host(value.decode("latin-1"))
|
||||
return ""
|
||||
|
||||
|
||||
async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> None:
|
||||
"""以 close 代 accept 拒绝 WS(ASGI 规范序列: 先收到 websocket.connect,
|
||||
再回 websocket.close)。reason 中性,不带租户信息。"""
|
||||
event = await receive()
|
||||
if event["type"] == "websocket.connect":
|
||||
await send({"type": "websocket.close", "code": code, "reason": reason})
|
||||
|
||||
|
||||
class TenantHostMiddleware:
|
||||
"""纯 ASGI 租户路由中间件(设计动机见模块 docstring)。"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] not in ("http", "websocket"):
|
||||
return await self.app(scope, receive, send)
|
||||
if scope.get("path") == "/health":
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
registry = getattr(scope["app"].state, "registry", None)
|
||||
if registry is None:
|
||||
raise RuntimeError(
|
||||
"app.state.registry 未挂载——TenantHostMiddleware 需要 "
|
||||
"lifespan(或测试注入缝)先加载 TenantRegistry")
|
||||
|
||||
try:
|
||||
runtime, site = await registry.resolve_site(_host_from_scope(scope))
|
||||
except UnknownTenantError:
|
||||
if scope["type"] == "websocket":
|
||||
await _close_ws(receive, send, 1008, "unknown host")
|
||||
else:
|
||||
await PlainTextResponse("Not Found", status_code=404)(
|
||||
scope, receive, send)
|
||||
return
|
||||
except TenantDisabledError:
|
||||
if scope["type"] == "websocket":
|
||||
await _close_ws(receive, send, 1013, "service paused")
|
||||
else:
|
||||
await HTMLResponse(PAUSE_PAGE, status_code=503)(
|
||||
scope, receive, send)
|
||||
return
|
||||
|
||||
state = scope.setdefault("state", {})
|
||||
state["tenant"] = runtime
|
||||
state["site"] = site
|
||||
await self.app(scope, receive, send)
|
||||
Reference in New Issue
Block a user