feat: admin生产服务(/admin SPA fallback+health分级) + Docker两阶段构建 + 生产compose
- app/main.py: AdminSPA 纯 ASGI 挂载 /admin(dist 存在=StaticFiles html
+ SPA fallback(非 /admin/api 的 404 回 index.html 200);缺失=503 说明页);
ADMIN_DIST env 覆盖、请求期现势解析(构建后不重启生效/测试可注入)。
/health 分级: {status, tenants:[{slug,status,engine_ok}], domains}——
懒建未就绪 engine_ok=None 不探库;单租户库损坏只 degraded 仍 200,
不让 healthcheck 杀容器。
- host_middleware: /admin 与 /admin/ 前缀豁免租户解析(平台面;/admin-evil
仍 404,前缀精确到斜杠)。
- admin: .env.production VITE_BASE_URL=/admin/(产物资源引用与挂载同源,
单一事实源,不靠构建期 env 注入);package.json 钉 packageManager
pnpm@10.7.1(corepack 与本地同版本)。
- Dockerfile 两阶段: node:20-alpine 构建 admin/dist(corepack 按清单钉版)
→ python:3.13-slim 只带运行时(app/static/seed_profiles/scripts+dist,
含 tzdata 使 TZ 生效)。
- docker-compose: 生产编排(8018/env_file 可缺省/TZ/数据 bind mount/
mem_limit 768m/日志轮转/python urllib healthcheck/restart)。
- .dockerignore 防泄(.env 不进镜像)+ .gitattributes 钉部署文件 LF。
- tests/test_admin_serving.py: 假 dist 夹具 7 例(服务/fallback 边界/
Host 豁免/缺失降级/health 分级含坏库 degraded)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# 构建上下文瘦身 + 防泄(Phase 4 Task 1):
|
||||
# - .env 必须排除(密钥不进镜像层);admin/.env.production 是构建输入,
|
||||
# 不受根级 .env 规则影响(.dockerignore 模式锚定在上下文根)
|
||||
# - tests/docs 不进生产镜像(镜像内不拷 tests,Dockerfile 与此保持一致)
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.venv
|
||||
venv
|
||||
**/node_modules
|
||||
admin/dist
|
||||
.worktrees
|
||||
tests
|
||||
docs
|
||||
data
|
||||
tenants
|
||||
platform.db*
|
||||
.playwright-mcp
|
||||
.claude
|
||||
.pytest_cache
|
||||
**/__pycache__
|
||||
*.md
|
||||
.env
|
||||
@@ -0,0 +1,5 @@
|
||||
# 部署关键文件钉 LF:Windows autocrlf 检出成 CRLF 会破坏 .dockerignore
|
||||
# 模式匹配(行尾 \r)并污染 Dockerfile/compose 的跨平台一致性
|
||||
Dockerfile text eol=lf
|
||||
.dockerignore text eol=lf
|
||||
docker-compose.yml text eol=lf
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# 两阶段构建(Phase 4 Task 1):
|
||||
# 阶段一 node:20-alpine 构建 admin 前端 → dist;
|
||||
# 阶段二 python:3.13-slim 只带运行时所需(app/static/seed_profiles/
|
||||
# scripts + 前端 dist),不带 node/tests/docs。
|
||||
|
||||
# ---- 阶段一: admin 前端构建 --------------------------------------------------
|
||||
FROM node:20-alpine AS admin-build
|
||||
WORKDIR /build
|
||||
# 先拷清单再拷源码:依赖层缓存与源码改动解耦
|
||||
COPY admin/package.json admin/pnpm-lock.yaml admin/pnpm-workspace.yaml ./
|
||||
# corepack 按 package.json 的 packageManager 字段钉 pnpm 版本(与本地
|
||||
# 一致的单一事实源);升级 corepack 规避 node20 镜像内置旧版的签名钥问题
|
||||
RUN npm install -g corepack@latest && corepack enable
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY admin/ ./
|
||||
# VITE_BASE_URL 取 admin/.env.production(=/admin/):产物资源引用与后端
|
||||
# 挂载路径同源;不靠构建期 env 注入(vite 的 env/文件优先级易漂移,
|
||||
# 文件为单一事实源)
|
||||
RUN pnpm build
|
||||
|
||||
# ---- 阶段二: 生产镜像 --------------------------------------------------------
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /app
|
||||
# tzdata: compose 设 TZ=Asia/Shanghai,推送窗口等业务时间依赖本地时区,
|
||||
# slim 基镜像不带 tzdata 时 TZ 会被静默忽略(退 UTC)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY static ./static
|
||||
COPY seed_profiles ./seed_profiles
|
||||
COPY scripts ./scripts
|
||||
COPY --from=admin-build /build/dist ./admin/dist
|
||||
EXPOSE 8018
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8018", "--workers", "1"]
|
||||
@@ -9,7 +9,9 @@
|
||||
VITE_VERSION = 0.1.0
|
||||
|
||||
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
|
||||
VITE_BASE_URL = /
|
||||
# Phase 4: 后端把 admin/dist 挂载在 /admin(见 app/main.py AdminSPA),
|
||||
# 生产产物资源引用必须同前缀;dev 保持 .env.development 的 /(走 Vite 代理)
|
||||
VITE_BASE_URL = /admin/
|
||||
|
||||
# 权限模式【 frontend 前端模式 / backend 后端模式 】
|
||||
VITE_ACCESS_MODE = frontend
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.7.1",
|
||||
"engines": {
|
||||
"node": ">=20.19.0",
|
||||
"pnpm": ">=8.8.0"
|
||||
|
||||
+112
-4
@@ -3,10 +3,13 @@ from pathlib import Path
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import FileResponse, PlainTextResponse
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from app.engine.config import STATIC_DIR, settings
|
||||
from app.platform.db import (
|
||||
@@ -28,6 +31,75 @@ TENANTS_DIR = BASE_DIR / "tenants" # .gitignore 已忽略 tenants/*.db
|
||||
PLATFORM_DB = BASE_DIR / "platform.db" # .gitignore 已忽略 platform.db*
|
||||
|
||||
|
||||
# ---- admin 前端生产服务(Phase 4 Task 1)---------------------------------------
|
||||
|
||||
ADMIN_NOT_BUILT_MSG = (
|
||||
"admin 前端未构建(admin/dist 缺失)。生产镜像内由两阶段 Dockerfile 的"
|
||||
" node 阶段产出;开发模式请用 cd admin && pnpm dev(Vite dev server,"
|
||||
" API 经代理指向本服务)。")
|
||||
|
||||
|
||||
def resolve_admin_dist() -> Path:
|
||||
"""admin 构建产物目录:env ADMIN_DIST 覆盖(测试注入/非常规部署),
|
||||
默认 <repo>/admin/dist(vite build 的 outDir)。请求期现势解析——
|
||||
构建完成后无需重启进程即可生效,也让测试能逐用例换目录。"""
|
||||
env = os.environ.get("ADMIN_DIST")
|
||||
return Path(env) if env else BASE_DIR / "admin" / "dist"
|
||||
|
||||
|
||||
class AdminSPA:
|
||||
"""/admin 的 SPA 静态服务(纯 ASGI,整体作为 Mount 的 app)。
|
||||
|
||||
- dist 存在(以 index.html 为准):等价 StaticFiles(html=True)——
|
||||
目录请求落 index.html、assets 按文件服务;
|
||||
- dist 缺失:503 纯文本说明(等价"不 mount + 说明页"的可观测行为,
|
||||
且免去模块导入期的双分支——导入时序决定挂载会让测试与"构建后
|
||||
不重启生效"都不可行);
|
||||
- SPA fallback:Mount 内 404(hash 路由下是加固——直连/刷新深链不
|
||||
白屏)回 index.html 200;/api 子前缀除外(未来 admin 侧 API 的路由
|
||||
404 必须按默认 JSON 暴露,不被 index.html 吞)。fallback 收在
|
||||
Mount 内部而非全局 ExceptionHandler:不吃全局 404 语义(其余路径
|
||||
的默认 JSON 404 原样保留,零越界面),子路径判 /api 即判
|
||||
/admin/api(见 __call__ 内的 root_path 归一)。
|
||||
|
||||
StaticFiles 按目录缓存(ADMIN_DIST 切换/dist 重建时换新实例);
|
||||
构造只查目录存在,并发首请求双建幂等,末次赋值胜出。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cached_dir: Path | None = None
|
||||
self._static: StaticFiles | None = None
|
||||
|
||||
def _static_for(self, dist: Path) -> StaticFiles:
|
||||
if self._static is None or self._cached_dir != dist:
|
||||
self._static = StaticFiles(directory=str(dist), html=True)
|
||||
self._cached_dir = dist
|
||||
return self._static
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive,
|
||||
send: Send) -> None:
|
||||
dist = resolve_admin_dist()
|
||||
if not (dist / "index.html").is_file():
|
||||
await PlainTextResponse(
|
||||
ADMIN_NOT_BUILT_MSG, status_code=503)(scope, receive, send)
|
||||
return
|
||||
try:
|
||||
await self._static_for(dist)(scope, receive, send)
|
||||
except StarletteHTTPException as exc:
|
||||
# 余段子路径 = path 剥去 root_path 挂载段:Starlette 1.8 的
|
||||
# Mount 不改写 scope["path"](仅追加 root_path),老版本语义则
|
||||
# 直接给剥好的 path——两种形态下都归一出 "/xxx" 再判 /api。
|
||||
full_path = scope.get("path") or ""
|
||||
root_path = scope.get("root_path") or ""
|
||||
sub = (full_path[len(root_path):]
|
||||
if root_path and full_path.startswith(root_path)
|
||||
else full_path)
|
||||
if (exc.status_code != 404
|
||||
or sub == "/api" or sub.startswith("/api/")):
|
||||
raise
|
||||
await FileResponse(str(dist / "index.html"))(scope, receive, send)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
|
||||
@@ -165,6 +237,9 @@ app.include_router(platform_stats_router)
|
||||
app.include_router(platform_auth_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
# admin SPA(Phase 4 Task 1): 与 API 路由前缀互斥,挂载次序无冲突;
|
||||
# Host 中间件已豁免 /admin 与 /admin/ 前缀(平台面不依赖租户域名)。
|
||||
app.mount("/admin", AdminSPA(), name="admin")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -191,5 +266,38 @@ async def index():
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
async def health(request: Request):
|
||||
"""分级健康检查(Phase 4 Task 1:Docker healthcheck/部署观测的事实源)。
|
||||
|
||||
- 整体 status:任一已建 engine 探库失败 → "degraded" 但仍 200——单
|
||||
租户库损坏不该让编排器杀容器(其余租户还在服务),也不整体 500;
|
||||
- tenants:逐租户 slug/status(引擎配置态)+ engine_ok:True=探活
|
||||
通过 / False=库损坏 / None=engine 懒建未就绪(不探——健康检查
|
||||
不应成为触发全租户建库的路径);
|
||||
- domains:域名绑定总数(导入完整性的人工核对口径)。
|
||||
|
||||
探活走租户自己的 session_factory(SELECT 1),任何异常都收敛成该
|
||||
租户 degraded,不炸整体。
|
||||
"""
|
||||
registry = getattr(request.app.state, "registry", None)
|
||||
tenants = []
|
||||
overall = "ok"
|
||||
domains = 0
|
||||
if registry is not None:
|
||||
for rt in registry.runtimes():
|
||||
domains += sum(len(site.domains) for site in rt.config.sites)
|
||||
engine_ok = None
|
||||
if rt.session_factory is not None:
|
||||
try:
|
||||
async with rt.session_factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
engine_ok = True
|
||||
except Exception:
|
||||
engine_ok = False
|
||||
overall = "degraded"
|
||||
tenants.append({
|
||||
"slug": rt.config.slug,
|
||||
"status": rt.config.status,
|
||||
"engine_ok": engine_ok,
|
||||
})
|
||||
return {"status": overall, "tenants": tenants, "domains": domains}
|
||||
|
||||
@@ -12,6 +12,8 @@ scope type,两条路都先过租户解析。
|
||||
/health,Host 是 IP:端口必然未知;精确匹配,``/healthxxx`` 不豁免);
|
||||
- path 前缀 ``/api/platform/`` 豁免(Phase 2 Task 3,平台后台不依赖租户
|
||||
域名解析;安全模型详见 ``__call__`` 内注释——豁免≠放行);
|
||||
- path 等于 ``/admin`` 或前缀 ``/admin/`` 豁免(Phase 4 Task 1,生产
|
||||
admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免);
|
||||
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
|
||||
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013);
|
||||
- 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
|
||||
@@ -80,23 +82,32 @@ class TenantHostMiddleware:
|
||||
# websocket 两类 scope 同构生效——当前 /api/platform 前缀没有 WS
|
||||
# 端点,保持与 /health 同构以防将来有):
|
||||
# - /health 精确匹配(见模块 docstring);
|
||||
# - /api/platform/ 前缀(Phase 2 Task 3)。
|
||||
# - /api/platform/ 前缀(Phase 2 Task 3);
|
||||
# - /admin 与 /admin/ 前缀(Phase 4 Task 1,生产 admin SPA 静态
|
||||
# 面;hash 路由下直连的都是 /admin/ 一级,深链防御同构保留)。
|
||||
#
|
||||
# 安全模型——豁免≠放行:本豁免只跳过"租户解析"这一层,不打开
|
||||
# 任何受保护端点:/api/platform/* 的认证由路由依赖守护
|
||||
# (platform_user = get_current_user + enforce_password_changed,
|
||||
# 未带有效 token 一律 401/409)。未知 Host 下 admin API 可达是
|
||||
# **有意设计**:生产的 admin 域名与租户域名在反代层区分(见
|
||||
# spec §4 边界规则与部署文档),应用层不按 Host 收紧 admin 面。
|
||||
# 前缀精确到斜杠:startswith("/api/platform/"),/api/platform-evil
|
||||
# 不豁免(仍走租户解析,测试 test_prefix_match_precise 钉住)。
|
||||
# 未带有效 token 一律 401/409)。/admin 只是静态资源(公开的前端
|
||||
# 产物),真正的数据面仍在 /api/platform/* 认证后。未知 Host 下
|
||||
# admin 面可达是 **有意设计**:生产的 admin 域名与租户域名在反代
|
||||
# 层区分(见 spec §4 边界规则与部署文档),应用层不按 Host 收紧
|
||||
# admin 面。
|
||||
# 前缀精确到斜杠:startswith("/api/platform/") 与 startswith(
|
||||
# "/admin/"),/api/platform-evil、/admin-evil 不豁免(仍走租户
|
||||
# 解析,测试 test_prefix_match_precise / test_admin_exempt_from_
|
||||
# tenant_middleware 钉住)。
|
||||
#
|
||||
# 已知观察项(本任务仅注记不改实现):反代部署形态下登录限流键
|
||||
# 取 request.client.host 不解析 XFF——所有请求同源(反代 IP),
|
||||
# 键退化为按用户名(fail-safe:只更严不会更松,单账号爆破面
|
||||
# 不变);可信代理头解析归部署批次统一处理。
|
||||
path = scope.get("path") or ""
|
||||
if path == "/health" or path.startswith("/api/platform/"):
|
||||
if (path == "/health"
|
||||
or path.startswith("/api/platform/")
|
||||
or path == "/admin"
|
||||
or path.startswith("/admin/")):
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
registry = getattr(scope["app"].state, "registry", None)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 生产编排(Phase 4 Task 1)——单容器全栈(uvicorn 直出,无反代层时
|
||||
# 8018 对外;有反代时由反代转发)。数据全在 bind mount,容器可随手重建。
|
||||
name: all-kefu
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8018:8018"
|
||||
env_file:
|
||||
# 生产服务器放 .env(ADMIN_PASSWORD/ZHIPU_API_KEY/SMTP 等);本机
|
||||
# 没有 .env 也能起(出厂默认+启动日志警示)——required: false 只是
|
||||
# 容忍缺省,不改变变量优先级
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
- ./data:/app/data # 头像上传等(data/uploads)
|
||||
- ./tenants:/app/tenants # 每租户 SQLite 库
|
||||
mem_limit: 768m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
# slim 镜像无 curl——用容器内自带的 python urllib 探 /health
|
||||
# (分级 health:单租户库损坏返回 200 degraded,不杀容器)
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8018/health',timeout=3)\""]
|
||||
interval: 30s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,179 @@
|
||||
# Phase 4 Task 1: admin 生产服务(/admin SPA)+ /health 分级。
|
||||
#
|
||||
# dist 的测试纪律: 夹具造最小假 dist(index.html + assets/app.js),经
|
||||
# ADMIN_DIST 环境变量注入——不依赖也不污染仓库 admin/dist(pnpm build
|
||||
# 前后测试行为一致);真 dist 的浏览器冒烟归验收阶段,不在单测内。
|
||||
#
|
||||
# 覆盖:
|
||||
# - /admin/ 静态 SPA 服务(html=True 语义:目录→index.html);
|
||||
# - SPA fallback 只吞 /admin 内的非 /admin/api 404;/api 未知路径保持
|
||||
# FastAPI 默认 JSON 404(不被 index.html 越界吞掉);
|
||||
# - Host 中间件 /admin 豁免(未知 Host 可达);/admin-evil 仍走租户解析;
|
||||
# - dist 缺失 → 503 说明页,应用其余功能照常(降级不炸);
|
||||
# - /health 分级: ok / 懒建未探(engine_ok=None) / 单租户库损坏只
|
||||
# degraded 不整体 500。
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.platform.registry import make_tenant_engine
|
||||
from tests.conftest import (
|
||||
AUTO_HOST,
|
||||
JIANCAI_HOST,
|
||||
NOPE_HOST,
|
||||
SEED_DIR,
|
||||
app_test_client,
|
||||
build_app,
|
||||
)
|
||||
|
||||
ADMIN_INDEX_HTML = (
|
||||
"<!DOCTYPE html><html><head><title>admin</title>"
|
||||
'<script type="module" src="/admin/assets/app.js"></script>'
|
||||
"</head><body>admin-spa-marker</body></html>"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(tmp_path, monkeypatch):
|
||||
"""app_client 变体: ADMIN_DIST 指向 tmp 假 dist。
|
||||
|
||||
/admin 挂载对 dist 请求期现势解析(见 app.main.AdminSPA)——env 覆盖
|
||||
即换目录,测试与真实构建产物解耦。"""
|
||||
dist = tmp_path / "admin-dist"
|
||||
(dist / "assets").mkdir(parents=True)
|
||||
(dist / "index.html").write_text(ADMIN_INDEX_HTML, encoding="utf-8")
|
||||
(dist / "assets" / "app.js").write_text("// admin bundle", encoding="utf-8")
|
||||
monkeypatch.setenv("ADMIN_DIST", str(dist))
|
||||
a = build_app(SEED_DIR, tmp_path)
|
||||
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def missing_dist_client(tmp_path, monkeypatch):
|
||||
"""ADMIN_DIST 指向不存在路径——dist 缺失降级(503 说明页)的确定性
|
||||
夹具:不依赖仓库 admin/dist 的有无(冒烟构建后它会真实存在)。"""
|
||||
monkeypatch.setenv("ADMIN_DIST", str(tmp_path / "no-such-dist"))
|
||||
a = build_app(SEED_DIR, tmp_path)
|
||||
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---- /admin 静态服务 ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_index_served(admin_client):
|
||||
# /admin(无斜杠)→ Mount 规范化重定向 → /admin/ 服务 index.html
|
||||
r = admin_client.get("/admin", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
assert "admin-spa-marker" in r.text
|
||||
assert r.headers["content-type"].startswith("text/html")
|
||||
# 直连 /admin/ 同样 200
|
||||
r = admin_client.get("/admin/")
|
||||
assert r.status_code == 200
|
||||
assert "admin-spa-marker" in r.text
|
||||
|
||||
|
||||
def test_admin_assets_served(admin_client):
|
||||
r = admin_client.get("/admin/assets/app.js")
|
||||
assert r.status_code == 200
|
||||
assert "admin bundle" in r.text
|
||||
|
||||
|
||||
# ---- SPA fallback 边界 -------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_fallback_only_admin(admin_client):
|
||||
# /admin 内未知路径 → index.html 200(hash 路由下的加固:直连/刷新深链
|
||||
# 不 404 白屏)
|
||||
r = admin_client.get("/admin/unknown/deep/link")
|
||||
assert r.status_code == 200
|
||||
assert "admin-spa-marker" in r.text
|
||||
# /admin/api 子前缀不吞:未来 admin 侧 API 的路由 404 必须按默认 JSON
|
||||
# 暴露,不被 index.html 掩盖
|
||||
r = admin_client.get("/admin/api/nope")
|
||||
assert r.status_code == 404
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
assert r.json() == {"detail": "Not Found"}
|
||||
# 其余路径保持 FastAPI 默认 JSON 404——SPA fallback 不得越界
|
||||
r = admin_client.get("/api/definitely-not-a-route")
|
||||
assert r.status_code == 404
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
assert r.json() == {"detail": "Not Found"}
|
||||
|
||||
|
||||
# ---- Host 中间件豁免 ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_exempt_from_tenant_middleware(admin_client):
|
||||
# 未知 Host 下 /admin 可达(不 404)——admin 是平台面,不依赖租户域名
|
||||
# 解析(与 /api/platform/ 前缀同一安全模型:豁免≠放行)
|
||||
r = admin_client.get("/admin/", headers={"host": NOPE_HOST})
|
||||
assert r.status_code == 200
|
||||
assert "admin-spa-marker" in r.text
|
||||
# 前缀精确到斜杠:/admin-evil 仍走租户解析 → 中间件 404(text/plain)
|
||||
r = admin_client.get("/admin-evil", headers={"host": NOPE_HOST})
|
||||
assert r.status_code == 404
|
||||
assert r.headers["content-type"].startswith("text/plain")
|
||||
|
||||
|
||||
# ---- dist 缺失降级 ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_dist_missing_degrades(missing_dist_client):
|
||||
r = missing_dist_client.get("/admin/")
|
||||
assert r.status_code == 503
|
||||
assert "未构建" in r.text
|
||||
assert "pnpm dev" in r.text
|
||||
# 降级不炸应用:健康检查与租户功能照常
|
||||
assert missing_dist_client.get("/health").status_code == 200
|
||||
assert missing_dist_client.post(
|
||||
"/api/chat/session", json={}).status_code == 200
|
||||
|
||||
|
||||
# ---- /health 分级 -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_health_tiered_ok_and_lazy(app_client):
|
||||
r = app_client.get("/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert {t["slug"] for t in body["tenants"]} == {"jiancai", "auto"}
|
||||
# 懒建未就绪:engine_ok=None——健康检查不触发全租户建库
|
||||
assert all(t["engine_ok"] is None for t in body["tenants"])
|
||||
# 域名绑定数(jiancai 2 + auto 1,种子档案)
|
||||
assert body["domains"] == 3
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def corrupt_jiancai_client(tmp_path):
|
||||
"""/health 分级的坏库夹具: jiancai 的 engine 直接指向损坏库文件。
|
||||
|
||||
绕过 ensure_ready 的建库路径(那会因 ensure_schema 失败把 engine 留
|
||||
None),手工把垃圾字节写进 db_path 再挂 engine——模拟"运行中库文件
|
||||
损坏"的形态:engine 在,一探就炸。lifespan 启动清扫对该租户失败只记
|
||||
日志(main.py 既有行为),不影响本夹具。"""
|
||||
a = build_app(SEED_DIR, tmp_path)
|
||||
rt = next(r for r in a.state.registry.runtimes()
|
||||
if r.config.slug == "jiancai")
|
||||
rt.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rt.db_path.write_bytes(b"definitely not a sqlite database")
|
||||
rt.engine = make_tenant_engine(rt.db_path)
|
||||
rt.session_factory = async_sessionmaker(rt.engine, expire_on_commit=False)
|
||||
with app_test_client(a, f"http://{JIANCAI_HOST}", tmp_path) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_health_tiered_degraded_on_corrupt_db(corrupt_jiancai_client):
|
||||
c = corrupt_jiancai_client
|
||||
r = c.get("/health")
|
||||
assert r.status_code == 200 # 单租户损坏 → degraded,不整体 500
|
||||
body = r.json()
|
||||
assert body["status"] == "degraded"
|
||||
by_slug = {t["slug"]: t for t in body["tenants"]}
|
||||
assert by_slug["jiancai"]["engine_ok"] is False
|
||||
assert by_slug["auto"]["engine_ok"] is None
|
||||
# 其余租户不受牵连:auto 懒建好库正常服务
|
||||
r = c.post("/api/chat/session", json={}, headers={"host": AUTO_HOST})
|
||||
assert r.status_code == 200
|
||||
Reference in New Issue
Block a user