|
@@ -0,0 +1,119 @@
|
|
|
|
|
+"""全局异常处理器,统一所有错误响应格式为 {code, data, msg}。"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import logging
|
|
|
|
|
+from uuid import uuid4
|
|
|
|
|
+
|
|
|
|
|
+from fastapi import FastAPI, Request, status
|
|
|
|
|
+from fastapi.encoders import jsonable_encoder
|
|
|
|
|
+from fastapi.exceptions import RequestValidationError
|
|
|
|
|
+from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
+from starlette.responses import JSONResponse
|
|
|
|
|
+
|
|
|
|
|
+from app.response import BusinessError, ResponseCode, error_response
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _get_request_info(request: Request) -> str:
|
|
|
|
|
+ """提取请求信息用于日志记录(不记录 query string 避免敏感信息泄露)。"""
|
|
|
|
|
+ x_forwarded_for = request.headers.get("x-forwarded-for", "")
|
|
|
|
|
+ real_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else ""
|
|
|
|
|
+ client_host = real_ip or (request.client.host if request.client else "unknown")
|
|
|
|
|
+ return f"{request.method} {request.url.path} (client: {client_host})"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# HTTP 状态码 -> 业务错误码 的映射
|
|
|
|
|
+_HTTP_TO_CODE: dict[int, ResponseCode] = {
|
|
|
|
|
+ 400: ResponseCode.REQUEST_INVALID,
|
|
|
|
|
+ 404: ResponseCode.NOT_FOUND,
|
|
|
|
|
+ 405: ResponseCode.METHOD_NOT_ALLOWED,
|
|
|
|
|
+ 422: ResponseCode.PARAM_INVALID,
|
|
|
|
|
+ 502: ResponseCode.UPSTREAM_ERROR,
|
|
|
|
|
+ 504: ResponseCode.UPSTREAM_TIMEOUT,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def validation_exception_handler(
|
|
|
|
|
+ request: Request, exc: RequestValidationError
|
|
|
|
|
+) -> JSONResponse:
|
|
|
|
|
+ """处理 Pydantic 请求验证错误(如 page=-1、缺失必填参数等)。
|
|
|
|
|
+
|
|
|
|
|
+ 返回示例:
|
|
|
|
|
+ {
|
|
|
|
|
+ "code": 1001,
|
|
|
|
|
+ "data": [{"loc": [...], "msg": "...", "type": "..."}],
|
|
|
|
|
+ "msg": "参数验证失败"
|
|
|
|
|
+ }
|
|
|
|
|
+ """
|
|
|
|
|
+ request_info = _get_request_info(request)
|
|
|
|
|
+ logger.warning("参数验证失败 [%s]: %s", request_info, exc.errors())
|
|
|
|
|
+
|
|
|
|
|
+ return error_response(
|
|
|
|
|
+ code=ResponseCode.PARAM_INVALID,
|
|
|
|
|
+ data=jsonable_encoder(exc.errors()),
|
|
|
|
|
+ http_status=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def http_exception_handler(
|
|
|
|
|
+ request: Request, exc: StarletteHTTPException
|
|
|
|
|
+) -> JSONResponse:
|
|
|
|
|
+ """处理 Starlette/FastAPI HTTPException(404/405/502 等)。"""
|
|
|
|
|
+ request_info = _get_request_info(request)
|
|
|
|
|
+ status_code = exc.status_code
|
|
|
|
|
+
|
|
|
|
|
+ if status_code >= 500:
|
|
|
|
|
+ logger.error("服务器错误 [%s]: %s", request_info, exc.detail)
|
|
|
|
|
+ elif status_code >= 400:
|
|
|
|
|
+ logger.warning("客户端错误 [%s]: %s", request_info, exc.detail)
|
|
|
|
|
+
|
|
|
|
|
+ code = _HTTP_TO_CODE.get(status_code, ResponseCode.INTERNAL_ERROR)
|
|
|
|
|
+ return error_response(
|
|
|
|
|
+ code=code,
|
|
|
|
|
+ msg=str(exc.detail) if exc.detail else None,
|
|
|
|
|
+ http_status=status_code,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def business_exception_handler(
|
|
|
|
|
+ request: Request, exc: BusinessError
|
|
|
|
|
+) -> JSONResponse:
|
|
|
|
|
+ """处理业务异常(BusinessError)。"""
|
|
|
|
|
+ request_info = _get_request_info(request)
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "业务异常 [%s]: code=%s msg=%s", request_info, exc.code, exc.msg
|
|
|
|
|
+ )
|
|
|
|
|
+ return error_response(
|
|
|
|
|
+ code=exc.response_code,
|
|
|
|
|
+ msg=exc.msg,
|
|
|
|
|
+ data=exc.data,
|
|
|
|
|
+ http_status=exc.http_status,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def general_exception_handler(
|
|
|
|
|
+ request: Request, exc: Exception
|
|
|
|
|
+) -> JSONResponse:
|
|
|
|
|
+ """兜底异常处理器,捕获所有未处理的异常,避免堆栈信息泄露。
|
|
|
|
|
+
|
|
|
|
|
+ 注意:仅捕获 Exception,不捕获 BaseException(如 asyncio.CancelledError),
|
|
|
|
|
+ 以确保 FastAPI lifespan 的取消逻辑正常工作。严禁改为捕获 BaseException!
|
|
|
|
|
+ """
|
|
|
|
|
+ request_info = _get_request_info(request)
|
|
|
|
|
+ request_id = str(uuid4())
|
|
|
|
|
+ logger.exception("未捕获异常 [%s] [request_id=%s]", request_info, request_id)
|
|
|
|
|
+
|
|
|
|
|
+ return error_response(
|
|
|
|
|
+ code=ResponseCode.INTERNAL_ERROR,
|
|
|
|
|
+ data={"request_id": request_id},
|
|
|
|
|
+ http_status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def register_exception_handlers(app: FastAPI) -> None:
|
|
|
|
|
+ """向 FastAPI 应用注册所有全局异常处理器。"""
|
|
|
|
|
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
|
|
|
|
+ app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
|
|
|
|
+ app.add_exception_handler(BusinessError, business_exception_handler) # type: ignore[arg-type]
|
|
|
|
|
+ app.add_exception_handler(Exception, general_exception_handler) # type: ignore[arg-type]
|