PayrollManagementSystem/backend/app/core/exception_handlers.py
liuwenxin 91fc6f6293 fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:48:32 +08:00

96 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""全局异常处理器。
把各类异常统一转换为 `{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, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
"""
logger.exception("未预期的服务端异常")
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
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)