9 Commits

Author SHA1 Message Date
v6ole 5b07ec6df0 refactor: 移除本地前端容器,仅保留远程部署
本地前端因 Casdoor OAuth 回调固定指向 onu.dhdx.fun,无法完成
登录流程,没有实际用途。转为仅远程服务器部署。

本地服务器现仅需 2 容器:backend + celery-worker(含beat)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 8a8ae4ed57 fix: 路由顺序导致 422 + 彻底消除 Header(...) 隐患
1. devices.py: /tags 和 /export/csv 移到 /{device_id}(int) 之前,
   避免 FastAPI 将 'tags'/'export' 当作 device_id 解析失败返回 422
2. audit.py: /logs/export/csv 移到 /logs/{log_id}(int) 之前,同上
3. auth.py: Header(...) → Header(None),统一为手动 401 返回,
   消除全局最后一个 Header(...) 导致的 422 隐患

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole a5ac25a01d refactor: 合并 celery-beat 到 celery-worker (4→3容器)
celery-worker 加 -B 参数启动嵌入式 Beat,省去独立 celery-beat 容器。
定时任务由代码中 beat_schedule 驱动,schedule 文件保持持久化。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 32b7a2cc6a fix: el-pagination 弃用属性 small → size="small"
消除 Element Plus 3.0 deprecated 控制台警告。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 6623169e8a fix: 快速扫描前端超时 30s→180s
快速扫描需要 SSH 连接 30+ 台 OLT 并解析入库,后端实测 61 秒,
Axios 默认 30 秒超时导致请求在前端被中断。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 0bab72ea98 fix: 修复 /api/health 404 和扫描/发现 500 错误
1. /api/health 404: 前端 fetchVersion 调用 /api/health,但后端只有 /health。
   OpenResty 保留完整路径,需要在后端添加 /api/health 端点。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole 3ee8846011 fix: 修复环路检测失败 + 多项UX增强
修复:
- 环路检测正则 \s+(Onu\S+)\s+ → \s+(Onu\S+) (splitlines移除换行后尾随\s无法匹配)
- 权限中间件 Header(...) → Header(None) 避免缺失Auth头返回422而非401
- 环路检测请求超时30s→120s (SSH连接30+台OLT实测需58秒)

重构 (ssh_service.py):
- 提取 _send_and_wait 为私有方法,消除3处重复内部函数
- 添加 __enter__/__exit__ 上下文管理器支持
- 加固 execute_command prompt检测 (按行匹配<DEVICE_NAME>)
- 移除未使用的settings import
- olt.py/devices.py 调用方改用 with 语法

新功能:
- 侧边栏退出登录上方显示当前用户名和角色
- 版本号从VERSION文件自动读取 (后端/health返回,前端动态显示)
- 基于广西南宁经纬度计算日落时间,自动切换深色/浅色主题
- /api/olt/loopback-detection 响应增加raw字段便于排查

基础设施:
- CLAUDE.md 加入 .gitignore
- 新增 .claude/rules/07-remote-operations.md (远程部署操作)
- 新增 .claude/rules/08-frp-notes.md (frp隧道注意事项)
- 新增 VERSION 文件 (版本号 0.10.0)
- 新增环路检测解析测试用例 (5个)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 14:45:48 +08:00
v6ole fe7649ed6e 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>
2026-06-12 14:45:48 +08:00
v6ole 6e5f16ecf2 Merge pull request 'feat(alerts): 添加微信告警、WebSocket实时推送和地图功能' (#1) from feature/alerts-and-map into main
Reviewed-on: #1
2026-06-11 15:18:53 +08:00
31 changed files with 638 additions and 1605 deletions
+7
View File
@@ -44,3 +44,10 @@ logs/
# Claude Code # Claude Code
.claude/ .claude/
.mcp.json .mcp.json
CLAUDE.md
# Deployment docs (contain credentials)
*部署交接文档.md
# Reasonix
.reasonix/
-611
View File
@@ -1,611 +0,0 @@
# H3C ONU设备管理系统 - Claude开发指南
## 项目概述
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
**当前版本**: v0.9.0
**开发状态**: 核心功能已完成,运维增强功能持续迭代
**已实现功能**
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理)
- ✅ Excel 数据导入和批量管理
- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发
- ✅ Casdoor 统一认证和 JWT 令牌管理
- ✅ 设备管理(列表、详情、筛选、分页)
- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图
- ✅ 完整 RBAC 权限管理(角色、权限、用户管理页面)
- ✅ OLT 管理(增删改查、批量导入、区域动态同步)
- ✅ OLT 端口管理(查看端口状态、开关端口)
- ✅ 重复 MAC 检测与清除
- ✅ 新设备发现与信息补全(扫描发现 → 补全信息 → 入库)
- ✅ 快速扫描(多线程并发)
- ✅ 环路检测
- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态)
- ✅ 库存管理模块(物料、序列号设备、出入库、盘点)
- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化)
- ✅ 审计日志(全量操作记录、筛选查询、CSV 导出)
- ✅ 设备更换记录(MAC 地址更换历史,与库存序列号联动)
- ✅ 操作记录日志(查看指定设备的上下线事件历史)
- ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT)
- ✅ IMC 网管服务集成(ONU 远程重启、光功率查询)
- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配)
**技术栈**
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
- 前端:Vue 3 + Element Plus + Pinia + Axios
- 部署:Docker Compose
---
## 已实现的 API 端点
### 认证相关
- `GET /api/auth/login` - 获取 Casdoor 登录 URL
- `POST /api/auth/callback` - Casdoor 登录回调
- `GET /api/auth/profile` - 获取当前用户信息
### 设备管理
- `GET /api/devices` - 获取设备列表(分页、筛选)
- `GET /api/devices/{id}` - 获取设备详情
- `PUT /api/devices/{id}` - 更新设备信息
- `GET /api/devices/{id}/events` - 获取设备上下线事件记录
- `POST /api/devices/{id}/reboot` - 远程重启 ONUIMC
- `GET /api/devices/{id}/optical-power` - 获取 ONU 光功率(IMC
### 状态检查
- `POST /api/check/status` - 手动触发全量状态检查(Celery 异步)
- `GET /api/check/status/{task_id}` - 查询检查任务进度
- `POST /api/check/scan/{olt_id}` - 扫描单台 OLT(预览,不入库)
- `POST /api/check/discover/{olt_id}` - 扫描单台 OLT 并入库新设备
### OLT 管理
- `GET /api/olt/devices` - OLT 设备列表
- `POST /api/olt/devices` - 新增 OLT
- `PUT /api/olt/devices/{id}` - 编辑 OLT
- `DELETE /api/olt/devices/{id}` - 删除 OLT
- `GET /api/olt/regions` - 获取 OLT 区域列表
- `POST /api/olt/quick-scan` - 多线程快速扫描所有 OLT
- `GET /api/olt/ports/{olt_id}` - 获取 OLT 端口状态
- `POST /api/olt/ports/{olt_id}/toggle` - 开关 OLT 端口
- `POST /api/olt/sync-ntp` - 同步 NTP 时间服务器配置
- `POST /api/olt/loopback-detection` - 环路检测
- `GET /api/olt/new-devices` - 获取新发现设备列表
- `PUT /api/olt/new-devices/{id}` - 补全新设备信息
- `DELETE /api/olt/new-devices/{id}` - 忽略新设备
### 数据导入
- `POST /api/import/upload` - 上传并导入 Excel 文件
- `GET /api/import/template` - 下载导入模板
### 统计信息
- `GET /api/stats/summary` - 获取统计摘要
- `GET /api/stats/trend` - 7天趋势数据
- `GET /api/stats/region-distribution` - 区域分布
### 权限管理
- `GET /api/roles` - 角色列表
- `POST /api/roles` - 创建角色
- `PUT /api/roles/{id}` - 编辑角色
- `DELETE /api/roles/{id}` - 删除角色
- `GET /api/permissions` - 权限列表
### 用户管理
- `GET /api/users` - 用户列表
- `PUT /api/users/{id}` - 编辑用户
- `DELETE /api/users/{id}` - 删除用户
### 库存管理
- `GET /api/inventory/materials` - 物料列表
- `POST /api/inventory/materials` - 新增物料
- `GET /api/inventory/serial-devices` - 序列号设备
- `POST /api/inventory/stock-in` - 入库
- `POST /api/inventory/stock-out` - 出库
- `POST /api/inventory/check` - 盘点
### 审计日志
- `GET /api/audit/logs` - 审计日志列表(筛选、分页)
- `GET /api/audit/logs/export` - 导出审计日志 CSV
### 设备更换记录
- `GET /api/devices/{id}/replacements` - 查看设备更换历史
- `GET /api/replacements` - 全量更换记录列表
### 系统设置
- `GET /api/settings` - 获取系统设置
- `PUT /api/settings` - 更新系统设置
### 系统
- `GET /health` - 健康检查
- `GET /docs` - API 文档(Swagger UI
---
## Rules
### 代码规范
#### Python 后端规范
- 遵循 PEP 8 规范
- 使用 Black 进行代码格式化
- 使用 isort 进行导入排序
- 使用类型注解(Type Hints
- 异步函数使用 async/await
- 错误处理使用自定义异常类
#### Vue 前端规范
- 使用 Composition APIsetup script
- 组件使用 PascalCase 命名
- 使用 TypeScript 类型检查
- 遵循 Vue 官方风格指南
- 使用 ESLint + Prettier 格式化
#### Git 提交规范
使用 Conventional Commits 格式:
```
<type>(<scope>): <subject>
类型:feat, fix, docs, style, refactor, test, chore
示例:feat(device): 添加设备导入功能
```
### 架构规则
#### 后端架构
- **分层架构**API → Service → Model
- **API 层**:仅处理请求/响应,调用 Service
- **Service 层**:业务逻辑,不直接操作数据库
- **Model 层**SQLAlchemy 模型定义
- **异步任务**:耗时操作使用 Celery
#### 前端架构
- **组件分类**
- `components/common/`:通用组件
- `components/business/`:业务组件
- `views/`:页面组件
- **状态管理**:使用 Pinia stores
- **API 调用**:统一在 `api/` 目录封装
### 安全规则
#### 数据安全
- SSH 密码必须加密存储(使用 Fernet 加密)
- 敏感信息通过环境变量配置
- 数据库连接使用 SSL
- 定期备份数据(90天历史记录)
#### 应用安全
- 所有 API 端点需要认证(除登录接口)
- 实现 CSRF 保护
- 输入验证使用 Pydantic
- SQL 注入防护(使用 ORM
- XSS 防护(前端转义)
#### 访问控制
- 基于 RBAC 的权限控制
- 数据级权限过滤(按区域/学校)
- 操作审计日志记录
- 设备信息变更需要审核
### 性能规则
#### 数据库优化
- 为常用查询字段添加索引
- 使用连接池(pool_size=20
- 避免 N+1 查询问题
- 定期清理历史数据(保留90天)
#### 缓存策略
- Redis 缓存热点数据(设备状态)
- 缓存过期时间:30分钟
- 手动刷新有5分钟冷却限制
#### 异步处理
- SSH 状态检查使用 Celery 异步任务
- 批量导入使用后台任务
- 定时任务使用 Celery Beat
### 开发规则
#### 提交前清理
- 每次提交前必须清理项目中不需要的文件
- 包括:测试文件(`test_*.py`)、调试脚本、不再使用的 shell 脚本、冗余 markdown 文档
- 检查是否有硬编码的敏感信息(密码、密钥、token)
- 检查 `.gitignore` 是否覆盖了所有不应提交的文件(`*.pem``.env``__pycache__` 等)
#### 环境配置
- 开发环境使用 `.env.development`
- 生产环境使用 `.env.production`
- 不提交 `.env` 文件到 Git
- 提供 `.env.example` 模板
- 敏感信息(数据库密码、Casdoor 密钥等)仅通过环境变量注入
#### 测试要求
- 核心业务逻辑需要单元测试
- API 端点需要集成测试
- 测试覆盖率目标:≥80%
#### 文档要求
- API 变更及时更新 Swagger 文档
- 复杂业务逻辑添加注释
- 重要配置添加说明
### 部署规则
#### Docker 部署
- 使用 Docker Compose 编排
- 外部 PostgreSQL 和 Redis(不在容器内)
- 日志挂载到宿主机
- 使用 Nginx 反向代理
#### 环境变量
必需配置:
- `DATABASE_URL`PostgreSQL 连接字符串
- `REDIS_URL`Redis 连接字符串
- `CASDOOR_*`Casdoor 认证配置
- `SECRET_KEY`:应用密钥
#### 监控告警
- 健康检查端点:`/health`
- 性能指标端点:`/metrics`
- 日志级别:生产环境使用 INFO
---
## 部署陷阱与经验教训
### 修改代码后必须重新构建镜像
**问题**:修改了宿主机上的源码后,直接 `docker compose up -d``docker compose restart` **不会**让容器使用新代码。容器运行的是构建时打包进镜像的旧代码。
**正确流程**
```bash
# 1. 重新构建镜像(必须加 --no-cache 确保不用旧层)
docker compose build --no-cache backend # 或 frontend,或两者
# 2. 重新创建容器(必须用 rm + up,或 up --force-recreate
docker compose rm -f backend
docker compose up -d backend
# 3. 如有数据库模型变更,执行迁移
docker compose exec backend alembic upgrade head
```
**不要用**
- `docker compose restart`:只重启进程,不更新镜像
- `docker compose up -d`(无 build):容器配置变了会重建,但镜像不变
### 端口被占用导致容器启动但无端口映射
**问题**:如果宿主机端口已被其他容器占用,新容器会启动成功(`docker compose up` 不报错),但端口映射为空 `{}`,外部无法访问。
**排查方法**
```bash
# 检查端口映射是否正常
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
# 查看谁占用了端口
docker ps | grep <port>
```
**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`
### frontend 容器必须配置 VITE_API_PROXY_TARGET
**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。
**必须在 docker-compose.yml 中配置**
```yaml
frontend:
build: ./frontend
ports:
- "5173:5173"
environment:
- VITE_API_PROXY_TARGET=http://backend:8000
```
### 新增数据库模型后必须执行迁移
**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot``SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。
**每次新增模型的完整流程**
```bash
# 1. 创建迁移文件(在宿主机或容器内)
docker compose exec backend alembic revision --autogenerate -m "描述"
# 2. 重建镜像
docker compose build --no-cache backend
# 3. 重启容器
docker compose rm -f backend && docker compose up -d backend
# 4. 执行迁移
docker compose exec backend alembic upgrade head
```
### Alembic 迁移文件 revision ID 不能重复
**问题**:手动创建迁移文件时,如果 `revision` 字段与已有文件重复,`alembic upgrade head` 会报 `Multiple head revisions are present` 错误。
**规则**:手动创建迁移文件时,revision ID 使用不与现有文件冲突的唯一字符串(如 `h8i9j0k1l2m3`),并确认 `down_revision` 指向正确的上一个版本。
### Celery worker 必须重建才能识别新任务
**问题**:新增 Celery 任务模块后,如果只重建 backend 而不重建 celery-workerworker 不会注册新任务,消息会被丢弃并报 `KeyError`
**规则**:新增任务模块后,backend 和 celery-worker 都必须重建:
```bash
docker compose build --no-cache backend celery-worker
docker compose rm -f backend celery-worker
docker compose up -d backend celery-worker
```
验证任务是否注册:
```bash
docker compose exec celery-worker celery -A celery_worker.celery_app inspect registered
```
### 区域管理员多区域过滤必须用 `.in_()` 而非 `==`
**问题**`assigned_area` 字段存储逗号分隔的多个区域(如 `"城区,郊区"`),用 `==` 只能匹配整个字符串,导致多区域管理员只能看到第一个区域的数据。
**规则**:所有涉及 `assigned_area` 的过滤都必须先 split 再用 `.in_()`
```python
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
query = query.filter(Model.region.in_(areas))
```
### Vite dev server 通过反向代理访问需配置 allowedHosts
**问题**:通过域名反向代理访问 Vite dev server 时,会报 `Blocked request. This host is not allowed`
**解决**:在 `vite.config.js` 中设置:
```js
server: {
allowedHosts: ['all', 'your-domain.com'],
}
```
### iOS PWA standalone 模式与 Safari 的 safe area 差异
**问题**`env(safe-area-inset-top)` 在 Safari 浏览器中为 0(有地址栏占位),在 standalone 模式(添加到主屏幕)下为真实刘海高度(44-59px)。直接使用会导致 Safari 中布局正常但 standalone 中顶栏/弹窗重叠。
**规则**:所有 safe area 相关样式必须包在 `@media (display-mode: standalone)` 中,Safari 不受影响:
```css
@media (display-mode: standalone) {
.mobile-topbar {
height: calc(52px + env(safe-area-inset-top));
}
}
```
`ElMessage` 的 offset 通过 `src/utils/message.js` 封装统一处理,所有页面从该文件导入而非直接从 `element-plus` 导入。
### Docker healthcheck 镜像内无 curl
**问题**backend 镜像基于 Python slim,没有 `curl`healthcheck 用 `curl` 会导致所有依赖服务启动失败。
**规则**healthcheck 使用 Python 内置模块:
```yaml
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 10s
retries: 5
start_period: 40s
```
### More 分页标记清除不能使用 `[^\n]*` 删除整行
**问题**`_clean_output``---- More ----[^\n]*` 会将 `---- More ----` 及其后同行所有内容删除。当 OLT 输出的 More 提示与下一页第一条设备数据出现在同一行时(如 `---- More ----\r\r 1484-778f-aa60 ...`),该设备行会被错误丢弃,导致部分设备扫描不到。
**规则**:仅移除 More 标记文本本身,保留同行后续内容:
```python
# 错误:删除整行
output = re.sub(r'---- More ----[^\n]*', '', output)
# 正确:仅移除标记文本
output = re.sub(r'---- More ----', '', output)
```
此问题在 OLT `172.16.0.18`(大化县新城初中)上发现:93 台设备仅解析出 90 台,丢失 3 台。
---
## 项目结构
```
H3ConuMS2/
├── backend/ # Python 后端
│ ├── app/
│ │ ├── api/v1/ # API 路由
│ │ ├── core/ # 核心配置
│ │ ├── middleware/ # 中间件(权限、审计)
│ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic 模式
│ │ ├── services/ # 业务逻辑(SSH、IMC、导入等)
│ │ └── tasks/ # Celery 任务
│ ├── alembic/ # 数据库迁移
│ ├── scripts/ # 初始化脚本
│ └── templates/ # Excel 导入模板
├── frontend/ # Vue 3 前端
│ └── src/
│ ├── api/ # API 调用封装
│ ├── components/ # 公共组件
│ ├── composables/ # 组合式函数
│ ├── router/ # 路由
│ ├── stores/ # Pinia 状态管理
│ ├── styles/ # 主题样式
│ ├── utils/ # 工具函数
│ └── views/ # 页面组件
├── deploy/ # 部署配置
│ ├── docker-compose.yml
│ ├── nginx/
│ └── scripts/
├── docs/ # 文档
└── docker-compose.yml # 主部署文件
```
---
## 核心业务逻辑
### SSH 状态检查流程
1. 从数据库获取 OLT 设备配置
2. 建立 SSH 连接(使用连接池)
3. 执行命令:`display onu slot {slot_number}`
4. 解析返回结果:
- 包含 "Up" → 在线
- 包含 "Offline" → 离线
5. 更新设备状态到数据库
6. 记录历史状态
### 权限控制逻辑
- **超级管理员**:所有权限
- **管理员**:管理所有设备和用户
- **区域管理员**:管理指定区域的设备
- **学校管理员**:管理指定学校的设备
- **普通用户**:只读权限
### 数据导入流程
1. 上传 Excel 文件
2. 使用 Pandas 解析数据
3. 数据验证(MAC 地址格式、必填字段)
4. 批量插入数据库
5. 返回导入结果(成功/失败记录)
---
## 开发指南
### 快速启动
**Docker 部署(推荐)**
```bash
# 1. 配置环境变量
cp backend/.env.example backend/.env
# 编辑 .env 填入实际配置
# 2. 启动所有服务
docker compose up -d
# 3. 初始化数据库
docker compose exec backend python scripts/init_db.py
```
**本地开发**
```bash
# 后端
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 前端
cd frontend
npm install && npm run dev
# Celery Worker
cd backend
celery -A celery_worker.celery_app worker --loglevel=info
# Celery Beat
cd backend
celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
```
### 数据库迁移
```bash
# 创建迁移
alembic revision --autogenerate -m "描述"
# 执行迁移
alembic upgrade head
# 回滚
alembic downgrade -1
```
### 添加新功能
1. **后端 API**
-`app/api/v1/` 创建路由文件
-`app/services/` 创建服务文件
-`app/schemas/` 定义请求/响应模式
-`app/models/` 定义数据模型(如需要)
2. **前端页面**
-`views/` 创建页面组件
-`api/` 添加 API 调用
-`router/` 添加路由配置
-`stores/` 添加状态管理(如需要)
---
## 常见问题
### SSH 连接失败
- 检查网络连通性
- 验证 SSH 凭证
- 检查防火墙设置
- 查看日志:`docker-compose logs backend`
### 数据库连接失败
- 检查 `DATABASE_URL` 配置
- 验证数据库服务状态
- 检查网络权限
### Casdoor 登录失败
- 检查 Casdoor 服务状态
- 验证 `CASDOOR_*` 配置
- 检查回调地址配置
---
## 参考文档
- [FastAPI 文档](https://fastapi.tiangolo.com/)
- [Vue 3 文档](https://vuejs.org/)
- [Element Plus 文档](https://element-plus.org/)
- [Casdoor 文档](https://casdoor.org/)
- [现场ONU故障排查指南](./docs/现场ONU故障排查指南.md)
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+1
View File
@@ -0,0 +1 @@
0.10.0
+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=
+1
View File
@@ -0,0 +1 @@
0.10.0
+39 -39
View File
@@ -49,45 +49,6 @@ def get_audit_logs(
} }
@router.get("/logs/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
@router.get("/logs/export/csv") @router.get("/logs/export/csv")
def export_audit_logs( def export_audit_logs(
start_time: Optional[datetime] = Query(None), start_time: Optional[datetime] = Query(None),
@@ -130,6 +91,45 @@ def export_audit_logs(
) )
@router.get("/logs/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
def _fmt(r: AuditLog, detail: bool = False) -> dict: def _fmt(r: AuditLog, detail: bool = False) -> dict:
base = { base = {
"id": r.id, "id": r.id,
+4 -4
View File
@@ -84,12 +84,12 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
@router.get("/permissions") @router.get("/permissions")
def get_my_permissions( def get_my_permissions(
authorization: str = Header(..., alias="Authorization"), authorization: str = Header(None, alias="Authorization"),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取当前用户的权限码列表""" """获取当前用户的权限码列表"""
from app.middleware.permission_middleware import get_role_permissions from app.middleware.permission_middleware import get_role_permissions
if not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权") raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:] token = authorization[7:]
payload = verify_token(token) payload = verify_token(token)
@@ -102,11 +102,11 @@ def get_my_permissions(
@router.get("/profile") @router.get("/profile")
def get_profile( def get_profile(
authorization: str = Header(..., alias="Authorization"), authorization: str = Header(None, alias="Authorization"),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""获取当前用户信息""" """获取当前用户信息"""
if not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权") raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:] token = authorization[7:]
payload = verify_token(token) payload = verify_token(token)
+50 -53
View File
@@ -338,6 +338,55 @@ def export_replacements(
) )
@router.get("/tags")
def get_all_tags(db: Session = Depends(get_db)):
"""获取所有不重复的设备标签"""
from sqlalchemy import func as _func
rows = db.query(ONUDevice.tags).filter(
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
).all()
tags = set()
for (tag_str,) in rows:
for t in tag_str.split(','):
t = t.strip()
if t:
tags.add(t)
return sorted(tags)
@router.get("/export/csv")
def export_devices_csv(
region: Optional[str] = Query(None),
school_name: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""导出设备列表为 CSV"""
from sqlalchemy import func as _func
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name == school_name)
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
for d in devices:
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
d.loid or "", d.distance_m or "", d.notes or ""])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
)
@router.get("/{device_id}", response_model=ONUDeviceResponse) @router.get("/{device_id}", response_model=ONUDeviceResponse)
def get_device( def get_device(
device_id: int, device_id: int,
@@ -668,9 +717,8 @@ def get_onu_events(
raise HTTPException(status_code=404, detail="关联的 OLT 不存在") raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
from app.services.ssh_service import SSHService from app.services.ssh_service import SSHService
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try: try:
ssh.connect() with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
events = ssh.get_onu_events(device.port_id) events = ssh.get_onu_events(device.port_id)
return { return {
"interface": f"Onu{device.port_id}", "interface": f"Onu{device.port_id}",
@@ -679,8 +727,6 @@ def get_onu_events(
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
finally:
ssh.close()
@router.get("/{device_id}/optical-power-history") @router.get("/{device_id}/optical-power-history")
@@ -704,52 +750,3 @@ def get_optical_power_history(
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None} "recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
for r in rows for r in rows
] ]
@router.get("/tags")
def get_all_tags(db: Session = Depends(get_db)):
"""获取所有不重复的设备标签"""
from sqlalchemy import func as _func
rows = db.query(ONUDevice.tags).filter(
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
).all()
tags = set()
for (tag_str,) in rows:
for t in tag_str.split(','):
t = t.strip()
if t:
tags.add(t)
return sorted(tags)
@router.get("/export/csv")
def export_devices_csv(
region: Optional[str] = Query(None),
school_name: Optional[str] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""导出设备列表为 CSV"""
from sqlalchemy import func as _func
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name == school_name)
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
for d in devices:
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
d.loid or "", d.distance_m or "", d.notes or ""])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
)
+9 -22
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
@@ -262,14 +263,11 @@ def clear_onu_port(
if body.port_id not in port_ids: if body.port_id not in port_ids:
raise HTTPException(status_code=400, detail="端口不在重复记录中") raise HTTPException(status_code=400, detail="端口不在重复记录中")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try: try:
ssh.connect() with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
ssh.clear_onu_port(body.port_id) ssh.clear_onu_port(body.port_id)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}") raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
finally:
ssh.close()
# 从 ports 列表移除已清除的端口 # 从 ports 列表移除已清除的端口
remaining = [p for p in record.ports if p["port_id"] != body.port_id] remaining = [p for p in record.ports if p["port_id"] != body.port_id]
@@ -450,9 +448,8 @@ def loopback_detection(
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id} onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
def check_one(olt): def check_one(olt):
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try: try:
ssh.connect() with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
detection = ssh.detect_loopback() detection = ssh.detect_loopback()
except Exception as e: except Exception as e:
return { return {
@@ -463,8 +460,6 @@ def loopback_detection(
"has_loop": False, "has_loop": False,
"loop_interfaces": [], "loop_interfaces": [],
} }
finally:
ssh.close()
loop_interfaces = [] loop_interfaces = []
for iface in detection.get("interfaces", []): for iface in detection.get("interfaces", []):
@@ -487,6 +482,7 @@ def loopback_detection(
"has_loop": detection["has_loop"], "has_loop": detection["has_loop"],
"loop_interfaces": loop_interfaces, "loop_interfaces": loop_interfaces,
"error": None, "error": None,
"raw": detection.get("raw", ""),
} }
results_map = {} results_map = {}
@@ -501,8 +497,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")
@@ -521,9 +517,8 @@ def sync_ntp(
olts = [o for o in olts if o.region in areas] if areas else [] olts = [o for o in olts if o.region in areas] if areas else []
def sync_one(olt): def sync_one(olt):
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try: try:
ssh.connect() with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
ssh.sync_ntp(body.old_server, body.new_server) ssh.sync_ntp(body.old_server, body.new_server)
return { return {
"olt_ip": olt.ip_address, "olt_ip": olt.ip_address,
@@ -538,8 +533,6 @@ def sync_ntp(
"success": False, "success": False,
"error": str(e), "error": str(e),
} }
finally:
ssh.close()
results_map = {} results_map = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor: with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
@@ -573,15 +566,12 @@ def get_olt_ports(
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first() olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt: if not olt:
raise HTTPException(status_code=404, detail="OLT 不存在") raise HTTPException(status_code=404, detail="OLT 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try: try:
ssh.connect() with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
ports = ssh.get_olt_ports() ports = ssh.get_olt_ports()
return {"ports": ports} return {"ports": ports}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()
@router.post("/devices/{olt_id}/ports/toggle") @router.post("/devices/{olt_id}/ports/toggle")
@@ -593,13 +583,10 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first() olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt: if not olt:
raise HTTPException(status_code=404, detail="OLT 不存在") raise HTTPException(status_code=404, detail="OLT 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try: try:
ssh.connect() with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
ssh.toggle_olt_port(port_name, body.action) ssh.toggle_olt_port(port_name, body.action)
return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"} return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()
+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()
+32 -11
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=["*"],
@@ -76,9 +81,19 @@ async def startup():
asyncio.create_task(ws._redis_listener()) asyncio.create_task(ws._redis_listener())
def _read_version() -> str:
"""读取项目版本号"""
version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")]
for p in version_paths:
if os.path.exists(p):
with open(p) as f:
return f.read().strip()
return "0.0.0"
@app.get("/health") @app.get("/health")
def health_check(): def health_check():
status = {"status": "ok", "db": "ok", "redis": "ok"} status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()}
try: try:
import redis import redis
import psycopg2 import psycopg2
@@ -97,3 +112,9 @@ def health_check():
status["db"] = "error" status["db"] = "error"
status["status"] = "degraded" status["status"] = "degraded"
return status return status
@app.get("/api/health")
def api_health_check():
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
return health_check()
@@ -1,8 +1,11 @@
"""权限检查中间件(数据库驱动 + Redis 缓存)""" """权限检查中间件(数据库驱动 + Redis 缓存)"""
import json import json
import logging
from fastapi import HTTPException, Depends, Header from fastapi import HTTPException, Depends, Header
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text from sqlalchemy import text
logger = logging.getLogger(__name__)
import redis import redis
from app.core.database import get_db from app.core.database import get_db
@@ -52,14 +55,19 @@ def invalidate_role_cache(role: str) -> None:
def require_permission(permission: str): def require_permission(permission: str):
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限""" """FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
def dependency( def dependency(
authorization: str = Header(..., alias="Authorization"), authorization: str = Header(None, alias="Authorization"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
if not authorization:
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
raise HTTPException(status_code=401, detail="未授权")
if not authorization.startswith("Bearer "): if not authorization.startswith("Bearer "):
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
raise HTTPException(status_code=401, detail="未授权") raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:] token = authorization[7:]
payload = verify_token(token) payload = verify_token(token)
if not payload: if not payload:
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
raise HTTPException(status_code=401, detail="令牌无效或已过期") raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user') role = payload.get('role', 'user')
@@ -83,11 +91,11 @@ def require_permission(permission: str):
def get_current_user( def get_current_user(
authorization: str = Header(..., alias="Authorization"), authorization: str = Header(None, alias="Authorization"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""仅验证登录状态,不检查具体权限""" """仅验证登录状态,不检查具体权限"""
if not authorization.startswith("Bearer "): if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权") raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:] token = authorization[7:]
payload = verify_token(token) payload = verify_token(token)
+10 -6
View File
@@ -103,8 +103,10 @@ class CheckService:
"online": online_count, "online": online_count,
"offline": offline_count, "offline": offline_count,
} }
finally: except Exception:
ssh.close() # 连接异常时清除缓存,下次自动重连
_conn_pool.pop(olt_id, None)
raise
def check_single_device(self, device_id: int) -> Dict: def check_single_device(self, device_id: int) -> Dict:
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。 """通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
@@ -251,8 +253,9 @@ class CheckService:
"offline": offline_count, "offline": offline_count,
"new_discovered": new_count, "new_discovered": new_count,
} }
finally: except Exception:
ssh.close() _conn_pool.pop(olt_id, None)
raise
async def scan_olt(self, olt_id: int) -> Dict: async def scan_olt(self, olt_id: int) -> Dict:
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)""" """仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
@@ -300,8 +303,9 @@ class CheckService:
"devices": devices, "devices": devices,
"duplicates": duplicates, "duplicates": duplicates,
} }
finally: except Exception:
ssh.close() _conn_pool.pop(olt_id, None)
raise
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict): def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
for mac, records in duplicate_dict.items(): for mac, records in duplicate_dict.items():
+39 -56
View File
@@ -4,8 +4,6 @@ import re
import time import time
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass from dataclasses import dataclass
from app.core.config import settings
@dataclass @dataclass
class ONUInfo: class ONUInfo:
@@ -36,7 +34,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,
@@ -51,7 +49,7 @@ class SSHService:
while time.time() < deadline: while time.time() < deadline:
if self.shell.recv_ready(): if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore') buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if ">" in buf: if re.search(r'<[^>]+>', buf):
break break
else: else:
time.sleep(0.2) time.sleep(0.2)
@@ -77,22 +75,16 @@ class SSHService:
if "---- More ----" in chunk: if "---- More ----" in chunk:
self.shell.send(" ") self.shell.send(" ")
time.sleep(0.3) time.sleep(0.3)
elif ">" in chunk: elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
# 命令提示符出现,说明输出完毕 # 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">"
break break
else: else:
time.sleep(0.2) time.sleep(0.2)
return output return output
def clear_onu_port(self, port_id: str) -> bool: def _send_and_wait(self, cmd: str, expect: str, timeout: int = 10) -> str:
"""清除指定端口的 ONU 配置(恢复默认) """发送命令并等待期望字符串出现,超时返回已收集的输出"""
流程: system-view -> interface Onu{port_id} -> default -> Y
"""
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
self.shell.send(cmd + "\n") self.shell.send(cmd + "\n")
buf = "" buf = ""
deadline = time.time() + timeout deadline = time.time() + timeout
@@ -105,13 +97,20 @@ class SSHService:
time.sleep(0.2) time.sleep(0.2)
return buf return buf
def clear_onu_port(self, port_id: str) -> bool:
"""清除指定端口的 ONU 配置(恢复默认)
流程: system-view -> interface Onu{port_id} -> default -> Y
"""
if not self.shell:
raise Exception("SSH 未连接")
# 进入系统视图 # 进入系统视图
out = send_and_wait("system-view", "]") out = self._send_and_wait("system-view", "]")
if "]" not in out: if "]" not in out:
raise Exception("进入 system-view 失败") raise Exception("进入 system-view 失败")
# 进入端口 # 进入端口
out = send_and_wait(f"interface Onu{port_id}", "]") out = self._send_and_wait(f"interface Onu{port_id}", "]")
if "]" not in out: if "]" not in out:
raise Exception(f"进入端口 Onu{port_id} 失败") raise Exception(f"进入端口 Onu{port_id} 失败")
@@ -138,8 +137,8 @@ class SSHService:
self.shell.recv(4096) self.shell.recv(4096)
# 退出到用户视图 # 退出到用户视图
send_and_wait("quit", "]", timeout=5) self._send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5) self._send_and_wait("quit", ">", timeout=5)
return True return True
@@ -150,7 +149,7 @@ class SSHService:
interfaces = [] interfaces = []
if has_loop: if has_loop:
for line in output.splitlines(): for line in output.splitlines():
m = re.match(r'\s+(Onu\S+)\s+', line) m = re.match(r'\s+(Onu\S+)', line)
if m: if m:
interfaces.append(m.group(1)) interfaces.append(m.group(1))
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output} return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
@@ -319,33 +318,20 @@ class SSHService:
if not self.shell: if not self.shell:
raise Exception("SSH 未连接") raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str: out = self._send_and_wait("system-view", "]")
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
out = send_and_wait("system-view", "]")
if "]" not in out: if "]" not in out:
raise Exception("进入 system-view 失败") raise Exception("进入 system-view 失败")
out = send_and_wait(f"interface {port_name}", "]") out = self._send_and_wait(f"interface {port_name}", "]")
if "]" not in out: if "]" not in out:
raise Exception(f"进入端口 {port_name} 失败") raise Exception(f"进入端口 {port_name} 失败")
out = send_and_wait(action, "]") out = self._send_and_wait(action, "]")
if "]" not in out: if "]" not in out:
raise Exception(f"执行 {action} 失败") raise Exception(f"执行 {action} 失败")
send_and_wait("quit", "]", timeout=5) self._send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5) self._send_and_wait("quit", ">", timeout=5)
return True return True
def sync_ntp(self, old_server: str, new_server: str) -> bool: def sync_ntp(self, old_server: str, new_server: str) -> bool:
@@ -356,41 +342,28 @@ class SSHService:
if not self.shell: if not self.shell:
raise Exception("SSH 未连接") raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str: out = self._send_and_wait("system-view", "]")
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
out = send_and_wait("system-view", "]")
if "]" not in out: if "]" not in out:
raise Exception("进入 system-view 失败") raise Exception("进入 system-view 失败")
# 删除旧 NTP 服务器(若不存在会报错,忽略即可) # 删除旧 NTP 服务器(若不存在会报错,忽略即可)
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]") self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
# 添加新 NTP 服务器 # 添加新 NTP 服务器
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]") out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
if "]" not in out: if "]" not in out:
raise Exception(f"配置 NTP 服务器 {new_server} 失败") raise Exception(f"配置 NTP 服务器 {new_server} 失败")
# 设置时区为北京时间 # 设置时区为北京时间
out = send_and_wait("clock timezone Beijing add 08:00:00", "]") out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
if "]" not in out: if "]" not in out:
raise Exception("配置时区失败") raise Exception("配置时区失败")
# 退出系统视图 # 退出系统视图
send_and_wait("quit", ">") self._send_and_wait("quit", ">")
# 强制保存配置 # 强制保存配置
send_and_wait("save force", ">", timeout=30) self._send_and_wait("save force", ">", timeout=30)
return True return True
@@ -427,6 +400,16 @@ class SSHService:
}) })
return events return events
def __enter__(self):
"""上下文管理器入口,自动连接"""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口,自动关闭连接"""
self.close()
return False
def close(self): def close(self):
"""关闭 SSH 连接""" """关闭 SSH 连接"""
if self.client: if self.client:
+69
View File
@@ -1,4 +1,5 @@
"""SSH 输出解析测试""" """SSH 输出解析测试"""
import re
import pytest import pytest
from app.services.ssh_service import SSHService from app.services.ssh_service import SSHService
@@ -91,3 +92,71 @@ class TestCleanOutput:
cleaned = svc._clean_output(output) cleaned = svc._clean_output(output)
assert "1484-778f-aa60" in cleaned assert "1484-778f-aa60" in cleaned
assert "---- More ----" not in cleaned assert "---- More ----" not in cleaned
class TestDetectLoopback:
"""环路检测输出解析测试"""
def _parse(self, output: str):
"""模拟 detect_loopback 中的解析逻辑"""
has_loop = "Loop is detected on following interfaces" in output
interfaces = []
if has_loop:
for line in output.splitlines():
m = re.match(r'\s+(Onu\S+)', line)
if m:
interfaces.append(m.group(1))
return has_loop, interfaces
def test_no_loop(self):
output = """
Loopback detection is enabled.
Loopback detection interval is 30 second(s).
No loopback is detected.
"""
has_loop, interfaces = self._parse(output)
assert not has_loop
assert interfaces == []
def test_has_loop_single(self):
output = """
Loopback detection is enabled.
Loopback detection interval is 30 second(s).
Loop is detected on following interfaces:
Onu1/0/1:1
"""
has_loop, interfaces = self._parse(output)
assert has_loop
assert interfaces == ["Onu1/0/1:1"]
def test_has_loop_multiple(self):
output = """
Loop is detected on following interfaces:
Onu1/0/1:1
Onu1/0/2:3
Onu2/0/5:10
"""
has_loop, interfaces = self._parse(output)
assert has_loop
assert interfaces == ["Onu1/0/1:1", "Onu1/0/2:3", "Onu2/0/5:10"]
def test_has_loop_with_extra_whitespace(self):
"""接口行有多余空白字符"""
output = """
Loop is detected on following interfaces:
Onu1/0/1:1
"""
has_loop, interfaces = self._parse(output)
assert has_loop
assert interfaces == ["Onu1/0/1:1"]
def test_no_false_positive_on_prompt(self):
"""确保设备提示符不被误识别为接口"""
output = """
Loop is detected on following interfaces:
Onu1/0/1:1
<H3C_Device>
"""
has_loop, interfaces = self._parse(output)
assert has_loop
assert interfaces == ["Onu1/0/1:1"]
+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;
}
}
+3 -21
View File
@@ -17,7 +17,7 @@ services:
celery-worker: celery-worker:
build: ./backend build: ./backend
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms command: celery -A celery_worker.celery_app worker -B --loglevel=info -Q h3c_onu_ms --schedule=/tmp/celerybeat-schedule
env_file: env_file:
- ./backend/.env - ./backend/.env
volumes: volumes:
@@ -27,23 +27,5 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
celery-beat: # frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
build: ./backend # 部署命令见 .claude/rules/07-remote-operations.md
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
env_file:
- ./backend/.env
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "18002:5173"
environment:
- VITE_API_PROXY_TARGET=http://backend:8000
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
+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;
}
}
+64 -1
View File
@@ -74,6 +74,13 @@
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div v-if="authStore.user" class="sidebar-user">
<div class="sidebar-user-avatar">{{ (authStore.user.display_name || authStore.user.username || '?')[0] }}</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">{{ authStore.user.display_name || authStore.user.username }}</div>
<div class="sidebar-user-role">{{ roleLabel }}</div>
</div>
</div>
<button class="logout-btn" @click="handleLogout"> <button class="logout-btn" @click="handleLogout">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
@@ -116,7 +123,7 @@
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/> <polyline points="12 6 12 12 16 14"/>
</svg> </svg>
v0.5.0 v{{ appVersion }}
</div> </div>
</div> </div>
</header> </header>
@@ -199,6 +206,7 @@ import { useMobile } from '../composables/useMobile'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const appVersion = ref('0.0.0')
const themeStore = useThemeStore() const themeStore = useThemeStore()
const { isMobile } = useMobile() const { isMobile } = useMobile()
@@ -218,6 +226,14 @@ const onKeydown = (e) => {
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) } if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
} }
const fetchVersion = async () => {
try {
const res = await fetch('/api/health')
const data = await res.json()
if (data.version) appVersion.value = data.version
} catch { /* 静默 */ }
}
onMounted(async () => { onMounted(async () => {
updateTime() updateTime()
timer = setInterval(updateTime, 1000) timer = setInterval(updateTime, 1000)
@@ -225,6 +241,7 @@ onMounted(async () => {
if (authStore.token && !authStore.user) { if (authStore.token && !authStore.user) {
await authStore.fetchProfile() await authStore.fetchProfile()
} }
fetchVersion()
}) })
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) }) onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
@@ -351,6 +368,9 @@ const pageNameMap = {
} }
const currentPageName = computed(() => pageNameMap[route.path] || '页面') const currentPageName = computed(() => pageNameMap[route.path] || '页面')
const roleLabels = { admin: '超级管理员', area_admin: '区域管理员', school_admin: '学校管理员', user: '普通用户' }
const roleLabel = computed(() => roleLabels[authStore.role] || authStore.role)
const handleLogout = () => { const handleLogout = () => {
authStore.logout() authStore.logout()
router.push('/login') router.push('/login')
@@ -510,6 +530,49 @@ const handleLogout = () => {
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
} }
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: var(--radius-md);
background: var(--bg-subtle);
}
.sidebar-user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.sidebar-user-info {
overflow: hidden;
}
.sidebar-user-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-user-role {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
.logout-btn { .logout-btn {
width: 100%; width: 100%;
display: flex; display: flex;
+18 -1
View File
@@ -1,17 +1,34 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { isAfterSunset, getMsUntilNextSwitch } from '../utils/sunset'
export const useThemeStore = defineStore('theme', () => { export const useThemeStore = defineStore('theme', () => {
const STORAGE_KEY = 'onu-theme' const STORAGE_KEY = 'onu-theme'
const theme = ref(localStorage.getItem(STORAGE_KEY) || 'dark') const savedTheme = localStorage.getItem(STORAGE_KEY)
// 无手动偏好时,根据广西日落时间自动选择
const theme = ref(savedTheme || (isAfterSunset() ? 'dark' : 'light'))
const applyTheme = (t) => { const applyTheme = (t) => {
document.documentElement.setAttribute('data-theme', t) document.documentElement.setAttribute('data-theme', t)
} }
let switchTimer = null
// 初始化时立即应用 // 初始化时立即应用
applyTheme(theme.value) applyTheme(theme.value)
// 设置日落/日出自动切换定时器,仅在用户未手动选择时生效
const scheduleAutoSwitch = () => {
if (switchTimer) clearTimeout(switchTimer)
// 始终在日落/日出时自动切换
const delay = getMsUntilNextSwitch()
switchTimer = setTimeout(() => {
theme.value = isAfterSunset() ? 'dark' : 'light'
scheduleAutoSwitch() // 递归调度下一次
}, delay + 60000) // 加 1 分钟余量
}
scheduleAutoSwitch()
// 切换 // 切换
const toggle = () => { const toggle = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark' theme.value = theme.value === 'dark' ? 'light' : 'dark'
+76
View File
@@ -0,0 +1,76 @@
/**
* 广西(南宁)日落时间计算
* 纬度 22.82°N, 经度 108.37°E, 时区 UTC+8
*/
const LAT = 22.82 // 南宁纬度
const LON = 108.37 // 南宁经度
function toRad(deg) { return deg * Math.PI / 180 }
function toDeg(rad) { return rad * 180 / Math.PI }
/**
* 计算指定日期的日落时间(北京时间)
* @param {Date} date
* @returns {{ hour: number, minute: number }} 日落时分
*/
export function getSunsetTime(date = new Date()) {
const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000)
// 太阳赤纬 (solar declination)
const declination = 23.45 * Math.sin(toRad(360 / 365 * (284 + dayOfYear)))
// 日落时角 cos(ω) = -tan(lat)*tan(δ)
const cosOmega = -Math.tan(toRad(LAT)) * Math.tan(toRad(declination))
const omega = Math.acos(Math.max(-1, Math.min(1, cosOmega))) // 弧度
// 日落地方太阳时(小时)
const solarHour = 12 + toDeg(omega) / 15
// 修正:时区经度(120°E)与本地经度差
const correction = (120 - LON) / 15 * 60 // 分钟
const totalMinutes = solarHour * 60 + correction
const hour = Math.floor(totalMinutes / 60) % 24
const minute = Math.round(totalMinutes % 60)
return { hour, minute }
}
/**
* 判断当前是否在日落之后(应使用深色模式)
*/
export function isAfterSunset() {
const now = new Date()
const sunset = getSunsetTime(now)
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const sunsetMinutes = sunset.hour * 60 + sunset.minute
// 日出约为 12 - (sunset - 12) = 24 - sunset(粗略估算)
const sunriseMinutes = (24 * 60 - sunsetMinutes) % (24 * 60)
// 深色时间:日落之后 到 日出之前
return currentMinutes >= sunsetMinutes || currentMinutes < sunriseMinutes
}
/**
* 获取距离下次切换的毫秒数
* 用于设置定时器在日落/日出时自动切换
*/
export function getMsUntilNextSwitch() {
const now = new Date()
const sunset = getSunsetTime(now)
const sunsetMin = sunset.hour * 60 + sunset.minute
const sunriseMin = (24 * 60 - sunsetMin) % (24 * 60)
const currentMin = now.getHours() * 60 + now.getMinutes()
let targetMin
if (currentMin >= sunsetMin || currentMin < sunriseMin) {
// 当前是深色时间,下次切换是日出
targetMin = sunriseMin
} else {
// 当前是浅色时间,下次切换是日落
targetMin = sunsetMin
}
const diffMin = (targetMin - currentMin + 24 * 60) % (24 * 60)
return diffMin * 60 * 1000
}
+1 -1
View File
@@ -83,7 +83,7 @@
:total="total" :total="total"
:page-sizes="[20, 50, 100]" :page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
small size="small"
@change="fetchLogs" @change="fetchLogs"
/> />
</div> </div>
+2 -2
View File
@@ -938,7 +938,7 @@ const dismissNewDevice = async (id) => {
const runQuickScan = async () => { const runQuickScan = async () => {
quickScanning.value = true quickScanning.value = true
try { try {
const { data } = await request.post('/olt/quick-scan') const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
quickScanResult.value = data quickScanResult.value = data
quickScanVisible.value = true quickScanVisible.value = true
} catch (error) { } catch (error) {
@@ -951,7 +951,7 @@ const runQuickScan = async () => {
const runLoopbackDetection = async () => { const runLoopbackDetection = async () => {
loopDetecting.value = true loopDetecting.value = true
try { try {
const { data } = await request.post('/olt/loopback-detection') const { data } = await request.post('/olt/loopback-detection', null, { timeout: 120000 })
loopResults.value = data loopResults.value = data
const map = {} const map = {}
for (const item of data) { for (const item of data) {
+2 -2
View File
@@ -124,7 +124,7 @@
:total="repTotal" :total="repTotal"
:page-sizes="[50, 100, 200]" :page-sizes="[50, 100, 200]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
small size="small"
@size-change="repLoad" @size-change="repLoad"
@current-change="repLoad" @current-change="repLoad"
/> />
@@ -203,7 +203,7 @@
:total="auditTotal" :total="auditTotal"
:page-sizes="[20, 50, 100]" :page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
small size="small"
@change="fetchAudit" @change="fetchAudit"
/> />
</div> </div>
-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行) |