190 lines
6.5 KiB
Python
190 lines
6.5 KiB
Python
"""应用入口:FastAPI 应用创建与生命周期管理
|
|
|
|
职责:
|
|
- 构建服务容器(依赖注入)
|
|
- 管理应用生命周期(启动/关闭抓取会话与兜底浏览器)
|
|
- 注册路由和全局异常处理器
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import ValidationError
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.api.routes.health import router as health_router
|
|
from app.api.routes.rakuma import router as rakuma_router
|
|
from app.api.routes.scrape import router as scrape_router
|
|
from app.core.config import get_settings
|
|
from app.core.container import ServiceContainer
|
|
from app.core.errors import AppError
|
|
from app.core.logging_setup import configure_logging
|
|
from app.models.scrape import ApiResponse
|
|
from app.services.browser_fallback import BrowserFallback
|
|
from app.services.rakuma_client import RakumaClient
|
|
from app.services.rakuma_session import RakumaSession
|
|
from app.services.rakuten_client import RakutenClient
|
|
from app.services.site_session import SiteSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _format_validation_msg(errors: list[dict]) -> str:
|
|
"""将校验错误整理为便于前端展示的消息。"""
|
|
if not errors:
|
|
return "Validation error"
|
|
|
|
messages: list[str] = []
|
|
for error in errors:
|
|
loc = ".".join(str(part) for part in error.get("loc", []) if part != "body")
|
|
msg = str(error.get("msg", "Validation error"))
|
|
messages.append(f"{loc}: {msg}" if loc else msg)
|
|
|
|
return "; ".join(messages)
|
|
|
|
|
|
def build_container() -> ServiceContainer:
|
|
"""构建服务容器,组装所有依赖"""
|
|
settings = get_settings()
|
|
browser_fallback = BrowserFallback(settings)
|
|
site_session = SiteSession(settings, browser_fallback)
|
|
rakuten_client = RakutenClient(settings, site_session)
|
|
rakuma_session = RakumaSession(settings)
|
|
rakuma_client = RakumaClient(settings, rakuma_session)
|
|
return ServiceContainer(
|
|
settings=settings,
|
|
browser_fallback=browser_fallback,
|
|
site_session=site_session,
|
|
rakuten_client=rakuten_client,
|
|
rakuma_session=rakuma_session,
|
|
rakuma_client=rakuma_client,
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""应用生命周期管理:启动时初始化各服务,关闭时释放资源"""
|
|
container = build_container()
|
|
app.state.container = container
|
|
|
|
configure_logging(container.settings)
|
|
logger.info("应用启动:%s:%s", container.settings.app_host, container.settings.app_port)
|
|
logger.info("日志级别:%s", container.settings.log_level)
|
|
logger.info("当前环境:%s", container.settings.app_env)
|
|
await container.site_session.start()
|
|
await container.rakuma_session.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
await container.rakuma_session.close()
|
|
await container.site_session.close()
|
|
await container.browser_fallback.close()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
"""创建 FastAPI 应用实例,注册路由和异常处理器"""
|
|
app = FastAPI(title="Rakuten Scraper Service", lifespan=lifespan)
|
|
app.include_router(health_router)
|
|
app.include_router(scrape_router)
|
|
app.include_router(rakuma_router)
|
|
|
|
@app.exception_handler(AppError)
|
|
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
|
"""业务异常处理器:返回结构化的错误响应"""
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=ApiResponse[None](
|
|
success=False,
|
|
code=exc.err_code,
|
|
msg=exc.message,
|
|
data=None,
|
|
).model_dump(),
|
|
headers=exc.headers,
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
"""请求参数校验异常处理器"""
|
|
errors = exc.errors()
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content=ApiResponse[object](
|
|
success=False,
|
|
msg=_format_validation_msg(errors),
|
|
data=jsonable_errors(errors),
|
|
code=1002,
|
|
).model_dump(),
|
|
)
|
|
|
|
@app.exception_handler(ValidationError)
|
|
async def pydantic_validation_error_handler(_: Request, exc: ValidationError) -> JSONResponse:
|
|
"""Pydantic 模型校验异常处理器"""
|
|
errors = exc.errors()
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content=ApiResponse[object](
|
|
success=False,
|
|
msg=_format_validation_msg(errors),
|
|
data=jsonable_errors(errors),
|
|
code=1002,
|
|
).model_dump(),
|
|
)
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
"""HTTP 异常处理器(404、500 等)"""
|
|
status_code = int(getattr(exc, "status_code", 500) or 500)
|
|
err_code = 1404 if status_code == 404 else 1500
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=ApiResponse[None](
|
|
success=False,
|
|
msg=str(getattr(exc, "detail", "HTTP error")),
|
|
data=None,
|
|
code=err_code,
|
|
).model_dump(),
|
|
headers=getattr(exc, "headers", None),
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
|
"""兜底异常处理器:捕获所有未处理的异常"""
|
|
logger.exception("未处理异常:%s", exc)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content=ApiResponse[None](
|
|
success=False,
|
|
msg="Internal server error",
|
|
data=None,
|
|
code=1500,
|
|
).model_dump(),
|
|
)
|
|
|
|
return app
|
|
|
|
|
|
def jsonable_errors(errors: list[dict]) -> list[dict]:
|
|
"""剔除校验错误里不可 JSON 序列化的 ctx(如原始异常对象)"""
|
|
return [{key: value for key, value in error.items() if key != "ctx"} for error in errors]
|
|
|
|
|
|
app = create_app()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
settings = get_settings()
|
|
configure_logging(settings)
|
|
uvicorn.run(
|
|
"app.main:app",
|
|
host=settings.app_host,
|
|
port=settings.app_port,
|
|
log_config=None,
|
|
timeout_keep_alive=120,
|
|
)
|