Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec1d9860ff | |||
| 4488e0ef42 | |||
| e87e3ebe4b | |||
| 8dc66d7f88 | |||
| a9551d87f5 | |||
| 0d3fa8dd51 | |||
| e5d6d843c3 | |||
| fcfa5af614 |
@@ -44,3 +44,10 @@ logs/
|
||||
# Claude Code
|
||||
.claude/
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
|
||||
# Deployment docs (contain credentials)
|
||||
*部署交接文档.md
|
||||
|
||||
# Reasonix
|
||||
.reasonix/
|
||||
|
||||
@@ -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` - 远程重启 ONU(IMC)
|
||||
- `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 API(setup 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-worker,worker 不会注册新任务,消息会被丢弃并报 `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.
|
||||
+18
-2
@@ -16,7 +16,7 @@ CASDOOR_CLIENT_SECRET=your_client_secret
|
||||
CASDOOR_ORG_NAME=your_org
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
|
||||
CASDOOR_REDIRECT_URL=
|
||||
|
||||
# SSH配置
|
||||
SSH_TIMEOUT=30
|
||||
@@ -25,6 +25,22 @@ SSH_TIMEOUT=30
|
||||
CHECK_INTERVAL=1800
|
||||
MANUAL_COOLDOWN=300
|
||||
|
||||
# CORS & 前端
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:18002
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# NTP 同步
|
||||
NTP_OLD_SERVER=172.16.0.254
|
||||
NTP_NEW_SERVER=172.16.1.252
|
||||
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL=
|
||||
IMC_API_USERNAME=
|
||||
IMC_API_PASSWORD=
|
||||
IMC_API_VERIFY_SSL=false
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
|
||||
# 企业微信告警配置
|
||||
WECHAT_CORPID=
|
||||
WECHAT_CORPSECRET=
|
||||
@@ -32,4 +48,4 @@ WECHAT_AGENTID=
|
||||
WECHAT_TOKEN=
|
||||
WECHAT_ENCODING_AES_KEY=
|
||||
WECHAT_USE_PROXY=True
|
||||
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
||||
WECHAT_PROXY_API_URL=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.10.0
|
||||
+39
-39
@@ -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")
|
||||
def export_audit_logs(
|
||||
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:
|
||||
base = {
|
||||
"id": r.id,
|
||||
|
||||
@@ -84,12 +84,12 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/permissions")
|
||||
def get_my_permissions(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户的权限码列表"""
|
||||
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="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
@@ -102,11 +102,11 @@ def get_my_permissions(
|
||||
|
||||
@router.get("/profile")
|
||||
def get_profile(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
|
||||
@@ -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)
|
||||
def get_device(
|
||||
device_id: int,
|
||||
@@ -668,9 +717,8 @@ def get_onu_events(
|
||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||
|
||||
from app.services.ssh_service import SSHService
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
events = ssh.get_onu_events(device.port_id)
|
||||
return {
|
||||
"interface": f"Onu{device.port_id}",
|
||||
@@ -679,8 +727,6 @@ def get_onu_events(
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@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}
|
||||
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"}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import distinct
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.config import settings
|
||||
from app.middleware.permission_middleware import require_permission
|
||||
from app.models.device import OLTDevice
|
||||
import pandas as pd
|
||||
@@ -262,14 +263,11 @@ def clear_onu_port(
|
||||
if body.port_id not in port_ids:
|
||||
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.clear_onu_port(body.port_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
# 从 ports 列表移除已清除的端口
|
||||
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}
|
||||
|
||||
def check_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
detection = ssh.detect_loopback()
|
||||
except Exception as e:
|
||||
return {
|
||||
@@ -463,8 +460,6 @@ def loopback_detection(
|
||||
"has_loop": False,
|
||||
"loop_interfaces": [],
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
loop_interfaces = []
|
||||
for iface in detection.get("interfaces", []):
|
||||
@@ -487,6 +482,7 @@ def loopback_detection(
|
||||
"has_loop": detection["has_loop"],
|
||||
"loop_interfaces": loop_interfaces,
|
||||
"error": None,
|
||||
"raw": detection.get("raw", ""),
|
||||
}
|
||||
|
||||
results_map = {}
|
||||
@@ -501,8 +497,8 @@ def loopback_detection(
|
||||
|
||||
|
||||
class SyncNTPRequest(BaseModel):
|
||||
old_server: str = "172.16.0.254"
|
||||
new_server: str = "172.16.1.252"
|
||||
old_server: str = settings.NTP_OLD_SERVER
|
||||
new_server: str = settings.NTP_NEW_SERVER
|
||||
|
||||
|
||||
@router.post("/sync-ntp")
|
||||
@@ -521,9 +517,8 @@ def sync_ntp(
|
||||
olts = [o for o in olts if o.region in areas] if areas else []
|
||||
|
||||
def sync_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.sync_ntp(body.old_server, body.new_server)
|
||||
return {
|
||||
"olt_ip": olt.ip_address,
|
||||
@@ -538,8 +533,6 @@ def sync_ntp(
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
results_map = {}
|
||||
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()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ports = ssh.get_olt_ports()
|
||||
return {"ports": ports}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@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()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.toggle_olt_port(port_name, body.action)
|
||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from app.services.wechat_service import get_wechat_service
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
||||
@@ -235,6 +236,6 @@ def _handle_help_cmd(svc, from_user: str):
|
||||
"• 发送「全离线」查看全离线学校\n"
|
||||
"• 发送 MAC 地址后四位查询设备\n\n"
|
||||
"💡 发送「帮助」显示此信息\n"
|
||||
"💻 完整功能: https://onu.dhdx.fun",
|
||||
f"💻 完整功能: {settings.FRONTEND_URL}",
|
||||
to_user=from_user
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/api", tags=["WebSocket"])
|
||||
|
||||
REDIS_CHANNEL = "h3c_onu:status_updates"
|
||||
_connected: set[WebSocket] = set()
|
||||
|
||||
@@ -21,12 +21,20 @@ class Settings(BaseSettings):
|
||||
CASDOOR_ORG_NAME: str
|
||||
CASDOOR_APP_NAME: str
|
||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
|
||||
CASDOOR_REDIRECT_URL: str = ""
|
||||
|
||||
SSH_TIMEOUT: int = 30
|
||||
CHECK_INTERVAL: int = 1800
|
||||
MANUAL_COOLDOWN: int = 300
|
||||
|
||||
# CORS & 前端
|
||||
CORS_ORIGINS: str = "" # 逗号分隔
|
||||
FRONTEND_URL: str = "https://onu.dhdx.fun"
|
||||
|
||||
# NTP 同步配置
|
||||
NTP_OLD_SERVER: str = "172.16.0.254"
|
||||
NTP_NEW_SERVER: str = "172.16.1.252"
|
||||
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL: str = ""
|
||||
|
||||
@@ -37,7 +45,7 @@ class Settings(BaseSettings):
|
||||
WECHAT_TOKEN: str = ""
|
||||
WECHAT_ENCODING_AES_KEY: str = ""
|
||||
WECHAT_USE_PROXY: bool = True
|
||||
WECHAT_PROXY_API_URL: str = "https://api.v6ole.top"
|
||||
WECHAT_PROXY_API_URL: str = ""
|
||||
IMC_API_USERNAME: str = ""
|
||||
IMC_API_PASSWORD: str = ""
|
||||
IMC_API_VERIFY_SSL: bool = False
|
||||
|
||||
@@ -4,7 +4,14 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=20,
|
||||
max_overflow=40,
|
||||
pool_recycle=3600,
|
||||
pool_timeout=30,
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
+32
-11
@@ -1,10 +1,10 @@
|
||||
"""FastAPI 主应用"""
|
||||
import os
|
||||
import logging
|
||||
from pythonjsonlogger import jsonlogger
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
@@ -29,15 +29,20 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
|
||||
return await call_next(request)
|
||||
|
||||
# CORS 白名单
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:18002",
|
||||
"https://onu.dhdx.fun",
|
||||
]
|
||||
allowed = [o for o in ALLOWED_ORIGINS if o]
|
||||
# CORS 白名单 — 支持通过环境变量 CORS_ORIGINS 覆盖(逗号分隔)
|
||||
CORS_ORIGINS_DEFAULT = "http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun"
|
||||
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", CORS_ORIGINS_DEFAULT).split(",") if o.strip()]
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"])
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""读取 X-Forwarded-For 首字段作为真实客户端 IP"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
limiter = Limiter(key_func=get_client_ip, default_limits=["120/minute"])
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||
app.state.limiter = limiter
|
||||
@@ -46,7 +51,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(RequestSizeLimitMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=allowed if allowed else ["*"],
|
||||
allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -76,9 +81,19 @@ async def startup():
|
||||
asyncio.create_task(ws._redis_listener())
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
"""读取项目版本号"""
|
||||
version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")]
|
||||
for p in version_paths:
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
return f.read().strip()
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
status = {"status": "ok", "db": "ok", "redis": "ok"}
|
||||
status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()}
|
||||
try:
|
||||
import redis
|
||||
import psycopg2
|
||||
@@ -97,3 +112,9 @@ def health_check():
|
||||
status["db"] = "error"
|
||||
status["status"] = "degraded"
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def api_health_check():
|
||||
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
|
||||
return health_check()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||
import json
|
||||
import logging
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import redis
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -52,14 +55,19 @@ def invalidate_role_cache(role: str) -> None:
|
||||
def require_permission(permission: str):
|
||||
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||
def dependency(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if not authorization:
|
||||
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
if not authorization.startswith("Bearer "):
|
||||
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
|
||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||
|
||||
role = payload.get('role', 'user')
|
||||
@@ -83,11 +91,11 @@ def require_permission(permission: str):
|
||||
|
||||
|
||||
def get_current_user(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""仅验证登录状态,不检查具体权限"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
|
||||
@@ -103,8 +103,10 @@ class CheckService:
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
# 连接异常时清除缓存,下次自动重连
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
def check_single_device(self, device_id: int) -> Dict:
|
||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||
@@ -251,8 +253,9 @@ class CheckService:
|
||||
"offline": offline_count,
|
||||
"new_discovered": new_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
async def scan_olt(self, olt_id: int) -> Dict:
|
||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||
@@ -300,8 +303,9 @@ class CheckService:
|
||||
"devices": devices,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||
for mac, records in duplicate_dict.items():
|
||||
|
||||
@@ -4,8 +4,6 @@ import re
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ONUInfo:
|
||||
@@ -36,7 +34,7 @@ class SSHService:
|
||||
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||
try:
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
self.client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
self.client.connect(
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
@@ -51,7 +49,7 @@ class SSHService:
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if ">" in buf:
|
||||
if re.search(r'<[^>]+>', buf):
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
@@ -77,22 +75,16 @@ class SSHService:
|
||||
if "---- More ----" in chunk:
|
||||
self.shell.send(" ")
|
||||
time.sleep(0.3)
|
||||
elif ">" in chunk:
|
||||
# 命令提示符出现,说明输出完毕
|
||||
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
|
||||
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">")
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
return output
|
||||
|
||||
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 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
def _send_and_wait(self, cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
"""发送命令并等待期望字符串出现,超时返回已收集的输出"""
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
@@ -105,13 +97,20 @@ class SSHService:
|
||||
time.sleep(0.2)
|
||||
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:
|
||||
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:
|
||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||
|
||||
@@ -138,8 +137,8 @@ class SSHService:
|
||||
self.shell.recv(4096)
|
||||
|
||||
# 退出到用户视图
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
self._send_and_wait("quit", "]", timeout=5)
|
||||
self._send_and_wait("quit", ">", timeout=5)
|
||||
|
||||
return True
|
||||
|
||||
@@ -150,7 +149,7 @@ class SSHService:
|
||||
interfaces = []
|
||||
if has_loop:
|
||||
for line in output.splitlines():
|
||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
||||
m = re.match(r'\s+(Onu\S+)', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||
@@ -319,33 +318,20 @@ class SSHService:
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
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", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
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:
|
||||
raise Exception(f"进入端口 {port_name} 失败")
|
||||
|
||||
out = send_and_wait(action, "]")
|
||||
out = self._send_and_wait(action, "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"执行 {action} 失败")
|
||||
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
self._send_and_wait("quit", "]", timeout=5)
|
||||
self._send_and_wait("quit", ">", timeout=5)
|
||||
return True
|
||||
|
||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||
@@ -356,41 +342,28 @@ class SSHService:
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
||||
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", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 删除旧 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 服务器
|
||||
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:
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -427,6 +400,16 @@ class SSHService:
|
||||
})
|
||||
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):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SSH 输出解析测试"""
|
||||
import re
|
||||
import pytest
|
||||
from app.services.ssh_service import SSHService
|
||||
|
||||
@@ -91,3 +92,71 @@ class TestCleanOutput:
|
||||
cleaned = svc._clean_output(output)
|
||||
assert "1484-778f-aa60" 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
@@ -65,6 +65,9 @@ CASDOOR_ORG_NAME=your_organization
|
||||
# 应用名称
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
|
||||
# Casdoor 回调地址(部署后改为实际域名)
|
||||
CASDOOR_REDIRECT_URL=
|
||||
|
||||
# ============================================
|
||||
# SSH连接配置
|
||||
# ============================================
|
||||
@@ -98,20 +101,35 @@ HISTORY_RETENTION=90
|
||||
BATCH_CHECK_SIZE=100
|
||||
|
||||
# ============================================
|
||||
# 前端配置
|
||||
# 前端 & CORS 配置
|
||||
# ============================================
|
||||
|
||||
# API基础URL (前端访问后端地址)
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
# 前端访问地址
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# Casdoor前端配置
|
||||
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id
|
||||
VITE_CASDOOR_ORG_NAME=your_organization
|
||||
VITE_CASDOOR_APP_NAME=h3c-onu-ms
|
||||
# CORS允许的域名 (逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=H3C ONU设备管理系统
|
||||
# ============================================
|
||||
# NTP 同步配置
|
||||
# ============================================
|
||||
|
||||
# NTP 旧服务器 IP
|
||||
NTP_OLD_SERVER=172.16.0.254
|
||||
# NTP 新服务器 IP
|
||||
NTP_NEW_SERVER=172.16.1.252
|
||||
|
||||
# ============================================
|
||||
# iMC 网管 API 配置(ONU 远程重启/光功率查询)
|
||||
# ============================================
|
||||
|
||||
IMC_API_URL=
|
||||
IMC_API_USERNAME=
|
||||
IMC_API_PASSWORD=
|
||||
# 本地认证不需要 SSL 验证
|
||||
IMC_API_VERIFY_SSL=false
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
@@ -154,9 +172,6 @@ METRICS_PORT=8000
|
||||
# 安全配置
|
||||
# ============================================
|
||||
|
||||
# CORS允许的域名 (逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
|
||||
# 速率限制配置
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
@@ -175,7 +190,7 @@ WECHAT_AGENTID=
|
||||
WECHAT_TOKEN=
|
||||
WECHAT_ENCODING_AES_KEY=
|
||||
WECHAT_USE_PROXY=True
|
||||
WECHAT_PROXY_API_URL=https://api.v6ole.top
|
||||
WECHAT_PROXY_API_URL=
|
||||
|
||||
# 备份保留天数
|
||||
BACKUP_RETENTION=30
|
||||
|
||||
+12
-11
@@ -16,15 +16,23 @@ services:
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
|
||||
- NTP_OLD_SERVER=${NTP_OLD_SERVER:-172.16.0.254}
|
||||
- NTP_NEW_SERVER=${NTP_NEW_SERVER:-172.16.1.252}
|
||||
- IMC_API_URL=${IMC_API_URL:-}
|
||||
- IMC_API_USERNAME=${IMC_API_USERNAME:-}
|
||||
- IMC_API_PASSWORD=${IMC_API_PASSWORD:-}
|
||||
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
- ../backend/static:/app/static
|
||||
@@ -96,26 +104,19 @@ services:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# 前端应用
|
||||
# 前端应用(生产模式:nginx 静态文件服务)
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
|
||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
||||
- VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT}
|
||||
- VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID}
|
||||
- VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME}
|
||||
- VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME}
|
||||
- "18062:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:5173"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name onu.dhdx.fun;
|
||||
index index.html;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
access_log /www/sites/onu.dhdx.fun/log/access.log main;
|
||||
error_log /www/sites/onu.dhdx.fun/log/error.log;
|
||||
|
||||
location ^~ /.well-known/acme-challenge {
|
||||
allow all;
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
if ($scheme = http) {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
ssl_certificate /www/sites/onu.dhdx.fun/ssl/fullchain.pem;
|
||||
ssl_certificate_key /www/sites/onu.dhdx.fun/ssl/privkey.pem;
|
||||
ssl_protocols TLSv1.3 TLSv1.2 TLSv1.1 TLSv1;
|
||||
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
error_page 497 https://$host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 前端(生产 nginx 容器,端口 18062)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18062;
|
||||
}
|
||||
|
||||
# 后端 API(frp 隧道 → 本机后端 /api/ws/dashboard WebSocket)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:18060;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
}
|
||||
+3
-21
@@ -17,7 +17,7 @@ services:
|
||||
|
||||
celery-worker:
|
||||
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:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
@@ -27,23 +27,5 @@ services:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
celery-beat:
|
||||
build: ./backend
|
||||
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
|
||||
# frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
|
||||
# 部署命令见 .claude/rules/07-remote-operations.md
|
||||
|
||||
+19
-3
@@ -1,4 +1,5 @@
|
||||
FROM node:18-alpine
|
||||
# Stage 1: Build
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,6 +8,21 @@ RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
# Build for production
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine AS serve
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built files from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,13 @@
|
||||
</nav>
|
||||
|
||||
<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">
|
||||
<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"/>
|
||||
@@ -116,7 +123,7 @@
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
v0.5.0
|
||||
v{{ appVersion }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -199,6 +206,7 @@ import { useMobile } from '../composables/useMobile'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appVersion = ref('0.0.0')
|
||||
const themeStore = useThemeStore()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -218,6 +226,14 @@ const onKeydown = (e) => {
|
||||
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 () => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
@@ -225,6 +241,7 @@ onMounted(async () => {
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchProfile()
|
||||
}
|
||||
fetchVersion()
|
||||
})
|
||||
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
||||
|
||||
@@ -351,6 +368,9 @@ const pageNameMap = {
|
||||
}
|
||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||
|
||||
const roleLabels = { admin: '超级管理员', area_admin: '区域管理员', school_admin: '学校管理员', user: '普通用户' }
|
||||
const roleLabel = computed(() => roleLabels[authStore.role] || authStore.role)
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
@@ -510,6 +530,49 @@ const handleLogout = () => {
|
||||
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 {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { isAfterSunset, getMsUntilNextSwitch } from '../utils/sunset'
|
||||
|
||||
export const useThemeStore = defineStore('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) => {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
}
|
||||
|
||||
let switchTimer = null
|
||||
|
||||
// 初始化时立即应用
|
||||
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 = () => {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -83,7 +83,7 @@
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@change="fetchLogs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -938,7 +938,7 @@ const dismissNewDevice = async (id) => {
|
||||
const runQuickScan = async () => {
|
||||
quickScanning.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/quick-scan')
|
||||
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
|
||||
quickScanResult.value = data
|
||||
quickScanVisible.value = true
|
||||
} catch (error) {
|
||||
@@ -951,7 +951,7 @@ const runQuickScan = async () => {
|
||||
const runLoopbackDetection = async () => {
|
||||
loopDetecting.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/loopback-detection')
|
||||
const { data } = await request.post('/olt/loopback-detection', null, { timeout: 120000 })
|
||||
loopResults.value = data
|
||||
const map = {}
|
||||
for (const item of data) {
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
:total="repTotal"
|
||||
:page-sizes="[50, 100, 200]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@size-change="repLoad"
|
||||
@current-change="repLoad"
|
||||
/>
|
||||
@@ -203,7 +203,7 @@
|
||||
:total="auditTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@change="fetchAudit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
# 为 H3ConuMS2 添加 ONU 远程重启 & 光功率查询功能 — 后端开发指南
|
||||
|
||||
> 目标:将 H3C iMC 平台的 ONU 远程重启和光功率查询功能集成到 H3ConuMS2 项目中
|
||||
> 技术栈:FastAPI + SQLAlchemy + requests (HTTP Digest Auth)
|
||||
> 源项目参考:`/home/v6ole/pyproject/H3ConuMS`
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────┐ REST API 调用 ┌──────────────────┐
|
||||
│ H3ConuMS2 后端 │ ──────────────────────► │ H3C iMC 平台 │
|
||||
│ (FastAPI) │ ◄────────────────────── │ │
|
||||
│ IMCService │ │ /imcrs/epon/... │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**后端提供的 API 端点:**
|
||||
| 端点 | 方法 | 说明 | iMC 后端接口 |
|
||||
|------|------|------|-------------|
|
||||
| `/api/devices/{id}/reboot` | POST | 远程重启 ONU | `/imcrs/epon/onu/reboot?mac={mac}` (POST) |
|
||||
| `/api/devices/{id}/optical-power` | GET | 获取 ONU 光功率 | `/imcrs/epon/onu/onuLightWaneInfo?mac={mac}` (GET) |
|
||||
|
||||
**数据流(以重启为例):**
|
||||
1. 客户端 POST `/api/devices/{id}/reboot`
|
||||
2. 后端控制器验证设备存在 + 权限 → 调用 `IMCService.reboot_onu()`
|
||||
3. `IMCService` 构建 HTTP Digest 认证头 → POST 到 iMC REST API
|
||||
4. iMC 返回结果 → 后端解析错误码 → 返回 JSON
|
||||
|
||||
---
|
||||
|
||||
## 2. Digest 认证原理解析
|
||||
|
||||
iMC 的 REST API 使用 **HTTP Digest Access Authentication**(RFC 2617),不是普通的 Cookie/Session 登录。
|
||||
|
||||
### 认证流程
|
||||
|
||||
```
|
||||
客户端 iMC 服务器
|
||||
│ │
|
||||
│──── GET /imcrs/... (无认证) ────│
|
||||
│ │──── 401 + WWW-Authenticate header
|
||||
│ │ (包含 nonce, realm, qop)
|
||||
│ │
|
||||
│ ── 解析 WWW-Authenticate ──► │
|
||||
│ 提取 nonce 和 realm │
|
||||
│ │
|
||||
│ ── 计算 Digest 响应 ────────► │
|
||||
│ HA1 = MD5(user:realm:pass) │
|
||||
│ HA2 = MD5(method:uri) │
|
||||
│ response = MD5(HA1:nonce:nc:cnonce:qop:HA2) │
|
||||
│ │
|
||||
│──── POST /imcrs/... ──────────►│
|
||||
│ Authorization: Digest ... │
|
||||
│ │──── 200 OK (成功)
|
||||
```
|
||||
|
||||
### 核心 MD5 计算
|
||||
|
||||
```python
|
||||
cnonce = md5(str(time.time())).hexdigest()[:16]
|
||||
ha1 = md5(f"{username}:{realm}:{password}").hexdigest()
|
||||
ha2 = md5(f"{method}:{uri}").hexdigest()
|
||||
response = md5(f"{ha1}:{nonce}:{nc:08d}:{cnonce}:auth:{ha2}").hexdigest()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 需要修改/新增的文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/imc_service.py` | **新增** | iMC API 服务(Digest 认证 + 重启 + 光功率) |
|
||||
| `backend/app/services/__init__.py` | 修改 | 导出 IMCService |
|
||||
| `backend/app/schemas/device.py` | 修改 | 添加重启/光功率响应 Schema |
|
||||
| `backend/app/api/v1/devices.py` | 修改 | 添加重启和光功率 API 路由 |
|
||||
| `backend/app/core/config.py` | 修改 | 添加 iMC 配置项 |
|
||||
| `.env` 或 `backend/.env` | 修改 | 添加 iMC 环境变量 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端实现
|
||||
|
||||
### 4.1 配置项 — `backend/app/core/config.py`
|
||||
|
||||
在 `Settings` 类中添加 iMC 相关配置:
|
||||
|
||||
```python
|
||||
# ===== iMC API 配置(用于 ONU 远程重启和光功率查询)=====
|
||||
IMC_API_URL: str = "" # 例如 https://172.16.1.252:8443
|
||||
IMC_API_USERNAME: str = "" # iMC 用户名
|
||||
IMC_API_PASSWORD: str = "" # iMC 密码(明文,Digest认证需要原始密码)
|
||||
IMC_API_VERIFY_SSL: bool = False # 是否验证 SSL 证书
|
||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||
IMC_READ_TIMEOUT: float = 20.0
|
||||
```
|
||||
|
||||
### 4.2 .env 配置
|
||||
|
||||
在 `backend/.env`(或项目根目录 `.env`)中添加:
|
||||
|
||||
```env
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL=https://172.16.1.252:8443
|
||||
IMC_API_USERNAME=admin
|
||||
IMC_API_PASSWORD=Pwd@12345
|
||||
IMC_API_VERIFY_SSL=false
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
```
|
||||
|
||||
### 4.3 IMCService — `backend/app/services/imc_service.py`
|
||||
|
||||
完整代码,包含 Digest 认证 + 重启 ONU + 光功率查询三大功能:
|
||||
|
||||
```python
|
||||
"""
|
||||
iMC REST API 服务
|
||||
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
||||
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
||||
- 功能:ONU 远程重启、光功率查询
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# iMC 重启错误码映射
|
||||
REBOOT_ERROR_CODES = {
|
||||
'103': 'ONU不存在',
|
||||
'119': 'SNMP连接超时',
|
||||
'120': '业务割接失败',
|
||||
'121': 'ONU未运行',
|
||||
'122': '重启失败',
|
||||
}
|
||||
|
||||
|
||||
class IMCService:
|
||||
"""iMC REST API 服务封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||
self.username = settings.IMC_API_USERNAME
|
||||
self.password = settings.IMC_API_PASSWORD
|
||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
||||
self.session = requests.Session()
|
||||
self.realm = "iMC RESTful Web Services"
|
||||
self.connect_timeout = getattr(settings, 'IMC_CONNECT_TIMEOUT', 5)
|
||||
self.read_timeout = getattr(settings, 'IMC_READ_TIMEOUT', 20)
|
||||
# Digest 认证状态(每次重新初始化时清空,让首次请求自动获取 nonce)
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 内部:Digest 认证
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
||||
"""
|
||||
构建 HTTP Digest 认证头
|
||||
|
||||
首次调用时会自动发一个请求获取 nonce(服务器返回 401 + WWW-Authenticate),
|
||||
后续复用 nonce 并递增 nc 值。
|
||||
nonce 过期时调用方捕获 401 后清空 self.nonce,下次自动重新握手。
|
||||
"""
|
||||
if not self.nonce:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
verify=self.verify_ssl,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers:
|
||||
auth_header = resp.headers['WWW-Authenticate']
|
||||
auth_parts = {}
|
||||
for part in auth_header.split(','):
|
||||
if '=' in part:
|
||||
key, value = part.split('=', 1)
|
||||
auth_parts[key.strip()] = value.strip(' "')
|
||||
self.nonce = auth_parts.get('nonce', '')
|
||||
self.realm = auth_parts.get('realm', self.realm)
|
||||
logger.info(f"获取 nonce 成功: {self.nonce}")
|
||||
else:
|
||||
logger.error(f"获取 nonce 失败, 状态码: {resp.status_code}")
|
||||
return None
|
||||
except requests.Timeout:
|
||||
logger.error("获取 nonce 超时")
|
||||
raise TimeoutError("iMC 认证超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取 nonce 异常: {e}")
|
||||
return None
|
||||
|
||||
# 计算 Digest 响应
|
||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
||||
ha1 = hashlib.md5(
|
||||
f"{self.username}:{self.realm}:{self.password}".encode()
|
||||
).hexdigest()
|
||||
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
|
||||
response_hash = hashlib.md5(
|
||||
f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode()
|
||||
).hexdigest()
|
||||
|
||||
auth_value = (
|
||||
f'Digest username="{self.username}", '
|
||||
f'realm="{self.realm}", '
|
||||
f'nonce="{self.nonce}", '
|
||||
f'uri="{uri}", '
|
||||
f'response="{response_hash}", '
|
||||
f'qop=auth, '
|
||||
f'nc={self.nc:08d}, '
|
||||
f'cnonce="{cnonce}"'
|
||||
)
|
||||
self.nc += 1
|
||||
return auth_value
|
||||
|
||||
def _clear_auth(self):
|
||||
"""清除认证状态(nonce 过期时调用)"""
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 公共:重启 ONU
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def reboot_onu(self, mac: str) -> dict:
|
||||
"""
|
||||
远程重启 ONU 设备
|
||||
|
||||
Args:
|
||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
||||
|
||||
Returns:
|
||||
{"success": True, "message": "设备正在重启,请稍后..."}
|
||||
或 {"success": False, "message": "重启失败: ..."}
|
||||
"""
|
||||
max_retries = 1
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/reboot?mac={mac}"
|
||||
auth = self._get_digest_auth_header("POST", uri)
|
||||
if not auth:
|
||||
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
||||
|
||||
headers = {
|
||||
"Accept": "application/xml",
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Length": "0",
|
||||
"Authorization": auth,
|
||||
}
|
||||
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}{uri}",
|
||||
headers=headers,
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
# 检查 XML 响应中是否有错误码
|
||||
if "<errorCode>" in resp.text:
|
||||
m = re.search(r"<errorCode>(\d+)</errorCode>", resp.text)
|
||||
if m:
|
||||
code = m.group(1)
|
||||
msg = REBOOT_ERROR_CODES.get(
|
||||
code, f"未知错误(代码: {code})"
|
||||
)
|
||||
return {"success": False, "message": f"重启失败: {msg}"}
|
||||
return {"success": True, "message": "设备正在重启,请稍后..."}
|
||||
|
||||
elif resp.status_code == 401:
|
||||
# nonce 过期,清空后重试
|
||||
self._clear_auth()
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"重启请求失败(HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
||||
except Exception as e:
|
||||
logger.error(f"重启异常: {e}")
|
||||
if retry < max_retries:
|
||||
time.sleep(3)
|
||||
continue
|
||||
return {"success": False, "message": f"重启异常: {e}"}
|
||||
|
||||
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 公共:获取光功率
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_optical_power(self, mac: str) -> dict | None:
|
||||
"""
|
||||
获取 ONU 设备光功率信息
|
||||
|
||||
接口: /imcrs/epon/onu/onuLightWaneInfo?mac={mac}
|
||||
响应 JSON 字段:powerIn(接收光功率), powerOut(发送光功率),
|
||||
bindMac, devId, eponDevName, oltIfName, onuIfDesc
|
||||
|
||||
Args:
|
||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"powerIn": "-18.5", # dBm,接收光功率
|
||||
"powerOut": "2.3", # dBm,发送光功率
|
||||
"bindMac": "...",
|
||||
"devId": ...,
|
||||
"eponDevName": "...",
|
||||
"oltIfName": "...",
|
||||
"onuIfDesc": "..."
|
||||
}
|
||||
或 None(失败时)
|
||||
"""
|
||||
max_retries = 1
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={mac}"
|
||||
auth = self._get_digest_auth_header("GET", uri)
|
||||
if not auth:
|
||||
logger.error("生成认证头失败,无法获取光功率")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth,
|
||||
}
|
||||
|
||||
logger.info(f"获取光功率: {self.base_url}{uri}")
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
headers=headers,
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
logger.info(f"光功率API响应状态码: {resp.status_code}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
logger.info(
|
||||
f"光功率响应: {json.dumps(data, ensure_ascii=False)}"
|
||||
)
|
||||
return {
|
||||
"powerIn": data.get("powerIn"),
|
||||
"powerOut": data.get("powerOut"),
|
||||
"bindMac": data.get("bindMac"),
|
||||
"devId": data.get("devId"),
|
||||
"eponDevName": data.get("eponDevName"),
|
||||
"oltIfName": data.get("oltIfName"),
|
||||
"onuIfDesc": data.get("onuIfDesc"),
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}")
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"光功率API请求失败, 状态码: {resp.status_code}, "
|
||||
f"内容: {resp.text}"
|
||||
)
|
||||
break # 非401不重试
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("获取光功率超时")
|
||||
raise
|
||||
except requests.Timeout:
|
||||
logger.error("光功率接口请求超时")
|
||||
raise TimeoutError("iMC 光功率接口请求超时,请稍后重试")
|
||||
except Exception as e:
|
||||
logger.error(f"获取光功率异常: {e}")
|
||||
if retry < max_retries:
|
||||
time.sleep(3)
|
||||
continue
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.4 Schema — `backend/app/schemas/device.py`
|
||||
|
||||
添加重启和光功率的响应模型:
|
||||
|
||||
```python
|
||||
class RebootResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class OpticalPowerResponse(BaseModel):
|
||||
power_in: Optional[str] = None # 接收光功率 (dBm)
|
||||
power_out: Optional[str] = None # 发送光功率 (dBm)
|
||||
bind_mac: Optional[str] = None
|
||||
dev_id: Optional[int] = None
|
||||
epon_dev_name: Optional[str] = None
|
||||
olt_if_name: Optional[str] = None
|
||||
onu_if_desc: Optional[str] = None
|
||||
```
|
||||
|
||||
### 4.5 API 路由 — `backend/app/api/v1/devices.py`
|
||||
|
||||
在文件顶部导入新 Schema:
|
||||
|
||||
```python
|
||||
from app.schemas.device import (
|
||||
DeviceListResponse,
|
||||
ONUDeviceResponse,
|
||||
RebootResponse,
|
||||
OpticalPowerResponse,
|
||||
)
|
||||
```
|
||||
|
||||
在文件末尾添加两个新端点:
|
||||
|
||||
```python
|
||||
# ═══════════════════════════════════════════════
|
||||
# 重启 ONU
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/{device_id}/reboot", response_model=RebootResponse)
|
||||
def reboot_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.check')),
|
||||
):
|
||||
"""
|
||||
远程重启 ONU 设备(通过 iMC REST API)
|
||||
|
||||
权限要求:device.check
|
||||
区域/学校管理员只能操作自己范围内的设备。
|
||||
"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 数据范围权限过滤
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
assigned = current.get('assigned_area') or ''
|
||||
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
assigned = current.get('assigned_school') or ''
|
||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
service = IMCService()
|
||||
mac = device.mac_address
|
||||
result = service.reboot_onu(mac)
|
||||
return RebootResponse(**result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 获取光功率
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||
def get_device_optical_power(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""
|
||||
获取 ONU 设备光功率信息(通过 iMC REST API)
|
||||
|
||||
返回接收光功率(power_in)和发送光功率(power_out),单位 dBm。
|
||||
权限要求:device.view(只读操作)
|
||||
"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 数据范围权限过滤(同上)
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
assigned = current.get('assigned_area') or ''
|
||||
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
assigned = current.get('assigned_school') or ''
|
||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
service = IMCService()
|
||||
mac = device.mac_address
|
||||
result = service.get_optical_power(mac)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||
|
||||
# 字段名转换:下划线转驼峰前先映射
|
||||
from app.schemas.device import OpticalPowerResponse
|
||||
return OpticalPowerResponse(
|
||||
power_in=result.get("powerIn"),
|
||||
power_out=result.get("powerOut"),
|
||||
bind_mac=result.get("bindMac"),
|
||||
dev_id=result.get("devId"),
|
||||
epon_dev_name=result.get("eponDevName"),
|
||||
olt_if_name=result.get("oltIfName"),
|
||||
onu_if_desc=result.get("onuIfDesc"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||
```
|
||||
|
||||
### 4.6 注册 Service — `backend/app/services/__init__.py`
|
||||
|
||||
```python
|
||||
from .imc_service import IMCService
|
||||
|
||||
__all__ = ["IMCService"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键陷阱与注意事项
|
||||
|
||||
### ⚠️ Digest 认证的 nonce 过期问题
|
||||
|
||||
iMC 的 nonce 有有效期(通常 5-10 分钟)。过期后服务器返回 **401**。
|
||||
- 代码中 `_clear_auth()` 清空 nonce,下次请求自动重新握手
|
||||
- 重启和光功率方法都在 for 循环中捕获 401 并 `continue` 重试
|
||||
|
||||
### ⚠️ MAC 地址格式
|
||||
|
||||
iMC REST API 要求 MAC 地址格式为 **1484-7790-4840**(连字符分隔,大写十六进制)。
|
||||
如果数据库存储格式不同,需要做格式转换:
|
||||
|
||||
```python
|
||||
def normalize_mac(mac: str) -> str:
|
||||
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840"""
|
||||
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
||||
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
||||
```
|
||||
|
||||
### ⚠️ 重启接口需要 Content-Length: 0
|
||||
|
||||
即使请求体为空,也必须显式设置 `Content-Length: 0` 头,否则 iMC 会报错。
|
||||
|
||||
### ⚠️ 光功率接口返回空数据的情况
|
||||
|
||||
当 ONU 离线或光模块故障时,iMC 返回的 `powerIn` / `powerOut` 可能是 `" --"`(两个空格+两个横线)或 `None`。前端需做占位符处理。
|
||||
|
||||
### ⚠️ 重启操作较慢
|
||||
|
||||
从发起请求到设备实际重启完成约需 **30-60 秒**(取决于 SNMP 响应)。建议:
|
||||
- 前端按钮显示 loading 状态
|
||||
- 后端设置合理超时(connect=5s, read=20s)
|
||||
- 不要在短时间内对同一设备重复操作
|
||||
|
||||
### ⚠️ 并发控制
|
||||
|
||||
建议对重启操作添加简单的并发控制,避免同一设备被多次重启:
|
||||
|
||||
```python
|
||||
import threading
|
||||
|
||||
_reboot_locks = {}
|
||||
_reboot_lock = threading.Lock()
|
||||
|
||||
def reboot_onu(self, mac):
|
||||
with _reboot_lock:
|
||||
if mac not in _reboot_locks:
|
||||
_reboot_locks[mac] = threading.Lock()
|
||||
lock = _reboot_locks[mac]
|
||||
|
||||
if not lock.acquire(blocking=False):
|
||||
return {"success": False, "message": "该设备正在重启中,请稍后"}
|
||||
try:
|
||||
# ... 重启逻辑 ...
|
||||
finally:
|
||||
lock.release()
|
||||
```
|
||||
|
||||
### ⚠️ Docker 部署注意
|
||||
|
||||
- 在 `docker-compose.yml` 的 `backend` 服务中新增环境变量:
|
||||
```yaml
|
||||
environment:
|
||||
- IMC_API_URL=https://172.16.1.252:8443
|
||||
- IMC_API_USERNAME=admin
|
||||
- IMC_API_PASSWORD=Pwd@12345
|
||||
- IMC_API_VERIFY_SSL=false
|
||||
```
|
||||
- `backend` 和 `celery-worker` 容器都需要这些变量
|
||||
- 修改后必须重新构建镜像:
|
||||
```bash
|
||||
docker compose build --no-cache backend
|
||||
docker compose rm -f backend && docker compose up -d backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试验证
|
||||
|
||||
### 手动测试
|
||||
|
||||
```bash
|
||||
# 1. 重启设备
|
||||
curl -X POST "http://localhost:8000/api/devices/1/reboot" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 2. 获取光功率
|
||||
curl "http://localhost:8000/api/devices/1/optical-power" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 3. 查看后端日志
|
||||
docker compose logs backend | grep IMCService
|
||||
|
||||
# 4. 直接测试 iMC API(验证认证是否工作)
|
||||
curl -k -v "https://172.16.1.252:8443/imcrs/epon/onu/onuLightWaneInfo?mac=1484-7790-4840"
|
||||
```
|
||||
|
||||
### 测试响应示例
|
||||
|
||||
**重启成功:**
|
||||
```json
|
||||
{"success": true, "message": "设备正在重启,请稍后..."}
|
||||
```
|
||||
|
||||
**重启失败(ONU不存在):**
|
||||
```json
|
||||
{"success": false, "message": "重启失败: ONU不存在"}
|
||||
```
|
||||
|
||||
**光功率获取成功:**
|
||||
```json
|
||||
{
|
||||
"power_in": "-18.5",
|
||||
"power_out": "2.3",
|
||||
"bind_mac": "1484-7790-4840",
|
||||
"dev_id": 123,
|
||||
"epon_dev_name": "OLT-1-1",
|
||||
"olt_if_name": "1/0/2",
|
||||
"onu_if_desc": "ONU-学校A"
|
||||
}
|
||||
```
|
||||
|
||||
**光功率获取失败(设备离线):**
|
||||
```json
|
||||
{
|
||||
"power_in": null,
|
||||
"power_out": null,
|
||||
"bind_mac": null,
|
||||
"dev_id": null,
|
||||
"epon_dev_name": null,
|
||||
"olt_if_name": null,
|
||||
"onu_if_desc": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 完整调用时序图
|
||||
|
||||
```
|
||||
客户端 后端 FastAPI iMC 平台
|
||||
│ │ │
|
||||
│ POST /api/devices/1/reboot │ │
|
||||
│ ────────────────────────────► │ │
|
||||
│ │ ── 查数据库:设备存在?──► │
|
||||
│ │ ◄── 返回设备信息 ────────── │
|
||||
│ │ ── 权限检查 ───────────── │
|
||||
│ │ │
|
||||
│ │ ── GET /imcrs/epon/onu/reboot │
|
||||
│ │ (无认证,获取 nonce) │
|
||||
│ │ ────────────────────────────► │
|
||||
│ │ ◄── 401 + WWW-Authenticate ──│
|
||||
│ │ nonce=xxx, realm=... │
|
||||
│ │ │
|
||||
│ │ ── POST 同 URI + Digest ────► │
|
||||
│ │ Authorization: Digest ... │
|
||||
│ │ ◄── 200 OK (XML) ────────────│
|
||||
│ │ │
|
||||
│ ◄── {success: true, │ │
|
||||
│ message: "设备重启中"} │ │
|
||||
│ │ │
|
||||
│ ── 或 ── │ │
|
||||
│ │ │
|
||||
│ GET /api/devices/1/optical-power │
|
||||
│ ────────────────────────────► │ │
|
||||
│ │ ── 查 + 权限 (同上) ──── │
|
||||
│ │ │
|
||||
│ │ ── GET /imcrs/epon/onu/ │
|
||||
│ │ onuLightWaneInfo?mac=... │
|
||||
│ │ (+ Digest Auth) │
|
||||
│ │ ────────────────────────────► │
|
||||
│ │ ◄── 200 OK (JSON) ───────────│
|
||||
│ │ {powerIn, powerOut, ...} │
|
||||
│ │ │
|
||||
│ ◄── {power_in: "-18.5", │ │
|
||||
│ power_out: "2.3", ...} │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:源项目参考文件位置
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| IMCService 完整实现 | `/home/v6ole/pyproject/H3ConuMS/app/services/imc_service.py` |
|
||||
| 重启控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/device.py` (第1059行) |
|
||||
| 优化版控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/optimized_device.py` (第157行) |
|
||||
| iMC 配置项 | `/home/v6ole/pyproject/H3ConuMS/app/config.py` (第61-67行) |
|
||||
Reference in New Issue
Block a user