feat: v0.10.0 生产环境优化 — HTTPS、前端生产构建、安全加固
- feat(deploy): 前端多阶段构建 (vite build + nginx:alpine),移除 Vite 开发模式 - feat(deploy): OpenResty HTTPS 配置 (SSL + HSTS + 安全头) - fix(ws): WebSocket 路由添加 /api 前缀,修正前后端路径不匹配 - security: SSH AutoAddPolicy → WarningPolicy - security: CORS 来源环境变量化 (CORS_ORIGINS) - security: 限流器使用 X-Forwarded-For 真实客户端 IP - perf(db): 数据库连接池配置 (pool_size=20, max_overflow=40) - refactor: 移除硬编码 URL/IP (NTP、域名、微信代理),改为环境变量 - chore: 更新 .env.example 模板,补充新增配置项 - chore: 清理 .reasonix/、scripts/、guide.md 无用文件 - docs: 更新 CLAUDE.md 至 v0.10.0,补充生产架构文档 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,3 +44,9 @@ logs/
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# Deployment docs (contain credentials)
|
||||||
|
*部署交接文档.md
|
||||||
|
|
||||||
|
# Reasonix
|
||||||
|
.reasonix/
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
|
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
|
||||||
|
|
||||||
**当前版本**: v0.9.0
|
**当前版本**: v0.10.0
|
||||||
**开发状态**: 核心功能已完成,运维增强功能持续迭代
|
**开发状态**: 生产环境优化完成,运维增强功能持续迭代
|
||||||
|
|
||||||
**已实现功能**:
|
**已实现功能**:
|
||||||
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理)
|
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理)
|
||||||
@@ -29,7 +29,12 @@
|
|||||||
- ✅ 操作记录日志(查看指定设备的上下线事件历史)
|
- ✅ 操作记录日志(查看指定设备的上下线事件历史)
|
||||||
- ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT)
|
- ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT)
|
||||||
- ✅ IMC 网管服务集成(ONU 远程重启、光功率查询)
|
- ✅ IMC 网管服务集成(ONU 远程重启、光功率查询)
|
||||||
|
- ✅ 企业微信告警通知(设备离线、全离线学校检测)
|
||||||
|
- ✅ WebSocket 实时推送(仪表板状态更新、检查进度)
|
||||||
- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配)
|
- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配)
|
||||||
|
- ✅ 生产环境 HTTPS 部署(OpenResty + SSL + 安全头)
|
||||||
|
- ✅ 前端生产构建(多阶段 Dockerfile,nginx:alpine 静态服务)
|
||||||
|
- ✅ 数据库连接池优化(pool_size=20, max_overflow=40)
|
||||||
|
|
||||||
**技术栈**:
|
**技术栈**:
|
||||||
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
|
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
|
||||||
@@ -115,6 +120,17 @@
|
|||||||
- `GET /api/settings` - 获取系统设置
|
- `GET /api/settings` - 获取系统设置
|
||||||
- `PUT /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 /health` - 健康检查
|
||||||
- `GET /docs` - API 文档(Swagger UI)
|
- `GET /docs` - API 文档(Swagger UI)
|
||||||
@@ -242,9 +258,17 @@
|
|||||||
必需配置:
|
必需配置:
|
||||||
- `DATABASE_URL`:PostgreSQL 连接字符串
|
- `DATABASE_URL`:PostgreSQL 连接字符串
|
||||||
- `REDIS_URL`:Redis 连接字符串
|
- `REDIS_URL`:Redis 连接字符串
|
||||||
- `CASDOOR_*`:Casdoor 认证配置
|
- `CASDOOR_*`:Casdoor 认证配置(`ENDPOINT`, `CLIENT_ID`, `CLIENT_SECRET`, `ORG_NAME`, `APP_NAME`)
|
||||||
|
- `CASDOOR_REDIRECT_URL`:Casdoor 登录回调地址(生产必填,无默认值)
|
||||||
- `SECRET_KEY`:应用密钥
|
- `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`
|
- 健康检查端点:`/health`
|
||||||
- 性能指标端点:`/metrics`
|
- 性能指标端点:`/metrics`
|
||||||
@@ -290,20 +314,99 @@ docker ps | grep <port>
|
|||||||
|
|
||||||
**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。
|
**解决方法**:先停掉占用端口的旧容器,再 `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
|
```dockerfile
|
||||||
frontend:
|
# 多阶段构建 — frontend/Dockerfile
|
||||||
build: ./frontend
|
# Stage 1: vite build → dist/
|
||||||
ports:
|
# Stage 2: nginx:alpine 静态文件服务
|
||||||
- "5173:5173"
|
|
||||||
environment:
|
|
||||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**生产模式下不需要 `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@<remote> "docker load"
|
||||||
|
ssh -p 7072 root@<remote> "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 错误。
|
**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。
|
||||||
@@ -429,6 +532,8 @@ H3ConuMS2/
|
|||||||
│ ├── scripts/ # 初始化脚本
|
│ ├── scripts/ # 初始化脚本
|
||||||
│ └── templates/ # Excel 导入模板
|
│ └── templates/ # Excel 导入模板
|
||||||
├── frontend/ # Vue 3 前端
|
├── frontend/ # Vue 3 前端
|
||||||
|
│ ├── Dockerfile # 多阶段构建(vite build + nginx:alpine)
|
||||||
|
│ ├── nginx.conf # 生产 nginx 配置(Gzip、缓存、SPA fallback)
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── api/ # API 调用封装
|
│ ├── api/ # API 调用封装
|
||||||
│ ├── components/ # 公共组件
|
│ ├── components/ # 公共组件
|
||||||
@@ -440,10 +545,11 @@ H3ConuMS2/
|
|||||||
│ └── views/ # 页面组件
|
│ └── views/ # 页面组件
|
||||||
├── deploy/ # 部署配置
|
├── deploy/ # 部署配置
|
||||||
│ ├── docker-compose.yml
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── openresty/ # OpenResty 站点配置
|
||||||
│ ├── nginx/
|
│ ├── nginx/
|
||||||
│ └── scripts/
|
│ └── scripts/
|
||||||
├── docs/ # 文档
|
├── docs/ # 文档
|
||||||
└── docker-compose.yml # 主部署文件
|
└── docker-compose.yml # 本地开发 Docker Compose
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+18
-2
@@ -16,7 +16,7 @@ CASDOOR_CLIENT_SECRET=your_client_secret
|
|||||||
CASDOOR_ORG_NAME=your_org
|
CASDOOR_ORG_NAME=your_org
|
||||||
CASDOOR_APP_NAME=h3c-onu-ms
|
CASDOOR_APP_NAME=h3c-onu-ms
|
||||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||||
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
|
CASDOOR_REDIRECT_URL=
|
||||||
|
|
||||||
# SSH配置
|
# SSH配置
|
||||||
SSH_TIMEOUT=30
|
SSH_TIMEOUT=30
|
||||||
@@ -25,6 +25,22 @@ SSH_TIMEOUT=30
|
|||||||
CHECK_INTERVAL=1800
|
CHECK_INTERVAL=1800
|
||||||
MANUAL_COOLDOWN=300
|
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_CORPID=
|
||||||
WECHAT_CORPSECRET=
|
WECHAT_CORPSECRET=
|
||||||
@@ -32,4 +48,4 @@ WECHAT_AGENTID=
|
|||||||
WECHAT_TOKEN=
|
WECHAT_TOKEN=
|
||||||
WECHAT_ENCODING_AES_KEY=
|
WECHAT_ENCODING_AES_KEY=
|
||||||
WECHAT_USE_PROXY=True
|
WECHAT_USE_PROXY=True
|
||||||
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
WECHAT_PROXY_API_URL=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import distinct
|
from sqlalchemy import distinct
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.config import settings
|
||||||
from app.middleware.permission_middleware import require_permission
|
from app.middleware.permission_middleware import require_permission
|
||||||
from app.models.device import OLTDevice
|
from app.models.device import OLTDevice
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -501,8 +502,8 @@ def loopback_detection(
|
|||||||
|
|
||||||
|
|
||||||
class SyncNTPRequest(BaseModel):
|
class SyncNTPRequest(BaseModel):
|
||||||
old_server: str = "172.16.0.254"
|
old_server: str = settings.NTP_OLD_SERVER
|
||||||
new_server: str = "172.16.1.252"
|
new_server: str = settings.NTP_NEW_SERVER
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-ntp")
|
@router.post("/sync-ntp")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Request, Response
|
from fastapi import APIRouter, Request, Response
|
||||||
from app.services.wechat_service import get_wechat_service
|
from app.services.wechat_service import get_wechat_service
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
||||||
@@ -235,6 +236,6 @@ def _handle_help_cmd(svc, from_user: str):
|
|||||||
"• 发送「全离线」查看全离线学校\n"
|
"• 发送「全离线」查看全离线学校\n"
|
||||||
"• 发送 MAC 地址后四位查询设备\n\n"
|
"• 发送 MAC 地址后四位查询设备\n\n"
|
||||||
"💡 发送「帮助」显示此信息\n"
|
"💡 发送「帮助」显示此信息\n"
|
||||||
"💻 完整功能: https://onu.dhdx.fun",
|
f"💻 完整功能: {settings.FRONTEND_URL}",
|
||||||
to_user=from_user
|
to_user=from_user
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/api", tags=["WebSocket"])
|
||||||
|
|
||||||
REDIS_CHANNEL = "h3c_onu:status_updates"
|
REDIS_CHANNEL = "h3c_onu:status_updates"
|
||||||
_connected: set[WebSocket] = set()
|
_connected: set[WebSocket] = set()
|
||||||
|
|||||||
@@ -21,12 +21,20 @@ class Settings(BaseSettings):
|
|||||||
CASDOOR_ORG_NAME: str
|
CASDOOR_ORG_NAME: str
|
||||||
CASDOOR_APP_NAME: str
|
CASDOOR_APP_NAME: str
|
||||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||||
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
|
CASDOOR_REDIRECT_URL: str = ""
|
||||||
|
|
||||||
SSH_TIMEOUT: int = 30
|
SSH_TIMEOUT: int = 30
|
||||||
CHECK_INTERVAL: int = 1800
|
CHECK_INTERVAL: int = 1800
|
||||||
MANUAL_COOLDOWN: int = 300
|
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 配置(用于 ONU 远程重启和光功率查询)
|
||||||
IMC_API_URL: str = ""
|
IMC_API_URL: str = ""
|
||||||
|
|
||||||
@@ -37,7 +45,7 @@ class Settings(BaseSettings):
|
|||||||
WECHAT_TOKEN: str = ""
|
WECHAT_TOKEN: str = ""
|
||||||
WECHAT_ENCODING_AES_KEY: str = ""
|
WECHAT_ENCODING_AES_KEY: str = ""
|
||||||
WECHAT_USE_PROXY: bool = True
|
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_USERNAME: str = ""
|
||||||
IMC_API_PASSWORD: str = ""
|
IMC_API_PASSWORD: str = ""
|
||||||
IMC_API_VERIFY_SSL: bool = False
|
IMC_API_VERIFY_SSL: bool = False
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ from sqlalchemy.ext.declarative import declarative_base
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from app.core.config import settings
|
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)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|||||||
+15
-10
@@ -1,10 +1,10 @@
|
|||||||
"""FastAPI 主应用"""
|
"""FastAPI 主应用"""
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
from pythonjsonlogger import jsonlogger
|
from pythonjsonlogger import jsonlogger
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
from slowapi.util import get_remote_address
|
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
@@ -29,15 +29,20 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
|
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
# CORS 白名单
|
# CORS 白名单 — 支持通过环境变量 CORS_ORIGINS 覆盖(逗号分隔)
|
||||||
ALLOWED_ORIGINS = [
|
CORS_ORIGINS_DEFAULT = "http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun"
|
||||||
"http://localhost:5173",
|
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", CORS_ORIGINS_DEFAULT).split(",") if o.strip()]
|
||||||
"http://localhost:18002",
|
|
||||||
"https://onu.dhdx.fun",
|
|
||||||
]
|
|
||||||
allowed = [o for o in ALLOWED_ORIGINS if o]
|
|
||||||
|
|
||||||
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 = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
@@ -46,7 +51,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|||||||
app.add_middleware(RequestSizeLimitMiddleware)
|
app.add_middleware(RequestSizeLimitMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed if allowed else ["*"],
|
allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SSHService:
|
|||||||
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||||
try:
|
try:
|
||||||
self.client = paramiko.SSHClient()
|
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(
|
self.client.connect(
|
||||||
hostname=self.host,
|
hostname=self.host,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
|
|||||||
+29
-14
@@ -65,6 +65,9 @@ CASDOOR_ORG_NAME=your_organization
|
|||||||
# 应用名称
|
# 应用名称
|
||||||
CASDOOR_APP_NAME=h3c-onu-ms
|
CASDOOR_APP_NAME=h3c-onu-ms
|
||||||
|
|
||||||
|
# Casdoor 回调地址(部署后改为实际域名)
|
||||||
|
CASDOOR_REDIRECT_URL=
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# SSH连接配置
|
# SSH连接配置
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -98,20 +101,35 @@ HISTORY_RETENTION=90
|
|||||||
BATCH_CHECK_SIZE=100
|
BATCH_CHECK_SIZE=100
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 前端配置
|
# 前端 & CORS 配置
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# API基础URL (前端访问后端地址)
|
# 前端访问地址
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
FRONTEND_URL=https://your-domain.com
|
||||||
|
|
||||||
# Casdoor前端配置
|
# CORS允许的域名 (逗号分隔)
|
||||||
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
|
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||||
VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id
|
|
||||||
VITE_CASDOOR_ORG_NAME=your_organization
|
|
||||||
VITE_CASDOOR_APP_NAME=h3c-onu-ms
|
|
||||||
|
|
||||||
# 应用标题
|
# ============================================
|
||||||
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_MINUTE=60
|
||||||
RATE_LIMIT_PER_HOUR=1000
|
RATE_LIMIT_PER_HOUR=1000
|
||||||
@@ -175,7 +190,7 @@ WECHAT_AGENTID=
|
|||||||
WECHAT_TOKEN=
|
WECHAT_TOKEN=
|
||||||
WECHAT_ENCODING_AES_KEY=
|
WECHAT_ENCODING_AES_KEY=
|
||||||
WECHAT_USE_PROXY=True
|
WECHAT_USE_PROXY=True
|
||||||
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
WECHAT_PROXY_API_URL=
|
||||||
|
|
||||||
# 备份保留天数
|
# 备份保留天数
|
||||||
BACKUP_RETENTION=30
|
BACKUP_RETENTION=30
|
||||||
|
|||||||
+12
-11
@@ -16,15 +16,23 @@ services:
|
|||||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
- DEBUG=${DEBUG:-false}
|
- 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_CORPID=${WECHAT_CORPID:-}
|
||||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
- 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:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
- ../backend/static:/app/static
|
- ../backend/static:/app/static
|
||||||
@@ -96,26 +104,19 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# 前端应用
|
# 前端应用(生产模式:nginx 静态文件服务)
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../frontend
|
context: ../frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: h3c-onu-ms-frontend
|
container_name: h3c-onu-ms-frontend
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "18062:80"
|
||||||
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}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:5173"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -40,10 +40,13 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
ports:
|
ports:
|
||||||
- "18002:5173"
|
- "18002:80"
|
||||||
environment:
|
|
||||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
|||||||
+19
-3
@@ -1,4 +1,5 @@
|
|||||||
FROM node:18-alpine
|
# Stage 1: Build
|
||||||
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,6 +8,21 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
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;"]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "<errorCode>" in resp.text:
|
|
||||||
m = re.search(r"<errorCode>(\d+)</errorCode>", 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 <token>"
|
|
||||||
|
|
||||||
# 2. 获取光功率
|
|
||||||
curl "http://localhost:8000/api/devices/1/optical-power" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
|
|
||||||
# 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行) |
|
|
||||||
Reference in New Issue
Block a user