Files

188 lines
6.7 KiB
Python
Raw Permalink 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": "服务端处理异常,请稍后重试或联系管理员",
}, 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