2026-10-09 06:46:36 -04:00
|
|
|
|
"""全局异常处理器。
|
|
|
|
|
|
|
|
|
|
|
|
把各类异常统一转换为 `{code, message, data}` 响应,
|
|
|
|
|
|
避免 FastAPI 默认的 422 `detail` 数组结构泄漏到前端。
|
|
|
|
|
|
|
|
|
|
|
|
注意:参数校验失败按规范映射为 **400**(FastAPI 默认是 422),
|
|
|
|
|
|
因为规范第 9 节只定义了 400/401/403/404/500 五个状态码。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import FastAPI, Request
|
|
|
|
|
|
from fastapi.exceptions import RequestValidationError
|
|
|
|
|
|
from fastapi.responses import JSONResponse
|
2026-10-14 06:48:32 -04:00
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
2026-10-09 06:46:36 -04:00
|
|
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
|
|
|
|
|
from app.core.exceptions import AppException
|
|
|
|
|
|
from app.core.response import ApiResponse
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_response(code: int, message: str) -> JSONResponse:
|
|
|
|
|
|
"""按统一格式构造 JSON 响应。"""
|
|
|
|
|
|
body = ApiResponse[None](code=code, message=message, data=None)
|
|
|
|
|
|
return JSONResponse(status_code=code, content=body.model_dump())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_validation_errors(exc: RequestValidationError) -> str:
|
|
|
|
|
|
"""把 pydantic 的校验错误压缩成一行中文提示。
|
|
|
|
|
|
|
|
|
|
|
|
只取首条错误的字段名与说明,避免把完整的错误数组抛给前端。
|
|
|
|
|
|
"""
|
|
|
|
|
|
errors = exc.errors()
|
|
|
|
|
|
if not errors:
|
|
|
|
|
|
return "参数错误"
|
|
|
|
|
|
first = errors[0]
|
|
|
|
|
|
# loc 形如 ("body", "username"),去掉来源前缀只留字段名
|
|
|
|
|
|
location = ".".join(
|
|
|
|
|
|
str(part) for part in first.get("loc", ()) if part not in ("body", "query", "path")
|
|
|
|
|
|
)
|
|
|
|
|
|
detail = first.get("msg", "参数错误")
|
|
|
|
|
|
return f"参数错误:{location} {detail}" if location else f"参数错误:{detail}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_app_exception(_: Request, exc: AppException) -> JSONResponse:
|
|
|
|
|
|
"""处理业务异常,按其 code 返回。"""
|
|
|
|
|
|
return _build_response(exc.code, exc.message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
|
|
|
|
|
"""处理请求参数校验失败,映射为 400。"""
|
|
|
|
|
|
return _build_response(CODE_BAD_REQUEST, _format_validation_errors(exc))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
|
|
|
|
"""处理框架级 HTTP 异常(404 路由不存在、405 方法不允许等)。"""
|
|
|
|
|
|
message = exc.detail if isinstance(exc.detail, str) else "请求处理失败"
|
|
|
|
|
|
return _build_response(exc.status_code, message)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-14 06:48:32 -04:00
|
|
|
|
async def handle_integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
|
|
|
|
|
|
"""完整性错误全局兜底为 400。
|
|
|
|
|
|
|
|
|
|
|
|
service 层已判别的 1062/引用冲突会自行翻译为业务提示,不会走到这里;
|
|
|
|
|
|
本 handler 拦截的是「新增父表后某 service 漏补引用保护」一类的遗漏
|
|
|
|
|
|
(e2e 撞见一个补一个的模式无法保证全覆盖):FK RESTRICT 等数据完整性
|
|
|
|
|
|
冲突对用户表现为「这行数据被别处引用/冲突」,转 400 比 500 更贴近事实。
|
|
|
|
|
|
完整异常记录日志供排查;不在此处 rollback —— 会话由请求级依赖统一关闭。
|
|
|
|
|
|
"""
|
|
|
|
|
|
logger.error("数据库完整性冲突(未在 service 层翻译)", exc_info=exc)
|
|
|
|
|
|
return _build_response(CODE_BAD_REQUEST, "数据完整性冲突,操作被拒绝")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
|
2026-10-09 06:46:36 -04:00
|
|
|
|
"""兜底处理未预期异常。
|
|
|
|
|
|
|
|
|
|
|
|
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
|
|
|
|
|
"""
|
2026-11-02 22:20:55 -05:00
|
|
|
|
request_id = getattr(request.state, "request_id", "-")
|
|
|
|
|
|
logger.exception("未预期的服务端异常", extra={"request_id": request_id})
|
|
|
|
|
|
response = _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
|
|
|
|
|
response.headers["X-Request-ID"] = request_id
|
|
|
|
|
|
return response
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_exception_handlers(app: FastAPI) -> None:
|
|
|
|
|
|
"""注册全部全局异常处理器。
|
|
|
|
|
|
|
|
|
|
|
|
注册顺序不影响匹配 —— FastAPI 按异常类型精确查找,越具体的类型优先。
|
|
|
|
|
|
"""
|
|
|
|
|
|
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
|
|
|
|
|
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
|
|
|
|
|
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
2026-10-14 06:48:32 -04:00
|
|
|
|
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
|
2026-10-09 06:46:36 -04:00
|
|
|
|
app.add_exception_handler(Exception, handle_unexpected_exception)
|