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:
+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}
|
||||
|
||||
Reference in New Issue
Block a user