A1 删除/匿名化 180 天保留期门禁 (P0): - admin/errors/codes.py + BIZ_ORDER_RETENTION_NOT_EXPIRED (E02002) -> HTTP 409 - admin/config.py + retention_days / GAOKAO_RETENTION_DAYS (默认 180) - admin/routes/orders.py + _assert_retention_expired 守卫 - data/orders/deletion_service.py + RETENTION_GUARDED_STATUSES - admin/tests/test_order_deletion.py + _expire_retention_window + 3 测试 B1 支付失败 webhook 持久化 (P1): - data/payments/dao.py + failed_at/failure_reason/provider_trade_no/callback_payload - data/payments/models.py + PaymentRecord 新字段 - data/payments/service.py handle_webhook 失败分支持久化 - data/payments/tests/test_webhook.py test_handle_webhook_persists_failed_status A2 文档校准 (P1): - README / PRD / ROADMAP / TECH_ARCHITECTURE 顶部对齐 6/19 B2 真相源分层 (P1): - 6/13 整改板/执行板加历史快照前缀 - 新建 6/19 整改板/执行板 - CURRENT_STATE 顶部加 6/19 增量段 (在 6/13 真相源之上叠加) dev-verify: 1175 passed (A1+B1+A2+B2) -> 1179 passed (+ T12-C), coverage overall=85.05% / core=100%
319 lines
10 KiB
Python
319 lines
10 KiB
Python
"""业务异常 + FastAPI 集成 (T9.2).
|
|
|
|
职责:
|
|
- BusinessError : 业务异常类, 携带 ErrorCode
|
|
- error_response(...) : 渲染为标准响应体 (与 OpenAPI 文档契约)
|
|
- register_exception_handler(app) : 把 BusinessError / HTTPException / 兜底异常
|
|
全部统一到 { code, message, suggestion, severity, retryable, detail } 形状
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import logging
|
|
import sys
|
|
from functools import wraps
|
|
from typing import Any, Callable, Mapping, Optional, TypeVar, cast
|
|
|
|
from fastapi import FastAPI, HTTPException, Request, status
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from admin.errors.codes import ErrorCode, FALLBACK_CODE
|
|
from admin.errors.registry import Message, get_message
|
|
from admin.logging_utils import log_event, log_event_exc
|
|
|
|
|
|
logger = logging.getLogger("admin.errors")
|
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
|
|
|
|
def catch(
|
|
code: ErrorCode,
|
|
*,
|
|
reraise: bool = False,
|
|
) -> Callable[[F], F]:
|
|
"""捕获函数内部异常并统一记录/翻译为 ``BusinessError``.
|
|
|
|
- ``BusinessError`` 直接透传,不重复包裹。
|
|
- 其它 ``Exception`` 先打结构化异常日志,再按 ``reraise`` 决定:
|
|
- ``False``: 转成 ``BusinessError(code)``
|
|
- ``True``: 记录后原样抛出
|
|
- 同时支持同步/异步函数。
|
|
"""
|
|
|
|
def decorator(func: F) -> F:
|
|
if inspect.iscoroutinefunction(func):
|
|
|
|
@wraps(func)
|
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
except BusinessError:
|
|
raise
|
|
except Exception as exc:
|
|
_log_caught_exception(func.__name__, code, exc)
|
|
if reraise:
|
|
raise
|
|
raise BusinessError(code) from exc
|
|
|
|
return cast(F, async_wrapper)
|
|
|
|
@wraps(func)
|
|
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
try:
|
|
return func(*args, **kwargs)
|
|
except BusinessError:
|
|
raise
|
|
except Exception as exc:
|
|
_log_caught_exception(func.__name__, code, exc)
|
|
if reraise:
|
|
raise
|
|
raise BusinessError(code) from exc
|
|
|
|
return cast(F, sync_wrapper)
|
|
|
|
return decorator
|
|
|
|
|
|
def _log_caught_exception(function_name: str, code: ErrorCode, exc: Exception) -> None:
|
|
log_event_exc(
|
|
logger,
|
|
logging.ERROR,
|
|
"caught_exception",
|
|
exc_info=(type(exc), exc, exc.__traceback__),
|
|
msg=f"Caught exception in {function_name}",
|
|
code=str(code),
|
|
function=function_name,
|
|
)
|
|
|
|
|
|
# ---------------- 响应体契约 ----------------
|
|
#
|
|
# {
|
|
# "code": "E01101", # 业务错误码 (T9.1)
|
|
# "message": "...", # 给用户看的中文消息 (T9.2)
|
|
# "suggestion": "...", # 给用户的解决建议 (T9.2)
|
|
# "severity": "warn", # info/warn/error
|
|
# "retryable": false, # 是否可重试
|
|
# "detail": {...} # 可选, 调试用上下文 (生产环境按 env 开关)
|
|
# }
|
|
|
|
|
|
# ---------------- 异常类 ----------------
|
|
|
|
|
|
class BusinessError(Exception):
|
|
"""业务异常 — 路由层抛出会被全局 handler 捕获并渲染.
|
|
|
|
使用示例::
|
|
|
|
if user is None:
|
|
raise BusinessError(AUTH_INVALID_CREDENTIALS)
|
|
if not user.is_active:
|
|
raise BusinessError(AUTH_ACCOUNT_DISABLED, detail={"user_id": user.id})
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
code: ErrorCode,
|
|
*,
|
|
detail: Optional[Mapping[str, Any]] = None,
|
|
locale: str = "zh-CN",
|
|
http_status: Optional[int] = None,
|
|
) -> None:
|
|
super().__init__(str(code))
|
|
self.code = code
|
|
self.detail = dict(detail) if detail else None
|
|
self.locale = locale
|
|
self.http_status = http_status # 显式覆盖时用, 如某些 4xx 业务码强制 401
|
|
|
|
|
|
def error_response(
|
|
code: str,
|
|
message: Message,
|
|
*,
|
|
detail: Optional[Mapping[str, Any]] = None,
|
|
include_detail: bool = False,
|
|
) -> dict:
|
|
"""组装标准响应体."""
|
|
body = {
|
|
"code": code,
|
|
"message": message.message,
|
|
"suggestion": message.suggestion,
|
|
"severity": message.severity,
|
|
"retryable": message.retryable,
|
|
}
|
|
if include_detail and detail:
|
|
body["detail"] = dict(detail)
|
|
return body
|
|
|
|
|
|
# ---------------- HTTP 状态码映射 ----------------
|
|
#
|
|
# 业务码 → 默认 HTTP 状态码. 同一 HTTP 状态可对应多个业务码, 但不同业务码
|
|
# 渲染的中文文案不同 (T9.1 决策: 业务码与 HTTP 状态码解耦).
|
|
|
|
# 兼容 starlette 新旧命名 (HTTP_422_UNPROCESSABLE_ENTITY → HTTP_422_UNPROCESSABLE_CONTENT).
|
|
# 旧 starlette 没有 HTTP_422_UNPROCESSABLE_CONTENT, 但 HTTP_422 数值就是 422.
|
|
_HTTP_422: int = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", None) or 422
|
|
|
|
|
|
_DEFAULT_HTTP_STATUS: Mapping[str, int] = {
|
|
# 01 用户域
|
|
"E01101": status.HTTP_401_UNAUTHORIZED,
|
|
"E01102": status.HTTP_403_FORBIDDEN,
|
|
"E01201": status.HTTP_401_UNAUTHORIZED,
|
|
"E01202": status.HTTP_401_UNAUTHORIZED,
|
|
"E01301": status.HTTP_403_FORBIDDEN,
|
|
# 02 业务域
|
|
"E02001": status.HTTP_404_NOT_FOUND,
|
|
"E02002": status.HTTP_409_CONFLICT,
|
|
"E02301": status.HTTP_409_CONFLICT,
|
|
"E02501": status.HTTP_429_TOO_MANY_REQUESTS,
|
|
# 03 数据域
|
|
"E03001": _HTTP_422,
|
|
"E03002": status.HTTP_404_NOT_FOUND,
|
|
"E03003": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
# 04 第三方域
|
|
"E04001": status.HTTP_502_BAD_GATEWAY,
|
|
"E04002": status.HTTP_504_GATEWAY_TIMEOUT,
|
|
# 05 系统域
|
|
"E05001": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
"E05002": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
"E05003": status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
}
|
|
|
|
_WWW_AUTHENTICATE_BEARER_CODES = {
|
|
"E01201", # AUTH_TOKEN_EXPIRED
|
|
"E01202", # AUTH_TOKEN_INVALID
|
|
}
|
|
|
|
|
|
def http_status_for(code: str) -> int:
|
|
"""业务码 → HTTP 状态码 (无显式映射时默认 400)."""
|
|
return _DEFAULT_HTTP_STATUS.get(code, status.HTTP_400_BAD_REQUEST)
|
|
|
|
|
|
# ---------------- FastAPI handler ----------------
|
|
|
|
|
|
def register_exception_handler(app: FastAPI) -> None:
|
|
"""注册全局异常 handler (在 create_app 内调用)."""
|
|
|
|
@app.exception_handler(BusinessError)
|
|
async def _handle_business_error(request: Request, exc: BusinessError):
|
|
code_str = str(exc.code)
|
|
msg = get_message(code_str, locale=exc.locale)
|
|
http_status = exc.http_status or http_status_for(code_str)
|
|
body = error_response(
|
|
code=code_str,
|
|
message=msg,
|
|
detail=exc.detail,
|
|
include_detail=exc.detail is not None,
|
|
)
|
|
log_event(
|
|
logger,
|
|
logging.WARNING,
|
|
"business_error",
|
|
msg=f"BusinessError code={code_str} status={http_status}",
|
|
code=code_str,
|
|
path=request.url.path,
|
|
method=request.method,
|
|
status=http_status,
|
|
)
|
|
headers = None
|
|
if code_str in _WWW_AUTHENTICATE_BEARER_CODES:
|
|
headers = {"WWW-Authenticate": "Bearer"}
|
|
return JSONResponse(status_code=http_status, content=body, headers=headers)
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def _handle_http_exception(request: Request, exc: HTTPException):
|
|
"""未升级为 BusinessError 的 FastAPI 内置异常 — 走兜底文案.
|
|
|
|
目的: 即使路由层忘了用 BusinessError, 用户也能看到中文提示, 而不是裸 500.
|
|
"""
|
|
fallback_code_str = str(FALLBACK_CODE)
|
|
msg = get_message(fallback_code_str)
|
|
# detail 是字符串时透传, 否则只记日志不暴露给用户
|
|
original_detail = exc.detail if isinstance(exc.detail, str) else None
|
|
body = error_response(
|
|
code=fallback_code_str,
|
|
message=msg,
|
|
detail={"http_status": exc.status_code, "reason": original_detail}
|
|
if original_detail
|
|
else None,
|
|
include_detail=original_detail is not None,
|
|
)
|
|
log_event(
|
|
logger,
|
|
logging.WARNING,
|
|
"http_exception_fallback",
|
|
msg=f"HTTPException status={exc.status_code} mapped to BusinessError",
|
|
path=request.url.path,
|
|
method=request.method,
|
|
status=exc.status_code,
|
|
)
|
|
return JSONResponse(status_code=exc.status_code, content=body)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def _handle_validation_error(request: Request, exc: RequestValidationError):
|
|
"""422 pydantic 校验失败 → DATA_VALIDATION_FAILED 业务码."""
|
|
from admin.errors.codes import DATA_VALIDATION_FAILED
|
|
|
|
msg = get_message(str(DATA_VALIDATION_FAILED))
|
|
# 错误字段摘要: 取每个错误的 loc + msg, 不暴露内部值
|
|
field_errors = [
|
|
{
|
|
"field": ".".join(str(p) for p in err.get("loc", [])),
|
|
"reason": err.get("msg"),
|
|
}
|
|
for err in exc.errors()
|
|
]
|
|
body = error_response(
|
|
code=str(DATA_VALIDATION_FAILED),
|
|
message=msg,
|
|
detail={"fields": field_errors},
|
|
include_detail=True,
|
|
)
|
|
log_event(
|
|
logger,
|
|
logging.INFO,
|
|
"validation_error",
|
|
msg=f"ValidationError fields={len(field_errors)}",
|
|
path=request.url.path,
|
|
method=request.method,
|
|
code_count=len(field_errors),
|
|
)
|
|
return JSONResponse(
|
|
status_code=_HTTP_422,
|
|
content=body,
|
|
)
|
|
|
|
@app.exception_handler(Exception)
|
|
async def _handle_unexpected(request: Request, exc: Exception):
|
|
"""任何未捕获异常 → SYS_INTERNAL_ERROR + 兜底文案.
|
|
|
|
注意: 不在响应里暴露 traceback / 异常类名 (信息泄露面), 仅日志记录.
|
|
"""
|
|
from admin.errors.codes import SYS_INTERNAL_ERROR
|
|
|
|
msg = get_message(str(SYS_INTERNAL_ERROR))
|
|
log_event_exc(
|
|
logger,
|
|
logging.ERROR,
|
|
"unhandled_exception",
|
|
exc_info=sys.exc_info(),
|
|
msg="Unhandled exception (mapped to SYS_INTERNAL_ERROR)",
|
|
path=request.url.path,
|
|
method=request.method,
|
|
)
|
|
body = error_response(
|
|
code=str(SYS_INTERNAL_ERROR),
|
|
message=msg,
|
|
)
|
|
return JSONResponse(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
content=body,
|
|
)
|