baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""统一业务异常 → 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
|
||||
Reference in New Issue
Block a user