Files
H3ConuMS-v2/backend/app/main.py
T
v6ole 0d3fa8dd51 fix: 修复 /api/health 404 和扫描/发现 500 错误
1. /api/health 404: 前端 fetchVersion 调用 /api/health,但后端只有 /health。
   OpenResty 保留完整路径,需要在后端添加 /api/health 端点。

2. 扫描/发现 500: check_service.py 的连接池 _get_cached_ssh 缓存了
   SSH 连接,但调用方在 finally 中 ssh.close() 关闭连接。第二次调用
   从缓存拿到已关闭的连接导致执行失败。改为异常时清除缓存、正常时
   保留连接(5分钟TTL自动管理生命周期)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 11:15:01 +08:00

121 lines
4.1 KiB
Python

"""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()