Files
group_xinghuo_jinrong/app/main.py
T
GaoYiYuan_0626 e04b44c44a 基金转换 T+1 模型:T-9 api 层(受理 202 + 撤单/确认/查询接口 + 网关分派 + redeem 份额申报)
三接口路径以 PRD 为准(裁定 11/12):
- app/api/convert_admin.py 新增 POST /api/simulate/trade/convert/{gid}/cancel(交易 owner 闸门)
  + POST /api/admin/convert/confirm?accept_date=(risk_officer)+ GET /api/simulate/trade/convert/{gid}(查询 scope 闸门)
- simulate.py convert 分支受理 202 + 受理回执(两类 202 分辨 accepted/processing)
- trade_gateway 分派改调 accept_convert(SUPPORTED_TRADE_TYPES 维持 2 类型)
- redeem 份额申报(D26/R-6:qty 入参,_redeem_quote = qty * T 净值 - 赎回费)
- errors.py 新增 CANCEL_NOT_ALLOWED / CONCURRENT_CONFLICT(均 409)

修正 2 处口径缺陷(测试打穿揭示,非只改测试):
- _rebuild_quote 的 out_nav 改直读 core_convert_lot_detail.nav(反推致 1.3604 -> 1.3600 漂移)
- _accept_idempotent 字段面扩展与受理回执对齐(幂等重试客户端可取到预计确认日)

验证:端到端 13 passed(T+1 两段链路重写);全量 834 passed / 10 skipped;
真库 verify_convert_api.py 88/88,存量 4 脚本零回归;3 组突变验证命中后还原
2026-09-11 20:37:45 +08:00

129 lines
5.0 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.convert_admin import (
admin_convert_router,
simulate_convert_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 对话最小闭环
# T-9 基金转换对外接口(路径以 PRD §5.5/§5.7 为准:撤单+查询挂 simulate 族,确认挂 admin 族)
app.include_router(simulate_convert_router)
app.include_router(admin_convert_router)
@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}