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
|
VITE_VERSION = 0.1.0
|
||||||
|
|
||||||
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
|
# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/)
|
||||||
VITE_BASE_URL = /
|
# Phase 4: 后端把 admin/dist 挂载在 /admin(见 app/main.py AdminSPA),
|
||||||
|
# 生产产物资源引用必须同前缀;dev 保持 .env.development 的 /(走 Vite 代理)
|
||||||
|
VITE_BASE_URL = /admin/
|
||||||
|
|
||||||
# 权限模式【 frontend 前端模式 / backend 后端模式 】
|
# 权限模式【 frontend 前端模式 / backend 后端模式 】
|
||||||
VITE_ACCESS_MODE = frontend
|
VITE_ACCESS_MODE = frontend
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.7.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0",
|
"node": ">=20.19.0",
|
||||||
"pnpm": ">=8.8.0"
|
"pnpm": ">=8.8.0"
|
||||||
|
|||||||
+112
-4
@@ -3,10 +3,13 @@ from pathlib import Path
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.engine.config import STATIC_DIR, settings
|
||||||
from app.platform.db import (
|
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*
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
|
# registry 装配(Task 11 生产切换):测试注入缝优先——conftest 预置过
|
||||||
@@ -165,6 +237,9 @@ app.include_router(platform_stats_router)
|
|||||||
app.include_router(platform_auth_router)
|
app.include_router(platform_auth_router)
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
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")
|
@app.middleware("http")
|
||||||
@@ -191,5 +266,38 @@ async def index():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health(request: Request):
|
||||||
return {"status": "ok"}
|
"""分级健康检查(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`` 不豁免);
|
/health,Host 是 IP:端口必然未知;精确匹配,``/healthxxx`` 不豁免);
|
||||||
- path 前缀 ``/api/platform/`` 豁免(Phase 2 Task 3,平台后台不依赖租户
|
- path 前缀 ``/api/platform/`` 豁免(Phase 2 Task 3,平台后台不依赖租户
|
||||||
域名解析;安全模型详见 ``__call__`` 内注释——豁免≠放行);
|
域名解析;安全模型详见 ``__call__`` 内注释——豁免≠放行);
|
||||||
|
- path 等于 ``/admin`` 或前缀 ``/admin/`` 豁免(Phase 4 Task 1,生产
|
||||||
|
admin SPA 静态面,同为平台级;``/admin-evil`` 不豁免);
|
||||||
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
|
- Host 头 → ``registry.resolve_site``: 未知 → http 404 / ws close(1008);
|
||||||
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013);
|
租户停用 → http 503 中性"服务暂停"页(不暴露租户信息)/ ws close(1013);
|
||||||
- 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
|
- 解析成功把 tenant/site 写进 ``scope["state"]``——Request/WebSocket.state
|
||||||
@@ -80,23 +82,32 @@ class TenantHostMiddleware:
|
|||||||
# websocket 两类 scope 同构生效——当前 /api/platform 前缀没有 WS
|
# websocket 两类 scope 同构生效——当前 /api/platform 前缀没有 WS
|
||||||
# 端点,保持与 /health 同构以防将来有):
|
# 端点,保持与 /health 同构以防将来有):
|
||||||
# - /health 精确匹配(见模块 docstring);
|
# - /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/* 的认证由路由依赖守护
|
# 任何受保护端点:/api/platform/* 的认证由路由依赖守护
|
||||||
# (platform_user = get_current_user + enforce_password_changed,
|
# (platform_user = get_current_user + enforce_password_changed,
|
||||||
# 未带有效 token 一律 401/409)。未知 Host 下 admin API 可达是
|
# 未带有效 token 一律 401/409)。/admin 只是静态资源(公开的前端
|
||||||
# **有意设计**:生产的 admin 域名与租户域名在反代层区分(见
|
# 产物),真正的数据面仍在 /api/platform/* 认证后。未知 Host 下
|
||||||
# spec §4 边界规则与部署文档),应用层不按 Host 收紧 admin 面。
|
# admin 面可达是 **有意设计**:生产的 admin 域名与租户域名在反代
|
||||||
# 前缀精确到斜杠:startswith("/api/platform/"),/api/platform-evil
|
# 层区分(见 spec §4 边界规则与部署文档),应用层不按 Host 收紧
|
||||||
# 不豁免(仍走租户解析,测试 test_prefix_match_precise 钉住)。
|
# 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),
|
# 取 request.client.host 不解析 XFF——所有请求同源(反代 IP),
|
||||||
# 键退化为按用户名(fail-safe:只更严不会更松,单账号爆破面
|
# 键退化为按用户名(fail-safe:只更严不会更松,单账号爆破面
|
||||||
# 不变);可信代理头解析归部署批次统一处理。
|
# 不变);可信代理头解析归部署批次统一处理。
|
||||||
path = scope.get("path") or ""
|
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)
|
return await self.app(scope, receive, send)
|
||||||
|
|
||||||
registry = getattr(scope["app"].state, "registry", None)
|
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