baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+164
View File
@@ -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