Files
group_xinghuo_jinrong/app/utils/trace.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

75 lines
2.8 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.
"""trace_id 全链路贯通(contextvars · 架构 §5.1)。
约束(编码前必读):
- 中间件必须在 call_next 之前 set(),禁止在 endpoint/service 内重新 set();
- 后台任务(create_task)须显式 contextvars.copy_context();
- 同步 def 路由跑线程池时 context 经 anyio 传播,由 B8 trace 一致性断言实测兜底。
"""
from __future__ import annotations
import re
from contextvars import ContextVar, Token
from uuid import uuid4
# HTTP 头部 ID 格式白名单(防响应头注入;不合规一律新生成)。
# **单点定义 · 三处共用**:X-Trace-Id(main 中间件)· X-Request-Id(bind_request_id)
# · client_request_id 幂等键(api/simulate,S4)。原先 main.py:44 另有一份同名副本
# (`_TRACE_ID_PATTERN`,内容与本行完全相同)→ T-9 已收敛到此处,避免白名单漂移。
HEADER_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
_trace_id: ContextVar[str] = ContextVar("trace_id", default="")
# 独立请求级 ID(T-02 · B7 复审 P3-4):trace_id 贯通链路,request_id 标识单次
# HTTP 请求(幂等/重试对账,手册 §4.6),两者不再互用。
_request_id: ContextVar[str] = ContextVar("request_id", default="")
def new_trace(trace_id: str | None = None) -> str:
"""生成并绑定新 trace_id;透传外部 X-Trace-Id 时传入该值。"""
tid = trace_id or f"trc-{uuid4().hex[:16]}"
_trace_id.set(tid)
return tid
def set_trace(trace_id: str) -> Token:
"""绑定外部 trace_id(中间件入口),返回 token 供请求结束复位。"""
return _trace_id.set(trace_id)
def reset_trace(token: Token) -> None:
"""复位到中间件之前的上下文(防异步任务间串号)。"""
_trace_id.reset(token)
def current_trace() -> str:
"""读取当前 trace_id;未初始化时返回空串(调用方应兜底生成)。"""
return _trace_id.get()
def ensure_trace() -> None:
"""无上下文时兜底归因(脚本/引擎入口),有值时保留(中间件场景不重新 set)。"""
if not current_trace():
new_trace()
def new_request_id() -> str:
"""生成并绑定 request_id(中间件入口每请求一次)。"""
rid = f"req-{uuid4().hex[:16]}"
_request_id.set(rid)
return rid
def bind_request_id(request_id: str | None) -> tuple[str, Token]:
"""绑定请求 request_id(合法透传否则生成);返回 (id, token) 供中间件复位。"""
rid = request_id if HEADER_ID_PATTERN.fullmatch(request_id or "") else f"req-{uuid4().hex[:16]}"
return rid, _request_id.set(rid)
def reset_request_id(token: Token) -> None:
_request_id.reset(token)
def current_request_id() -> str:
"""读取当前请求 request_id;未初始化返回空串(错误体兜底生成)。"""
return _request_id.get()