205 lines
7.8 KiB
Python
205 lines
7.8 KiB
Python
"""FastAPI 入口:宿主 Wave 0(auth/chat/TraceMiddleware)+ 风控/转换/顾问模块 router。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||
|
||
from app.api import deps
|
||
from app.api.audit_middleware import audit_middleware
|
||
from app.api.auth import router as auth_router
|
||
from app.api.advisors import router as advisors_router
|
||
from app.api.chat import router as chat_router
|
||
from app.api.compliance import router as compliance_router
|
||
from app.api.convert_admin import admin_convert_router, simulate_convert_router
|
||
from app.api.customers import router as customers_router
|
||
from app.api.products import router as products_router
|
||
from app.api.risk import router as risk_router
|
||
from app.api.simulate import router as simulate_router
|
||
from app.api.staff import router as staff_router
|
||
from app.api.advisor_compliance import router as advisor_compliance_router
|
||
from app.api.advisor_script_templates import router as advisor_script_templates_router
|
||
from app.api.allocation import router as advisor_allocation_router
|
||
from app.api.analyst import router as analyst_router
|
||
from app.api.copy import router as advisor_copy_router
|
||
from app.api.dashboard import router as advisor_dashboard_router
|
||
from app.api.guard import router as advisor_guard_router
|
||
from app.api.kyc import router as advisor_kyc_router
|
||
from app.api.market import fund_router as advisor_market_fund_router
|
||
from app.api.market import router as advisor_market_alerts_router
|
||
from app.api.ready import build_ready_payload, router as ready_router
|
||
from app.api.visitor import router as visitor_router
|
||
from app.advisor_exceptions import AdvisorAppError
|
||
from app.config.settings import settings
|
||
from app.middleware.trace import TraceMiddleware
|
||
from app.repository.audit_repository import AuditRepository
|
||
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.exceptions import ApiError, AppError, PermissionDenied
|
||
from app.utils.response import error_body, fail, register_error_handlers, trace_headers
|
||
from app.utils.trace import (
|
||
HEADER_ID_PATTERN,
|
||
bind_request_id,
|
||
new_trace,
|
||
reset_request_id,
|
||
reset_trace,
|
||
set_trace,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_TRACE_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
|
||
|
||
_HANDLED_APP_EXCEPTIONS = (
|
||
ApiError,
|
||
AppError,
|
||
PermissionDenied,
|
||
RequestValidationError,
|
||
StarletteHTTPException,
|
||
)
|
||
|
||
|
||
@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}")
|
||
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.add_middleware(TraceMiddleware)
|
||
app.include_router(auth_router)
|
||
app.include_router(customers_router)
|
||
app.include_router(products_router)
|
||
app.include_router(advisors_router)
|
||
app.include_router(staff_router)
|
||
app.include_router(compliance_router)
|
||
app.include_router(chat_router)
|
||
app.include_router(visitor_router)
|
||
app.include_router(risk_router)
|
||
app.include_router(simulate_router)
|
||
app.include_router(simulate_convert_router)
|
||
app.include_router(admin_convert_router)
|
||
app.include_router(analyst_router)
|
||
app.include_router(advisor_compliance_router)
|
||
app.include_router(advisor_script_templates_router)
|
||
app.include_router(advisor_kyc_router)
|
||
app.include_router(advisor_market_alerts_router)
|
||
app.include_router(advisor_market_fund_router)
|
||
app.include_router(advisor_copy_router)
|
||
app.include_router(advisor_guard_router)
|
||
app.include_router(advisor_dashboard_router)
|
||
app.include_router(advisor_allocation_router)
|
||
app.include_router(ready_router)
|
||
|
||
|
||
@app.get("/api/ready", tags=["platform"], include_in_schema=True)
|
||
def api_ready_probe(request: Request):
|
||
"""与 ready_router 同逻辑;在 app 上再挂一份,避免旧进程/路由表遗漏时 404。"""
|
||
trace_id = getattr(request.state, "trace_id", "unknown")
|
||
return build_ready_payload(app, trace_id)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def audit_middleware_entry(request: Request, call_next):
|
||
return await audit_middleware(request, call_next)
|
||
|
||
|
||
@app.middleware("http")
|
||
async def trace_middleware(request: Request, call_next):
|
||
incoming = request.headers.get("X-Trace-Id", "")
|
||
trace_id = (
|
||
incoming
|
||
if _TRACE_ID_PATTERN.fullmatch(incoming) or HEADER_ID_PATTERN.fullmatch(incoming)
|
||
else new_trace()
|
||
)
|
||
request.state.trace_id = trace_id
|
||
token = set_trace(trace_id)
|
||
request_id, rid_token = bind_request_id(request.headers.get("X-Request-Id", ""))
|
||
request.state.request_id = request_id
|
||
try:
|
||
try:
|
||
response = await call_next(request)
|
||
except _HANDLED_APP_EXCEPTIONS:
|
||
raise
|
||
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"),
|
||
headers=trace_headers(request),
|
||
)
|
||
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(request: Request, probe: bool = False):
|
||
trace_id = getattr(request.state, "trace_id", "unknown")
|
||
if probe:
|
||
return build_ready_payload(app, trace_id)
|
||
return {"status": "ok", "env": settings.app_env, "trace_id": trace_id}
|
||
|
||
|
||
@app.exception_handler(AdvisorAppError)
|
||
async def advisor_app_error_handler(request: Request, exc: AdvisorAppError):
|
||
trace_id = getattr(request.state, "trace_id", "unknown")
|
||
body = fail(int(exc.status_code), exc.message, trace_id, data={"error_code": exc.code, **(exc.data or {})})
|
||
return JSONResponse(status_code=exc.status_code, content=body.model_dump())
|
||
|
||
|
||
@app.exception_handler(AppError)
|
||
async def app_error_handler(request: Request, exc: AppError):
|
||
trace_id = getattr(request.state, "trace_id", "unknown")
|
||
if exc.audit_event:
|
||
agent_type = request.headers.get("X-Agent-Type", "platform")
|
||
try:
|
||
AuditRepository().insert(
|
||
trace_id=trace_id,
|
||
event_type=exc.audit_event,
|
||
agent_type=agent_type if agent_type in ("customer", "advisor", "analyst", "risk") else "platform",
|
||
actor_id="anonymous",
|
||
decision=exc.error_code,
|
||
input_summary={"message": exc.message},
|
||
)
|
||
except Exception:
|
||
pass
|
||
body = fail(exc.code, exc.message, trace_id, data={"error_code": exc.error_code})
|
||
return JSONResponse(status_code=exc.http_status, content=body.model_dump())
|