fix: Host中间件审查修复(本地开发文档/顺序守护测试/ws断连分支/夹具清理)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,12 @@
|
|||||||
- **uvicorn 必须以 `--workers 1` 运行**。内存中的 token 缓存、限流状态、配置缓存、每租户 engine 缓存均假设单进程,多 worker 会导致状态割裂。任何启动脚本/部署配置不得改动此项。
|
- **uvicorn 必须以 `--workers 1` 运行**。内存中的 token 缓存、限流状态、配置缓存、每租户 engine 缓存均假设单进程,多 worker 会导致状态割裂。任何启动脚本/部署配置不得改动此项。
|
||||||
- **Windows 环境**:跑 python / pytest 前设置 `PYTHONUTF8=1`(GBK 控制台遇 emoji / 中文 payload 会直接崩,血泪教训)。
|
- **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(出厂配置影响面大)。
|
- 改动 `seed_profiles` 后必须跑全量 pytest(出厂配置影响面大)。
|
||||||
|
|||||||
@@ -52,10 +52,16 @@ def _host_from_scope(scope: Scope) -> str:
|
|||||||
|
|
||||||
async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> None:
|
async def _close_ws(receive: Receive, send: Send, code: int, reason: str) -> None:
|
||||||
"""以 close 代 accept 拒绝 WS(ASGI 规范序列: 先收到 websocket.connect,
|
"""以 close 代 accept 拒绝 WS(ASGI 规范序列: 先收到 websocket.connect,
|
||||||
再回 websocket.close)。reason 中性,不带租户信息。"""
|
再回 websocket.close)。reason 中性,不带租户信息。
|
||||||
|
|
||||||
|
客户端在握手期间放弃时收到的首个事件是 websocket.disconnect 而非
|
||||||
|
connect——对端已撤,close 送达不了,直接 return。receive 本身抛异常
|
||||||
|
(传输层断)则向上传播,由外层 ServerErrorMiddleware 记日志:拒绝路径
|
||||||
|
无任何已建立状态可留,无需也不应再兜一层。"""
|
||||||
event = await receive()
|
event = await receive()
|
||||||
if event["type"] == "websocket.connect":
|
if event["type"] == "websocket.disconnect":
|
||||||
await send({"type": "websocket.close", "code": code, "reason": reason})
|
return
|
||||||
|
await send({"type": "websocket.close", "code": code, "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
class TenantHostMiddleware:
|
class TenantHostMiddleware:
|
||||||
|
|||||||
+4
-3
@@ -10,6 +10,7 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from app.engine.models import Base
|
from app.engine.models import Base
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ def app_test_client(app_instance, base_url, cleanup_root):
|
|||||||
try:
|
try:
|
||||||
with TenantTestClient(
|
with TenantTestClient(
|
||||||
app_instance, base_url=base_url,
|
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
|
yield c
|
||||||
finally:
|
finally:
|
||||||
app_instance.state.registry = None
|
app_instance.state.registry = None
|
||||||
@@ -171,8 +172,8 @@ def app_client(tmp_path):
|
|||||||
TestClient——同一 app 上两个 client 各跑一次 lifespan 会共享
|
TestClient——同一 app 上两个 client 各跑一次 lifespan 会共享
|
||||||
registry 而 engine 各绑一个循环,跨循环死锁。
|
registry 而 engine 各绑一个循环,跨循环死锁。
|
||||||
"""
|
"""
|
||||||
build_app(SEED_DIR, tmp_path)
|
a = build_app(SEED_DIR, tmp_path)
|
||||||
with app_test_client(app, f"http://{JIANCAI_HOST}", tmp_path) as c:
|
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import shutil
|
|||||||
import pytest
|
import pytest
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.platform.host_middleware import TenantHostMiddleware
|
||||||
from tests.conftest import (
|
from tests.conftest import (
|
||||||
AUTO_HOST,
|
AUTO_HOST,
|
||||||
JIANCAI_HOST,
|
JIANCAI_HOST,
|
||||||
@@ -22,10 +24,20 @@ from tests.conftest import (
|
|||||||
NOPE_HOST = "nope.example.com"
|
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,
|
# /health 在任何 Host 下可达(部署健康检查 curl 127.0.0.1:8018,
|
||||||
# Host 是 IP:端口,对 registry 必然未知)
|
# Host 是 IP:端口,对 registry 必然未知)
|
||||||
assert app_client.get("/health").status_code == 200
|
assert app_client.get("/health").status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user