"""统一异常 + 全局异常处理(单一入口) 设计原则(看一眼就懂): - 每个异常类把 code / http_status 写死,调用处不用记码。 - 抛的时候传一句具体原因(detail);不传就用类别名兜底。 例:raise BizError("不支持的商品") → msg = "业务异常[不支持的商品]" raise BizError() → msg = "业务异常" - 所有 handler(自定义异常 / 参数校验 / HTTP / 断言 / 未知兜底)都注册在本文件, main.py 只调用 register_exception_handlers(app)。 约定:自定义异常一律 HTTP 500,真实含义靠 body 里的 code 区分(前端拦截器读 code)。 | 异常类 | code | HTTP | 类别名 | 典型场景 | |-------------|------|------|-----------|--------------------------------------| | ParamError | 4000 | 500 | 参数异常 | 入参缺失/非法、模型档位不支持 | | BizError | 4200 | 500 | 业务异常 | 资源不存在、限频、验证码、越权、会员校验 | | CreditError | 4300 | 500 | 扣费异常 | 余额不足、扣费失败(前端引导充值) | | SysError | 5000 | 500 | 系统异常 | 已知的内部错误 / 上游模型失败(主动抛) | | (未捕获) | 5000 | 500 | 系统异常 | 没被建模的异常,兜底接住 | """ import traceback from fastapi import Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException from app.config import settings from app.core.logger import log from app.core.schemas.responses import StandardResponse # ==================== 异常定义 ==================== class AppError(Exception): """自定义异常基类(不直接抛,统一捕获时用它兜底) 子类只需覆盖 code / label 两个类属性(http_status 一律 500)。 """ code: int = 5000 # 业务错误码(固定) http_status: int = 500 # 自定义异常一律 HTTP 500,真实含义靠 code 区分 label: str = "系统异常" # 类别名,detail 为空时作默认描述 def __init__(self, detail: str = ""): # 传了原因 → "业务异常[不支持的商品]";没传 → "业务异常" self.msg = f"{self.label}[{detail}]" if detail else self.label super().__init__(self.msg) def to_dict(self) -> dict: """HTTP 统一响应体字段""" return {"code": self.code, "msg": self.msg} def to_event(self) -> dict: """SSE error 事件数据体(前端流式解析用)""" return {"code": self.code, "message": self.msg} class ParamError(AppError): """参数异常:入参缺失 / 非法 / 不在允许范围""" code = 4000 label = "参数异常" class BizError(AppError): """业务异常:可预期的业务拒绝(资源不存在 / 限频 / 验证码 / 越权 / 会员校验…)""" code = 4200 label = "业务异常" class CreditError(AppError): """扣费异常:余额不足 / 扣费失败等积分相关问题,前端引导充值""" code = 4300 label = "扣费异常" class AssertError(AppError): """断言异常:业务前置条件不满足""" code = 4100 label = "断言异常" class SysError(AppError): """系统异常:已知的内部错误、上游模型调用失败等(主动抛)""" code = 5000 label = "系统异常" @classmethod def from_exc(cls, exc: Exception) -> "SysError": """从任意异常包装成系统异常(流式兜底用)""" return cls(f"{type(exc).__name__}: {exc}") # ==================== 异常处理器 ==================== # HTTP 状态码 → 友好提示(框架抛出的 HTTPException 用) _FRIENDLY_MESSAGES = { 400: "请求参数错误", 401: "未经授权,请登录", 403: "权限不足,禁止访问", 404: "请求的资源不存在", 405: "请求方法不允许", 422: "数据验证失败", 429: "请求过于频繁", 500: "服务器内部错误", } def _get_uuid(request: Request) -> str | None: """从请求上下文中获取请求唯一标识""" return getattr(request.state, "uuid", None) def _resp(code: int, msg: str, http_status: int, uuid: str | None, data=None) -> JSONResponse: return JSONResponse( status_code=http_status, content=StandardResponse.fail(msg=msg, code=code, data=data, uuid=uuid).model_dump(), ) async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: """统一处理所有自定义异常(参数 / 业务 / 扣费 / 系统) 只在基类 AppError 上注册,Starlette 按继承链匹配,自动覆盖全部子类。 """ uuid = _get_uuid(request) # 系统异常(5xxx)记 error,其余可预期拒绝记 warning level = log.error if exc.code >= 5000 else log.warning level(f"{type(exc).__name__} -- uuid: {uuid} | code: {exc.code} | msg: {exc.msg}") return _resp(exc.code, exc.msg, exc.http_status, uuid) async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: """处理框架 / 手动抛出的 HTTPException""" uuid = _get_uuid(request) log.error(f"HTTPException -- uuid: {uuid} | status: {exc.status_code} | detail: {exc.detail}") message = _FRIENDLY_MESSAGES.get(exc.status_code, str(exc.detail)) return _resp(exc.status_code, message, exc.status_code, uuid) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """处理 Pydantic 参数校验异常""" uuid = _get_uuid(request) errors = exc.errors() log.error(f"ValidationError -- uuid: {uuid} | errors: {errors}") return _resp(ParamError.code, "数据验证失败", 422, uuid, data=errors) async def assertion_error_handler(request: Request, exc: AssertionError) -> JSONResponse: """处理 assert 断言异常""" uuid = _get_uuid(request) log.error(f"AssertionError -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}") return _resp(SysError.code, f"断言错误: {exc}", 500, uuid) async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: """兜底:所有未捕获异常统一按系统异常返回""" uuid = _get_uuid(request) log.error(f"Unhandled Exception -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}") # 开发环境暴露细节,生产环境只给通用提示 msg = f"{type(exc).__name__}: {exc}" if settings.env == "dev" else SysError.label return _resp(SysError.code, msg, 500, uuid) def register_exception_handlers(app) -> None: """将所有异常处理器挂载到 FastAPI 应用(main.py 只调这一个)""" app.add_exception_handler(AppError, app_error_handler) app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(AssertionError, assertion_error_handler) app.add_exception_handler(Exception, global_exception_handler)