"""FastAPI 主应用""" import os import logging from pythonjsonlogger import jsonlogger from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse from app.core.config import settings from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor from app.middleware.audit_middleware import AuditMiddleware # 结构化 JSON 日志 _handler = logging.StreamHandler() _handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s')) logging.getLogger().handlers = [_handler] logging.getLogger().setLevel(logging.INFO) logging.getLogger('uvicorn.access').handlers = [_handler] # 请求体大小限制中间件 MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB class RequestSizeLimitMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): if request.headers.get("content-length"): if int(request.headers["content-length"]) > MAX_BODY_SIZE: return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413) return await call_next(request) # CORS 白名单 — 支持通过环境变量 CORS_ORIGINS 覆盖(逗号分隔) CORS_ORIGINS_DEFAULT = "http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun" ALLOWED_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", CORS_ORIGINS_DEFAULT).split(",") if o.strip()] def get_client_ip(request: Request) -> str: """读取 X-Forwarded-For 首字段作为真实客户端 IP""" forwarded = request.headers.get("X-Forwarded-For") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown" limiter = Limiter(key_func=get_client_ip, default_limits=["120/minute"]) app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(RequestSizeLimitMiddleware) app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(AuditMiddleware) app.include_router(auth.router) app.include_router(devices.router) app.include_router(check.router) app.include_router(import_data.router) app.include_router(stats.router) app.include_router(olt.router) app.include_router(provision.router) app.include_router(users.router) app.include_router(roles.router) app.include_router(inventory.router) app.include_router(settings_api.router) app.include_router(audit.router) app.include_router(wechat.router) app.include_router(ws.router) app.include_router(monitor.router) @app.on_event("startup") async def startup(): import asyncio asyncio.create_task(ws._redis_listener()) def _read_version() -> str: """读取项目版本号""" version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")] for p in version_paths: if os.path.exists(p): with open(p) as f: return f.read().strip() return "0.0.0" @app.get("/health") def health_check(): status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()} try: import redis import psycopg2 r = redis.from_url(settings.REDIS_URL, socket_timeout=2) r.ping() except Exception: status["redis"] = "error" status["status"] = "degraded" try: from sqlalchemy import text from app.core.database import SessionLocal db = SessionLocal() db.execute(text("SELECT 1")) db.close() except Exception: status["db"] = "error" status["status"] = "degraded" return status @app.get("/api/health") def api_health_check(): """API 路径下的健康检查(用于前端通过 /api/ 代理访问)""" return health_check()