3ee8846011
修复: - 环路检测正则 \s+(Onu\S+)\s+ → \s+(Onu\S+) (splitlines移除换行后尾随\s无法匹配) - 权限中间件 Header(...) → Header(None) 避免缺失Auth头返回422而非401 - 环路检测请求超时30s→120s (SSH连接30+台OLT实测需58秒) 重构 (ssh_service.py): - 提取 _send_and_wait 为私有方法,消除3处重复内部函数 - 添加 __enter__/__exit__ 上下文管理器支持 - 加固 execute_command prompt检测 (按行匹配<DEVICE_NAME>) - 移除未使用的settings import - olt.py/devices.py 调用方改用 with 语法 新功能: - 侧边栏退出登录上方显示当前用户名和角色 - 版本号从VERSION文件自动读取 (后端/health返回,前端动态显示) - 基于广西南宁经纬度计算日落时间,自动切换深色/浅色主题 - /api/olt/loopback-detection 响应增加raw字段便于排查 基础设施: - CLAUDE.md 加入 .gitignore - 新增 .claude/rules/07-remote-operations.md (远程部署操作) - 新增 .claude/rules/08-frp-notes.md (frp隧道注意事项) - 新增 VERSION 文件 (版本号 0.10.0) - 新增环路检测解析测试用例 (5个) Co-Authored-By: Claude <noreply@anthropic.com>
115 lines
4.0 KiB
Python
115 lines
4.0 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
|