Files
group_xinghuo_jinrong/app/utils/response.py
T
GaoYiYuan_0626 d0097d6004 基金转换 T-9:API 模型 + 网关分派(HTTP 层 convert 走通)+ 展示位数口径修复
一、T-9 本体:HTTP 层 convert 端到端走通

- api/simulate.py:TradeRequest 三型字段分池(subscribe/redeem → product_id+amount;
  convert → from/to_product_id + qty + 可选 client_request_id)+ model_validator 分支校验;
  未知类型放行给网关抛 400(保住既有 purchase → 400 断言);model_dump(exclude_none=True);
  PROCESSING → 202;异常捕获由 except LookupError 收窄为 except NotFoundError
  (原写法把 KeyError 这类编程错误静默转成 404,实测掩盖 convert 分支真实诊断)。
- gateway/trade_gateway.py:移除 convert 显式拒绝,新增 _submit_convert 分派
  (只做参数映射 + 仓储装配);convert 不写 trade_request 审计(审计归 convert_service)。
- utils/response.py:错误体合入 exc.extra(TOO_MANY_LOTS 的 batch_count/max_lots);
  既有 ApiError 无 extra 属性 → 老错误体逐字节不变。
- utils/trace.py + main.py:正则收敛单点定义。执行期发现 trace.py 与 main.py 各有一份
  内容完全相同的白名单副本 —— S4 要防的「漂移」其实已经发生,现将常量上移 trace.py
  成公开 HEADER_ID_PATTERN(同时解决 main→simulate 反向导入成环)。

二、展示位数口径修复(执行期发现 → 联网核验 → 修复 → 文档订正)

发现:同一逻辑响应两种写法 —— 首次 "53456.95" vs 幂等重放 "53456.9500",数值相等、字符串不等。
根因不是 T-7 写错,是契约缺位:§2.5 只规定「金额/份额 2 位」,净值、费率、申请份额的
回显位数根本没定义 → 实现只能 str(Decimal) 原样出网 → 位数随数据来源漂移。

修复:convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口 ——
金额/份额 2 位、净值/费率/份额尾差 4 位;响应 + 审计 summary + 异常日志共用该出口;
原 _s() 全部替换。首次路径幂等(除 requested_qty/actual_qty/lot[].qty 由 4 位补齐 2 位外不变)。

依据(2026-09-10 联网核验 7 家管理人公告):金额/份额「四舍五入保留至小数点后两位」;
「申请转换份额精确到小数点后两位」;净值保留 4 位第 5 位四舍五入(中欧/国泰公告由 3 位提高至 4 位);
费率以百分比 2 位表示。已知不统一:易方达 ETF 场外份额取整数位、南方基金取截断 → 取主流口径
并记入 PRD 已知差异(未来接真实 TA 需按基金合同配置化)。

三、文档订正

- PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增按字段分类的规格表 + 外部依据);
  §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5
  「计算与对外展示按 2 位」自相矛盾,属漏改);字段类型约定补「位数不自由 + 两条路径须逐字节一致」。
- 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」,无结构变更。

四、验证

- 新增 tests/test_convert_integration.py(8 条真 MySQL 端到端,CNV-TEST-/TRD-TEST- 前缀隔离):
  折算与 PRD §5.3 逐项吻合、两条流水同组、持仓与批次如实变动、明细 completed + 审计、
  幂等重试不产生第二组、跨主体 400、未知类型 400,以及
  「首次与重放逐字段逐字节相等」+「展示位数规格」两条新闸门。
- test_trade_gateway.py +17(11 条错误码映射全表参数化 · 202 · 200 透传 · 不写 trade_request 审计)。
- test_integration_risk.py:R15 处置 —— 端到端已迁入新文件,原槽位改造为
  test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。
- pytest -q → 697 passed / 3 skipped(基线 672 +25,零回归)。
- 真库复跑:T-6 24/24 · T-7 35/35 · T-8 31/31;calc_convert_demo.py 15/15。
- 突变验证 4 组:关掉 convert 分派 → 21 条红;关掉错误体 extra 展开 → 精准 1 条;
  关掉 client_request_id 正则 → 精准 1 条;关掉 _q() 展示量化 → 2 条红
  (assert '50000.0000' == '50000' 直接复现原缺陷)。均已恢复。
2026-09-10 18:00:20 +08:00

70 lines
3.4 KiB
Python
Raw 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.
"""统一 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"),
)