188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""统一业务异常 → 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
|