From fd0f49870cd6d1c1b84541a881a2786c6e0d4eb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 12 Oct 2026 20:47:01 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Host=E4=B8=AD=E9=97=B4=E4=BB=B6=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88=E6=9C=AC=E5=9C=B0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=96=87=E6=A1=A3/=E9=A1=BA=E5=BA=8F=E5=AE=88?= =?UTF-8?q?=E6=8A=A4=E6=B5=8B=E8=AF=95/ws=E6=96=AD=E8=BF=9E=E5=88=86?= =?UTF-8?q?=E6=94=AF/=E5=A4=B9=E5=85=B7=E6=B8=85=E7=90=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- CLAUDE.md | 6 ++++++ app/platform/host_middleware.py | 12 +++++++++--- tests/conftest.py | 7 ++++--- tests/test_host_routing.py | 14 +++++++++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e851915..127ef9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,12 @@ - **uvicorn 必须以 `--workers 1` 运行**。内存中的 token 缓存、限流状态、配置缓存、每租户 engine 缓存均假设单进程,多 worker 会导致状态割裂。任何启动脚本/部署配置不得改动此项。 - **Windows 环境**:跑 python / pytest 前设置 `PYTHONUTF8=1`(GBK 控制台遇 emoji / 中文 payload 会直接崩,血泪教训)。 +## 本地开发访问方式 + +- 路由按 Host 解析租户:`/` 与 `/static` 也需要已知租户域名(未知 Host 一律 404,唯一豁免 `/health`,供部署健康检查 curl 用)。 +- 本地访问二选一:改 hosts 把 `chat.agent.hnxinyouhe.com` / `chat2.agent.hnxinyouhe.com` 指到 `127.0.0.1`;或 curl 显式带 `-H "Host: chat2.agent.hnxinyouhe.com"`。 +- uvicorn 起在 8018:`PYTHONUTF8=1 .venv/Scripts/python -m uvicorn app.main:app --port 8018 --workers 1`。 + ## 开发流程 - 改动 `seed_profiles` 后必须跑全量 pytest(出厂配置影响面大)。 diff --git a/app/platform/host_middleware.py b/app/platform/host_middleware.py index deede14..f3e0eee 100644 --- a/app/platform/host_middleware.py +++ b/app/platform/host_middleware.py @@ -52,10 +52,16 @@ def _host_from_scope(scope: Scope) -> str: async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> None: """以 close 代 accept 拒绝 WS(ASGI 规范序列: 先收到 websocket.connect, - 再回 websocket.close)。reason 中性,不带租户信息。""" + 再回 websocket.close)。reason 中性,不带租户信息。 + + 客户端在握手期间放弃时收到的首个事件是 websocket.disconnect 而非 + connect——对端已撤,close 送达不了,直接 return。receive 本身抛异常 + (传输层断)则向上传播,由外层 ServerErrorMiddleware 记日志:拒绝路径 + 无任何已建立状态可留,无需也不应再兜一层。""" event = await receive() - if event["type"] == "websocket.connect": - await send({"type": "websocket.close", "code": code, "reason": reason}) + if event["type"] == "websocket.disconnect": + return + await send({"type": "websocket.close", "code": code, "reason": reason}) class TenantHostMiddleware: diff --git a/tests/conftest.py b/tests/conftest.py index c935c52..b5abc32 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import pytest from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool +from urllib.parse import urlsplit from app.engine.models import Base @@ -152,7 +153,7 @@ def app_test_client(app_instance, base_url, cleanup_root): try: with TenantTestClient( app_instance, base_url=base_url, - ws_default_host=base_url.removeprefix("http://")) as c: + ws_default_host=urlsplit(base_url).netloc) as c: yield c finally: app_instance.state.registry = None @@ -171,8 +172,8 @@ def app_client(tmp_path): TestClient——同一 app 上两个 client 各跑一次 lifespan 会共享 registry 而 engine 各绑一个循环,跨循环死锁。 """ - build_app(SEED_DIR, tmp_path) - with app_test_client(app, f"http://{JIANCAI_HOST}", tmp_path) as c: + a = build_app(SEED_DIR, tmp_path) + with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c: yield c diff --git a/tests/test_host_routing.py b/tests/test_host_routing.py index 5da828c..e8b0afd 100644 --- a/tests/test_host_routing.py +++ b/tests/test_host_routing.py @@ -11,6 +11,8 @@ import shutil import pytest from starlette.websockets import WebSocketDisconnect +from app.main import app +from app.platform.host_middleware import TenantHostMiddleware from tests.conftest import ( AUTO_HOST, JIANCAI_HOST, @@ -22,10 +24,20 @@ from tests.conftest import ( NOPE_HOST = "nope.example.com" +# ---- 中间件挂载顺序守护 ------------------------------------------------------- + + +def test_tenant_middleware_is_outermost_user_middleware(): + # add_middleware 后加者居 user_middleware[0] = 最外层。租户解析必须 + # 先于一切用户中间件(里层中间件/路由才读得到 state.tenant)——未来 + # 新中间件若更晚 add_middleware 插到租户中间件外层,此测试即红。 + assert app.user_middleware[0].cls is TenantHostMiddleware + + # ---- 平台级豁免 ------------------------------------------------------------- -def test_known_host_serves_health(app_client): +def test_health_served_for_any_host(app_client): # /health 在任何 Host 下可达(部署健康检查 curl 127.0.0.1:8018, # Host 是 IP:端口,对 registry 必然未知) assert app_client.get("/health").status_code == 200