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

122 lines
4.7 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.
"""FastAPI 入口(B7 集成 · T-01/T-02 演进):路由挂载、trace 中间件、审计中间件、lifespan、统一错误体。
lifespan(B7 挂账⑤⑥):
- 启动期校验:非 development 环境须 JWT 就绪(RS256 公钥已配置;
T-01 前的 debug 工厂在场同样拒绝——AUTH_FACTORY_IS_DEBUG 双保险);
- Redis 网关单例注册(惰性连接,publish/DEL/EXISTS 失败降级不阻塞业务);
- shutdown 统一 dispose 数据库引擎(utils/db 工厂,B6 复审 P3 泄漏收口)。
trace:X-Trace-Id 透传/生成 + 响应头回写(trace.py 约束:call_next 前 set,
同步路由线程池由 anyio 传播,一致性 B8 断言兜底)。
audit(T-02):平台访问审计(http_access)+ input_guard_log 双写见
audit_middleware;X-Request-Id 独立生成(B7 复审 P3-4)。
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import deps
from app.api.audit_middleware import audit_middleware
from app.api.chat import router as chat_router
from app.api.risk import router as risk_router
from app.api.simulate import router as simulate_router
from app.config.settings import settings
from app.service.auth_service import jwt_ready
from app.service.risk import redis_gateway
from app.utils.db import dispose_engines
from app.utils.response import error_body, register_error_handlers
from app.utils.trace import (
HEADER_ID_PATTERN,
bind_request_id,
new_trace,
reset_request_id,
reset_trace,
set_trace,
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
if settings.app_env != "development":
if deps.AUTH_FACTORY_IS_DEBUG:
raise RuntimeError(
"debug auth factory is wired but app_env is not 'development'; "
"deploy T-01 JWT auth first or set app_env=development"
)
reason = jwt_ready()
if reason:
raise RuntimeError(f"JWT auth not ready for non-development env: {reason}")
reason = jwt_ready() if settings.app_env != "development" else None
if reason:
raise RuntimeError(f"JWT auth not ready for non-development env: {reason}")
if settings.app_env == "development" and settings.jwt_dev_secret == "change-me-in-dev-only":
logger.warning(
"JWT_DEV_SECRET is the public default; issued dev tokens are forgeable "
"(demo/CI only, never expose to untrusted networks)"
)
if not settings.deepseek_api_key:
from app.service.agent_service import _DEGRADED_PREFIX
logger.warning(
"DEEPSEEK_API_KEY 未配置,对话将走降级回复(前缀 %s),LLM 能力不可用",
_DEGRADED_PREFIX,
)
redis_gateway.set_gateway(redis_gateway.RedisGateway())
try:
yield
finally:
redis_gateway.set_gateway(None)
dispose_engines()
app = FastAPI(title="JinRong Agent Platform", version="0.2.0", lifespan=lifespan)
register_error_handlers(app)
app.include_router(risk_router)
app.include_router(simulate_router)
app.include_router(chat_router) # T-06 对话最小闭环
@app.middleware("http")
async def audit_middleware_entry(request: Request, call_next):
"""T-02 访问审计:先注册(执行序在 trace 之内,trace_id/request_id 已绑定)。"""
return await audit_middleware(request, call_next)
@app.middleware("http")
async def trace_middleware(request: Request, call_next):
"""trace_id/request_id 贯通 + 未捕获异常兜底(B7 复审 P2-2)。
异常发生在本中间件之内时,Starlette 的 ServerErrorMiddleware(栈外层)
生成的 500 响应不经过用户中间件——trace 头丢失的根因;此处 catch 后
直接产出统一错误体,保证 500 也带 X-Trace-Id/X-Request-Id。
"""
incoming = request.headers.get("X-Trace-Id", "")
trace_id = incoming if HEADER_ID_PATTERN.fullmatch(incoming) else new_trace()
token = set_trace(trace_id)
request_id, rid_token = bind_request_id(request.headers.get("X-Request-Id", ""))
try:
try:
response = await call_next(request)
except Exception:
logger.exception("unhandled error on %s %s", request.method, request.url.path)
response = JSONResponse(
status_code=500, content=error_body("INTERNAL_ERROR", "internal server error")
)
finally:
reset_trace(token)
reset_request_id(rid_token)
response.headers["X-Trace-Id"] = trace_id
response.headers["X-Request-Id"] = request_id
return response
@app.get("/health")
def health():
return {"status": "ok", "env": settings.app_env}