"""统一业务异常与错误响应体(需求 §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)