Files
dealerhub/backend/apps/core/exceptions.py
T

165 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一业务异常 → 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": "服务端处理异常,请稍后重试或联系管理员",
"exc_type": type(exc).__name__,
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
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