main.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """应用工厂集中管理资源启停、跨域、限流和异常响应,客户端只处理一种契约。"""
  2. import asyncio
  3. from collections import defaultdict, deque
  4. from contextlib import asynccontextmanager
  5. import logging
  6. import time
  7. import uuid
  8. from fastapi import FastAPI, Request
  9. from fastapi.exceptions import RequestValidationError
  10. from fastapi.middleware.cors import CORSMiddleware
  11. from fastapi.responses import JSONResponse
  12. from sqlalchemy import text
  13. from sqlalchemy.exc import IntegrityError
  14. from app.api import management, business, operations
  15. from app.application.services import request_context
  16. from app.application.worker import worker
  17. from app.infrastructure.ai import ai
  18. from app.infrastructure.database import engine, initialize, Session
  19. from app.shared.common import BusinessError, envelope, iso
  20. from app.shared.config import settings
  21. logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
  22. @asynccontextmanager
  23. async def lifespan(app):
  24. """消费者启动在建表与种子之后,关闭时先收尾任务再释放 HTTP/数据库连接。"""
  25. if ai.http.is_closed:
  26. import httpx
  27. ai.http = httpx.AsyncClient(timeout=settings.ai_timeout, follow_redirects=False)
  28. await initialize()
  29. await worker.start()
  30. yield
  31. await worker.close()
  32. await ai.close()
  33. await engine.dispose()
  34. def create_app():
  35. app = FastAPI(title="AI-Agent 财务审计系统", version="1.0.0", lifespan=lifespan,
  36. description="按课程设计:四节点审核、独立反馈、持久事件与人工审批。")
  37. app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins,
  38. allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "Idempotency-Key", "Last-Event-ID"],
  39. expose_headers=["X-Request-ID", "Content-Disposition"])
  40. limits = defaultdict(deque)
  41. @app.middleware("http")
  42. async def trace(request: Request, call_next):
  43. """请求 ID 由服务端产生,防止恶意用户污染日志;每分钟计数不会无限增长。"""
  44. request.state.request_id = uuid.uuid4().hex
  45. token = request_context.set(request.state.request_id)
  46. address = request.client.host if request.client else "local"
  47. now = time.monotonic()
  48. bucket = limits[address]
  49. while bucket and bucket[0] < now - 60:
  50. bucket.popleft()
  51. if len(limits) > 10000:
  52. for key in list(limits):
  53. if not limits[key] or limits[key][-1] < now - 60:
  54. del limits[key]
  55. try:
  56. if request.method != "OPTIONS" and len(bucket) >= settings.rate_limit_per_minute:
  57. return JSONResponse({"error": {"code": "RATE_LIMIT", "message": "请求过于频繁,请稍后重试", "details": None}, "request_id": request.state.request_id}, status_code=429)
  58. if request.method != "OPTIONS":
  59. bucket.append(now)
  60. response = await call_next(request)
  61. response.headers["X-Request-ID"] = request.state.request_id
  62. response.headers["X-Content-Type-Options"] = "nosniff"
  63. response.headers["Referrer-Policy"] = "same-origin"
  64. logging.getLogger("audit.http").info("request_id=%s method=%s path=%s status=%s", request.state.request_id, request.method, request.url.path, response.status_code)
  65. return response
  66. finally:
  67. request_context.reset(token)
  68. @app.exception_handler(BusinessError)
  69. async def business_error(request, exc):
  70. """业务错误保留机器码,前端能区分版本冲突和登录失效。"""
  71. return JSONResponse({"error": {"code": exc.code, "message": str(exc), "details": exc.details}, "request_id": request.state.request_id}, status_code=exc.status)
  72. @app.exception_handler(RequestValidationError)
  73. async def validation_error(request, exc):
  74. """不返回错误请求的 input,尤其避免把密码、刷新 token 带入错误响应。"""
  75. errors = [{"field": ".".join(str(value) for value in item["loc"]), "message": item["msg"], "type": item["type"]} for item in exc.errors()]
  76. return JSONResponse({"error": {"code": "VALIDATION_ERROR", "message": "请求参数校验失败", "details": errors}, "request_id": request.state.request_id}, status_code=422)
  77. @app.exception_handler(IntegrityError)
  78. async def integrity_error(request, exc):
  79. """唯一/外键冲突不暴露 SQL 和数据库文件路径。"""
  80. return JSONResponse({"error": {"code": "DATA_CONFLICT", "message": "数据冲突或引用不存在", "details": None}, "request_id": request.state.request_id}, status_code=409)
  81. @app.exception_handler(Exception)
  82. async def internal_error(request, exc):
  83. logging.getLogger("audit.errors").exception("request_failed request_id=%s", request.state.request_id)
  84. return JSONResponse({"error": {"code": "INTERNAL_ERROR", "message": "服务处理失败,请凭请求ID联系管理员", "details": None}, "request_id": request.state.request_id}, status_code=500)
  85. app.include_router(management.router, prefix=settings.api_prefix)
  86. app.include_router(business.router, prefix=settings.api_prefix)
  87. app.include_router(operations.router, prefix=settings.api_prefix)
  88. @app.get(settings.api_prefix + "/health", tags=["健康"])
  89. async def health(request: Request):
  90. """功能:报告实际数据库和消费者状态。参数:无。返回:组件状态,无凭据。异常:503数据库不可用。"""
  91. try:
  92. async with Session() as session:
  93. await session.execute(text("SELECT 1"))
  94. except Exception:
  95. raise BusinessError("数据库不可用", 503, "DATABASE_UNAVAILABLE")
  96. return envelope({"database": "ok", "storage": "local", "worker": "running" if worker.task and not worker.task.done() else "disabled",
  97. "worker_event": worker.current, "last_timeout_scan": iso(worker.last_scan),
  98. "llm": "configured" if settings.llm_base_url else "not_configured",
  99. "vlm": "configured" if settings.vlm_base_url or settings.llm_base_url else "not_configured",
  100. "embedding": "configured" if settings.embedding_base_url else "not_configured",
  101. "breakers": {key.rsplit("/", 1)[-1]: value.status() for key, value in ai.breakers.items()}}, request.state.request_id)
  102. return app