Files
2026-09-02 18:04:41 +08:00

174 lines
5.2 KiB
Python

"""统一业务异常与错误响应体(需求 §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 LinkExpiredError(AppError):
"""AI 链接已过期(HTTP 410 Gone)。"""
status_code = status.HTTP_410_GONE
code = "LINK_EXPIRED"
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:
# 序列化错误详情,过滤不可 JSON 序列化的对象
errors = []
for err in exc.errors():
clean_err = {
"type": err.get("type", ""),
"loc": err.get("loc", []),
"msg": err.get("msg", ""),
}
if "input" in err:
clean_err["input"] = str(err["input"])
errors.append(clean_err)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"code": "VALIDATION_ERROR",
"message": "请求参数不正确。",
"detail": errors,
},
)
_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)