from pathlib import Path from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from app.api.controllers.admin import router as admin_router from app.api.controllers.agent_runs import router as agent_runs_router from app.api.controllers.asset_allocation import router as asset_allocation_router from app.api.controllers.auth import router as auth_router from app.api.controllers.conversations import router as conversations_router from app.api.controllers.health import router as health_router from app.api.controllers.investment_goals import router as investment_goals_router from app.api.controllers.knowledge import router as knowledge_router from app.api.controllers.knowledge_management import router as knowledge_management_router from app.api.controllers.offsite_fund import operation_router as offsite_operation_router from app.api.controllers.offsite_fund import router as offsite_fund_router from app.api.controllers.onboarding import router as onboarding_router from app.api.controllers.portfolio_analysis import router as portfolio_analysis_router from app.api.controllers.promotion_material import router as promotion_material_router from app.api.controllers.public_platform import router as public_platform_router from app.api.controllers.rbac import router as rbac_router from app.api.controllers.recommendations import ( admin_router as recommendation_admin_router, ) from app.api.controllers.recommendations import ( advisor_router as recommendation_advisor_router, ) from app.api.controllers.risk import router as risk_router from app.api.controllers.trading import router as trading_router from app.api.controllers.visitor_tokens import router as visitor_tokens_router from app.api.middleware import attach_trace_id from app.core.config import get_settings from app.core.errors import AgentError def _trace_id(request: Request) -> str: """取本次请求的追踪标识:请求上下文 → state → 客户端 `X-Trace-ID` → 空串。 没有就返回空字符串,绝不凭空生成——凭空生成会让客户端拿到的 trace_id 与服务端日志 里的不是同一个,反而失去定位价值。 """ context = getattr(request.state, "request_context", None) return str( getattr(context, "trace_id", None) or getattr(request.state, "trace_id", None) or request.headers.get("X-Trace-ID") or "" ) def create_app() -> FastAPI: settings = get_settings() application = FastAPI(title=settings.app_name, version="0.1.0") # 接口文档承诺的 X-Trace-ID 此前完全没实现;中间件对成功与错误响应都生效。 application.middleware("http")(attach_trace_id) application.add_middleware( CORSMiddleware, allow_origins=[ origin.strip() for origin in settings.cors_allowed_origins.split(",") if origin.strip() ], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) @application.exception_handler(AgentError) async def agent_error_handler(request: Request, exc: AgentError) -> JSONResponse: context = getattr(request.state, "request_context", None) # 认证失败时请求上下文尚未建立(`build_request_context` 不会写 request_context), # 按文档 §3.4 优先复用请求头里客户端带来的 `X-Trace-ID`;都没有就是空字符串, # 绝不凭空生成 id——会让排障时把两个请求认成同一个。 trace_id = (getattr(context, "trace_id", None) or getattr(request.state, "trace_id", None) or request.headers.get("X-Trace-ID") or "") # retryable 按文档 §3.6 逐码标注,不再简单按 5xx 推导 # (例如 RESOURCE_VERSION_CONFLICT 是 409 但文档标注可重试)。 headers: dict[str, str] = {} retry_after = getattr(exc, "retry_after_seconds", None) if isinstance(retry_after, int): # 文档 §3.6 把 RATE_LIMITED 标注为可重试:只给 retryable=true 而不给 # Retry-After,客户端只能自己猜退避时长(或立刻重试再被拒)。 headers["Retry-After"] = str(retry_after) return JSONResponse(status_code=exc.status_code, content={ "error": {"code": exc.code, "message": exc.message, "retryable": exc.is_retryable, "field_errors": []}, "meta": {"trace_id": trace_id}, }, headers=headers or None) @application.exception_handler(RequestValidationError) async def request_validation_error_handler( request: Request, exc: RequestValidationError ) -> JSONResponse: """请求校验失败也必须走统一错误信封(文档 §3.4 / §3.6 `AGENT_INPUT_INVALID`)。 不加这个处理器时,FastAPI 会返回自己的 `{"detail": [...]}` 结构(422),客户端 必须为"参数错误"单独兼容一套解析逻辑;同一套接口因此出现两种错误体形态。 这里保留 422 状态码(文档 §3.5:已解析请求不满足字段或业务输入约束), 把字段级原因放进 `error.field_errors`,与业务异常的信封完全一致。 """ field_errors = [ { "field": ".".join(str(part) for part in error.get("loc", ())), "message": str(error.get("msg", "")), } for error in exc.errors() ] return JSONResponse(status_code=422, content={ "error": { "code": "AGENT_INPUT_INVALID", "message": "请求参数不满足接口约束", "retryable": False, "field_errors": field_errors, }, "meta": {"trace_id": _trace_id(request)}, }) application.include_router(auth_router) application.include_router(agent_runs_router) application.include_router(conversations_router) application.include_router(public_platform_router) application.include_router(rbac_router) application.include_router(risk_router) application.include_router(visitor_tokens_router) application.include_router(offsite_fund_router) application.include_router(offsite_operation_router) application.include_router(promotion_material_router) application.include_router(knowledge_router) application.include_router(knowledge_management_router) application.include_router(health_router) application.include_router(onboarding_router) application.include_router(investment_goals_router) application.include_router(portfolio_analysis_router) application.include_router(asset_allocation_router) application.include_router(recommendation_advisor_router) application.include_router(recommendation_admin_router) application.include_router(admin_router) application.include_router(trading_router) application.mount( "/customer-service-test", StaticFiles(directory=Path(__file__).resolve().parent / "static", html=True), name="customer-service-test", ) return application app = create_app()