| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114 |
- """应用工厂集中管理资源启停、跨域、限流和异常响应,客户端只处理一种契约。"""
- import asyncio
- from collections import defaultdict, deque
- from contextlib import asynccontextmanager
- import logging
- import time
- import uuid
- from fastapi import FastAPI, Request
- from fastapi.exceptions import RequestValidationError
- from fastapi.middleware.cors import CORSMiddleware
- from fastapi.responses import JSONResponse
- from sqlalchemy import text
- from sqlalchemy.exc import IntegrityError
- from app.api import management, business, operations
- from app.application.services import request_context
- from app.application.worker import worker
- from app.infrastructure.ai import ai
- from app.infrastructure.database import engine, initialize, Session
- from app.shared.common import BusinessError, envelope, iso
- from app.shared.config import settings
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
- @asynccontextmanager
- async def lifespan(app):
- """消费者启动在建表与种子之后,关闭时先收尾任务再释放 HTTP/数据库连接。"""
- if ai.http.is_closed:
- import httpx
- ai.http = httpx.AsyncClient(timeout=settings.ai_timeout, follow_redirects=False)
- await initialize()
- await worker.start()
- yield
- await worker.close()
- await ai.close()
- await engine.dispose()
- def create_app():
- app = FastAPI(title="AI-Agent 财务审计系统", version="1.0.0", lifespan=lifespan,
- description="按课程设计:四节点审核、独立反馈、持久事件与人工审批。")
- app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins,
- allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "Idempotency-Key", "Last-Event-ID"],
- expose_headers=["X-Request-ID", "Content-Disposition"])
- limits = defaultdict(deque)
- @app.middleware("http")
- async def trace(request: Request, call_next):
- """请求 ID 由服务端产生,防止恶意用户污染日志;每分钟计数不会无限增长。"""
- request.state.request_id = uuid.uuid4().hex
- token = request_context.set(request.state.request_id)
- address = request.client.host if request.client else "local"
- now = time.monotonic()
- bucket = limits[address]
- while bucket and bucket[0] < now - 60:
- bucket.popleft()
- if len(limits) > 10000:
- for key in list(limits):
- if not limits[key] or limits[key][-1] < now - 60:
- del limits[key]
- try:
- if request.method != "OPTIONS" and len(bucket) >= settings.rate_limit_per_minute:
- return JSONResponse({"error": {"code": "RATE_LIMIT", "message": "请求过于频繁,请稍后重试", "details": None}, "request_id": request.state.request_id}, status_code=429)
- if request.method != "OPTIONS":
- bucket.append(now)
- response = await call_next(request)
- response.headers["X-Request-ID"] = request.state.request_id
- response.headers["X-Content-Type-Options"] = "nosniff"
- response.headers["Referrer-Policy"] = "same-origin"
- 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)
- return response
- finally:
- request_context.reset(token)
- @app.exception_handler(BusinessError)
- async def business_error(request, exc):
- """业务错误保留机器码,前端能区分版本冲突和登录失效。"""
- return JSONResponse({"error": {"code": exc.code, "message": str(exc), "details": exc.details}, "request_id": request.state.request_id}, status_code=exc.status)
- @app.exception_handler(RequestValidationError)
- async def validation_error(request, exc):
- """不返回错误请求的 input,尤其避免把密码、刷新 token 带入错误响应。"""
- errors = [{"field": ".".join(str(value) for value in item["loc"]), "message": item["msg"], "type": item["type"]} for item in exc.errors()]
- return JSONResponse({"error": {"code": "VALIDATION_ERROR", "message": "请求参数校验失败", "details": errors}, "request_id": request.state.request_id}, status_code=422)
- @app.exception_handler(IntegrityError)
- async def integrity_error(request, exc):
- """唯一/外键冲突不暴露 SQL 和数据库文件路径。"""
- return JSONResponse({"error": {"code": "DATA_CONFLICT", "message": "数据冲突或引用不存在", "details": None}, "request_id": request.state.request_id}, status_code=409)
- @app.exception_handler(Exception)
- async def internal_error(request, exc):
- logging.getLogger("audit.errors").exception("request_failed request_id=%s", request.state.request_id)
- return JSONResponse({"error": {"code": "INTERNAL_ERROR", "message": "服务处理失败,请凭请求ID联系管理员", "details": None}, "request_id": request.state.request_id}, status_code=500)
- app.include_router(management.router, prefix=settings.api_prefix)
- app.include_router(business.router, prefix=settings.api_prefix)
- app.include_router(operations.router, prefix=settings.api_prefix)
- @app.get(settings.api_prefix + "/health", tags=["健康"])
- async def health(request: Request):
- """功能:报告实际数据库和消费者状态。参数:无。返回:组件状态,无凭据。异常:503数据库不可用。"""
- try:
- async with Session() as session:
- await session.execute(text("SELECT 1"))
- except Exception:
- raise BusinessError("数据库不可用", 503, "DATABASE_UNAVAILABLE")
- return envelope({"database": "ok", "storage": "local", "worker": "running" if worker.task and not worker.task.done() else "disabled",
- "worker_event": worker.current, "last_timeout_scan": iso(worker.last_scan),
- "llm": "configured" if settings.llm_base_url else "not_configured",
- "vlm": "configured" if settings.vlm_base_url or settings.llm_base_url else "not_configured",
- "embedding": "configured" if settings.embedding_base_url else "not_configured",
- "breakers": {key.rsplit("/", 1)[-1]: value.status() for key, value in ai.breakers.items()}}, request.state.request_id)
- return app
|