diff --git a/.gitignore b/.gitignore index 0464772..7272784 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ logs/ # Claude Code .claude/ .mcp.json + +# Deployment docs (contain credentials) +*部署交接文档.md + +# Reasonix +.reasonix/ diff --git a/CLAUDE.md b/CLAUDE.md index 1ffce7e..48119c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,8 @@ 这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。 -**当前版本**: v0.9.0 -**开发状态**: 核心功能已完成,运维增强功能持续迭代 +**当前版本**: v0.10.0 +**开发状态**: 生产环境优化完成,运维增强功能持续迭代 **已实现功能**: - ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理) @@ -29,7 +29,12 @@ - ✅ 操作记录日志(查看指定设备的上下线事件历史) - ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT) - ✅ IMC 网管服务集成(ONU 远程重启、光功率查询) +- ✅ 企业微信告警通知(设备离线、全离线学校检测) +- ✅ WebSocket 实时推送(仪表板状态更新、检查进度) - ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配) +- ✅ 生产环境 HTTPS 部署(OpenResty + SSL + 安全头) +- ✅ 前端生产构建(多阶段 Dockerfile,nginx:alpine 静态服务) +- ✅ 数据库连接池优化(pool_size=20, max_overflow=40) **技术栈**: - 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko @@ -115,6 +120,17 @@ - `GET /api/settings` - 获取系统设置 - `PUT /api/settings` - 更新系统设置 +### 企业微信 +- `GET /api/wechat/callback` - 企业微信回调 URL 验证 +- `POST /api/wechat/callback` - 企业微信消息接收 +- `POST /api/wechat/menu/create` - 创建企业微信菜单 + +### WebSocket +- `WS /api/ws/dashboard` - 仪表板实时状态推送(Redis pub/sub) + +### 任务监控 +- `GET /api/monitor/tasks` - 查看正在运行的后台任务 + ### 系统 - `GET /health` - 健康检查 - `GET /docs` - API 文档(Swagger UI) @@ -242,9 +258,17 @@ 必需配置: - `DATABASE_URL`:PostgreSQL 连接字符串 - `REDIS_URL`:Redis 连接字符串 -- `CASDOOR_*`:Casdoor 认证配置 +- `CASDOOR_*`:Casdoor 认证配置(`ENDPOINT`, `CLIENT_ID`, `CLIENT_SECRET`, `ORG_NAME`, `APP_NAME`) +- `CASDOOR_REDIRECT_URL`:Casdoor 登录回调地址(生产必填,无默认值) - `SECRET_KEY`:应用密钥 +可选配置: +- `CORS_ORIGINS`:CORS 允许的来源(逗号分隔) +- `FRONTEND_URL`:前端访问地址(用于微信帮助消息等,默认 `https://onu.dhdx.fun`) +- `NTP_OLD_SERVER` / `NTP_NEW_SERVER`:NTP 同步服务器 IP +- `IMC_API_*`:iMC 网管 API 配置(ONU 重启/光功率查询) +- `WECHAT_*`:企业微信告警配置 + #### 监控告警 - 健康检查端点:`/health` - 性能指标端点:`/metrics` @@ -290,20 +314,99 @@ docker ps | grep **解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。 -### frontend 容器必须配置 VITE_API_PROXY_TARGET +### 前端生产构建(v0.10.0+) -**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`,Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。 +**问题**:早期版本前端容器运行 `npm run dev`(Vite 开发服务器),存在热更新开销、源码暴露、无压缩等问题。 -**必须在 docker-compose.yml 中配置**: -```yaml -frontend: - build: ./frontend - ports: - - "5173:5173" - environment: - - VITE_API_PROXY_TARGET=http://backend:8000 +**生产构建流程**: +```dockerfile +# 多阶段构建 — frontend/Dockerfile +# Stage 1: vite build → dist/ +# Stage 2: nginx:alpine 静态文件服务 ``` +**生产模式下不需要 `VITE_*` 环境变量**:前端所有 API 调用使用相对路径 `/api`,由 OpenResty/Nginx 在边缘层代理。Vite 的 proxy 仅用于本地开发。 + +**前端部署到远程服务器**: +```bash +cd frontend +docker build -t h3conums2-frontend:latest . +docker save h3conums2-frontend:latest | ssh -p 7072 root@ "docker load" +ssh -p 7072 root@ "docker stop h3conums2-frontend && docker rm h3conums2-frontend && docker run -d --name h3conums2-frontend --restart always -p 18062:80 h3conums2-frontend:latest" +``` + +### 生产部署架构(v0.10.0+) + +**部署拓扑**:前端在远程服务器,后端在本地服务器,通过 frp 隧道通信。 + +``` +用户浏览器 → onu.dhdx.fun (HTTPS) + │ + OpenResty (80/443) + │ + ┌───────────┴───────────┐ + ▼ ▼ +前端容器(:18062) frp 隧道(:18060) +nginx:alpine │ +静态文件服务 frpc → frps → 本机后端(:8000) +``` + +**关键配置**: +- OpenResty 由 1Panel 管理,配置文件位于 `/opt/1panel/apps/openresty/openresty/conf/conf.d/` +- SSL 证书位于 `/www/sites/onu.dhdx.fun/ssl/`(OpenResty 容器内路径) +- 前端容器端口映射:`18062:80` +- frp 后端隧道:远程 `127.0.0.1:18060` → 本机 `127.0.0.1:8000` + +### 新增环境变量(v0.10.0) + +生产环境新增配置项: +```bash +# CORS & 前端 +CORS_ORIGINS=http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun +FRONTEND_URL=https://onu.dhdx.fun + +# NTP 同步 +NTP_OLD_SERVER=172.16.0.254 +NTP_NEW_SERVER=172.16.1.252 + +# iMC 网管 API +IMC_API_URL=https://172.16.1.252:8443 +IMC_API_USERNAME=admin +IMC_API_PASSWORD=... +IMC_API_VERIFY_SSL=false + +# Casdoor 回调(生产必填,无默认值) +CASDOOR_REDIRECT_URL=https://onu.dhdx.fun/callback + +# 企业微信代理(无默认值,按需配置) +WECHAT_PROXY_API_URL=https://api.v6ole.top +``` + +### 数据库连接池 + +**配置**(`backend/app/core/database.py`): +```python +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=20, + max_overflow=40, + pool_recycle=3600, # 1小时回收,防 PostgreSQL 断闲置连接 + pool_timeout=30, +) +``` + +### SSH 主机密钥策略 + +**当前使用 `WarningPolicy`**:记录未知主机密钥警告但允许连接。生产环境 OLT 设备在内网,安全风险可接受。如需严格验证,改为 `RejectPolicy` 并预置 `known_hosts` 文件。 + +### OpenResty / 1Panel 注意事项 + +- 配置文件由 1Panel 管理,直接修改文件后需重载:`docker exec openresty openresty -s reload` +- 1Panel 面板重新保存站点配置会覆盖手动修改 +- WebSocket 通过 HTTP/1.1 升级,需确保 `/api/` location 传递 `Upgrade` 和 `Connection` 头 +- 当前使用自签名证书,可通过 1Panel 面板申请 Let's Encrypt 正式证书 + ### 新增数据库模型后必须执行迁移 **问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。 @@ -429,6 +532,8 @@ H3ConuMS2/ │ ├── scripts/ # 初始化脚本 │ └── templates/ # Excel 导入模板 ├── frontend/ # Vue 3 前端 +│ ├── Dockerfile # 多阶段构建(vite build + nginx:alpine) +│ ├── nginx.conf # 生产 nginx 配置(Gzip、缓存、SPA fallback) │ └── src/ │ ├── api/ # API 调用封装 │ ├── components/ # 公共组件 @@ -440,10 +545,11 @@ H3ConuMS2/ │ └── views/ # 页面组件 ├── deploy/ # 部署配置 │ ├── docker-compose.yml +│ ├── openresty/ # OpenResty 站点配置 │ ├── nginx/ │ └── scripts/ ├── docs/ # 文档 -└── docker-compose.yml # 主部署文件 +└── docker-compose.yml # 本地开发 Docker Compose ``` --- diff --git a/backend/.env.example b/backend/.env.example index 36b79d4..6bf7265 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,7 +16,7 @@ CASDOOR_CLIENT_SECRET=your_client_secret CASDOOR_ORG_NAME=your_org CASDOOR_APP_NAME=h3c-onu-ms CASDOOR_CERTIFICATE=backend/token_jwt_key.pem -CASDOOR_REDIRECT_URL=http://localhost:5173/callback +CASDOOR_REDIRECT_URL= # SSH配置 SSH_TIMEOUT=30 @@ -25,6 +25,22 @@ SSH_TIMEOUT=30 CHECK_INTERVAL=1800 MANUAL_COOLDOWN=300 +# CORS & 前端 +CORS_ORIGINS=http://localhost:5173,http://localhost:18002 +FRONTEND_URL=https://your-domain.com + +# NTP 同步 +NTP_OLD_SERVER=172.16.0.254 +NTP_NEW_SERVER=172.16.1.252 + +# iMC API 配置(用于 ONU 远程重启和光功率查询) +IMC_API_URL= +IMC_API_USERNAME= +IMC_API_PASSWORD= +IMC_API_VERIFY_SSL=false +IMC_CONNECT_TIMEOUT=5 +IMC_READ_TIMEOUT=20 + # 企业微信告警配置 WECHAT_CORPID= WECHAT_CORPSECRET= @@ -32,4 +48,4 @@ WECHAT_AGENTID= WECHAT_TOKEN= WECHAT_ENCODING_AES_KEY= WECHAT_USE_PROXY=True -WECHAT_PROXY_API_URL=https://api.v6ole.top +WECHAT_PROXY_API_URL= diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py index bed40e5..1a2fc5d 100644 --- a/backend/app/api/v1/olt.py +++ b/backend/app/api/v1/olt.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from sqlalchemy import distinct from pydantic import BaseModel from app.core.database import get_db +from app.core.config import settings from app.middleware.permission_middleware import require_permission from app.models.device import OLTDevice import pandas as pd @@ -501,8 +502,8 @@ def loopback_detection( class SyncNTPRequest(BaseModel): - old_server: str = "172.16.0.254" - new_server: str = "172.16.1.252" + old_server: str = settings.NTP_OLD_SERVER + new_server: str = settings.NTP_NEW_SERVER @router.post("/sync-ntp") diff --git a/backend/app/api/v1/wechat.py b/backend/app/api/v1/wechat.py index b09c069..73cf8b8 100644 --- a/backend/app/api/v1/wechat.py +++ b/backend/app/api/v1/wechat.py @@ -2,6 +2,7 @@ import logging from fastapi import APIRouter, Request, Response from app.services.wechat_service import get_wechat_service +from app.core.config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"]) @@ -235,6 +236,6 @@ def _handle_help_cmd(svc, from_user: str): "• 发送「全离线」查看全离线学校\n" "• 发送 MAC 地址后四位查询设备\n\n" "💡 发送「帮助」显示此信息\n" - "💻 完整功能: https://onu.dhdx.fun", + f"💻 完整功能: {settings.FRONTEND_URL}", to_user=from_user ) diff --git a/backend/app/api/v1/ws.py b/backend/app/api/v1/ws.py index 7b45c4a..9f707f0 100644 --- a/backend/app/api/v1/ws.py +++ b/backend/app/api/v1/ws.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.core.config import settings logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(prefix="/api", tags=["WebSocket"]) REDIS_CHANNEL = "h3c_onu:status_updates" _connected: set[WebSocket] = set() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0e43c5f..b363402 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,12 +21,20 @@ class Settings(BaseSettings): CASDOOR_ORG_NAME: str CASDOOR_APP_NAME: str CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容 - CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback" + CASDOOR_REDIRECT_URL: str = "" SSH_TIMEOUT: int = 30 CHECK_INTERVAL: int = 1800 MANUAL_COOLDOWN: int = 300 + # CORS & 前端 + CORS_ORIGINS: str = "" # 逗号分隔 + FRONTEND_URL: str = "https://onu.dhdx.fun" + + # NTP 同步配置 + NTP_OLD_SERVER: str = "172.16.0.254" + NTP_NEW_SERVER: str = "172.16.1.252" + # iMC API 配置(用于 ONU 远程重启和光功率查询) IMC_API_URL: str = "" @@ -37,7 +45,7 @@ class Settings(BaseSettings): WECHAT_TOKEN: str = "" WECHAT_ENCODING_AES_KEY: str = "" WECHAT_USE_PROXY: bool = True - WECHAT_PROXY_API_URL: str = "https://api.v6ole.top" + WECHAT_PROXY_API_URL: str = "" IMC_API_USERNAME: str = "" IMC_API_PASSWORD: str = "" IMC_API_VERIFY_SSL: bool = False diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0dc12e1..89bf9dc 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -4,7 +4,14 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from app.core.config import settings -engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=20, + max_overflow=40, + pool_recycle=3600, + pool_timeout=30, +) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() diff --git a/backend/app/main.py b/backend/app/main.py index c115fc6..441712e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,10 @@ """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.util import get_remote_address from slowapi.errors import RateLimitExceeded from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse @@ -29,15 +29,20 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware): return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413) return await call_next(request) -# CORS 白名单 -ALLOWED_ORIGINS = [ - "http://localhost:5173", - "http://localhost:18002", - "https://onu.dhdx.fun", -] -allowed = [o for o in ALLOWED_ORIGINS if o] +# 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()] -limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"]) + +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 @@ -46,7 +51,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(RequestSizeLimitMiddleware) app.add_middleware( CORSMiddleware, - allow_origins=allowed if allowed else ["*"], + allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/services/ssh_service.py b/backend/app/services/ssh_service.py index 49e0449..0709792 100644 --- a/backend/app/services/ssh_service.py +++ b/backend/app/services/ssh_service.py @@ -36,7 +36,7 @@ class SSHService: """建立 SSH 连接,等待初始 banner 输出完毕""" try: self.client = paramiko.SSHClient() - self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + self.client.set_missing_host_key_policy(paramiko.WarningPolicy()) self.client.connect( hostname=self.host, port=self.port, diff --git a/deploy/.env.example b/deploy/.env.example index 1da49ce..8460523 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -65,6 +65,9 @@ CASDOOR_ORG_NAME=your_organization # 应用名称 CASDOOR_APP_NAME=h3c-onu-ms +# Casdoor 回调地址(部署后改为实际域名) +CASDOOR_REDIRECT_URL= + # ============================================ # SSH连接配置 # ============================================ @@ -98,20 +101,35 @@ HISTORY_RETENTION=90 BATCH_CHECK_SIZE=100 # ============================================ -# 前端配置 +# 前端 & CORS 配置 # ============================================ -# API基础URL (前端访问后端地址) -VITE_API_BASE_URL=http://localhost:8000 +# 前端访问地址 +FRONTEND_URL=https://your-domain.com -# Casdoor前端配置 -VITE_CASDOOR_ENDPOINT=https://casdoor.example.com -VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id -VITE_CASDOOR_ORG_NAME=your_organization -VITE_CASDOOR_APP_NAME=h3c-onu-ms +# CORS允许的域名 (逗号分隔) +CORS_ORIGINS=http://localhost:8080,http://localhost:5173 -# 应用标题 -VITE_APP_TITLE=H3C ONU设备管理系统 +# ============================================ +# NTP 同步配置 +# ============================================ + +# NTP 旧服务器 IP +NTP_OLD_SERVER=172.16.0.254 +# NTP 新服务器 IP +NTP_NEW_SERVER=172.16.1.252 + +# ============================================ +# iMC 网管 API 配置(ONU 远程重启/光功率查询) +# ============================================ + +IMC_API_URL= +IMC_API_USERNAME= +IMC_API_PASSWORD= +# 本地认证不需要 SSL 验证 +IMC_API_VERIFY_SSL=false +IMC_CONNECT_TIMEOUT=5 +IMC_READ_TIMEOUT=20 # ============================================ # 日志配置 @@ -154,9 +172,6 @@ METRICS_PORT=8000 # 安全配置 # ============================================ -# CORS允许的域名 (逗号分隔) -CORS_ORIGINS=http://localhost:8080,http://localhost:5173 - # 速率限制配置 RATE_LIMIT_PER_MINUTE=60 RATE_LIMIT_PER_HOUR=1000 @@ -175,7 +190,7 @@ WECHAT_AGENTID= WECHAT_TOKEN= WECHAT_ENCODING_AES_KEY= WECHAT_USE_PROXY=True -WECHAT_PROXY_API_URL=https://api.v6ole.top +WECHAT_PROXY_API_URL= # 备份保留天数 BACKUP_RETENTION=30 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f864411..d63fd0a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,15 +16,23 @@ services: - CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE} - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} + - CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-} - SECRET_KEY=${SECRET_KEY} - DEBUG=${DEBUG:-false} + - CORS_ORIGINS=${CORS_ORIGINS:-} + - FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun} + - NTP_OLD_SERVER=${NTP_OLD_SERVER:-172.16.0.254} + - NTP_NEW_SERVER=${NTP_NEW_SERVER:-172.16.1.252} + - IMC_API_URL=${IMC_API_URL:-} + - IMC_API_USERNAME=${IMC_API_USERNAME:-} + - IMC_API_PASSWORD=${IMC_API_PASSWORD:-} - WECHAT_CORPID=${WECHAT_CORPID:-} - WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-} - WECHAT_AGENTID=${WECHAT_AGENTID:-} - WECHAT_TOKEN=${WECHAT_TOKEN:-} - WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-} - WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True} - - WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top} + - WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-} volumes: - ../backend/logs:/app/logs - ../backend/static:/app/static @@ -96,26 +104,19 @@ services: - backend restart: unless-stopped - # 前端应用 + # 前端应用(生产模式:nginx 静态文件服务) frontend: build: context: ../frontend dockerfile: Dockerfile container_name: h3c-onu-ms-frontend ports: - - "5173:5173" - environment: - - VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001} - - VITE_API_PROXY_TARGET=http://backend:8000 - - VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT} - - VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID} - - VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME} - - VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME} + - "18062:80" depends_on: - backend restart: unless-stopped healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:5173"] + test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] interval: 30s timeout: 10s retries: 3 diff --git a/deploy/openresty/onu.dhdx.fun.conf b/deploy/openresty/onu.dhdx.fun.conf new file mode 100644 index 0000000..99962ee --- /dev/null +++ b/deploy/openresty/onu.dhdx.fun.conf @@ -0,0 +1,54 @@ +server { + listen 80; + listen 443 ssl http2; + server_name onu.dhdx.fun; + index index.html; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + access_log /www/sites/onu.dhdx.fun/log/access.log main; + error_log /www/sites/onu.dhdx.fun/log/error.log; + + location ^~ /.well-known/acme-challenge { + allow all; + root /usr/share/nginx/html; + } + + if ($scheme = http) { + return 301 https://$host$request_uri; + } + + ssl_certificate /www/sites/onu.dhdx.fun/ssl/fullchain.pem; + ssl_certificate_key /www/sites/onu.dhdx.fun/ssl/privkey.pem; + ssl_protocols TLSv1.3 TLSv1.2 TLSv1.1 TLSv1; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + error_page 497 https://$host$request_uri; + proxy_set_header X-Forwarded-Proto https; + add_header Strict-Transport-Security "max-age=31536000"; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 前端(生产 nginx 容器,端口 18062) + location / { + proxy_pass http://127.0.0.1:18062; + } + + # 后端 API(frp 隧道 → 本机后端 /api/ws/dashboard WebSocket) + location /api/ { + proxy_pass http://127.0.0.1:18060; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 60s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1b27a8c..f7353c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,10 +40,13 @@ services: frontend: build: ./frontend ports: - - "18002:5173" - environment: - - VITE_API_PROXY_TARGET=http://backend:8000 + - "18002:80" restart: unless-stopped depends_on: backend: condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] + interval: 15s + timeout: 10s + retries: 5 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index db6ef60..74cc71f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,5 @@ -FROM node:18-alpine +# Stage 1: Build +FROM node:18-alpine AS build WORKDIR /app @@ -7,6 +8,21 @@ RUN npm install COPY . . -EXPOSE 5173 +# Build for production +RUN npm run build -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] +# Stage 2: Serve with nginx +FROM nginx:alpine AS serve + +# Remove default nginx config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built files from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..08aefde --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression for text-based assets + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript + application/json application/javascript application/xml+rss + image/svg+xml; + + # Cache static assets with content hash names (Vite output) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # SPA fallback - all routes serve index.html + location / { + try_files $uri $uri/ /index.html; + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # Health check endpoint for docker + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} diff --git a/guide.md b/guide.md deleted file mode 100644 index a0c11b0..0000000 --- a/guide.md +++ /dev/null @@ -1,726 +0,0 @@ -# 为 H3ConuMS2 添加 ONU 远程重启 & 光功率查询功能 — 后端开发指南 - -> 目标:将 H3C iMC 平台的 ONU 远程重启和光功率查询功能集成到 H3ConuMS2 项目中 -> 技术栈:FastAPI + SQLAlchemy + requests (HTTP Digest Auth) -> 源项目参考:`/home/v6ole/pyproject/H3ConuMS` - ---- - -## 1. 整体架构 - -``` -┌──────────────────┐ REST API 调用 ┌──────────────────┐ -│ H3ConuMS2 后端 │ ──────────────────────► │ H3C iMC 平台 │ -│ (FastAPI) │ ◄────────────────────── │ │ -│ IMCService │ │ /imcrs/epon/... │ -└──────────────────┘ └──────────────────┘ -``` - -**后端提供的 API 端点:** -| 端点 | 方法 | 说明 | iMC 后端接口 | -|------|------|------|-------------| -| `/api/devices/{id}/reboot` | POST | 远程重启 ONU | `/imcrs/epon/onu/reboot?mac={mac}` (POST) | -| `/api/devices/{id}/optical-power` | GET | 获取 ONU 光功率 | `/imcrs/epon/onu/onuLightWaneInfo?mac={mac}` (GET) | - -**数据流(以重启为例):** -1. 客户端 POST `/api/devices/{id}/reboot` -2. 后端控制器验证设备存在 + 权限 → 调用 `IMCService.reboot_onu()` -3. `IMCService` 构建 HTTP Digest 认证头 → POST 到 iMC REST API -4. iMC 返回结果 → 后端解析错误码 → 返回 JSON - ---- - -## 2. Digest 认证原理解析 - -iMC 的 REST API 使用 **HTTP Digest Access Authentication**(RFC 2617),不是普通的 Cookie/Session 登录。 - -### 认证流程 - -``` -客户端 iMC 服务器 - │ │ - │──── GET /imcrs/... (无认证) ────│ - │ │──── 401 + WWW-Authenticate header - │ │ (包含 nonce, realm, qop) - │ │ - │ ── 解析 WWW-Authenticate ──► │ - │ 提取 nonce 和 realm │ - │ │ - │ ── 计算 Digest 响应 ────────► │ - │ HA1 = MD5(user:realm:pass) │ - │ HA2 = MD5(method:uri) │ - │ response = MD5(HA1:nonce:nc:cnonce:qop:HA2) │ - │ │ - │──── POST /imcrs/... ──────────►│ - │ Authorization: Digest ... │ - │ │──── 200 OK (成功) -``` - -### 核心 MD5 计算 - -```python -cnonce = md5(str(time.time())).hexdigest()[:16] -ha1 = md5(f"{username}:{realm}:{password}").hexdigest() -ha2 = md5(f"{method}:{uri}").hexdigest() -response = md5(f"{ha1}:{nonce}:{nc:08d}:{cnonce}:auth:{ha2}").hexdigest() -``` - ---- - -## 3. 需要修改/新增的文件清单 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `backend/app/services/imc_service.py` | **新增** | iMC API 服务(Digest 认证 + 重启 + 光功率) | -| `backend/app/services/__init__.py` | 修改 | 导出 IMCService | -| `backend/app/schemas/device.py` | 修改 | 添加重启/光功率响应 Schema | -| `backend/app/api/v1/devices.py` | 修改 | 添加重启和光功率 API 路由 | -| `backend/app/core/config.py` | 修改 | 添加 iMC 配置项 | -| `.env` 或 `backend/.env` | 修改 | 添加 iMC 环境变量 | - ---- - -## 4. 后端实现 - -### 4.1 配置项 — `backend/app/core/config.py` - -在 `Settings` 类中添加 iMC 相关配置: - -```python -# ===== iMC API 配置(用于 ONU 远程重启和光功率查询)===== -IMC_API_URL: str = "" # 例如 https://172.16.1.252:8443 -IMC_API_USERNAME: str = "" # iMC 用户名 -IMC_API_PASSWORD: str = "" # iMC 密码(明文,Digest认证需要原始密码) -IMC_API_VERIFY_SSL: bool = False # 是否验证 SSL 证书 -IMC_CONNECT_TIMEOUT: float = 5.0 -IMC_READ_TIMEOUT: float = 20.0 -``` - -### 4.2 .env 配置 - -在 `backend/.env`(或项目根目录 `.env`)中添加: - -```env -# iMC API 配置(用于 ONU 远程重启和光功率查询) -IMC_API_URL=https://172.16.1.252:8443 -IMC_API_USERNAME=admin -IMC_API_PASSWORD=Pwd@12345 -IMC_API_VERIFY_SSL=false -IMC_CONNECT_TIMEOUT=5 -IMC_READ_TIMEOUT=20 -``` - -### 4.3 IMCService — `backend/app/services/imc_service.py` - -完整代码,包含 Digest 认证 + 重启 ONU + 光功率查询三大功能: - -```python -""" -iMC REST API 服务 -- 使用 HTTP Digest Access Authentication (RFC 2617) -- 支持 nonce 过期自动续约(401 时自动重新握手) -- 功能:ONU 远程重启、光功率查询 -""" -import hashlib -import re -import json -import time -import logging -import requests -from app.core.config import settings - -logger = logging.getLogger(__name__) - -# iMC 重启错误码映射 -REBOOT_ERROR_CODES = { - '103': 'ONU不存在', - '119': 'SNMP连接超时', - '120': '业务割接失败', - '121': 'ONU未运行', - '122': '重启失败', -} - - -class IMCService: - """iMC REST API 服务封装""" - - def __init__(self): - self.base_url = settings.IMC_API_URL.rstrip('/') - self.username = settings.IMC_API_USERNAME - self.password = settings.IMC_API_PASSWORD - self.verify_ssl = settings.IMC_API_VERIFY_SSL - self.session = requests.Session() - self.realm = "iMC RESTful Web Services" - self.connect_timeout = getattr(settings, 'IMC_CONNECT_TIMEOUT', 5) - self.read_timeout = getattr(settings, 'IMC_READ_TIMEOUT', 20) - # Digest 认证状态(每次重新初始化时清空,让首次请求自动获取 nonce) - self.nonce = None - self.nc = 1 - - # ═══════════════════════════════════════════════ - # 内部:Digest 认证 - # ═══════════════════════════════════════════════ - - def _get_digest_auth_header(self, method: str, uri: str) -> str | None: - """ - 构建 HTTP Digest 认证头 - - 首次调用时会自动发一个请求获取 nonce(服务器返回 401 + WWW-Authenticate), - 后续复用 nonce 并递增 nc 值。 - nonce 过期时调用方捕获 401 后清空 self.nonce,下次自动重新握手。 - """ - if not self.nonce: - try: - resp = self.session.get( - f"{self.base_url}{uri}", - verify=self.verify_ssl, - headers={"Accept": "application/json"}, - timeout=(self.connect_timeout, self.read_timeout), - ) - if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers: - auth_header = resp.headers['WWW-Authenticate'] - auth_parts = {} - for part in auth_header.split(','): - if '=' in part: - key, value = part.split('=', 1) - auth_parts[key.strip()] = value.strip(' "') - self.nonce = auth_parts.get('nonce', '') - self.realm = auth_parts.get('realm', self.realm) - logger.info(f"获取 nonce 成功: {self.nonce}") - else: - logger.error(f"获取 nonce 失败, 状态码: {resp.status_code}") - return None - except requests.Timeout: - logger.error("获取 nonce 超时") - raise TimeoutError("iMC 认证超时") - except Exception as e: - logger.error(f"获取 nonce 异常: {e}") - return None - - # 计算 Digest 响应 - cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16] - ha1 = hashlib.md5( - f"{self.username}:{self.realm}:{self.password}".encode() - ).hexdigest() - ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest() - response_hash = hashlib.md5( - f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode() - ).hexdigest() - - auth_value = ( - f'Digest username="{self.username}", ' - f'realm="{self.realm}", ' - f'nonce="{self.nonce}", ' - f'uri="{uri}", ' - f'response="{response_hash}", ' - f'qop=auth, ' - f'nc={self.nc:08d}, ' - f'cnonce="{cnonce}"' - ) - self.nc += 1 - return auth_value - - def _clear_auth(self): - """清除认证状态(nonce 过期时调用)""" - self.nonce = None - self.nc = 1 - - # ═══════════════════════════════════════════════ - # 公共:重启 ONU - # ═══════════════════════════════════════════════ - - def reboot_onu(self, mac: str) -> dict: - """ - 远程重启 ONU 设备 - - Args: - mac: MAC 地址,格式如 "1484-7790-4840" - - Returns: - {"success": True, "message": "设备正在重启,请稍后..."} - 或 {"success": False, "message": "重启失败: ..."} - """ - max_retries = 1 - for retry in range(max_retries + 1): - try: - uri = f"/imcrs/epon/onu/reboot?mac={mac}" - auth = self._get_digest_auth_header("POST", uri) - if not auth: - return {"success": False, "message": "认证失败,无法发送重启请求"} - - headers = { - "Accept": "application/xml", - "Content-Type": "application/xml", - "Content-Length": "0", - "Authorization": auth, - } - - resp = self.session.post( - f"{self.base_url}{uri}", - headers=headers, - verify=self.verify_ssl, - timeout=(self.connect_timeout, self.read_timeout), - ) - - if resp.status_code == 200: - # 检查 XML 响应中是否有错误码 - if "" in resp.text: - m = re.search(r"(\d+)", resp.text) - if m: - code = m.group(1) - msg = REBOOT_ERROR_CODES.get( - code, f"未知错误(代码: {code})" - ) - return {"success": False, "message": f"重启失败: {msg}"} - return {"success": True, "message": "设备正在重启,请稍后..."} - - elif resp.status_code == 401: - # nonce 过期,清空后重试 - self._clear_auth() - continue - else: - return { - "success": False, - "message": f"重启请求失败(HTTP {resp.status_code})", - } - - except TimeoutError: - return {"success": False, "message": "iMC 接口超时,请稍后重试"} - except Exception as e: - logger.error(f"重启异常: {e}") - if retry < max_retries: - time.sleep(3) - continue - return {"success": False, "message": f"重启异常: {e}"} - - return {"success": False, "message": "重启失败,已达最大重试次数"} - - # ═══════════════════════════════════════════════ - # 公共:获取光功率 - # ═══════════════════════════════════════════════ - - def get_optical_power(self, mac: str) -> dict | None: - """ - 获取 ONU 设备光功率信息 - - 接口: /imcrs/epon/onu/onuLightWaneInfo?mac={mac} - 响应 JSON 字段:powerIn(接收光功率), powerOut(发送光功率), - bindMac, devId, eponDevName, oltIfName, onuIfDesc - - Args: - mac: MAC 地址,格式如 "1484-7790-4840" - - Returns: - dict: { - "powerIn": "-18.5", # dBm,接收光功率 - "powerOut": "2.3", # dBm,发送光功率 - "bindMac": "...", - "devId": ..., - "eponDevName": "...", - "oltIfName": "...", - "onuIfDesc": "..." - } - 或 None(失败时) - """ - max_retries = 1 - for retry in range(max_retries + 1): - try: - uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={mac}" - auth = self._get_digest_auth_header("GET", uri) - if not auth: - logger.error("生成认证头失败,无法获取光功率") - return None - - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": auth, - } - - logger.info(f"获取光功率: {self.base_url}{uri}") - resp = self.session.get( - f"{self.base_url}{uri}", - headers=headers, - verify=self.verify_ssl, - timeout=(self.connect_timeout, self.read_timeout), - ) - logger.info(f"光功率API响应状态码: {resp.status_code}") - - if resp.status_code == 200: - try: - data = resp.json() - logger.info( - f"光功率响应: {json.dumps(data, ensure_ascii=False)}" - ) - return { - "powerIn": data.get("powerIn"), - "powerOut": data.get("powerOut"), - "bindMac": data.get("bindMac"), - "devId": data.get("devId"), - "eponDevName": data.get("eponDevName"), - "oltIfName": data.get("oltIfName"), - "onuIfDesc": data.get("onuIfDesc"), - } - except json.JSONDecodeError as e: - logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}") - - elif resp.status_code == 401: - self._clear_auth() - continue - else: - logger.error( - f"光功率API请求失败, 状态码: {resp.status_code}, " - f"内容: {resp.text}" - ) - break # 非401不重试 - - except TimeoutError: - logger.error("获取光功率超时") - raise - except requests.Timeout: - logger.error("光功率接口请求超时") - raise TimeoutError("iMC 光功率接口请求超时,请稍后重试") - except Exception as e: - logger.error(f"获取光功率异常: {e}") - if retry < max_retries: - time.sleep(3) - continue - return None -``` - -### 4.4 Schema — `backend/app/schemas/device.py` - -添加重启和光功率的响应模型: - -```python -class RebootResponse(BaseModel): - success: bool - message: str - - -class OpticalPowerResponse(BaseModel): - power_in: Optional[str] = None # 接收光功率 (dBm) - power_out: Optional[str] = None # 发送光功率 (dBm) - bind_mac: Optional[str] = None - dev_id: Optional[int] = None - epon_dev_name: Optional[str] = None - olt_if_name: Optional[str] = None - onu_if_desc: Optional[str] = None -``` - -### 4.5 API 路由 — `backend/app/api/v1/devices.py` - -在文件顶部导入新 Schema: - -```python -from app.schemas.device import ( - DeviceListResponse, - ONUDeviceResponse, - RebootResponse, - OpticalPowerResponse, -) -``` - -在文件末尾添加两个新端点: - -```python -# ═══════════════════════════════════════════════ -# 重启 ONU -# ═══════════════════════════════════════════════ - -@router.post("/{device_id}/reboot", response_model=RebootResponse) -def reboot_device( - device_id: int, - db: Session = Depends(get_db), - current: dict = Depends(require_permission('device.check')), -): - """ - 远程重启 ONU 设备(通过 iMC REST API) - - 权限要求:device.check - 区域/学校管理员只能操作自己范围内的设备。 - """ - device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() - if not device: - raise HTTPException(status_code=404, detail="设备不存在") - - # 数据范围权限过滤 - role = current.get('role', 'user') - if role == 'area_admin': - assigned = current.get('assigned_area') or '' - areas = [a.strip() for a in assigned.split(',') if a.strip()] - if device.region not in areas: - raise HTTPException(status_code=403, detail="无权限操作此区域的设备") - elif role == 'school_admin': - assigned = current.get('assigned_school') or '' - schools = [s.strip() for s in assigned.split(',') if s.strip()] - if device.school_name not in schools: - raise HTTPException(status_code=403, detail="无权限操作此学校的设备") - - try: - from app.services.imc_service import IMCService - service = IMCService() - mac = device.mac_address - result = service.reboot_onu(mac) - return RebootResponse(**result) - except Exception as e: - raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}") - - -# ═══════════════════════════════════════════════ -# 获取光功率 -# ═══════════════════════════════════════════════ - -@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse) -def get_device_optical_power( - device_id: int, - db: Session = Depends(get_db), - current: dict = Depends(require_permission('device.view')), -): - """ - 获取 ONU 设备光功率信息(通过 iMC REST API) - - 返回接收光功率(power_in)和发送光功率(power_out),单位 dBm。 - 权限要求:device.view(只读操作) - """ - device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first() - if not device: - raise HTTPException(status_code=404, detail="设备不存在") - - # 数据范围权限过滤(同上) - role = current.get('role', 'user') - if role == 'area_admin': - assigned = current.get('assigned_area') or '' - areas = [a.strip() for a in assigned.split(',') if a.strip()] - if device.region not in areas: - raise HTTPException(status_code=403, detail="无权限操作此区域的设备") - elif role == 'school_admin': - assigned = current.get('assigned_school') or '' - schools = [s.strip() for s in assigned.split(',') if s.strip()] - if device.school_name not in schools: - raise HTTPException(status_code=403, detail="无权限操作此学校的设备") - - try: - from app.services.imc_service import IMCService - service = IMCService() - mac = device.mac_address - result = service.get_optical_power(mac) - if result is None: - raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应") - - # 字段名转换:下划线转驼峰前先映射 - from app.schemas.device import OpticalPowerResponse - return OpticalPowerResponse( - power_in=result.get("powerIn"), - power_out=result.get("powerOut"), - bind_mac=result.get("bindMac"), - dev_id=result.get("devId"), - epon_dev_name=result.get("eponDevName"), - olt_if_name=result.get("oltIfName"), - onu_if_desc=result.get("onuIfDesc"), - ) - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}") -``` - -### 4.6 注册 Service — `backend/app/services/__init__.py` - -```python -from .imc_service import IMCService - -__all__ = ["IMCService"] -``` - ---- - -## 5. 关键陷阱与注意事项 - -### ⚠️ Digest 认证的 nonce 过期问题 - -iMC 的 nonce 有有效期(通常 5-10 分钟)。过期后服务器返回 **401**。 -- 代码中 `_clear_auth()` 清空 nonce,下次请求自动重新握手 -- 重启和光功率方法都在 for 循环中捕获 401 并 `continue` 重试 - -### ⚠️ MAC 地址格式 - -iMC REST API 要求 MAC 地址格式为 **1484-7790-4840**(连字符分隔,大写十六进制)。 -如果数据库存储格式不同,需要做格式转换: - -```python -def normalize_mac(mac: str) -> str: - """标准化 MAC 为 iMC 要求的格式:1484-7790-4840""" - clean = mac.replace(':', '').replace('-', '').replace('.', '').upper() - return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}" -``` - -### ⚠️ 重启接口需要 Content-Length: 0 - -即使请求体为空,也必须显式设置 `Content-Length: 0` 头,否则 iMC 会报错。 - -### ⚠️ 光功率接口返回空数据的情况 - -当 ONU 离线或光模块故障时,iMC 返回的 `powerIn` / `powerOut` 可能是 `" --"`(两个空格+两个横线)或 `None`。前端需做占位符处理。 - -### ⚠️ 重启操作较慢 - -从发起请求到设备实际重启完成约需 **30-60 秒**(取决于 SNMP 响应)。建议: -- 前端按钮显示 loading 状态 -- 后端设置合理超时(connect=5s, read=20s) -- 不要在短时间内对同一设备重复操作 - -### ⚠️ 并发控制 - -建议对重启操作添加简单的并发控制,避免同一设备被多次重启: - -```python -import threading - -_reboot_locks = {} -_reboot_lock = threading.Lock() - -def reboot_onu(self, mac): - with _reboot_lock: - if mac not in _reboot_locks: - _reboot_locks[mac] = threading.Lock() - lock = _reboot_locks[mac] - - if not lock.acquire(blocking=False): - return {"success": False, "message": "该设备正在重启中,请稍后"} - try: - # ... 重启逻辑 ... - finally: - lock.release() -``` - -### ⚠️ Docker 部署注意 - -- 在 `docker-compose.yml` 的 `backend` 服务中新增环境变量: - ```yaml - environment: - - IMC_API_URL=https://172.16.1.252:8443 - - IMC_API_USERNAME=admin - - IMC_API_PASSWORD=Pwd@12345 - - IMC_API_VERIFY_SSL=false - ``` -- `backend` 和 `celery-worker` 容器都需要这些变量 -- 修改后必须重新构建镜像: - ```bash - docker compose build --no-cache backend - docker compose rm -f backend && docker compose up -d backend - ``` - ---- - -## 6. 测试验证 - -### 手动测试 - -```bash -# 1. 重启设备 -curl -X POST "http://localhost:8000/api/devices/1/reboot" \ - -H "Authorization: Bearer " - -# 2. 获取光功率 -curl "http://localhost:8000/api/devices/1/optical-power" \ - -H "Authorization: Bearer " - -# 3. 查看后端日志 -docker compose logs backend | grep IMCService - -# 4. 直接测试 iMC API(验证认证是否工作) -curl -k -v "https://172.16.1.252:8443/imcrs/epon/onu/onuLightWaneInfo?mac=1484-7790-4840" -``` - -### 测试响应示例 - -**重启成功:** -```json -{"success": true, "message": "设备正在重启,请稍后..."} -``` - -**重启失败(ONU不存在):** -```json -{"success": false, "message": "重启失败: ONU不存在"} -``` - -**光功率获取成功:** -```json -{ - "power_in": "-18.5", - "power_out": "2.3", - "bind_mac": "1484-7790-4840", - "dev_id": 123, - "epon_dev_name": "OLT-1-1", - "olt_if_name": "1/0/2", - "onu_if_desc": "ONU-学校A" -} -``` - -**光功率获取失败(设备离线):** -```json -{ - "power_in": null, - "power_out": null, - "bind_mac": null, - "dev_id": null, - "epon_dev_name": null, - "olt_if_name": null, - "onu_if_desc": null -} -``` - ---- - -## 7. 完整调用时序图 - -``` -客户端 后端 FastAPI iMC 平台 - │ │ │ - │ POST /api/devices/1/reboot │ │ - │ ────────────────────────────► │ │ - │ │ ── 查数据库:设备存在?──► │ - │ │ ◄── 返回设备信息 ────────── │ - │ │ ── 权限检查 ───────────── │ - │ │ │ - │ │ ── GET /imcrs/epon/onu/reboot │ - │ │ (无认证,获取 nonce) │ - │ │ ────────────────────────────► │ - │ │ ◄── 401 + WWW-Authenticate ──│ - │ │ nonce=xxx, realm=... │ - │ │ │ - │ │ ── POST 同 URI + Digest ────► │ - │ │ Authorization: Digest ... │ - │ │ ◄── 200 OK (XML) ────────────│ - │ │ │ - │ ◄── {success: true, │ │ - │ message: "设备重启中"} │ │ - │ │ │ - │ ── 或 ── │ │ - │ │ │ - │ GET /api/devices/1/optical-power │ - │ ────────────────────────────► │ │ - │ │ ── 查 + 权限 (同上) ──── │ - │ │ │ - │ │ ── GET /imcrs/epon/onu/ │ - │ │ onuLightWaneInfo?mac=... │ - │ │ (+ Digest Auth) │ - │ │ ────────────────────────────► │ - │ │ ◄── 200 OK (JSON) ───────────│ - │ │ {powerIn, powerOut, ...} │ - │ │ │ - │ ◄── {power_in: "-18.5", │ │ - │ power_out: "2.3", ...} │ │ -``` - ---- - -## 附录:源项目参考文件位置 - -| 内容 | 路径 | -|------|------| -| IMCService 完整实现 | `/home/v6ole/pyproject/H3ConuMS/app/services/imc_service.py` | -| 重启控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/device.py` (第1059行) | -| 优化版控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/optimized_device.py` (第157行) | -| iMC 配置项 | `/home/v6ole/pyproject/H3ConuMS/app/config.py` (第61-67行) |