155 lines
5.6 KiB
Python
155 lines
5.6 KiB
Python
"""FastAPI 入口:宿主 Wave 0(auth/chat/TraceMiddleware)+ 风控模块 router/中间件/lifespan。"""
|
||
|
||
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.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.analyst import router as analyst_router
|
||
from app.api.visitor import router as visitor_router
|
||
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 AppError
|
||
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 (
|
||
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}$")
|
||
|
||
# FastAPI 已注册 handler 的异常须原样冒泡;BaseHTTPMiddleware 对未捕获异常会 re-raise。
|
||
_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)"
|
||
)
|
||
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(analyst_router)
|
||
|
||
|
||
@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) 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):
|
||
trace_id = getattr(request.state, "trace_id", "unknown")
|
||
return {"status": "ok", "env": settings.app_env, "trace_id": trace_id}
|
||
|
||
|
||
@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())
|