第一步
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""统一业务异常与错误响应体(需求 §37 → 统一 code+message+detail)。
|
||||
|
||||
对外格式:{"code": "...", "message": "中文文案", "detail": null}
|
||||
未捕获异常兜底为 500 INTERNAL_ERROR,原始异常只进日志。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""业务异常基类。"""
|
||||
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
code: str = "INTERNAL_ERROR"
|
||||
message: str = "服务内部错误,请稍后重试。"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
*,
|
||||
detail: Any = None,
|
||||
code: str | None = None,
|
||||
) -> None:
|
||||
self.detail = detail
|
||||
if message is not None:
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_response(self) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=self.status_code,
|
||||
content={"code": self.code, "message": self.message, "detail": self.detail},
|
||||
)
|
||||
|
||||
|
||||
# --- 通用 ---
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
code = "NOT_FOUND"
|
||||
message = "资源不存在。"
|
||||
|
||||
|
||||
class PermissionDeniedError(AppError):
|
||||
status_code = status.HTTP_403_FORBIDDEN
|
||||
code = "PERMISSION_DENIED"
|
||||
message = "没有权限访问该资源。"
|
||||
|
||||
|
||||
class AuthRequiredError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "AUTH_REQUIRED"
|
||||
message = "请先登录。"
|
||||
|
||||
|
||||
class InvalidCredentialsError(AppError):
|
||||
status_code = status.HTTP_401_UNAUTHORIZED
|
||||
code = "AUTH_INVALID_CREDENTIALS"
|
||||
message = "用户名或密码错误。"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = status.HTTP_409_CONFLICT
|
||||
code = "CONFLICT"
|
||||
message = "资源冲突。"
|
||||
|
||||
|
||||
class RateLimitedError(AppError):
|
||||
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||
code = "RATE_LIMITED"
|
||||
message = "请求过于频繁,请稍后再试。"
|
||||
|
||||
|
||||
# --- 上传与配额 ---
|
||||
|
||||
|
||||
class StorageQuotaExceededError(AppError):
|
||||
status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
||||
code = "STORAGE_QUOTA_EXCEEDED"
|
||||
message = "存储空间不足。"
|
||||
|
||||
|
||||
class FileTooLargeError(AppError):
|
||||
status_code = status.HTTP_413_CONTENT_TOO_LARGE
|
||||
code = "FILE_TOO_LARGE"
|
||||
message = "单个文件大小超出限制。"
|
||||
|
||||
|
||||
class FileTypeUnsupportedError(AppError):
|
||||
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
code = "FILE_TYPE_UNSUPPORTED"
|
||||
message = "不支持的文件类型。"
|
||||
|
||||
|
||||
# --- 异常处理器 ---
|
||||
|
||||
|
||||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||||
if exc.status_code >= 500:
|
||||
logger.error("AppError %s: %s", exc.code, exc.message, exc_info=exc)
|
||||
return exc.to_response()
|
||||
|
||||
|
||||
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "请求参数不正确。",
|
||||
"detail": exc.errors(include_url=False, include_input=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_STATUS_CODE_MAP = {
|
||||
status.HTTP_404_NOT_FOUND: ("NOT_FOUND", "资源不存在。"),
|
||||
status.HTTP_405_METHOD_NOT_ALLOWED: ("METHOD_NOT_ALLOWED", "请求方法不被允许。"),
|
||||
status.HTTP_401_UNAUTHORIZED: ("AUTH_REQUIRED", "请先登录。"),
|
||||
}
|
||||
|
||||
|
||||
async def http_exception_handler(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""把框架层 HTTPException(如未匹配路由的 404)转成统一错误体。"""
|
||||
code, message = _STATUS_CODE_MAP.get(exc.status_code, ("HTTP_ERROR", str(exc.detail)))
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": code, "message": message, "detail": None},
|
||||
)
|
||||
|
||||
|
||||
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("Unhandled: %s %s", request.method, request.url.path, exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"code": "INTERNAL_ERROR", "message": "服务内部错误,请稍后重试。", "detail": None},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: Any) -> None:
|
||||
app.add_exception_handler(AppError, app_error_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_error_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_error_handler)
|
||||
Reference in New Issue
Block a user