"""统一 API 响应外壳(B7 · 挂账④;T-02 扩展)。 成功响应:业务字段平铺不变(B5 评审 P2-2 口径「路由返回体不变」),trace_id 经 X-Trace-Id 响应头贯通;错误响应统一 JWT 手册 §10 结构 {error_code, message, trace_id, request_id}——request_id 为独立请求级标识 (T-02,B7 复审 P3-4 收口),trace_id/request_id 分别对齐响应头。 T-02 补齐(B7 复审 P2-2):422 请求校验失败、404/405 路由方法错误改用统一 错误体(原 FastAPI detail 结构);未捕获异常的 500 由 main.trace 中间件 兜底(异常穿透 exception handler,直接在中间件层生成错误体并回写 trace 头)。 main 与测试 app 共用 register_error_handlers。 """ from __future__ import annotations from typing import Any from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException from app.utils.exceptions import ApiError, PermissionDenied from app.utils.trace import current_request_id, current_trace, new_trace, new_request_id # 路由级 HTTP 状态 → 错误码(手册 §10 之外的平台通用码) _HTTP_ERROR_CODES = {404: "NOT_FOUND", 405: "METHOD_NOT_ALLOWED"} def error_body(error_code: str, message: str) -> dict[str, Any]: """手册 §10 错误体(trace/request 缺失时兜底生成,保证响应可归因)。 返回 `dict[str, Any]`:基础四字段均为 str,但 `_api_error_handler` 会 继续合入异常的结构性附加字段(如 `batch_count`/`max_lots`,T-9)。 """ tid = current_trace() or new_trace() rid = current_request_id() or new_request_id() return {"error_code": error_code, "message": message, "trace_id": tid, "request_id": rid} def register_error_handlers(app: FastAPI) -> None: """注册统一错误体 handler(替代 FastAPI 默认 detail 结构)。""" @app.exception_handler(ApiError) async def _api_error_handler(request: Request, exc: ApiError) -> JSONResponse: body = error_body(exc.error_code, exc.message) # T-9:合入结构性附加字段(架构 §8.3 契约)。 # 仅 convert 的错误子类(`ConvertError`)带 `extra` 属性,既有 ApiError # 无此属性 → `getattr` 取 None → 既有错误体**逐字节不变**。 extra = getattr(exc, "extra", None) if extra: body.update(extra) return JSONResponse(status_code=exc.status_code, content=body) @app.exception_handler(PermissionDenied) async def _permission_denied_handler(request: Request, exc: PermissionDenied) -> JSONResponse: return JSONResponse(status_code=403, content=error_body(exc.code, str(exc))) @app.exception_handler(StarletteHTTPException) async def _http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: code = _HTTP_ERROR_CODES.get(exc.status_code, "HTTP_ERROR") return JSONResponse(status_code=exc.status_code, content=error_body(code, str(exc.detail))) @app.exception_handler(RequestValidationError) async def _validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse: return JSONResponse( status_code=422, content=error_body("REQUEST_VALIDATION_FAILED", "request validation failed"), )