269 lines
7.9 KiB
Python
269 lines
7.9 KiB
Python
"""
|
||
FastAPI 应用入口
|
||
|
||
配置应用实例、中间件、异常处理器等。
|
||
"""
|
||
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
from typing import AsyncGenerator
|
||
|
||
from fastapi import FastAPI, Request, status
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
from app.api.v1.router import api_router
|
||
from app.core.config import settings
|
||
from app.core.exceptions import (
|
||
AppException,
|
||
AuthenticationError,
|
||
AuthorizationError,
|
||
ResourceConflictError,
|
||
ResourceNotFoundError,
|
||
ValidationError,
|
||
)
|
||
from app.database import close_db, init_db
|
||
|
||
|
||
def get_frontend_dir() -> Path:
|
||
"""
|
||
获取前端静态文件目录
|
||
|
||
支持相对路径和绝对路径配置
|
||
"""
|
||
path = Path(settings.frontend_static_path)
|
||
if path.is_absolute():
|
||
return path
|
||
# 相对路径基于项目根目录
|
||
return Path(__file__).parent.parent / path
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||
"""
|
||
应用生命周期管理
|
||
|
||
启动时初始化数据库,关闭时释放资源。
|
||
"""
|
||
# 启动时
|
||
await init_db()
|
||
yield
|
||
# 关闭时
|
||
await close_db()
|
||
|
||
|
||
def create_application() -> FastAPI:
|
||
"""
|
||
创建 FastAPI 应用实例
|
||
|
||
Returns:
|
||
配置完成的 FastAPI 应用
|
||
"""
|
||
app = FastAPI(
|
||
title=settings.app_name,
|
||
version=settings.app_version,
|
||
description="现代化用户认证系统 API",
|
||
docs_url="/docs" if not settings.is_production else None,
|
||
redoc_url="/redoc" if not settings.is_production else None,
|
||
openapi_url="/openapi.json" if not settings.is_production else None,
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
# 配置 CORS
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"] if settings.debug else [],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# 注册异常处理器
|
||
register_exception_handlers(app)
|
||
|
||
# 注册路由
|
||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||
|
||
# 健康检查端点
|
||
@app.get("/health", tags=["健康检查"])
|
||
async def health_check():
|
||
"""健康检查接口"""
|
||
return {"status": "healthy", "version": settings.app_version}
|
||
|
||
# 挂载前端静态文件(当静态文件目录存在时)
|
||
frontend_dir = get_frontend_dir()
|
||
if frontend_dir.exists():
|
||
setup_frontend_routes(app, frontend_dir)
|
||
|
||
return app
|
||
|
||
|
||
def setup_frontend_routes(app: FastAPI, frontend_dir: Path) -> None:
|
||
"""
|
||
配置前端静态文件路由
|
||
|
||
Args:
|
||
app: FastAPI 应用实例
|
||
frontend_dir: 前端静态文件目录
|
||
"""
|
||
# 挂载 _next 静态资源目录(Next.js 构建产物)
|
||
next_static_dir = frontend_dir / "_next"
|
||
if next_static_dir.exists():
|
||
app.mount(
|
||
"/_next",
|
||
StaticFiles(directory=str(next_static_dir)),
|
||
name="next_static",
|
||
)
|
||
|
||
# 挂载 assets 目录(通用静态资源)
|
||
assets_dir = frontend_dir / "assets"
|
||
if assets_dir.exists():
|
||
app.mount(
|
||
"/assets",
|
||
StaticFiles(directory=str(assets_dir)),
|
||
name="assets",
|
||
)
|
||
|
||
# 挂载 static 目录(通用静态资源)
|
||
static_dir = frontend_dir / "static"
|
||
if static_dir.exists():
|
||
app.mount(
|
||
"/static",
|
||
StaticFiles(directory=str(static_dir)),
|
||
name="static",
|
||
)
|
||
|
||
# SPA 路由处理 - 必须在最后注册,以避免覆盖 API 路由
|
||
@app.get("/{full_path:path}", include_in_schema=False)
|
||
async def serve_spa(request: Request, full_path: str):
|
||
"""
|
||
SPA 路由处理
|
||
|
||
- 对于静态文件请求,直接返回文件
|
||
- 对于 SPA 路由请求,返回 index.html
|
||
"""
|
||
# 跳过 API 路由和健康检查
|
||
if full_path.startswith("api/") or full_path == "health":
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={"success": False, "message": "Not Found"},
|
||
)
|
||
|
||
# 尝试查找静态文件
|
||
file_path = frontend_dir / full_path
|
||
|
||
# 如果是目录,尝试查找 index.html
|
||
if file_path.is_dir():
|
||
file_path = file_path / "index.html"
|
||
|
||
# 如果文件存在,返回文件
|
||
if file_path.is_file():
|
||
return FileResponse(
|
||
file_path,
|
||
headers=_get_cache_headers(full_path),
|
||
)
|
||
|
||
# 尝试添加 .html 后缀(Next.js 静态导出格式)
|
||
html_path = frontend_dir / f"{full_path}.html"
|
||
if html_path.is_file():
|
||
return FileResponse(html_path)
|
||
|
||
# 默认返回 index.html(SPA 路由回退)
|
||
index_path = frontend_dir / "index.html"
|
||
if index_path.is_file():
|
||
return FileResponse(index_path)
|
||
|
||
# 前端文件不存在
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={"success": False, "message": "Not Found"},
|
||
)
|
||
|
||
|
||
def _get_cache_headers(path: str) -> dict[str, str]:
|
||
"""
|
||
根据文件类型返回适当的缓存头
|
||
|
||
Args:
|
||
path: 请求路径
|
||
|
||
Returns:
|
||
缓存相关的 HTTP 头
|
||
"""
|
||
# 静态资源(带 hash 的文件)使用长期缓存
|
||
if "/_next/" in path or path.startswith("_next/"):
|
||
return {"Cache-Control": "public, max-age=31536000, immutable"}
|
||
|
||
# 其他静态文件使用短期缓存
|
||
static_extensions = {".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2"}
|
||
if any(path.endswith(ext) for ext in static_extensions):
|
||
return {"Cache-Control": "public, max-age=86400"}
|
||
|
||
# HTML 文件不缓存
|
||
return {"Cache-Control": "no-cache"}
|
||
|
||
|
||
def register_exception_handlers(app: FastAPI) -> None:
|
||
"""注册全局异常处理器"""
|
||
|
||
@app.exception_handler(AppException)
|
||
async def app_exception_handler(
|
||
request: Request,
|
||
exc: AppException,
|
||
) -> JSONResponse:
|
||
"""应用异常处理"""
|
||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||
|
||
if isinstance(exc, AuthenticationError):
|
||
status_code = status.HTTP_401_UNAUTHORIZED
|
||
elif isinstance(exc, AuthorizationError):
|
||
status_code = status.HTTP_403_FORBIDDEN
|
||
elif isinstance(exc, ResourceNotFoundError):
|
||
status_code = status.HTTP_404_NOT_FOUND
|
||
elif isinstance(exc, ResourceConflictError):
|
||
status_code = status.HTTP_409_CONFLICT
|
||
elif isinstance(exc, ValidationError):
|
||
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
|
||
|
||
return JSONResponse(
|
||
status_code=status_code,
|
||
content={
|
||
"success": False,
|
||
"message": exc.message,
|
||
"code": exc.code,
|
||
"details": exc.details,
|
||
},
|
||
)
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_exception_handler(
|
||
request: Request,
|
||
exc: RequestValidationError,
|
||
) -> JSONResponse:
|
||
"""请求验证异常处理"""
|
||
errors = []
|
||
for error in exc.errors():
|
||
field = ".".join(str(loc) for loc in error["loc"])
|
||
errors.append({
|
||
"field": field,
|
||
"message": error["msg"],
|
||
"type": error["type"],
|
||
})
|
||
|
||
return JSONResponse(
|
||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
content={
|
||
"success": False,
|
||
"message": "请求数据验证失败",
|
||
"code": "VALIDATION_ERROR",
|
||
"details": {"errors": errors},
|
||
},
|
||
)
|
||
|
||
|
||
# 创建应用实例
|
||
app = create_application()
|
||
|