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:
2026-06-11 19:00:08 +08:00
parent 6e5f16ecf2
commit fe7649ed6e
17 changed files with 340 additions and 791 deletions
+6
View File
@@ -44,3 +44,9 @@ logs/
# Claude Code # Claude Code
.claude/ .claude/
.mcp.json .mcp.json
# Deployment docs (contain credentials)
*部署交接文档.md
# Reasonix
.reasonix/
+120 -14
View File
@@ -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 + 安全头)
- ✅ 前端生产构建(多阶段 Dockerfilenginx: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
View File
@@ -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=
+3 -2
View File
@@ -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 -1
View File
@@ -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
) )
+1 -1
View File
@@ -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()
+10 -2
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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=["*"],
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+54
View File
@@ -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;
}
# 后端 APIfrp 隧道 → 本机后端 /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
View File
@@ -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
View File
@@ -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;"]
+36
View File
@@ -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;
}
}
-726
View File
@@ -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行) |