PayrollManagementSystem/backend/app/core/exception_handlers.py

99 lines
4.4 KiB
Python
Raw Normal View History

"""全局异常处理器。
把各类异常统一转换为 `{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
from sqlalchemy.exc import IntegrityError
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)
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, "数据完整性冲突,操作被拒绝")
async def handle_unexpected_exception(request: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
"""
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
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]
app.add_exception_handler(IntegrityError, handle_integrity_error) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception)