一、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' 直接复现原缺陷)。均已恢复。
122 lines
4.7 KiB
Python
122 lines
4.7 KiB
Python
"""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}
|