"""统一业务异常 → HTTP 语义映射(迭代第 2 轮)。 **为什么需要**:迭代第 1 轮发现 `InvalidLineQuantity` / `BelowMinPrice` / `CreditLimitExceeded` 等业务异常要在每个视图里手写 try/except——漏一处就是 500。 本模块把它们收敛到一处映射,视图只写业务逻辑。 映射表(HTTP 语义 + `code` 供前端判别): | 异常 | HTTP | code | |---|---|---| | InvalidLineQuantity / InvalidLinePrice | 400 | invalid_quantity / invalid_price | | BelowMinPrice | 400 | below_min_price | | InsufficientStock | 400 | insufficient_stock | | StorefrontError(各类) | 400/402/403 | 见下方细分 | | QuotaExceeded(billing / ai) | 403 | quota_exceeded | | CreditLimitExceeded | 402 | credit_limit_exceeded | | DrawingStateError / ValueError(业务) | 400 | invalid_state | | Unhandled Exception | 500 | server_error(含日志) | """ from __future__ import annotations import logging from rest_framework import status from rest_framework.response import Response from rest_framework.views import exception_handler as drf_exception_handler logger = logging.getLogger("dealerhub.api") def api_exception_handler(exc, context): """DRF 全局异常处理器:业务异常 → 结构化 JSON。 返回值约定:与 DRF 一致(Response 或 None);None 表示交给 Django 处理。 """ # 1. 业务异常:显式映射(这些是"可预期"的错误,用 4xx + code 表达) mapped = _map_business_exception(exc) if mapped is not None: return mapped # 2. 其他异常交给 DRF(ValidationError / NotFound / PermissionDenied 等) response = drf_exception_handler(exc, context) # 3. 兜底:未被处理的异常(DRF 也返回 None → Django 会 500) # 这里统一记日志并返回结构化 500,避免把栈信息暴露给客户端 if response is None: view = context.get("view") logger.exception( "未处理异常 · view=%s · path=%s · exc=%s", view.__class__.__name__ if view else "?", context.get("request").path if context.get("request") else "?", type(exc).__name__, ) return Response({ "code": "server_error", "detail": "服务端处理异常,请稍后重试或联系管理员", }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return _with_error_code(response, exc) def _with_error_code(response, exc): """给"单条 detail"型错误补一个顶层 `code`,供前端判别。 DRF 把 code 藏在 ErrorDetail 里,序列化成纯字符串就丢了,所以 PermissionDenied / NotAuthenticated / NotFound 这类响应到这里补回来。 只处理 detail 为字符串的情况:ValidationError 的 dict 形态是字段级错误, 顶层再塞一个 `code` 会和真实字段名(如 `{"code": ["已存在"]}`)撞车。 """ data = getattr(response, "data", None) if not isinstance(data, dict) or "code" in data: return response if not isinstance(getattr(exc, "detail", None), str): return response try: codes = exc.get_codes() except Exception: # 非 DRF 异常 return response if not isinstance(codes, str): return response data["code"] = codes return response def _map_business_exception(exc): """把已知业务异常翻译成 Response;不认识则返回 None。""" # ---- 单据行校验(D4 / 迭代第 1 轮) ---- from apps.core.services import ( BelowMinPrice, InvalidLinePrice, InvalidLineQuantity, ) if isinstance(exc, InvalidLineQuantity): return Response({ "code": "invalid_quantity", "detail": str(exc), "product_code": exc.product_code, "reason": exc.reason, }, status=status.HTTP_400_BAD_REQUEST) if isinstance(exc, InvalidLinePrice): return Response({ "code": "invalid_price", "detail": str(exc), "product_code": exc.product_code, "reason": exc.reason, }, status=status.HTTP_400_BAD_REQUEST) if isinstance(exc, BelowMinPrice): return Response({ "code": "below_min_price", "detail": str(exc), "product_code": exc.product_code, "unit_price": str(exc.unit_price), "min_price": str(exc.min_price), }, status=status.HTTP_400_BAD_REQUEST) # ---- 库存 ---- from apps.inventory.services import InsufficientStock if isinstance(exc, InsufficientStock): return Response({ "code": "insufficient_stock", "detail": str(exc), }, status=status.HTTP_400_BAD_REQUEST) # ---- 信用额度(销售) ---- from apps.sales.services import CreditLimitExceeded if isinstance(exc, CreditLimitExceeded): return Response({ "code": "credit_limit_exceeded", "detail": ( f"客户 {exc.customer_code} 信用超限:已用 {exc.outstanding} + " f"本单 {exc.extra} > 额度 {exc.limit}" ), "customer_code": exc.customer_code, "outstanding": str(exc.outstanding), "limit": str(exc.limit), "this_bill": str(exc.extra), }, status=status.HTTP_402_PAYMENT_REQUIRED) # ---- 套餐/配额(billing 与 ai 两处,ai 的继承自 billing) ---- try: from apps.billing.quota import QuotaExceeded if isinstance(exc, QuotaExceeded): return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN) except ImportError: pass try: from apps.ai.usage import QuotaExceeded as AiQuotaExceeded if isinstance(exc, AiQuotaExceeded): return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN) except ImportError: pass # ---- 商城 ---- try: from apps.storefront.services import ( CreditLimitExceeded as SfCreditLimit, ProductNotAuthorized, StorefrontError, ) if isinstance(exc, ProductNotAuthorized): return Response({ "code": "product_not_authorized", "detail": str(exc), }, status=status.HTTP_403_FORBIDDEN) if isinstance(exc, SfCreditLimit): return Response({ "code": "credit_limit_exceeded", "detail": str(exc), }, status=status.HTTP_402_PAYMENT_REQUIRED) if isinstance(exc, StorefrontError): return Response({ "code": "invalid_order", "detail": str(exc), }, status=status.HTTP_400_BAD_REQUEST) except ImportError: pass return None