"""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 import re 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 ( bind_request_id, new_trace, reset_request_id, reset_trace, set_trace, ) logger = logging.getLogger(__name__) # 透传外部 X-Trace-Id 的格式白名单(防响应头注入;不合规一律新生成) _TRACE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$") @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 _TRACE_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}