Files
geMoldInsight/src/shared/app_factory.py
T
2026-08-31 18:01:34 +08:00

218 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
shared/app_factory.py — FastAPI 应用工厂
将 moldinsight.py / inventory.py 两个入口的重复引导代码
(CORS、日志中间件、startup/shutdown、/health、SPA fallback)
收敛到一个工厂函数,消除漂移风险。
"""
import os
import time
from pathlib import Path
from typing import List, Optional, Callable, Awaitable
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from shared.config.settings import settings
from shared.utils.logger import setup_logging, get_logger, generate_request_id, set_request_id
setup_logging()
logger = get_logger(__name__)
def create_app(
*,
title: str,
service_name: str,
version: str = "4.0.0",
mount_html: bool = False,
serve_frontend_static: bool = False,
startup_hooks: Optional[List[Callable[[], Awaitable[None]]]] = None,
register_routers: Optional[Callable[[FastAPI], None]] = None,
) -> FastAPI:
"""创建标准化的 FastAPI 应用实例。
Args:
title: 应用标题
service_name: 服务名(用于 /health 响应)
version: 版本号
mount_html: 是否挂载 /html 静态目录(moldinsight 需要)
serve_frontend_static: 是否由后端托管 /static 与 SPA fallback(默认关闭,前端独立部署)
startup_hooks: 额外的 startup 钩子列表(在数据库/RustFS/Redis 初始化后执行)
register_routers: 回调函数,用于注册业务路由
"""
app = FastAPI(title=title, version=version)
# ── CORS 白名单 ──────────────────────────────────────────────
cors_origins = settings.CORS_ORIGINS or ["*"]
if cors_origins == ["*"]:
logger.warning(
"CORS 使用通配符 ['*'],生产环境请设置 CORS_ORIGINS 环境变量"
)
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 请求日志中间件(结构化 + request_id 追踪)─────────────────
@app.middleware("http")
async def log_requests(request: Request, call_next):
# 生成/提取 request_id
rid = request.headers.get("X-Request-ID") or generate_request_id()
set_request_id(rid)
start_time = time.time()
response = await call_next(request)
duration_ms = round((time.time() - start_time) * 1000, 1)
# 跳过静态资源和健康检查的详细日志
path = request.url.path
is_static = (serve_frontend_static and path.startswith("/static")) or path == "/health"
if not is_static:
log_level = "warning" if response.status_code >= 400 else "info"
extra = {
"method": request.method,
"path": path,
"status": response.status_code,
"duration_ms": duration_ms,
"client_ip": request.client.host if request.client else "-",
}
getattr(logger, log_level)(
f"{request.method} {path} -> {response.status_code} ({duration_ms}ms)",
extra=extra,
)
# 注入 X-Request-ID 响应头,方便前端/运维追踪
response.headers["X-Request-ID"] = rid
return response
# ── 目录准备 ─────────────────────────────────────────────────
Path("uploads").mkdir(exist_ok=True)
if serve_frontend_static:
Path("static").mkdir(exist_ok=True)
if mount_html:
Path("html_output").mkdir(exist_ok=True)
# ── 静态文件挂载 ─────────────────────────────────────────────
if serve_frontend_static:
app.mount(
"/static",
StaticFiles(directory=os.path.join(os.getcwd(), "static")),
name="static",
)
if mount_html:
app.mount(
"/html",
StaticFiles(directory=os.path.join(os.getcwd(), "html_output")),
name="html",
)
# ── Startup ──────────────────────────────────────────────────
@app.on_event("startup")
async def startup_event():
from shared.database.init_db import init_database
success = await init_database(keep_connected=True)
print(f"[{'OK' if success else 'FAIL'}] 数据库初始化")
# RustFS(仅 moldinsight 需要)
if mount_html:
try:
from moldinsight.storage.rustfs_storage import rustfs_manager
await rustfs_manager.connect(
endpoint=settings.RUSTFS_ENDPOINT,
access_key=settings.RUSTFS_ACCESS_KEY,
secret_key=settings.RUSTFS_SECRET_KEY,
timeout=settings.RUSTFS_TIMEOUT,
)
print("[OK] RustFS连接成功")
except Exception as e:
print(f"[WARN] RustFS连接失败: {e}")
# Redis
try:
from shared.services.redis_task_manager import redis_task_manager
await redis_task_manager.connect()
print(f"[{'OK' if redis_task_manager.is_connected else 'WARN'}] Redis")
except Exception as e:
print(f"[WARN] Redis异常: {e}")
# 额外钩子
for hook in (startup_hooks or []):
try:
await hook()
except Exception as e:
print(f"[WARN] startup hook 异常: {e}")
# ── Shutdown ─────────────────────────────────────────────────
@app.on_event("shutdown")
async def shutdown_event():
try:
from shared.services.redis_task_manager import redis_task_manager
await redis_task_manager.disconnect()
except Exception:
pass
# ── 认证路由 ─────────────────────────────────────────────────
from shared.services.auth_routes import router as auth_router
app.include_router(auth_router)
# ── 业务路由注册 ─────────────────────────────────────────────
if register_routers:
register_routers(app)
# ── /health 统一端点 ─────────────────────────────────────────
@app.get("/health")
@app.post("/health")
async def health():
from shared.database.database import db_manager
from sqlalchemy import text
db_ok = False
db_error = None
try:
if not db_manager.is_connected:
await db_manager.connect()
async with db_manager.engine.begin() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception as e:
db_error = str(e)
return {
"status": "healthy" if db_ok else "degraded",
"service": service_name,
"version": version,
"database_connected": db_ok,
"database_error": db_error,
}
# ── SPA fallback(独立前端部署时默认关闭)────────────────────
if serve_frontend_static:
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
# API 路径不走 SPA fallback,让 FastAPI 正常返回 404 JSON
if full_path.startswith("api/") or full_path.startswith("api"):
raise _api_not_found(full_path)
# 健康检查 / 文档路径也排除
if full_path.startswith("docs") or full_path.startswith("openapi"):
raise _api_not_found(full_path)
static_index = os.path.join(os.getcwd(), "static", "index.html")
if os.path.exists(static_index):
return FileResponse(static_index)
return JSONResponse({"detail": "SPA index not found"}, status_code=404)
return app
def _api_not_found(path: str):
"""为 API 路径生成标准 404 异常"""
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Not Found: /{path}")