初始化

This commit is contained in:
2026-04-02 23:40:04 +08:00
commit 381ea7085d
107 changed files with 11858 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# 代码规范
## Python 后端规范
- 遵循 PEP 8 规范
- 使用 Black 进行代码格式化
- 使用 isort 进行导入排序
- 使用类型注解(Type Hints
- 异步函数使用 async/await
- 错误处理使用自定义异常类
**示例**
```python
from typing import List, Optional
from fastapi import HTTPException
async def get_devices(
skip: int = 0,
limit: int = 100
) -> List[Device]:
"""获取设备列表"""
try:
devices = await device_service.get_all(skip, limit)
return devices
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
## Vue 前端规范
- 使用 Composition APIsetup script
- 组件使用 PascalCase 命名
- 使用 TypeScript 类型检查
- 遵循 Vue 官方风格指南
- 使用 ESLint + Prettier 格式化
**示例**
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Device } from '@/types'
const devices = ref<Device[]>([])
const fetchDevices = async () => {
// 实现逻辑
}
onMounted(() => {
fetchDevices()
})
</script>
```
## Git 提交规范
使用 Conventional Commits 格式:
```
<type>(<scope>): <subject>
类型:
- feat: 新功能
- fix: 修复bug
- docs: 文档更新
- style: 代码格式
- refactor: 重构
- test: 测试
- chore: 构建/工具
示例:
feat(device): 添加设备导入功能
fix(auth): 修复登录token过期问题
```
+40
View File
@@ -0,0 +1,40 @@
# 架构规则
## 后端架构
### 分层架构
- **API 层**:仅处理请求/响应,调用 Service
- **Service 层**:业务逻辑,不直接操作数据库
- **Model 层**SQLAlchemy 模型定义
### 目录结构
```
app/
├── api/v1/ # API 路由
├── services/ # 业务逻辑
├── models/ # 数据模型
├── schemas/ # Pydantic 模式
├── core/ # 核心配置
├── tasks/ # Celery 任务
└── utils/ # 工具函数
```
### 异步任务
- 耗时操作使用 Celery
- SSH 状态检查使用异步任务
- 批量导入使用后台任务
## 前端架构
### 组件分类
- `components/common/`:通用组件(Layout、Table、Form
- `components/business/`:业务组件(Device、User
- `views/`:页面组件
### 状态管理
- 使用 Pinia stores
- 按模块划分:auth、device、user、system
### API 调用
- 统一在 `api/` 目录封装
- 使用 Axios 拦截器处理认证和错误
+44
View File
@@ -0,0 +1,44 @@
# 安全规则
## 数据安全
- SSH 密码必须加密存储(使用 Fernet 加密)
- 敏感信息通过环境变量配置
- 数据库连接使用 SSL
- 定期备份数据(90天历史记录)
**密码加密示例**
```python
from cryptography.fernet import Fernet
def encrypt_password(password: str, key: bytes) -> str:
f = Fernet(key)
return f.encrypt(password.encode()).decode()
def decrypt_password(encrypted: str, key: bytes) -> str:
f = Fernet(key)
return f.decrypt(encrypted.encode()).decode()
```
## 应用安全
- 所有 API 端点需要认证(除登录接口)
- 实现 CSRF 保护
- 输入验证使用 Pydantic
- SQL 注入防护(使用 ORM
- XSS 防护(前端转义)
- 实现速率限制
## 访问控制
- 基于 RBAC 的权限控制
- 数据级权限过滤(按区域/学校)
- 操作审计日志记录
- 设备信息变更需要审核
**权限级别**
- 超级管理员:所有权限
- 管理员:管理所有设备和用户
- 区域管理员:管理指定区域的设备
- 学校管理员:管理指定学校的设备
- 普通用户:只读权限
+41
View File
@@ -0,0 +1,41 @@
# 性能规则
## 数据库优化
- 为常用查询字段添加索引(mac_address, region, school_name
- 使用连接池(pool_size=20, max_overflow=40
- 避免 N+1 查询问题
- 定期清理历史数据(保留90天)
- 使用 select_related/joinedload 预加载关联数据
**索引示例**
```python
class ONUDevice(Base):
__tablename__ = "onu_devices"
mac_address = Column(String(17), index=True)
region = Column(String(100), index=True)
school_name = Column(String(200), index=True)
```
## 缓存策略
- Redis 缓存热点数据(设备状态)
- 缓存过期时间:30分钟
- 手动刷新有5分钟冷却限制
- 使用缓存键命名规范:`device:status:{device_id}`
## 异步处理
- SSH 状态检查使用 Celery 异步任务
- 批量导入使用后台任务
- 定时任务使用 Celery Beat(每30分钟)
- 分批处理大量设备(每批100个)
## 前端优化
- 组件懒加载
- 图片懒加载
- 虚拟滚动(大列表)
- 防抖和节流
- 资源压缩和 CDN
+48
View File
@@ -0,0 +1,48 @@
# 开发规则
## 环境配置
- 开发环境使用 `.env.development`
- 生产环境使用 `.env.production`
- 不提交 `.env` 文件到 Git
- 提供 `.env.example` 模板
**必需环境变量**
```bash
DATABASE_URL=postgresql://user:pass@host:5432/dbname
REDIS_URL=redis://host:6379/0
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
CASDOOR_CLIENT_SECRET=your_client_secret
SECRET_KEY=your-secret-key
```
## 测试要求
- 核心业务逻辑需要单元测试
- API 端点需要集成测试
- 测试覆盖率目标:≥80%
- 使用 pytest(后端)和 vitest(前端)
**测试示例**
```python
def test_device_status_check():
device = create_test_device()
result = check_device_status(device)
assert result.status in ["online", "offline"]
```
## 文档要求
- API 变更及时更新 Swagger 文档
- 复杂业务逻辑添加注释
- 重要配置添加说明
- 保持 README 和设计文档同步
## 开发流程
1. 创建功能分支:`git checkout -b feature/xxx`
2. 开发并提交代码
3. 运行测试:`pytest` / `npm run test`
4. 提交 PR 并等待审核
5. 合并到主分支
+55
View File
@@ -0,0 +1,55 @@
# 部署规则
## Docker 部署
- 使用 Docker Compose 编排
- 外部 PostgreSQL 和 Redis(不在容器内)
- 日志挂载到宿主机
- 使用 Nginx 反向代理
**服务列表**
- backend: FastAPI 应用
- celery-worker: Celery 工作进程
- celery-beat: Celery 定时任务
- frontend: Vue 静态文件(Nginx
- nginx: 反向代理
## 环境变量
**后端必需配置**
```bash
DATABASE_URL # PostgreSQL 连接
REDIS_URL # Redis 连接
CASDOOR_* # Casdoor 认证配置
SECRET_KEY # 应用密钥
SSH_TIMEOUT=30 # SSH 超时时间
```
**前端必需配置**
```bash
VITE_API_BASE_URL # 后端 API 地址
VITE_CASDOOR_* # Casdoor 前端配置
```
## 监控告警
- 健康检查端点:`GET /health`
- 性能指标端点:`GET /metrics`
- 日志级别:生产环境使用 INFO
- 日志轮转:10MB 每个文件,保留30天
## 部署流程
1. 配置环境变量(`.env` 文件)
2. 构建镜像:`docker-compose build`
3. 启动服务:`docker-compose up -d`
4. 运行数据库迁移:`docker-compose exec backend alembic upgrade head`
5. 检查服务状态:`docker-compose ps`
6. 查看日志:`docker-compose logs -f`
## 备份策略
- 数据库每日自动备份
- 备份保留30天
- 重要操作前手动备份
- 定期测试恢复流程
+37
View File
@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
*.egg-info/
dist/
build/
# Node
node_modules/
dist/
.DS_Store
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
*.swp
# Logs
logs/
*.log
# Database
*.db
*.sqlite
# Temp
/tmp/
*.tmp
+335
View File
@@ -0,0 +1,335 @@
# H3C ONU设备管理系统 - Claude开发指南
## 项目概述
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
**当前版本**: v0.5.0
**开发状态**: 核心功能已完成
**已实现功能**
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页)
- ✅ Excel 数据导入和批量管理
- ✅ 定时自动状态检查(每30分钟)+ 手动触发
- ✅ Casdoor 统一认证和 JWT 令牌管理
- ✅ 设备管理(列表、详情、筛选、分页)
- ✅ 统计仪表板(总数、在线、离线)
- ✅ 权限管理基础框架(RBAC
**技术栈**
- 后端: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}` - 获取设备详情
### 状态检查
- `POST /api/check/status` - 手动触发状态检查
### 数据导入
- `POST /api/import/upload` - 上传并导入 Excel 文件
### 统计信息
- `GET /api/stats/summary` - 获取统计摘要(总数、在线、离线)
### 系统
- `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
### 开发规则
#### 环境配置
- 开发环境使用 `.env.development`
- 生产环境使用 `.env.production`
- 不提交 `.env` 文件到 Git
- 提供 `.env.example` 模板
#### 测试要求
- 核心业务逻辑需要单元测试
- API 端点需要集成测试
- 测试覆盖率目标:≥80%
#### 文档要求
- API 变更及时更新 Swagger 文档
- 复杂业务逻辑添加注释
- 重要配置添加说明
### 部署规则
#### Docker 部署
- 使用 Docker Compose 编排
- 外部 PostgreSQL 和 Redis(不在容器内)
- 日志挂载到宿主机
- 使用 Nginx 反向代理
#### 环境变量
必需配置:
- `DATABASE_URL`PostgreSQL 连接字符串
- `REDIS_URL`Redis 连接字符串
- `CASDOOR_*`Casdoor 认证配置
- `SECRET_KEY`:应用密钥
#### 监控告警
- 健康检查端点:`/health`
- 性能指标端点:`/metrics`
- 日志级别:生产环境使用 INFO
---
## 项目结构
```
H3ConuMS2/
├── backend/ # Python 后端
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic 模式
│ │ ├── services/ # 业务逻辑
│ │ ├── tasks/ # Celery 任务
│ │ └── utils/ # 工具函数
│ ├── alembic/ # 数据库迁移
│ └── tests/ # 测试代码
├── frontend/ # Vue 前端
│ └── src/
│ ├── api/ # API 调用
│ ├── components/ # 组件
│ ├── router/ # 路由
│ ├── stores/ # 状态管理
│ ├── views/ # 页面
│ └── utils/ # 工具函数
├── deploy/ # 部署配置
│ ├── docker-compose.yml
│ └── nginx.conf
└── docs/ # 文档
```
---
## 核心业务逻辑
### SSH 状态检查流程
1. 从数据库获取 OLT 设备配置
2. 建立 SSH 连接(使用连接池)
3. 执行命令:`display onu slot {slot_number}`
4. 解析返回结果:
- 包含 "Up" → 在线
- 包含 "Offline" → 离线
5. 更新设备状态到数据库
6. 记录历史状态
### 权限控制逻辑
- **超级管理员**:所有权限
- **管理员**:管理所有设备和用户
- **区域管理员**:管理指定区域的设备
- **学校管理员**:管理指定学校的设备
- **普通用户**:只读权限
### 数据导入流程
1. 上传 Excel 文件
2. 使用 Pandas 解析数据
3. 数据验证(MAC 地址格式、必填字段)
4. 批量插入数据库
5. 返回导入结果(成功/失败记录)
---
## 开发指南
### 快速启动
**后端**
```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
```
**前端**
```bash
cd frontend
npm install
npm run dev
```
**Celery Worker**
```bash
cd backend
celery -A app.core.celery_app worker --loglevel=info
```
**Celery Beat**
```bash
cd backend
celery -A app.core.celery_app beat --loglevel=info
```
**Docker 部署**
```bash
# 配置环境变量
cp backend/.env.example backend/.env
# 启动所有服务
docker-compose up -d
# 初始化数据库
docker-compose exec backend python scripts/init_db.py
```
### 数据库迁移
```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/)
- [项目详细设计](./系统设计文档.md)
- [开发计划](./开发计划.md)
+34
View File
@@ -0,0 +1,34 @@
MAC 地址关联OLT的流程应该是这样:
1. 先远程连接上OLT设备,然后输入 `dis onu slot 1`命令,查看olt设备中已经注册的ONU设备信息。注意有“---- More ----”的时候需要键入空格查看更多信息。
```
[16OLT]dis onu slot 1
---------------------------------- Olt1/0/1 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
1484-778f-cb20 N/A N/A Onu1/0/1:1 N/A N/A Offline N/A
1484-7790-2d20 N/A N/A Onu1/0/1:2 N/A N/A Offline N/A
1484-778f-d620 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
1484-7790-6220 N/A N/A Onu1/0/1:4 N/A N/A Offline N/A
1484-778f-8e80 N/A N/A Onu1/0/1:5 N/A N/A Offline N/A
1484-778f-a420 N/A N/A Onu1/0/1:6 N/A N/A Offline N/A
---------------------------------- Olt1/0/2 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
1484-778e-b7e0 1 12384 Onu1/0/2:1 WA6520H-EGPON/A 106/ Up N/A
1484-778e-b4a0 2 12364 Onu1/0/2:2 WA6520H-EGPON/A 106/ Up N/A
1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A
1484-778e-bce0 5 12387 Onu1/0/2:5 WA6520H-EGPON/A 106/ Up N/A
1484-778e-b400 6 12388 Onu1/0/2:6 WA6520H-EGPON/A 106/ Up N/A
1484-778f-49c0 7 12364 Onu1/0/2:7 WA6520H-EGPON/A 106/ Up N/A
1484-778f-22a0 8 12384 Onu1/0/2:8 WA6520H-EGPON/A 106/ Up N/A
1484-778e-b9c0 9 12417 Onu1/0/2:9 WA6520H-EGPON/A 106/ Up N/A
1484-778f-1aa0 N/A N/A Onu1/0/2:3 N/A N/A Offline N/A
1484-778f-1dc0 N/A N/A Onu1/0/2:10 N/A N/A Offline N/A
1484-778e-f1e0 N/A N/A Onu1/0/2:11 N/A N/A Offline N/A
---------------------------------- Olt1/0/3 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
---- More ----
```
2. 将OLT返回的信息分析后返回到前端的“设备列表”,并展示给用户。比如“1484-778e-b7e0”这个MAC地址,表示的离OLT的距离是12384米,在Onu1/0/2:1端口中,它的状态是Up(在线)。只需要向前端提供这三个信息就好了。然后再将这个MAC地址关联到这台OLT下。
3. 在数据库中创建一个表,用于存储MAC地址和olt设备之间的关联关系。这个表应该包含三个字段:MAC地址、olt设备ID和olt设备端口。
1. 将设备列表页面的MAC地址变为可以点击,点击后弹出对话框,可以显示距离(12384米)、端口(Onu1/0/2:1),以及后续可能需要新增的编辑按钮、业务下发按钮等等帮我
+434
View File
@@ -0,0 +1,434 @@
# MAC关联OLT流程改进建议
## 文档信息
- **分析时间**: 2026年4月2日
- **分析对象**: `MAC关联OLT流程.md`
- **分析重点**: 与系统设计匹配度、40个OLT设备扩展性
- **目标读者**: 后端开发同事
## 一、当前流程分析
### 1.1 文档描述流程
1. 远程连接OLT设备 → 执行`dis onu slot 1`命令
2. 解析返回的ONU设备信息
3. 提取MAC地址、距离、端口、状态信息
4. 将信息展示给前端并关联到OLT
### 1.2 系统实现现状
**匹配度评分**: 70%
| 功能点 | 文档描述 | 系统实现 | 状态 |
|--------|----------|----------|------|
| SSH连接 | 远程连接OLT | `SSHService.connect()` | ✅ 已实现 |
| 命令执行 | `dis onu slot 1` | `execute_command()` | ✅ 已实现 |
| 分页处理 | "---- More ----" | 空格键继续 | ✅ 已实现 |
| MAC地址提取 | 1484-778f-cb20格式 | `parse_onu_status()` | ✅ 已实现 |
| 状态解析 | Up/Offline | 解析状态字段 | ✅ 已实现 |
| 距离提取 | 12384米 | ❌ 未实现 | ❌ 缺失 |
| 端口解析 | Onu1/0/2:1 | ❌ 未完整解析 | ⚠️ 需改进 |
| 数据关联 | 关联到OLT | `olt_id`外键 | ✅ 已实现 |
### 1.3 主要问题
1. **数据提取不完整**: 当前只解析状态,未提取距离和详细端口信息
2. **数据库字段缺失**: 缺少`distance`字段存储距离信息
3. **端口解析不足**: 需要从"Onu1/0/2:1"解析为`slot_number``port_number`
## 二、40个OLT设备扩展性挑战
### 2.1 当前方案问题(串行执行)
| 问题 | 影响程度 | 说明 |
|------|----------|------|
| 执行效率低 | 高 | 40个OLT串行检查,预计20-40分钟 |
| SSH连接开销 | 中 | 每个OLT独立连接,建立/断开开销大 |
| 网络延迟累积 | 中 | 网络不佳的OLT会阻塞整个流程 |
| 资源占用 | 中 | 可能同时维护多个SSH连接 |
| 错误传播 | 高 | 一个OLT失败可能影响后续检查 |
### 2.2 性能预估
| 方案 | 40个OLT检查时间 | 资源占用 | 实现复杂度 | 推荐度 |
|------|----------------|----------|------------|--------|
| 当前串行 | ~20-40分钟 | 低 | 低 | ⭐ |
| 并行10并发 | ~2-4分钟 | 中 | 中 | ⭐⭐⭐⭐⭐ |
| 并行20并发 | ~1-2分钟 | 高 | 高 | ⭐⭐⭐ |
## 三、改进方案建议
### 3.1 方案一:并行处理 + 连接池(推荐)
#### 3.1.1 架构设计
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Celery主任务 │───▶│ OLT检查任务分发 │───▶│ 并行执行检查 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ SSH连接池管理 │◄───│ 连接复用机制 │◄───│ 并发控制(10) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
#### 3.1.2 核心组件
1. **任务分发器**: 将40个OLT检查拆分为独立Celery任务
2. **连接池管理器**: 复用SSH连接,减少建立/断开开销
3. **并发控制器**: 限制最大并发数(建议10个)
4. **结果聚合器**: 收集各OLT检查结果,统一存储
#### 3.1.3 代码实现建议
**A. SSH连接池实现**
```python
# app/services/ssh_pool.py
import paramiko
from typing import Dict, Optional
from threading import Lock
class SSHConnectionPool:
"""SSH连接池,支持连接复用"""
def __init__(self, max_connections: int = 10):
self.pool: Dict[str, paramiko.SSHClient] = {}
self.lock = Lock()
self.max_connections = max_connections
def get_connection(self, host: str, username: str, password: str) -> paramiko.SSHClient:
"""获取或创建SSH连接"""
key = f"{host}:{username}"
with self.lock:
if key in self.pool:
client = self.pool[key]
# 检查连接是否仍然有效
try:
transport = client.get_transport()
if transport and transport.is_active():
return client
except:
pass
# 创建新连接
if len(self.pool) >= self.max_connections:
# 清理最久未使用的连接
self._cleanup_oldest()
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(hostname=host, username=username, password=password, timeout=30)
self.pool[key] = client
return client
def _cleanup_oldest(self):
"""清理最久未使用的连接"""
# 简化实现:清理第一个连接
if self.pool:
key = next(iter(self.pool))
try:
self.pool[key].close()
except:
pass
del self.pool[key]
```
**B. 增强的解析器**
```python
# app/services/onu_parser.py
import re
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class ONUInfo:
"""ONU设备完整信息"""
mac_address: str
status: str # online/offline
distance_m: Optional[int] = None # 距离(米)
slot_number: Optional[int] = None # 插槽号
port_number: Optional[int] = None # 端口号
loid: Optional[str] = None # LOID
model: Optional[str] = None # 设备型号
class ONUParser:
"""增强的ONU信息解析器"""
@staticmethod
def parse_output(output: str) -> List[ONUInfo]:
"""解析OLT命令输出"""
devices = []
lines = output.split('\n')
current_slot = None
for line in lines:
# 检测新的插槽区域
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
if slot_match:
current_slot = int(slot_match.group(1))
continue
# 跳过表头行
if 'MAC' in line and 'LOID' in line:
continue
# 解析设备行
device = ONUParser._parse_device_line(line, current_slot)
if device:
devices.append(device)
return devices
@staticmethod
def _parse_device_line(line: str, slot: Optional[int]) -> Optional[ONUInfo]:
"""解析单行设备信息"""
# 匹配MAC地址
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if not mac_match:
return None
mac = mac_match.group(1).lower()
# 提取状态
status = 'offline'
if 'Up' in line:
status = 'online'
# 提取距离
distance = None
dist_match = re.search(r'(\d+)\s*Dist\(M\)', line)
if dist_match:
distance = int(dist_match.group(1))
# 提取端口信息
slot_num, port_num = None, None
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
if port_match:
slot_num = int(port_match.group(1))
port_num = int(port_match.group(4))
# 提取LOID
loid = None
loid_match = re.search(r'\s+(\d+)\s+', line)
if loid_match:
loid = loid_match.group(1)
return ONUInfo(
mac_address=mac,
status=status,
distance_m=distance,
slot_number=slot_num or slot,
port_number=port_num,
loid=loid
)
```
**C. 并行检查任务**
```python
# app/tasks/parallel_check_tasks.py
from celery import group
from app.tasks.check_tasks import check_single_olt
from app.models.device import OLTDevice
from sqlalchemy.orm import Session
def check_all_olts_parallel(db: Session, max_concurrent: int = 10):
"""并行检查所有OLT设备"""
# 获取所有OLT设备
olts = db.query(OLTDevice).all()
if not olts:
return {"message": "没有可检查的OLT设备"}
# 创建任务组
tasks = []
for olt in olts:
tasks.append(check_single_olt.s(olt.id))
# 分批执行,控制并发数
results = []
for i in range(0, len(tasks), max_concurrent):
batch = tasks[i:i + max_concurrent]
job = group(batch)
result = job.apply_async()
results.extend(result.get()) # 等待批次完成
return {
"total_olts": len(olts),
"completed": len([r for r in results if r.get("success")]),
"failed": len([r for r in results if not r.get("success")]),
"results": results
}
```
### 3.2 方案二:数据库优化
#### 3.2.1 新增字段建议
```sql
-- 在onu_devices表中新增字段
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS distance_m INTEGER;
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS loid VARCHAR(50);
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS model VARCHAR(100);
ALTER TABLE onu_devices ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP;
-- 在olt_devices表中新增字段
ALTER TABLE olt_devices ADD COLUMN IF NOT EXISTS max_concurrent INTEGER DEFAULT 5;
ALTER TABLE olt_devices ADD COLUMN IF NOT EXISTS check_timeout INTEGER DEFAULT 60;
```
#### 3.2.2 数据模型更新
```python
# app/models/device.py - 更新ONUDevice模型
class ONUDevice(Base):
__tablename__ = "onu_devices"
# 现有字段...
distance_m = Column(Integer) # 新增:距离(米)
loid = Column(String(50)) # 新增:LOID
model = Column(String(100)) # 新增:设备型号
last_seen = Column(TIMESTAMP) # 新增:最后在线时间
# 索引优化
__table_args__ = (
Index('idx_mac_olt', 'mac_address', 'olt_id'),
Index('idx_status_checked', 'status', 'last_seen'),
)
```
### 3.3 方案三:监控与告警增强
#### 3.3.1 检查任务监控
```python
# app/services/monitor_service.py
class CheckMonitor:
"""检查任务监控服务"""
@staticmethod
def get_check_stats(db: Session):
"""获取检查统计信息"""
stats = {
"total_olts": db.query(OLTDevice).count(),
"total_onus": db.query(ONUDevice).count(),
"online_onus": db.query(ONUDevice)
.join(DeviceStatusHistory)
.filter(DeviceStatusHistory.status == 'online')
.distinct().count(),
"recent_checks": db.query(DeviceStatusHistory)
.order_by(DeviceStatusHistory.checked_at.desc())
.limit(10).all()
}
return stats
@staticmethod
def check_olt_health(olt: OLTDevice) -> Dict:
"""检查OLT健康状态"""
# 测试连接
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
start_time = time.time()
ssh.connect()
connect_time = time.time() - start_time
# 执行简单命令测试
output = ssh.execute_command("display version")
return {
"olt_id": olt.id,
"status": "healthy",
"connect_time": connect_time,
"response_time": len(output) / 1024, # KB/s
"timestamp": datetime.utcnow()
}
except Exception as e:
return {
"olt_id": olt.id,
"status": "unhealthy",
"error": str(e),
"timestamp": datetime.utcnow()
}
finally:
ssh.close()
```
## 四、实施计划建议
### 4.1 第一阶段:基础改进(1-2天)
1. 增强ONU信息解析器,提取完整字段
2. 更新数据库模型,新增必要字段
3. 修改现有检查逻辑,存储完整信息
### 4.2 第二阶段:并行化改造(3-5天)
1. 实现SSH连接池
2. 改造Celery任务为并行执行
3. 添加并发控制和错误处理
4. 测试10个OLT并发场景
### 4.3 第三阶段:优化与监控(2-3天)
1. 添加检查任务监控
2. 实现健康检查机制
3. 优化数据库查询性能
4. 添加告警通知功能
### 4.4 第四阶段:压力测试(1-2天)
1. 模拟40个OLT并发检查
2. 测试网络异常情况
3. 验证系统稳定性
4. 性能调优
## 五、风险与应对
### 5.1 技术风险
| 风险 | 可能性 | 影响 | 应对措施 |
|------|--------|------|----------|
| OLT连接超时 | 中 | 高 | 实现超时重试机制 |
| 并发连接过多 | 低 | 中 | 限制最大并发数 |
| 内存泄漏 | 低 | 高 | 添加连接池清理机制 |
| 数据库锁竞争 | 中 | 中 | 优化事务处理,使用批量操作 |
### 5.2 业务风险
| 风险 | 可能性 | 影响 | 应对措施 |
|------|--------|------|----------|
| 检查结果不一致 | 低 | 中 | 添加数据校验机制 |
| 检查时间过长 | 中 | 高 | 实现增量检查,优化查询 |
| 用户等待时间 | 中 | 中 | 提供进度查询接口 |
## 六、预期收益
### 6.1 性能提升
- **检查时间**: 从20-40分钟缩短到2-4分钟(10倍提升)
- **资源利用率**: SSH连接复用减少50%连接开销
- **系统稳定性**: 错误隔离避免单点故障影响全局
### 6.2 功能增强
- **数据完整性**: 完整记录距离、端口、LOID等信息
- **监控能力**: 实时监控检查任务状态和OLT健康度
- **扩展性**: 支持未来扩展到更多OLT设备
### 6.3 运维改善
- **故障诊断**: 详细的检查日志和错误信息
- **性能分析**: 检查任务性能统计和趋势分析
- **容量规划**: 基于实际负载的资源规划依据
## 七、后续建议
### 7.1 短期建议(1个月内)
1. 实现基础并行检查功能
2. 完成数据库字段扩展
3. 添加基础监控告警
### 7.2 中期建议(1-3个月)
1. 实现智能调度(基于OLT负载和网络状况)
2. 添加检查结果分析和报表
3. 集成自动化运维工具
### 7.3 长期建议(3-6个月)
1. 机器学习预测设备故障
2. 自动化故障恢复机制
3. 多数据中心部署支持
---
**文档维护**:
- 本文档应随系统改进同步更新
- 重大架构变更需更新本文档
- 实际实施中的调整应记录在案
**联系方式**:
- 如有疑问或建议,请通过项目沟通渠道反馈
- 实施过程中遇到问题及时记录并分享解决方案
+32
View File
@@ -0,0 +1,32 @@
<16OLT>dis onu slot 1
---------------------------------- Olt1/0/1 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A
1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A
1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
---------------------------------- Olt1/0/2 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
34dc-99c8-8320 5 <1000 Onu1/0/2:1 WA6520H-EGPON/A 106/ Up N/A
1484-778f-7b80 N/A N/A Onu1/0/2:2 N/A N/A Offline N/A
---------------------------------- Olt1/0/3 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
1484-778f-ba60 3 <1000 Onu1/0/3:1 WA6520H-EGPON/A 106/ Up N/A
1484-778f-5480 2 <1000 Onu1/0/3:2 WA6520H-EGPON/A 106/ Up N/A
1484-778f-eb80 N/A N/A Onu1/0/3:3 N/A N/A Offline N/A
1484-778f-b280 N/A N/A Onu1/0/3:4 N/A N/A Offline N/A
1484-778f-00c0 N/A N/A Onu1/0/3:5 N/A N/A Offline N/A
---------------------------------- Olt1/0/4 ---------------------------------
MAC LOID LLID Dist(M) Port Model/Version Sft/Epm State Aging
1484-778f-8960 13 <1000 Onu1/0/4:1 WA6520H-EGPON/A 106/ Up N/A
1484-778f-e9e0 8 <1000 Onu1/0/4:2 WA6520H-EGPON/A 106/ Up N/A
---- More ----
OLT可能会遇到多个MAC地址在不同端口的情况,这个情况是因为之前这个MAC已经在某个端口上,但是后来因为其他原因更换了端口,但是旧的端口上还有它的记录。如果是这种情况,需要把这个MAC记录下来,在OLT管理页面中新增一个“重复MAC地址”的按钮,后续可以通过命令删除这些旧的MAC地址(后期再实现)。
再帮我优化一下设备列表页面:
1. “状态”新增“未知”选项
2. 点击“全部更新”按钮后,执行所有OLT扫描功能,并更新设备状态(不需要入库,只需要更新MAC的在线情况和距离)。
再帮我优化一下OLT管理功能:
1.
+580
View File
@@ -0,0 +1,580 @@
# 项目结构详细说明
## 后端目录结构 (backend/)
```
backend/
├── app/ # 主应用目录
│ ├── __init__.py
│ ├── main.py # FastAPI应用入口
│ ├── api/ # API路由
│ │ ├── __init__.py
│ │ ├── v1/ # API版本1
│ │ │ ├── __init__.py
│ │ │ ├── auth.py # 认证相关API
│ │ │ ├── devices.py # 设备管理API
│ │ │ ├── check.py # 状态检查API
│ │ │ ├── stats.py # 统计API
│ │ │ ├── users.py # 用户管理API
│ │ │ ├── import.py # 数据导入API
│ │ │ └── system.py # 系统设置API
│ │ └── dependencies.py # 依赖注入
│ ├── core/ # 核心配置
│ │ ├── __init__.py
│ │ ├── config.py # 应用配置
│ │ ├── security.py # 安全相关
│ │ ├── database.py # 数据库配置
│ │ ├── casdoor.py # Casdoor配置
│ │ └── celery_app.py # Celery配置
│ ├── models/ # SQLAlchemy模型
│ │ ├── __init__.py
│ │ ├── base.py # 基础模型
│ │ ├── device.py # 设备相关模型
│ │ ├── user.py # 用户相关模型
│ │ ├── permission.py # 权限相关模型
│ │ └── history.py # 历史记录模型
│ ├── schemas/ # Pydantic模式
│ │ ├── __init__.py
│ │ ├── device.py # 设备模式
│ │ ├── user.py # 用户模式
│ │ ├── auth.py # 认证模式
│ │ └── common.py # 通用模式
│ ├── services/ # 业务逻辑服务
│ │ ├── __init__.py
│ │ ├── device_service.py # 设备服务
│ │ ├── ssh_service.py # SSH连接服务
│ │ ├── check_service.py # 状态检查服务
│ │ ├── import_service.py # 数据导入服务
│ │ ├── user_service.py # 用户服务
│ │ ├── permission_service.py # 权限服务
│ │ └── casdoor_service.py # Casdoor服务
│ ├── tasks/ # Celery任务
│ │ ├── __init__.py
│ │ ├── check_tasks.py # 状态检查任务
│ │ ├── import_tasks.py # 数据导入任务
│ │ └── notification_tasks.py # 通知任务
│ ├── utils/ # 工具函数
│ │ ├── __init__.py
│ │ ├── excel_parser.py # Excel解析工具
│ │ ├── ssh_utils.py # SSH工具函数
│ │ ├── validators.py # 数据验证
│ │ ├── encryption.py # 加密工具
│ │ └── logger.py # 日志配置
│ └── middleware/ # 中间件
│ ├── __init__.py
│ ├── auth_middleware.py # 认证中间件
│ ├── permission_middleware.py # 权限中间件
│ └── logging_middleware.py # 日志中间件
├── alembic/ # 数据库迁移
│ ├── versions/ # 迁移版本
│ ├── env.py
│ └── alembic.ini
├── tests/ # 测试代码
│ ├── __init__.py
│ ├── conftest.py # 测试配置
│ ├── test_api/ # API测试
│ ├── test_services/ # 服务测试
│ └── test_utils/ # 工具测试
├── scripts/ # 脚本文件
│ ├── init_db.py # 数据库初始化
│ ├── create_admin.py # 创建管理员
│ └── backup_data.py # 数据备份
├── requirements/ # 依赖管理
│ ├── base.txt # 基础依赖
│ ├── dev.txt # 开发依赖
│ └── prod.txt # 生产依赖
├── logs/ # 日志目录
├── static/ # 静态文件
├── Dockerfile # Docker构建文件
├── docker-entrypoint.sh # Docker入口脚本
├── requirements.txt # 依赖文件
├── .env.example # 环境变量示例
└── pyproject.toml # Python项目配置
```
## 前端目录结构 (frontend/)
```
frontend/
├── public/ # 静态资源
│ ├── index.html # 主HTML文件
│ ├── favicon.ico # 网站图标
│ └── robots.txt # 搜索引擎配置
├── src/ # 源代码
│ ├── main.js # 应用入口
│ ├── App.vue # 根组件
│ ├── api/ # API调用
│ │ ├── index.js # API配置
│ │ ├── auth.js # 认证API
│ │ ├── device.js # 设备API
│ │ ├── check.js # 状态检查API
│ │ ├── user.js # 用户API
│ │ └── import.js # 数据导入API
│ ├── assets/ # 资源文件
│ │ ├── css/ # 样式文件
│ │ │ ├── main.css # 主样式
│ │ │ ├── variables.css # CSS变量
│ │ │ └── components.css # 组件样式
│ │ └── images/ # 图片资源
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件
│ │ │ ├── Layout/ # 布局组件
│ │ │ │ ├── AppLayout.vue
│ │ │ │ ├── Header.vue
│ │ │ │ ├── Sidebar.vue
│ │ │ │ └── Footer.vue
│ │ │ ├── Table/ # 表格组件
│ │ │ │ ├── DataTable.vue
│ │ │ │ └── Pagination.vue
│ │ │ ├── Form/ # 表单组件
│ │ │ │ ├── SearchForm.vue
│ │ │ │ └── FilterForm.vue
│ │ │ ├── Chart/ # 图表组件
│ │ │ │ ├── StatusChart.vue
│ │ │ │ └── TrendChart.vue
│ │ │ └── Dialog/ # 对话框组件
│ │ │ ├── ConfirmDialog.vue
│ │ │ └── ImportDialog.vue
│ │ └── business/ # 业务组件
│ │ ├── Device/ # 设备相关组件
│ │ ├── User/ # 用户相关组件
│ │ └── System/ # 系统相关组件
│ ├── router/ # 路由配置
│ │ ├── index.js # 路由主文件
│ │ ├── routes.js # 路由定义
│ │ └── guards.js # 路由守卫
│ ├── stores/ # 状态管理 (Pinia)
│ │ ├── index.js # Store配置
│ │ ├── auth.js # 认证状态
│ │ ├── device.js # 设备状态
│ │ ├── user.js # 用户状态
│ │ └── system.js # 系统状态
│ ├── views/ # 页面组件
│ │ ├── Auth/ # 认证页面
│ │ │ ├── Login.vue # 登录页面
│ │ │ └── Callback.vue # 回调页面
│ │ ├── Dashboard/ # 仪表板
│ │ │ └── index.vue # 仪表板主页
│ │ ├── Device/ # 设备管理
│ │ │ ├── List.vue # 设备列表
│ │ │ ├── Detail.vue # 设备详情
│ │ │ └── Import.vue # 数据导入
│ │ ├── Check/ # 状态检查
│ │ │ ├── Status.vue # 状态页面
│ │ │ └── History.vue # 检查历史
│ │ ├── User/ # 用户管理
│ │ │ ├── List.vue # 用户列表
│ │ │ ├── Profile.vue # 用户资料
│ │ │ └── Permission.vue # 权限管理
│ │ ├── System/ # 系统设置
│ │ │ ├── OltConfig.vue # OLT配置
│ │ │ ├── TaskConfig.vue # 任务配置
│ │ │ └── Audit.vue # 审核中心
│ │ └── Error/ # 错误页面
│ │ ├── 404.vue # 404页面
│ │ └── 500.vue # 500页面
│ ├── utils/ # 工具函数
│ │ ├── auth.js # 认证工具
│ │ ├── request.js # 请求工具
│ │ ├── permission.js # 权限工具
│ │ ├── format.js # 格式化工具
│ │ ├── validate.js # 验证工具
│ │ └── storage.js # 存储工具
│ ├── directives/ # 自定义指令
│ │ ├── permission.js # 权限指令
│ │ └── loading.js # 加载指令
│ └── plugins/ # 插件
│ ├── element-plus.js # Element Plus插件
│ └── echarts.js # ECharts插件
├── tests/ # 测试文件
│ ├── unit/ # 单元测试
│ └── e2e/ # 端到端测试
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .env.example # 环境变量示例
├── package.json # 项目配置
├── package-lock.json # 依赖锁文件
├── vite.config.js # Vite配置
├── index.html # HTML入口
└── Dockerfile # Docker构建文件
```
## 部署目录结构 (deploy/)
```
deploy/
├── docker-compose.yml # Docker Compose配置
├── docker-compose.dev.yml # 开发环境配置
├── docker-compose.prod.yml # 生产环境配置
├── nginx/ # Nginx配置
│ ├── nginx.conf # 主配置文件
│ ├── conf.d/ # 站点配置
│ │ └── h3c-onu-ms.conf # 应用配置
│ └── ssl/ # SSL证书
├── scripts/ # 部署脚本
│ ├── deploy.sh # 部署脚本
│ ├── backup.sh # 备份脚本
│ ├── restore.sh # 恢复脚本
│ └── monitor.sh # 监控脚本
├── config/ # 配置文件
│ ├── backend.env # 后端环境变量
│ ├── frontend.env # 前端环境变量
│ └── celery.env # Celery环境变量
└── .env.example # 环境变量示例
```
## 文档目录结构 (docs/)
```
docs/
├── api/ # API文档
│ ├── overview.md # API概述
│ ├── auth.md # 认证API文档
│ ├── device.md # 设备API文档
│ ├── check.md # 状态检查API文档
│ └── user.md # 用户API文档
├── deployment/ # 部署文档
│ ├── requirements.md # 环境要求
│ ├── installation.md # 安装指南
│ ├── configuration.md # 配置说明
│ ├── docker.md # Docker部署
│ └── troubleshooting.md # 故障排除
├── user-guide/ # 用户指南
│ ├── getting-started.md # 快速开始
│ ├── device-management.md # 设备管理指南
│ ├── data-import.md # 数据导入指南
│ ├── permission-guide.md # 权限管理指南
│ └── faq.md # 常见问题
├── development/ # 开发文档
│ ├── setup.md # 开发环境搭建
│ ├── architecture.md # 架构说明
│ ├── coding-standards.md # 编码规范
│ ├── testing.md # 测试指南
│ └── contributing.md # 贡献指南
└── images/ # 文档图片
```
## 脚本目录结构 (scripts/)
```
scripts/
├── database/ # 数据库脚本
│ ├── init.sql # 初始化SQL
│ ├── backup.sh # 数据库备份
│ ├── restore.sh # 数据库恢复
│ └── migrate.sh # 迁移脚本
├── monitoring/ # 监控脚本
│ ├── health-check.sh # 健康检查
│ ├── log-rotate.sh # 日志轮转
│ └── alert.sh # 告警脚本
├── deployment/ # 部署脚本
│ ├── build.sh # 构建脚本
│ ├── deploy.sh # 部署脚本
│ └── rollback.sh # 回滚脚本
└── utils/ # 工具脚本
├── cleanup.sh # 清理脚本
├── generate-cert.sh # 证书生成
└── update-config.sh # 配置更新
```
## 环境变量说明
### 后端环境变量 (.env)
```bash
# 应用配置
APP_NAME=H3C-ONU-MS
APP_ENV=production
DEBUG=false
SECRET_KEY=your-secret-key-here
# 数据库配置
DATABASE_URL=postgresql://user:password@host:5432/dbname
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=40
# Redis配置
REDIS_URL=redis://host:6379/0
REDIS_POOL_SIZE=10
# Casdoor配置
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
CASDOOR_CLIENT_SECRET=your_client_secret
CASDOOR_CERTIFICATE=your_certificate
CASDOOR_ORG_NAME=your_org
CASDOOR_APP_NAME=h3c-onu-ms
# SSH配置
SSH_TIMEOUT=30
SSH_MAX_CONNECTIONS=10
SSH_RETRY_COUNT=3
# 任务配置
CHECK_INTERVAL=1800 # 30分钟(秒)
MANUAL_COOLDOWN=300 # 5分钟(秒)
HISTORY_RETENTION=90 # 90天
# 日志配置
LOG_LEVEL=INFO
LOG_FILE=/app/logs/app.log
LOG_ROTATION=10MB
LOG_RETENTION=30
```
### 前端环境变量 (.env)
```bash
# 应用配置
VITE_APP_TITLE=H3C ONU设备管理系统
VITE_APP_VERSION=1.0.0
# API配置
VITE_API_BASE_URL=http://localhost:8000
VITE_API_TIMEOUT=30000
# Casdoor配置
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
VITE_CASDOOR_CLIENT_ID=your_client_id
VITE_CASDOOR_ORG_NAME=your_org
VITE_CASDOOR_APP_NAME=h3c-onu-ms
# 功能开关
VITE_ENABLE_SSO=true
VITE_ENABLE_WEBSOCKET=true
VITE_ENABLE_ANALYTICS=false
```
## 开发工作流
### 1. 环境准备
```bash
# 克隆项目
git clone <repository-url>
cd H3ConuMS2
# 后端环境
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements/dev.txt
# 前端环境
cd ../frontend
npm install
```
### 2. 数据库初始化
```bash
# 创建数据库
createdb h3c_onu_ms
# 运行迁移
cd backend
alembic upgrade head
# 初始化数据
python scripts/init_data.py
```
### 3. 启动开发服务
```bash
# 启动后端
cd backend
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 启动前端(新终端)
cd frontend
npm run dev
# 启动Celery Worker(新终端)
cd backend
celery -A app.core.celery_app worker --loglevel=info
# 启动Celery Beat(新终端)
cd backend
celery -A app.core.celery_app beat --loglevel=info
```
### 4. 访问应用
- 前端开发服务器: http://localhost:5173
- 后端API服务器: http://localhost:8000
- API文档: http://localhost:8000/docs
- 管理界面: http://localhost:8000/admin
## 代码规范
### Python代码规范
- 遵循PEP 8规范
- 使用Black进行代码格式化
- 使用isort进行导入排序
- 使用Flake8进行代码检查
- 使用mypy进行类型检查
### Vue代码规范
- 使用ESLint + Prettier
- 遵循Vue官方风格指南
- 使用TypeScript进行类型检查
- 组件使用PascalCase命名
- 单文件组件结构规范
### Git提交规范
- 使用Conventional Commits规范
- 提交信息格式: `<type>(<scope>): <subject>`
- 类型: feat, fix, docs, style, refactor, test, chore
- 示例: `feat(device): 添加设备导入功能`
## 测试策略
### 单元测试
```bash
# 后端单元测试
cd backend
pytest tests/unit/
# 前端单元测试
cd frontend
npm run test:unit
```
### 集成测试
```bash
# 后端集成测试
cd backend
pytest tests/integration/
# API测试
pytest tests/api/
```
### 端到端测试
```bash
# 前端E2E测试
cd frontend
npm run test:e2e
```
## 部署流程
### 开发环境部署
```bash
cd deploy
docker-compose -f docker-compose.dev.yml up -d
```
### 生产环境部署
```bash
# 构建镜像
docker-compose -f docker-compose.prod.yml build
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f
```
### 持续集成/持续部署
1. 代码推送到Git仓库
2. 自动运行测试
3. 构建Docker镜像
4. 推送到镜像仓库
5. 部署到服务器
6. 运行健康检查
## 监控和告警
### 应用监控
- 健康检查端点: `/health`
- 性能指标: `/metrics` (Prometheus格式)
- 请求统计: 中间件记录
- 错误跟踪: Sentry集成
### 系统监控
- 服务器资源使用率
- 数据库连接池状态
- Redis内存使用情况
- 网络连接状态
### 告警规则
- 设备离线率超过阈值
- 系统资源使用率过高
- 服务不可用
- 安全相关事件
## 安全考虑
### 数据安全
- SSH密码加密存储
- 数据库连接加密
- 敏感信息环境变量管理
- 定期数据备份
### 应用安全
- CSRF保护
- XSS防护
- SQL注入防护
- 速率限制
- 输入验证
### 访问安全
- 基于角色的访问控制
- 会话管理
- 双因素认证支持
- 登录尝试限制
## 性能优化
### 数据库优化
- 合理使用索引
- 查询优化
- 连接池配置
- 定期清理历史数据
### 缓存策略
- Redis缓存热点数据
- 浏览器缓存静态资源
- CDN加速前端资源
### 异步处理
- Celery处理耗时任务
- WebSocket实时更新
- 批量操作优化
## 扩展性设计
### 水平扩展
- 无状态API服务
- 数据库读写分离
- Redis集群支持
- 负载均衡配置
### 功能扩展
- 插件化架构设计
- 模块化代码组织
- 配置驱动功能开关
- API版本管理
## 维护计划
### 日常维护
- 日志监控和分析
- 数据库备份验证
- 系统更新和补丁
- 性能监控和优化
### 定期维护
- 每月安全审计
- 每季度性能评估
- 每年架构评审
- 数据归档和清理
## 文档更新
- API变更及时更新文档
- 部署流程变更记录
- 故障处理经验总结
- 用户反馈整理
---
**文档版本**: v1.0
**最后更新**: 2026年4月1日
**维护人员**: 系统开发团队
+37
View File
@@ -0,0 +1,37 @@
# H3C ONU设备管理系统 - 快速开始
## 项目状态
✅ 第一阶段:基础框架已完成
## 已完成功能
- ✅ 后端 FastAPI 框架
- ✅ 前端 Vue 3 框架
- ✅ SSH 连接和 ONU 状态解析
- ✅ 数据库模型设计
- ✅ Docker 配置
## 快速启动
### 1. 配置环境变量
```bash
cp backend/.env.example backend/.env
# 编辑 backend/.env 配置数据库等信息
```
### 2. 启动后端
```bash
./start-backend.sh
```
### 3. 启动前端
```bash
./start-frontend.sh
```
### 4. 访问系统
- 前端:http://localhost:5173
- 后端 APIhttp://localhost:8000
- API 文档:http://localhost:8000/docs
## 下一步开发
参考 `开发计划.md` 继续第一阶段第2周任务
+282
View File
@@ -0,0 +1,282 @@
# H3C ONU设备管理系统
基于Python FastAPI + Vue 3的H3C OLT设备监控管理系统,用于监控和管理4000+ ONU设备的在线状态。
## 功能特性
### 核心功能
- ✅ H3C OLT设备SSH连接和状态查询
- ✅ ONU设备在线状态监控(4000+设备)
- ✅ Excel数据导入和批量管理
- ✅ 定时自动状态检查(每30分钟)
- ✅ 手动刷新功能(5分钟冷却限制)
- ✅ 90天状态历史记录
### 权限管理
- ✅ Casdoor统一认证集成
- ✅ 基于RBAC的多级权限控制
- ✅ 区域/学校级别的数据隔离
- ✅ 设备信息变更审核流程
- ✅ 用户角色和权限管理
### 界面功能
- ✅ 响应式Web界面
- ✅ 设备列表表格展示
- ✅ 高级筛选和搜索
- ✅ 实时统计图表
- ✅ 设备详情和历史趋势
- ✅ 数据导入和管理界面
## 技术栈
### 后端
- **Python FastAPI** - 高性能异步Web框架
- **PostgreSQL** - 关系型数据库
- **Paramiko** - SSH连接库
- **Celery + Redis** - 异步任务队列
- **Pandas** - Excel数据处理
- **SQLAlchemy** - ORM框架
- **Casdoor SDK** - 统一认证
### 前端
- **Vue 3 + Composition API** - 前端框架
- **Element Plus** - UI组件库
- **Axios** - HTTP客户端
- **Vue Router** - 路由管理
- **Pinia** - 状态管理
- **ECharts** - 图表库
### 部署
- **Docker + Docker Compose** - 容器化部署
- **Nginx** - 反向代理
- **外部PostgreSQL/Redis** - 数据库服务
## 快速开始
### 环境要求
- Docker 20.10+
- Docker Compose 2.0+
- 外部PostgreSQL数据库
- 外部Redis服务
- Casdoor认证服务器
### 配置步骤
1. **克隆项目**
```bash
git clone <repository-url>
cd H3ConuMS2
```
2. **配置环境变量**
```bash
cp deploy/.env.example deploy/.env
# 编辑deploy/.env文件,配置数据库和Casdoor信息
```
3. **启动服务**
```bash
cd deploy
docker-compose up -d
```
4. **访问系统**
- 前端界面: http://localhost:8080
- API文档: http://localhost:8000/docs
- 健康检查: http://localhost:8000/health
## 项目结构
```
H3ConuMS2/
├── backend/ # Python后端代码
│ ├── app/
│ │ ├── api/ # API路由
│ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic模式
│ │ ├── services/ # 业务逻辑
│ │ ├── tasks/ # Celery任务
│ │ └── utils/ # 工具函数
│ ├── alembic/ # 数据库迁移
│ ├── tests/ # 测试代码
│ ├── requirements.txt # Python依赖
│ └── Dockerfile # 后端Dockerfile
├── frontend/ # Vue前端代码
│ ├── public/ # 静态资源
│ ├── src/
│ │ ├── api/ # API调用
│ │ ├── assets/ # 资源文件
│ │ ├── components/ # 组件
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # 状态管理
│ │ ├── views/ # 页面组件
│ │ └── utils/ # 工具函数
│ ├── package.json # 前端依赖
│ └── Dockerfile # 前端Dockerfile
├── deploy/ # 部署配置
│ ├── docker-compose.yml # Docker编排
│ ├── nginx.conf # Nginx配置
│ └── .env.example # 环境变量示例
├── docs/ # 文档
│ ├── api/ # API文档
│ ├── deployment/ # 部署文档
│ └── user-guide/ # 用户指南
├── scripts/ # 脚本文件
│ ├── init-db.sh # 数据库初始化
│ ├── backup.sh # 数据备份
│ └── monitor.sh # 系统监控
└── 系统设计文档.md # 详细设计文档
```
## 数据库初始化
1. **创建数据库**
```sql
CREATE DATABASE h3c_onu_ms;
CREATE USER h3c_user WITH PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE h3c_onu_ms TO h3c_user;
```
2. **运行迁移**
```bash
cd backend
alembic upgrade head
```
3. **初始化数据**
```bash
python scripts/init_data.py
```
## 配置说明
### Casdoor配置
1. 在Casdoor中创建应用
2. 配置回调地址: `http://your-domain/api/auth/callback`
3. 获取Client ID、Client Secret和Certificate
4. 在.env文件中配置Casdoor参数
### OLT设备配置
1. 通过系统界面添加OLT设备
2. 配置SSH连接信息(IP、用户名、密码)
3. 设置插槽命令(如`display onu slot 1`
### 权限配置
1. 在Casdoor中配置用户角色
2. 在系统中配置角色权限映射
3. 分配用户区域和学校权限
## 开发指南
### 后端开发
```bash
cd backend
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 前端开发
```bash
cd frontend
npm install
npm run dev
```
### 测试
```bash
# 后端测试
cd backend
pytest
# 前端测试
cd frontend
npm run test
```
## API文档
启动后端服务后,访问以下地址查看API文档:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## 监控和日志
### 健康检查
```
GET /health
```
### 指标监控
```
GET /metrics
```
### 日志查看
```bash
# 查看后端日志
docker-compose logs backend
# 查看Celery日志
docker-compose logs celery-worker
# 查看Nginx日志
docker-compose logs nginx
```
## 故障排除
### 常见问题
1. **SSH连接失败**
- 检查网络连通性
- 验证SSH凭证
- 检查防火墙设置
2. **数据库连接失败**
- 检查数据库服务状态
- 验证连接字符串
- 检查网络权限
3. **Casdoor登录失败**
- 检查Casdoor服务状态
- 验证应用配置
- 检查回调地址配置
### 日志级别
- DEBUG: 开发环境详细日志
- INFO: 生产环境常规日志
- ERROR: 错误信息记录
## 贡献指南
1. Fork项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建Pull Request
## 许可证
本项目采用MIT许可证 - 查看LICENSE文件了解详情。
## 联系方式
- 项目维护者: [Your Name]
- 问题反馈: [GitHub Issues]
- 文档更新: [GitHub Wiki]
## 更新日志
### v1.0.0 (计划中)
- 基础设备监控功能
- Casdoor认证集成
- 权限管理系统
- Docker化部署
---
**重要提示**: 生产环境部署前,请务必修改默认密码和密钥,并配置适当的防火墙规则。
+26
View File
@@ -0,0 +1,26 @@
# 应用配置
APP_NAME=H3C-ONU-MS
DEBUG=false
SECRET_KEY=your-secret-key-change-this
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
# Redis配置
REDIS_URL=redis://localhost:6379/0
# Casdoor配置
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
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
# SSH配置
SSH_TIMEOUT=30
# 任务配置
CHECK_INTERVAL=1800
MANUAL_COOLDOWN=300
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
# 使用清华镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+40
View File
@@ -0,0 +1,40 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core.config import settings
from app.core.database import Base
from app.models import device
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,47 @@
"""add location to olt_devices
Revision ID: 32c84173ef05
Revises: 5c24b07e0f9c
Create Date: 2026-04-02 10:52:19.883293
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '32c84173ef05'
down_revision: Union[str, None] = '5c24b07e0f9c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.add_column('olt_devices', sa.Column('location', sa.String(length=200), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('olt_devices', 'location')
op.create_table('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# ### end Alembic commands ###
@@ -0,0 +1,47 @@
"""add port_id to onu_devices
Revision ID: 3e108afa9bba
Revises: add_onu_extended_fields
Create Date: 2026-04-02 20:30:23.475918
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '3e108afa9bba'
down_revision: Union[str, None] = 'add_onu_extended_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.add_column('onu_devices', sa.Column('port_id', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('onu_devices', 'port_id')
op.create_table('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# ### end Alembic commands ###
@@ -0,0 +1,66 @@
"""add place_type and notes fields
Revision ID: 5c24b07e0f9c
Revises:
Create Date: 2026-04-01 17:51:21.451465
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '5c24b07e0f9c'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('role_permissions')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.drop_table('permissions')
op.add_column('onu_devices', sa.Column('place_type', sa.String(length=50), nullable=True))
op.drop_column('onu_devices', 'location_type')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('onu_devices', sa.Column('location_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
op.drop_column('onu_devices', 'place_type')
op.create_table('role_permissions',
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('permission_id', sa.BIGINT(), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('role_permissions_permission_id_fkey')),
sa.PrimaryKeyConstraint('role', 'permission_id', name=op.f('role_permissions_pkey'))
)
op.create_table('users',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('module', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('permissions_pkey')),
sa.UniqueConstraint('code', name=op.f('permissions_code_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""添加距离、LOID、型号等扩展字段
Revision ID: add_onu_extended_fields
Revises: 32c84173ef05
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'add_onu_extended_fields'
down_revision = '32c84173ef05'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ONUDevice 表新增字段
op.add_column('onu_devices', sa.Column('loid', sa.String(50), nullable=True))
op.add_column('onu_devices', sa.Column('model', sa.String(100), nullable=True))
op.add_column('onu_devices', sa.Column('distance_m', sa.Integer, nullable=True))
# DeviceStatusHistory 表新增字段
op.add_column('device_status_history', sa.Column('distance_m', sa.Integer, nullable=True))
def downgrade() -> None:
op.drop_column('device_status_history', 'distance_m')
op.drop_column('onu_devices', 'distance_m')
op.drop_column('onu_devices', 'model')
op.drop_column('onu_devices', 'loid')
View File
View File
View File
+88
View File
@@ -0,0 +1,88 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
from datetime import datetime
router = APIRouter(prefix="/api/auth", tags=["认证"])
def decode_jwt_payload(token: str) -> dict:
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
payload_b64 = token.split(".")[1]
rem = len(payload_b64) % 4
if rem:
payload_b64 += "=" * (4 - rem)
return json.loads(base64.urlsafe_b64decode(payload_b64))
@router.get("/login")
def login():
"""获取 Casdoor 登录 URL"""
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
class CallbackRequest(BaseModel):
code: str
state: str
@router.post("/callback", response_model=Token)
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
"""Casdoor 登录回调"""
try:
token_response = casdoor_sdk.get_oauth_token(code=body.code)
if isinstance(token_response, dict) and "error" in token_response:
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
if not access_token:
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
casdoor_user = decode_jwt_payload(access_token)
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
if not user:
user = User(
casdoor_id=casdoor_user["sub"],
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
email=casdoor_user.get("email"),
role="user"
)
db.add(user)
user.last_login = datetime.utcnow()
db.commit()
jwt_token = create_access_token({"sub": str(user.id), "username": user.username})
return {"access_token": jwt_token}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/profile", response_model=UserInfo)
def get_profile(token: str, db: Session = Depends(get_db)):
"""获取当前用户信息"""
from app.core.security import verify_token
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="无效的令牌")
user = db.query(User).filter(User.id == int(payload["sub"])).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
+86
View File
@@ -0,0 +1,86 @@
"""状态检查 API"""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Depends
from celery.result import AsyncResult
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.services.check_service import CheckService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/check", tags=["状态检查"])
class CheckResult(BaseModel):
olt_id: int
olt_name: str
online: Optional[int] = 0
offline: Optional[int] = 0
success: bool
class CheckError(BaseModel):
olt_id: int
olt_name: str
error: str
@router.post("/status")
def trigger_check():
"""手动触发状态检查"""
try:
task = check_all_devices.delay()
return {"task_id": task.id, "status": "started"}
except Exception as e:
logger.error(f"触发状态检查失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
@router.get("/status/{task_id}")
def get_check_status(task_id: str):
"""查询状态检查任务进度和结果"""
task_result = AsyncResult(task_id, app=celery_app)
state = task_result.state
result = {
"task_id": task_id,
"status": state,
"progress": None,
"result": None
}
if state == 'PROGRESS':
result["progress"] = task_result.info
if state == 'SUCCESS':
result["result"] = task_result.result
elif state == 'FAILURE':
result["error"] = str(task_result.info)
return result
@router.post("/scan/{olt_id}")
def scan_olt(olt_id: int, db: Session = Depends(get_db)):
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
try:
service = CheckService(db)
result = asyncio.run(service.scan_olt(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/discover/{olt_id}")
def discover_olt(olt_id: int, db: Session = Depends(get_db)):
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
try:
service = CheckService(db)
result = asyncio.run(service.check_olt_devices(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+231
View File
@@ -0,0 +1,231 @@
"""设备管理 API"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice
from app.schemas.device import DeviceListResponse, ONUDeviceResponse
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
@router.get("", response_model=DeviceListResponse)
def get_devices(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
region: str = None,
school_name: str = None,
keyword: str = None,
status: str = None, # online / offline
db: Session = Depends(get_db)
):
"""获取设备列表"""
# 子查询:每台设备最新一条状态记录
from sqlalchemy import func
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_history = (
db.query(DeviceStatusHistory)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
query = db.query(ONUDevice)
if region:
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name.contains(school_name))
if keyword:
query = query.filter(
or_(
ONUDevice.mac_address.contains(keyword.lower()),
ONUDevice.building.contains(keyword),
ONUDevice.place_type.contains(keyword),
ONUDevice.school_name.contains(keyword),
ONUDevice.region.contains(keyword),
ONUDevice.room_number.contains(keyword),
)
)
if status in ("online", "offline", "unknown"):
if status in ("online", "offline"):
query = query.join(
latest_history,
ONUDevice.id == latest_history.c.onu_device_id
).filter(latest_history.c.status == status)
else:
# unknown:最新状态为 unknown,或没有任何状态记录
query = query.outerjoin(
latest_history,
ONUDevice.id == latest_history.c.onu_device_id
).filter(
or_(
latest_history.c.onu_device_id == None,
latest_history.c.status == "unknown"
)
)
# 多级排序:区域 > 学校名称 > 楼宇 > 房间号(均为升序)
query = query.order_by(
asc(ONUDevice.region),
asc(ONUDevice.school_name),
asc(ONUDevice.building),
asc(ONUDevice.room_number)
)
total = query.count()
items = query.offset(skip).limit(limit).all()
# 获取每个设备最新的状态(批量,避免 N+1)
device_ids = [item.id for item in items]
history_map = {}
if device_ids:
histories = (
db.query(DeviceStatusHistory)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.filter(DeviceStatusHistory.onu_device_id.in_(device_ids))
.all()
)
history_map = {h.onu_device_id: h for h in histories}
# 批量加载 OLT 信息
olt_ids = {item.olt_id for item in items if item.olt_id}
olt_map = {}
if olt_ids:
olts = db.query(OLTDevice).filter(OLTDevice.id.in_(olt_ids)).all()
olt_map = {o.id: o for o in olts}
result_items = []
for item in items:
latest_status = history_map.get(item.id)
olt = olt_map.get(item.olt_id)
item_dict = {
"id": item.id,
"mac_address": item.mac_address,
"olt_id": item.olt_id,
"region": item.region,
"school_name": item.school_name,
"building": item.building,
"place_type": item.place_type,
"room_number": item.room_number,
"notes": item.notes,
"status": latest_status.status if latest_status else None,
"distance_m": latest_status.distance_m if latest_status else None,
"slot_number": item.slot_number,
"port_number": item.port_number,
"port_id": item.port_id,
"model": item.model,
"olt_location": olt.location if olt else None,
"created_at": item.created_at
}
result_items.append(ONUDeviceResponse(**item_dict))
return {"total": total, "items": result_items}
@router.get("/regions")
def get_regions(db: Session = Depends(get_db)):
"""获取所有区域列表"""
regions = db.query(distinct(ONUDevice.region)).filter(
ONUDevice.region.isnot(None),
ONUDevice.region != ''
).order_by(ONUDevice.region).all()
return [r[0] for r in regions]
@router.get("/{device_id}", response_model=ONUDeviceResponse)
def get_device(device_id: int, db: Session = Depends(get_db)):
"""获取设备详情"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="设备不存在")
latest_status = db.query(DeviceStatusHistory).filter(
DeviceStatusHistory.onu_device_id == device.id
).order_by(desc(DeviceStatusHistory.checked_at)).first()
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if device.olt_id else None
return ONUDeviceResponse(
id=device.id,
mac_address=device.mac_address,
olt_id=device.olt_id,
region=device.region,
school_name=device.school_name,
building=device.building,
place_type=device.place_type,
room_number=device.room_number,
notes=device.notes,
status=latest_status.status if latest_status else None,
distance_m=latest_status.distance_m if latest_status else None,
slot_number=device.slot_number,
port_number=device.port_number,
port_id=device.port_id,
model=device.model,
olt_location=olt.location if olt else None,
created_at=device.created_at
)
@router.post("/{device_id}/refresh")
def refresh_device_status(device_id: int, db: Session = Depends(get_db)):
"""通过 SSH 单独更新一台设备的状态和距离"""
from app.services.check_service import CheckService
try:
service = CheckService(db)
result = service.check_single_device(device_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class DeviceUpdate(BaseModel):
region: Optional[str] = None
school_name: Optional[str] = None
building: Optional[str] = None
room_number: Optional[str] = None
notes: Optional[str] = None
@router.delete("/status/all")
def clear_all_status(db: Session = Depends(get_db)):
"""清空所有设备状态历史记录"""
db.query(DeviceStatusHistory).delete()
db.commit()
return {"message": "已清空所有设备状态"}
@router.put("/{device_id}")
def update_device(device_id: int, body: DeviceUpdate, db: Session = Depends(get_db)):
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
device.region = body.region
device.school_name = body.school_name
device.building = body.building or None
device.room_number = body.room_number or None
device.notes = body.notes or None
db.commit()
return {"message": "更新成功"}
+77
View File
@@ -0,0 +1,77 @@
"""数据导入 API"""
from fastapi import APIRouter, UploadFile, File, Depends, Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.import_service import ImportService
import shutil
import io
from openpyxl import Workbook
router = APIRouter(prefix="/api/import", tags=["数据导入"])
@router.get("/template")
def download_template():
"""下载导入数据模板"""
wb = Workbook()
ws = wb.active
ws.title = "ONU设备导入模板"
# 表头:序号|区域|学校名称|楼宇|场所类型|房间号|MAC地址|备注
headers = ["mac_address", "region", "school_name", "building", "place_type", "room_number", "notes"]
ws.append(headers)
# 示例数据
example_data = [
["AA:BB:CC:DD:EE:01", "区域1", "学校1", "1号楼", "教室", "101", ""],
["AA:BB:CC:DD:EE:02", "区域1", "学校1", "1号楼", "办公室", "102", ""],
]
for row in example_data:
ws.append(row)
# 设置列宽
ws.column_dimensions['A'].width = 20 # mac_address
ws.column_dimensions['B'].width = 12 # region
ws.column_dimensions['C'].width = 18 # school_name
ws.column_dimensions['D'].width = 12 # building
ws.column_dimensions['E'].width = 12 # place_type
ws.column_dimensions['F'].width = 12 # room_number
ws.column_dimensions['G'].width = 20 # notes
# 保存到内存
output = io.BytesIO()
wb.save(output)
output.seek(0)
return Response(
content=output.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=onu_import_template.xlsx"}
)
@router.post("/upload")
async def upload_excel(
file: UploadFile = File(...),
olt_id: int = None,
db: Session = Depends(get_db)
):
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
file_path = f"/tmp/{file.filename}"
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
service = ImportService(db)
records = service.parse_excel(file_path)
validation = service.validate_data(records)
success_count = 0
if validation['valid']:
result = service.import_devices(validation['valid'], olt_id)
success_count = result['success']
return {
"success": success_count,
"failed": validation['invalid']
}
+402
View File
@@ -0,0 +1,402 @@
"""OLT 设备管理 API"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
from app.models.device import OLTDevice
import pandas as pd
import io
router = APIRouter(prefix="/api/olt", tags=["OLT设备"])
class OLTCreate(BaseModel):
ip_address: str
username: str
password: str
slot_command: str = "display onu slot"
location: str = ""
description: str = ""
class OLTEdit(BaseModel):
username: str
password: str = None
slot_command: str = "display onu slot"
location: str = ""
description: str = ""
@router.get("/devices")
def get_devices(db: Session = Depends(get_db)):
return db.query(OLTDevice).all()
@router.post("/devices")
def create_device(device: OLTCreate, db: Session = Depends(get_db)):
db_device = OLTDevice(**device.dict())
db.add(db_device)
db.commit()
return {"message": "创建成功"}
@router.put("/devices/{ip_address}")
def update_device(ip_address: str, device: OLTEdit, db: Session = Depends(get_db)):
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not db_device:
raise HTTPException(status_code=404, detail="设备不存在")
db_device.username = device.username
if device.password:
db_device.password = device.password
db_device.slot_command = device.slot_command
db_device.location = device.location
db_device.description = device.description
db.commit()
return {"message": "更新成功"}
@router.delete("/devices/{ip_address}")
def delete_device(ip_address: str, db: Session = Depends(get_db)):
from app.models.device import ONUDevice
device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
# 检查是否有关联的 ONU 设备
onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count()
if onu_count > 0:
raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除")
db.delete(device)
db.commit()
return {"message": "删除成功"}
@router.post("/import")
async def import_devices(file: UploadFile = File(...), db: Session = Depends(get_db)):
try:
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
# 标准化列名
df.columns = [str(c).strip() for c in df.columns]
except Exception as e:
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
required_cols = ['IP地址', '用户名', '密码']
missing = [c for c in required_cols if c not in df.columns]
if missing:
raise HTTPException(status_code=400, detail=f"缺少必填列: {', '.join(missing)},当前列: {', '.join(df.columns.tolist())}")
success, failed = 0, []
for idx, row in df.iterrows():
try:
ip = str(row['IP地址']).strip()
if not ip or ip == 'nan':
continue
existing = db.query(OLTDevice).filter(OLTDevice.ip_address == ip).first()
if existing:
failed.append({"row": idx + 2, "ip": ip, "reason": "IP 已存在"})
continue
device = OLTDevice(
ip_address=ip,
username=str(row['用户名']).strip(),
password=str(row['密码']).strip(),
slot_command=str(row['槽位命令']).strip() if '槽位命令' in df.columns and str(row['槽位命令']) != 'nan' else 'display onu slot',
location=str(row['安装位置']).strip() if '安装位置' in df.columns and str(row['安装位置']) != 'nan' else '',
description=str(row['描述']).strip() if '描述' in df.columns and str(row['描述']) != 'nan' else '',
)
db.add(device)
success += 1
except Exception as e:
failed.append({"row": idx + 2, "ip": str(row.get('IP地址', '')), "reason": str(e)})
db.commit()
return {"message": f"成功导入 {success} 条记录", "success": success, "failed": failed}
@router.get("/template")
def download_template():
from fastapi.responses import FileResponse
return FileResponse(
path="/home/v6ole/pyproject/H3ConuMS2/backend/templates/OLT设备导入模板.xlsx",
filename="OLT设备导入模板.xlsx"
)
@router.get("/duplicate-macs")
def get_duplicate_macs(olt_id: int = None, db: Session = Depends(get_db)):
"""查询重复 MAC 地址记录"""
from app.models.device import DuplicateMac
query = db.query(DuplicateMac)
if olt_id:
query = query.filter(DuplicateMac.olt_id == olt_id)
records = query.order_by(DuplicateMac.last_seen_at.desc()).all()
return [
{
"id": r.id,
"olt_id": r.olt_id,
"mac_address": r.mac_address,
"ports": r.ports,
"first_seen_at": r.first_seen_at,
"last_seen_at": r.last_seen_at,
}
for r in records
]
@router.delete("/duplicate-macs/{record_id}")
def delete_duplicate_mac(record_id: int, db: Session = Depends(get_db)):
"""删除重复 MAC 记录(已处理后清除)"""
from app.models.device import DuplicateMac
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(record)
db.commit()
return {"message": "已删除"}
class ClearPortRequest(BaseModel):
port_id: str # 如 "1/0/1:3"
@router.post("/duplicate-macs/{record_id}/clear-port")
def clear_onu_port(record_id: int, body: ClearPortRequest, db: Session = Depends(get_db)):
"""通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口"""
from app.models.device import DuplicateMac
from app.services.ssh_service import SSHService
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
olt = db.query(OLTDevice).filter(OLTDevice.id == record.olt_id).first()
if not olt:
raise HTTPException(status_code=404, detail="OLT 设备不存在")
# 验证 port_id 在记录中
port_ids = [p["port_id"] for p in (record.ports or [])]
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()
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]
if remaining:
record.ports = remaining
else:
# 所有端口都清除了,删除整条记录
db.delete(record)
db.commit()
return {"message": f"端口 Onu{body.port_id} 已清除", "remaining_ports": remaining}
@router.get("/new-devices")
def get_new_devices(db: Session = Depends(get_db)):
"""查询新发现的设备列表(待补全信息)"""
from app.models.device import NewDevice, ONUDevice
rows = (
db.query(NewDevice, ONUDevice)
.join(ONUDevice, NewDevice.onu_device_id == ONUDevice.id)
.all()
)
return [
{
"id": nd.id,
"onu_device_id": nd.onu_device_id,
"olt_id": nd.olt_id,
"discovered_at": nd.discovered_at,
"mac_address": onu.mac_address,
"port_id": f"{onu.slot_number}/{onu.port_number}" if onu.slot_number else None,
"loid": onu.loid,
"model": onu.model,
"region": onu.region,
"school_name": onu.school_name,
"building": onu.building,
"room_number": onu.room_number,
}
for nd, onu in rows
]
class NewDeviceUpdate(BaseModel):
region: str
school_name: str
building: str = ""
place_type: str = ""
room_number: str = ""
notes: str = ""
@router.put("/new-devices/{record_id}")
def update_new_device(record_id: int, body: NewDeviceUpdate, db: Session = Depends(get_db)):
"""补全新设备信息,完成后从 new_devices 移除"""
from app.models.device import NewDevice, ONUDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
onu = db.query(ONUDevice).filter(ONUDevice.id == record.onu_device_id).first()
if not onu:
raise HTTPException(status_code=404, detail="ONU 设备不存在")
onu.region = body.region
onu.school_name = body.school_name
onu.building = body.building or None
onu.place_type = body.place_type or None
onu.room_number = body.room_number or None
onu.notes = body.notes or None
db.delete(record)
db.commit()
return {"message": "信息已补全"}
@router.delete("/new-devices/{record_id}")
def dismiss_new_device(record_id: int, db: Session = Depends(get_db)):
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
from app.models.device import NewDevice
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
db.delete(record)
db.commit()
return {"message": "已忽略"}
@router.post("/quick-scan")
def quick_scan(db: Session = Depends(get_db)):
"""多线程对所有 OLT 同时执行扫描,更新已有设备状态"""
from app.services.check_service import CheckService
from concurrent.futures import ThreadPoolExecutor, as_completed
olts = db.query(OLTDevice).all()
def scan_one(olt):
from app.core.database import SessionLocal
thread_db = SessionLocal()
try:
service = CheckService(thread_db)
result = service.scan_and_discover(olt.id)
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
"online": result.get("online", 0), "offline": result.get("offline", 0),
"new_discovered": result.get("new_discovered", 0),
"error": None}
except Exception as e:
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
"online": 0, "offline": 0, "new_discovered": 0, "error": str(e)}
finally:
thread_db.close()
olt_results = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
futures = {executor.submit(scan_one, olt): olt.id for olt in olts}
for future in as_completed(futures):
r = future.result()
olt_results[r["olt_id"]] = r
results = []
errors = []
total_online = 0
total_offline = 0
total_new = 0
for olt in olts:
r = olt_results.get(olt.id, {})
if r.get("error"):
errors.append({"olt_location": r["olt_location"], "error": r["error"]})
else:
total_online += r.get("online", 0)
total_offline += r.get("offline", 0)
total_new += r.get("new_discovered", 0)
results.append({
"olt_location": r.get("olt_location", olt.location or olt.ip_address),
"online": r.get("online", 0),
"offline": r.get("offline", 0),
"new_discovered": r.get("new_discovered", 0),
"success": not r.get("error"),
"error": r.get("error"),
})
return {
"total_online": total_online,
"total_offline": total_offline,
"total_new": total_new,
"results": results,
"errors": errors,
}
@router.post("/loopback-detection")
def loopback_detection(db: Session = Depends(get_db)):
"""对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息"""
from app.models.device import ONUDevice
from app.services.ssh_service import SSHService
from concurrent.futures import ThreadPoolExecutor, as_completed
olts = db.query(OLTDevice).all()
# 预加载所有 ONU 设备,按 (olt_id, port_id) 索引,避免多线程操作 Session
all_onus = db.query(ONUDevice).all()
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()
detection = ssh.detect_loopback()
except Exception as e:
return {
"olt_id": olt.id,
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"error": str(e),
"has_loop": False,
"loop_interfaces": [],
}
finally:
ssh.close()
loop_interfaces = []
for iface in detection.get("interfaces", []):
port_id = iface.removeprefix("Onu")
onu = onu_map.get((olt.id, port_id))
loop_interfaces.append({
"interface": iface,
"port_id": port_id,
"mac_address": onu.mac_address if onu else None,
"region": onu.region if onu else None,
"school_name": onu.school_name if onu else None,
"building": onu.building if onu else None,
"room_number": onu.room_number if onu else None,
})
return {
"olt_id": olt.id,
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"has_loop": detection["has_loop"],
"loop_interfaces": loop_interfaces,
"error": None,
}
results_map = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
futures = {executor.submit(check_one, olt): olt.id for olt in olts}
for future in as_completed(futures):
olt_id = futures[future]
results_map[olt_id] = future.result()
# 按原始顺序返回
return [results_map[olt.id] for olt in olts]
+59
View File
@@ -0,0 +1,59 @@
"""业务下发 API"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from app.core.database import get_db
from app.services.provision_service import ProvisionService
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
class ProvisionRequest(BaseModel):
device_id: int
class BatchProvisionRequest(BaseModel):
device_ids: List[int]
class ProvisionResponse(BaseModel):
success: bool
message: str
device_id: Optional[int] = None
mac_address: Optional[str] = None
olt_ip: Optional[str] = None
port: Optional[str] = None
@router.post("/service", response_model=ProvisionResponse)
def provision_single_device(
request: ProvisionRequest,
db: Session = Depends(get_db)
):
"""下发业务到单个设备"""
service = ProvisionService(db)
result = service.provision_device(request.device_id)
if result.get("success"):
return ProvisionResponse(
success=True,
message=f"业务下发成功,MAC: {result['mac_address']}, 端口: {result['port']}",
device_id=result["device_id"],
mac_address=result["mac_address"],
olt_ip=result.get("olt_ip"),
port=result.get("port")
)
else:
raise HTTPException(status_code=400, detail=result.get("error", "业务下发失败"))
@router.post("/batch", response_model=dict)
def provision_batch_devices(
request: BatchProvisionRequest,
db: Session = Depends(get_db)
):
"""批量下发业务"""
service = ProvisionService(db)
result = service.batch_provision(request.device_ids)
return result
+118
View File
@@ -0,0 +1,118 @@
"""统计 API"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func, case
from app.core.database import get_db
from app.models.device import ONUDevice, DeviceStatusHistory
from datetime import datetime, timedelta
router = APIRouter(prefix="/api/stats", tags=["统计"])
@router.get("/dashboard")
def get_dashboard(db: Session = Depends(get_db)):
"""仪表板统计:总体、城区、城郊、乡镇在线率"""
# 每台设备最新状态子查询
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
DeviceStatusHistory.status
)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
rows = (
db.query(
ONUDevice.region,
ONUDevice.school_name,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(ONUDevice.region, ONUDevice.school_name)
.order_by(ONUDevice.region, ONUDevice.school_name)
.all()
)
overall = {"total": 0, "online": 0, "offline": 0}
urban_schools, suburban_schools = [], []
rural_towns = {}
for row in rows:
region = row.region or ""
total = int(row.total or 0)
online = int(row.online or 0)
offline = int(row.offline or 0)
overall["total"] += total
overall["online"] += online
overall["offline"] += offline
school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline}
if region == "城区":
urban_schools.append(school_stat)
elif region == "城郊":
suburban_schools.append(school_stat)
else:
if region not in rural_towns:
rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []}
rural_towns[region]["total"] += total
rural_towns[region]["online"] += online
rural_towns[region]["offline"] += offline
rural_towns[region]["schools"].append(school_stat)
def sort_by_rate(items):
return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0)
def agg(items):
return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)}
rural_list = sort_by_rate(list(rural_towns.values()))
return {
"overall": overall,
"urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)},
"suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)},
"rural": {**agg(list(rural_towns.values())), "towns": rural_list},
}
@router.get("/summary")
def get_summary(db: Session = Depends(get_db)):
"""获取统计摘要"""
total = db.query(ONUDevice).count()
latest_status = db.query(
DeviceStatusHistory.status,
func.count(DeviceStatusHistory.id)
).group_by(DeviceStatusHistory.status).all()
status_dict = dict(latest_status)
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
@router.get("/trend")
def get_trend(days: int = 7, db: Session = Depends(get_db)):
"""获取状态趋势数据"""
start_date = datetime.utcnow() - timedelta(days=days)
history = db.query(
func.date(DeviceStatusHistory.checked_at).label('date'),
func.sum(func.case((DeviceStatusHistory.status == 'online', 1), else_=0)).label('online'),
func.sum(func.case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label('offline')
).filter(DeviceStatusHistory.checked_at >= start_date).group_by(
func.date(DeviceStatusHistory.checked_at)
).all()
return [{"date": str(h.date), "online": h.online, "offline": h.offline} for h in history]
View File
+12
View File
@@ -0,0 +1,12 @@
"""Casdoor 认证配置"""
from casdoor import CasdoorSDK
from app.core.config import settings
casdoor_sdk = CasdoorSDK(
endpoint=settings.CASDOOR_ENDPOINT,
client_id=settings.CASDOOR_CLIENT_ID,
client_secret=settings.CASDOOR_CLIENT_SECRET,
certificate=settings.casdoor_cert_content,
org_name=settings.CASDOOR_ORG_NAME,
application_name=settings.CASDOOR_APP_NAME,
)
+23
View File
@@ -0,0 +1,23 @@
"""Celery 配置"""
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"h3c_onu_ms",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL
)
celery_app.conf.update(
task_serializer='json',
result_serializer='json',
accept_content=['json'],
timezone='UTC',
enable_utc=True,
beat_schedule={
'check-devices-every-30-minutes': {
'task': 'app.tasks.check_tasks.check_all_devices',
'schedule': settings.CHECK_INTERVAL,
},
},
)
+50
View File
@@ -0,0 +1,50 @@
"""应用配置"""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
# 项目根目录(config.py 位于 backend/app/core/parent.parent.parent 即 backend/
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
APP_NAME: str = "H3C-ONU-MS"
DEBUG: bool = False
SECRET_KEY: str
DATABASE_URL: str
REDIS_URL: str
CASDOOR_ENDPOINT: str
CASDOOR_CLIENT_ID: str
CASDOOR_CLIENT_SECRET: str
CASDOOR_ORG_NAME: str
CASDOOR_APP_NAME: str
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
SSH_TIMEOUT: int = 30
CHECK_INTERVAL: int = 1800
MANUAL_COOLDOWN: int = 300
class Config:
env_file = str(PROJECT_ROOT / ".env")
@property
def casdoor_cert_content(self) -> str:
"""读取证书文件内容或直接返回证书字符串"""
cert = self.CASDOOR_CERTIFICATE
if not cert:
return ""
cert_path = Path(cert)
if cert_path.is_absolute():
path = cert_path
else:
# 相对路径基于项目根目录解析
path = PROJECT_ROOT / cert
if path.is_file():
return path.read_text()
return cert # 直接是 PEM 内容
settings = Settings()
+17
View File
@@ -0,0 +1,17 @@
"""数据库配置"""
from sqlalchemy import create_engine
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)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+22
View File
@@ -0,0 +1,22 @@
"""JWT 安全配置"""
from datetime import datetime, timedelta
from jose import JWTError, jwt
from app.core.config import settings
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
+28
View File
@@ -0,0 +1,28 @@
"""FastAPI 主应用"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(devices.router)
app.include_router(check.router)
app.include_router(import_data.router)
app.include_router(stats.router)
app.include_router(olt.router)
app.include_router(provision.router)
@app.get("/health")
def health_check():
return {"status": "ok"}
@@ -0,0 +1,27 @@
"""权限检查中间件"""
from fastapi import HTTPException, Depends
from app.core.security import verify_token
ROLE_PERMISSIONS = {
'admin': ['*'],
'area_admin': ['device.view', 'device.check'],
'school_admin': ['device.view'],
'user': ['device.view']
}
def check_permission(required_permission: str):
def permission_checker(token: str):
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="未授权")
role = payload.get('role', 'user')
permissions = ROLE_PERMISSIONS.get(role, [])
if '*' in permissions or required_permission in permissions:
return payload
raise HTTPException(status_code=403, detail="权限不足")
return permission_checker
View File
+82
View File
@@ -0,0 +1,82 @@
"""设备数据模型"""
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class OLTDevice(Base):
__tablename__ = "olt_devices"
id = Column(BigInteger, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False)
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
slot_command = Column(String(50), nullable=False)
location = Column(String(200))
description = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class ONUDevice(Base):
__tablename__ = "onu_devices"
id = Column(BigInteger, primary_key=True, index=True)
mac_address = Column(String(17), nullable=False, index=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"))
slot_number = Column(Integer)
port_number = Column(Integer)
port_id = Column(String(20)) # 完整端口标识,如 "1/0/2:4"
# OLT 返回的额外信息
loid = Column(String(50)) # LOID
model = Column(String(100)) # 设备型号
distance_m = Column(Integer) # 距离(米)
region = Column(String(100), index=True)
school_name = Column(String(200), index=True)
building = Column(String(100))
place_type = Column(String(50)) # 场所类型
room_number = Column(String(50))
notes = Column(Text) # 备注
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
# 关联状态历史
status_history = relationship("DeviceStatusHistory", back_populates="device", lazy="selectin")
class DeviceStatusHistory(Base):
__tablename__ = "device_status_history"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"))
status = Column(String(20), nullable=False) # online, offline
distance_m = Column(Integer) # 距离(米)
checked_at = Column(TIMESTAMP, nullable=False)
response_data = Column(Text)
created_at = Column(TIMESTAMP, server_default=func.now())
device = relationship("ONUDevice", back_populates="status_history")
class DuplicateMac(Base):
"""重复 MAC 地址记录(同一 MAC 出现在多个端口)"""
__tablename__ = "duplicate_macs"
id = Column(BigInteger, primary_key=True, index=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
mac_address = Column(String(17), nullable=False, index=True)
# 所有出现的端口列表,JSON 格式: [{"port_id": "1/0/1:1", "status": "online"}, ...]
ports = Column(JSON, nullable=False)
first_seen_at = Column(TIMESTAMP, server_default=func.now())
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
class NewDevice(Base):
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
__tablename__ = "new_devices"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
discovered_at = Column(TIMESTAMP, server_default=func.now())
+21
View File
@@ -0,0 +1,21 @@
"""权限数据模型"""
from sqlalchemy import Column, BigInteger, String, Text, ForeignKey, Table
from app.core.database import Base
role_permissions = Table(
'role_permissions',
Base.metadata,
Column('role', String(50), primary_key=True),
Column('permission_id', BigInteger, ForeignKey('permissions.id'), primary_key=True)
)
class Permission(Base):
__tablename__ = "permissions"
id = Column(BigInteger, primary_key=True)
name = Column(String(100), nullable=False)
code = Column(String(50), unique=True, nullable=False)
module = Column(String(50))
description = Column(Text)
+19
View File
@@ -0,0 +1,19 @@
"""用户数据模型"""
from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP
from sqlalchemy.sql import func
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(BigInteger, primary_key=True, index=True)
casdoor_id = Column(String(100), unique=True, nullable=False)
username = Column(String(100), nullable=False)
email = Column(String(255))
role = Column(String(50), default="user")
assigned_area = Column(String(100))
assigned_school = Column(String(200))
is_active = Column(Boolean, default=True)
last_login = Column(TIMESTAMP)
created_at = Column(TIMESTAMP, server_default=func.now())
View File
+20
View File
@@ -0,0 +1,20 @@
"""认证相关 Schema"""
from pydantic import BaseModel
from typing import Optional
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class UserInfo(BaseModel):
id: int
username: str
email: Optional[str]
role: str
assigned_area: Optional[str]
assigned_school: Optional[str]
class Config:
from_attributes = True
+36
View File
@@ -0,0 +1,36 @@
"""设备相关 Schema"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ONUDeviceBase(BaseModel):
mac_address: str
olt_id: Optional[int] = None
region: Optional[str] = None
school_name: Optional[str] = None
building: Optional[str] = None
place_type: Optional[str] = None # 场所类型
room_number: Optional[str] = None
notes: Optional[str] = None # 备注
class ONUDeviceResponse(ONUDeviceBase):
id: int
status: Optional[str] = None
distance_m: Optional[int] = None
slot_number: Optional[int] = None
port_number: Optional[int] = None
port_id: Optional[str] = None # 完整端口标识,如 "1/0/2:4"
model: Optional[str] = None
olt_location: Optional[str] = None # OLT 安装位置
created_at: datetime
class Config:
from_attributes = True
class DeviceListResponse(BaseModel):
total: int
items: list[ONUDeviceResponse]
View File
+263
View File
@@ -0,0 +1,263 @@
"""设备状态检查服务"""
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
from datetime import datetime
import re
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
if not distance_str:
return None
m = re.search(r'\d+', distance_str)
return int(m.group()) if m else None
class CheckService:
"""设备状态检查服务"""
def __init__(self, db: Session):
self.db = db
def update_status_only(self, olt_id: int) -> Dict:
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, _ = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
# 全局 MAC → id 索引,只取需要的字段
existing = {
row.mac_address.lower(): row.id
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
}
for mac, onu_info in onu_info_dict.items():
onu_id = existing.get(mac)
if onu_id is None:
continue # 不在库中,跳过
status = onu_info.status
distance_m = parse_distance(onu_info.distance_str)
if status == 'online':
online_count += 1
else:
offline_count += 1
self.db.add(DeviceStatusHistory(
onu_device_id=onu_id,
status=status,
distance_m=distance_m,
checked_at=checked_at,
response_data=None,
))
self.db.commit()
return {
"online": online_count,
"offline": offline_count,
}
finally:
ssh.close()
def check_single_device(self, device_id: int) -> Dict:
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
命令格式: display onu slot {slot} | include {mac}
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise Exception("设备不存在")
if not device.olt_id:
raise Exception("该设备未关联 OLT,无法查询")
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
raise Exception("关联的 OLT 不存在")
mac = device.mac_address.lower()
base_cmd = olt.slot_command # e.g. "display onu slot"
if device.slot_number is not None:
cmd = f"{base_cmd} {device.slot_number} | include {mac}"
else:
cmd = f"{base_cmd} | include {mac}"
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(cmd)
# 从输出中找到包含该 MAC 的行并解析
info = None
for line in output.splitlines():
if mac in line.lower():
info = ssh._parse_device_line(line, device.slot_number)
if info:
break
if info is None:
# 未找到该 MAC,视为离线
status = 'offline'
distance_m = None
else:
status = info.status
distance_m = parse_distance(info.distance_str)
self.db.add(DeviceStatusHistory(
onu_device_id=device.id,
status=status,
distance_m=distance_m,
checked_at=datetime.utcnow(),
response_data=None,
))
self.db.commit()
return {"status": status, "distance_m": distance_m}
finally:
ssh.close()
def scan_and_discover(self, olt_id: int) -> Dict:
"""扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。
不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
new_count = 0
existing = {
row.mac_address.lower(): row.id
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
}
for mac, onu_info in onu_info_dict.items():
onu_id = existing.get(mac)
if onu_id is None:
# 新设备:只入库在线的
if onu_info.status != 'online':
continue
onu = ONUDevice(
mac_address=mac,
olt_id=olt_id,
slot_number=onu_info.slot_number,
port_number=onu_info.port_number,
port_id=onu_info.port_id,
distance_m=parse_distance(onu_info.distance_str),
loid=onu_info.loid,
model=onu_info.model,
)
self.db.add(onu)
self.db.flush()
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
onu_id = onu.id
new_count += 1
status = onu_info.status
distance_m = parse_distance(onu_info.distance_str)
if status == 'online':
online_count += 1
else:
offline_count += 1
self.db.add(DeviceStatusHistory(
onu_device_id=onu_id,
status=status,
distance_m=distance_m,
checked_at=checked_at,
response_data=None,
))
self._save_duplicate_macs(olt_id, duplicate_dict)
self.db.commit()
return {
"online": online_count,
"offline": offline_count,
"new_discovered": new_count,
}
finally:
ssh.close()
async def scan_olt(self, olt_id: int) -> Dict:
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
# 对比全局 MAC
existing_macs = {
onu.mac_address.lower()
for onu in self.db.query(ONUDevice.mac_address).all()
}
devices = []
for mac, info in onu_info_dict.items():
devices.append({
"mac_address": mac,
"status": info.status,
"distance_m": info.distance_str,
"slot_number": info.slot_number,
"port_number": info.port_number,
"port_id": info.port_id,
"loid": info.loid,
"model": info.model,
"is_new": mac not in existing_macs,
})
duplicates = []
for mac, records in duplicate_dict.items():
duplicates.append({
"mac_address": mac,
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
})
return {
"olt_id": olt_id,
"olt_ip": olt.ip_address,
"total": len(devices),
"new": sum(1 for d in devices if d["is_new"]),
"devices": devices,
"duplicates": duplicates,
}
finally:
ssh.close()
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
for mac, records in duplicate_dict.items():
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
existing = self.db.query(DuplicateMac).filter(
DuplicateMac.olt_id == olt_id,
DuplicateMac.mac_address == mac
).first()
if existing:
existing.ports = ports
existing.last_seen_at = datetime.utcnow()
else:
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
+117
View File
@@ -0,0 +1,117 @@
"""Excel 导入服务"""
import pandas as pd
from typing import List, Dict
from sqlalchemy.orm import Session
from app.models.device import ONUDevice
import math
# 定义字段映射:Excel列名 -> 数据库字段名
FIELD_MAPPING = {
'mac_address': 'mac_address',
'region': 'region',
'school_name': 'school_name',
'building': 'building',
'place_type': 'place_type',
'room_number': 'room_number',
'notes': 'notes',
}
def clean_value(value) -> str:
"""清理单元格值,处理NaN和None"""
if value is None:
return ''
if isinstance(value, float) and math.isnan(value):
return ''
return str(value).strip()
class ImportService:
def __init__(self, db: Session):
self.db = db
def parse_excel(self, file_path: str) -> List[Dict]:
"""解析 Excel 文件"""
df = pd.read_excel(file_path)
# 标准化列名(去除空格,转小写)
df.columns = [col.strip().lower() for col in df.columns]
# 转换为记录列表
records = []
for _, row in df.iterrows():
record = {}
for col_name, db_field in FIELD_MAPPING.items():
if col_name in row:
record[db_field] = clean_value(row[col_name])
records.append(record)
return records
def validate_data(self, records: List[Dict]) -> Dict:
"""验证数据"""
valid = []
invalid = []
for idx, record in enumerate(records):
mac = record.get('mac_address', '').strip()
if not mac:
invalid.append({'record': record, 'error': f'{idx+2}行: MAC地址缺失'})
continue
# 标准化MAC地址:统一使用横杠分隔小写格式
# 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff
mac_clean = mac.upper().replace(':', '-')
# 验证基本格式:12个十六进制字符(可能有分隔符)
hex_chars = mac_clean.replace('-', '')
if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars):
invalid.append({'record': record, 'error': f'{idx+2}行: MAC地址格式错误 "{mac}"'})
continue
# 转换为标准格式 34dc-99c8-56e0(小写4位分组)
mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)])
record['mac_address'] = mac_formatted
valid.append(record)
return {'valid': valid, 'invalid': invalid}
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
"""批量导入设备,存在则更新,不存在则新增"""
success_count = 0
skip_count = 0
invalid_count = 0
for record in records:
mac = record.get('mac_address', '')
if not mac:
skip_count += 1
continue
# 查询是否已存在该 MAC 地址
existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first()
if existing:
# 更新现有记录
existing.region = record.get('region', '')
existing.school_name = record.get('school_name', '')
existing.building = record.get('building') or None
existing.place_type = record.get('place_type') or None
existing.room_number = record.get('room_number') or None
existing.notes = record.get('notes') or None
success_count += 1
else:
# 新增记录
device = ONUDevice(
mac_address=mac,
olt_id=olt_id,
region=record.get('region', ''),
school_name=record.get('school_name', ''),
building=record.get('building') or None,
place_type=record.get('place_type') or None,
room_number=record.get('room_number') or None,
notes=record.get('notes') or None
)
self.db.add(device)
success_count += 1
self.db.commit()
return {'success': success_count}
+114
View File
@@ -0,0 +1,114 @@
"""业务下发服务"""
from typing import Dict
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import ONUDevice, OLTDevice
import time
class ProvisionService:
"""业务下发服务"""
def __init__(self, db: Session):
self.db = db
def provision_device(self, device_id: int) -> Dict[str, any]:
"""
业务下发流程(参照下发业务流程.md):
1. system-view
2. interface Onu{port_id}
3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
4. port link-type trunk
5. undo port trunk permit vlan 1
6. port trunk permit vlan 2000 3000 to 3010 4094
7. save force
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
return {"success": False, "error": f"设备不存在: {device_id}"}
if not device.olt_id:
return {"success": False, "error": "设备未关联 OLT,请先进行扫描"}
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
return {"success": False, "error": "关联的 OLT 设备不存在"}
if not device.port_id:
return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"}
port_name = f"Onu{device.port_id}"
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
ssh.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if ssh.shell.recv_ready():
buf += ssh.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:
raise Exception("进入 system-view 失败")
# 进入端口
out = send_and_wait(f"interface {port_name}", "]")
if "]" not in out:
raise Exception(f"进入端口 {port_name} 失败")
# 配置 VLAN
send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]")
send_and_wait("port link-type trunk", "]")
send_and_wait("undo port trunk permit vlan 1", "]")
send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]")
# 保存配置(等待 "successfully" 出现)
save_out = send_and_wait("save force", "successfully", timeout=30)
if "successfully" not in save_out:
raise Exception("save force 未确认成功,请检查 OLT 日志")
return {
"success": True,
"device_id": device_id,
"mac_address": device.mac_address,
"olt_ip": olt.ip_address,
"port": port_name,
}
except Exception as e:
return {
"success": False,
"device_id": device_id,
"mac_address": device.mac_address,
"error": str(e),
}
finally:
ssh.close()
def batch_provision(self, device_ids: list) -> Dict[str, any]:
results = []
success_count = 0
fail_count = 0
for device_id in device_ids:
result = self.provision_device(device_id)
results.append(result)
if result.get("success"):
success_count += 1
else:
fail_count += 1
return {
"total": len(device_ids),
"success": success_count,
"failed": fail_count,
"results": results,
}
+282
View File
@@ -0,0 +1,282 @@
"""SSH 连接服务"""
import paramiko
import re
import time
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from app.core.config import settings
@dataclass
class ONUInfo:
"""ONU 设备完整信息"""
mac_address: str
status: str # online/offline
distance_m: Optional[int] = None # 距离(米)
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
slot_number: Optional[int] = None # 插槽号
port_number: Optional[int] = None # 端口号
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
loid: Optional[str] = None # LOID
model: Optional[str] = None # 设备型号
class SSHService:
"""SSH 连接和命令执行服务"""
def __init__(self, host: str, username: str, password: str, port: int = 22):
self.host = host
self.username = username
self.password = password
self.port = port
self.client: Optional[paramiko.SSHClient] = None
self.shell = None
def connect(self) -> bool:
"""建立 SSH 连接,等待初始 banner 输出完毕"""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host,
port=self.port,
username=self.username,
password=self.password,
timeout=30
)
self.shell = self.client.invoke_shell(width=200, height=50)
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
deadline = time.time() + 10
buf = ""
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if ">" in buf:
break
else:
time.sleep(0.2)
return True
except Exception as e:
raise Exception(f"SSH 连接失败: {str(e)}")
def execute_command(self, command: str) -> str:
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
if not self.shell:
raise Exception("SSH 未连接")
self.shell.send(command + "\n")
output = ""
# 等待命令回显出现,再开始收集输出
time.sleep(0.5)
deadline = time.time() + 60
while time.time() < deadline:
if self.shell.recv_ready():
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
output += chunk
if "---- More ----" in chunk:
self.shell.send(" ")
time.sleep(0.3)
elif ">" in chunk:
# 命令提示符出现,说明输出完毕
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:
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:
raise Exception("进入 system-view 失败")
# 进入端口
out = send_and_wait(f"interface Onu{port_id}", "]")
if "]" not in out:
raise Exception(f"进入端口 Onu{port_id} 失败")
# 执行 default,等待确认提示
self.shell.send("default\n")
buf = ""
deadline = time.time() + 10
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if "[Y/N]" in buf or "[y/n]" in buf:
break
else:
time.sleep(0.2)
if "[Y/N]" not in buf and "[y/n]" not in buf:
raise Exception("未收到确认提示")
# 确认
self.shell.send("Y\n")
time.sleep(1)
# 排空缓冲区
if self.shell.recv_ready():
self.shell.recv(4096)
# 退出到用户视图
send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5)
return True
def detect_loopback(self) -> dict:
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
output = self.execute_command("display loopback-detection")
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+)\s+', line)
if m:
interfaces.append(m.group(1))
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
def parse_onu_status(self, output: str) -> Dict[str, str]:
"""解析 ONU 状态输出"""
devices = {}
lines = output.split('\n')
for line in lines:
# 匹配包含 MAC 地址的行
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
# 提取状态字段
if 'Up' in line:
devices[mac] = 'online'
elif 'Offline' in line:
devices[mac] = 'offline'
return devices
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
"""增强解析:提取完整 ONU 信息
返回: (unique_devices, duplicate_devices)
- unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口)
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
"""
all_records: Dict[str, List[ONUInfo]] = {}
lines = output.split('\n')
current_slot = None
for line in lines:
# 检测新的插槽区域: Olt1/0/1
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
if slot_match:
current_slot = int(slot_match.group(3))
continue
# 跳过表头行和空行
if 'MAC' in line and 'LOID' in line:
continue
if not line.strip():
continue
# 解析设备行
device = self._parse_device_line(line, current_slot)
if device:
if device.mac_address not in all_records:
all_records[device.mac_address] = []
all_records[device.mac_address].append(device)
unique_devices: Dict[str, ONUInfo] = {}
duplicate_devices: Dict[str, List[ONUInfo]] = {}
for mac, records in all_records.items():
if len(records) == 1:
unique_devices[mac] = records[0]
else:
duplicate_devices[mac] = records
# unique 中保留在线的,若都离线则保留最后一条
online = [r for r in records if r.status == 'online']
unique_devices[mac] = online[0] if online else records[-1]
return unique_devices, duplicate_devices
def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]:
"""解析单行设备信息"""
# 匹配 MAC 地址
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if not mac_match:
return None
mac = mac_match.group(1).lower()
# 提取状态
status = 'offline'
if 'Up' in line:
status = 'online'
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
slot_num, port_num, port_id = None, None, None
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
if port_match:
slot_num = int(port_match.group(3)) # 第三段数字为槽位
port_num = int(port_match.group(4)) # 冒号后为端口号
port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}"
# 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A"
distance_str = None
dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line)
if dist_match:
val = dist_match.group(1)
if val != 'N/A':
distance_str = val
# 提取 LOID - MAC 后第一个非空字段
loid = None
loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE)
if loid_match:
loid_val = loid_match.group(1)
if loid_val != 'N/A' and loid_val.isdigit():
loid = loid_val
# 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A"
model = None
model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line)
if model_match:
potential_model = model_match.group(1)
if potential_model != 'N/A':
model = potential_model
return ONUInfo(
mac_address=mac,
status=status,
distance_m=None,
distance_str=distance_str,
slot_number=slot_num or slot,
port_number=port_num,
port_id=port_id,
loid=loid,
model=model
)
def close(self):
"""关闭 SSH 连接"""
if self.client:
self.client.close()
+4
View File
@@ -0,0 +1,4 @@
"""Celery 任务模块"""
from app.tasks.check_tasks import check_all_devices # noqa: F401
__all__ = ['check_all_devices']
+72
View File
@@ -0,0 +1,72 @@
"""状态检查任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
from app.services.check_service import CheckService
@celery_app.task(bind=True)
def check_all_devices(self):
"""检查所有设备状态"""
db = SessionLocal()
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
try:
service = CheckService(db)
from app.models.device import OLTDevice
olts = db.query(OLTDevice).all()
total = len(olts)
self.update_state(state='PROGRESS', meta={'current': 0, 'total': total, 'status': f'准备检查 {total} 个OLT...'})
results = []
errors = []
total_online = 0
total_offline = 0
for idx, olt in enumerate(olts):
self.update_state(state='PROGRESS', meta={
'current': idx, 'total': total,
'status': f'检查 OLT: {olt.location or olt.ip_address}...'
})
try:
result = service.update_status_only(olt.id)
total_online += result.get('online', 0)
total_offline += result.get('offline', 0)
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'online': result.get('online', 0),
'offline': result.get('offline', 0),
'success': True
})
except Exception as e:
errors.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'error': str(e)
})
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'success': False,
'error': str(e)
})
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
return {
'success': True,
'total_olts': total,
'total_online': total_online,
'total_offline': total_offline,
'results': results,
'errors': errors
}
except Exception as e:
return {
'success': False,
'error': str(e),
'traceback': traceback.format_exc()
}
finally:
db.close()
+5
View File
@@ -0,0 +1,5 @@
"""Celery Worker 入口模块"""
from app.core.celery_app import celery_app
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
__all__ = ['celery_app']
+20
View File
@@ -0,0 +1,20 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
alembic==1.13.1
psycopg2-binary==2.9.9
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
celery==5.3.6
redis==5.0.1
paramiko==3.4.0
pandas==2.1.4
openpyxl==3.1.2
cryptography==42.0.0
casdoor==1.18.0
aiohttp>=3.9.0
PyJWT>=2.8.0
requests>=2.31.0
+11
View File
@@ -0,0 +1,11 @@
"""数据库初始化脚本"""
from app.core.database import engine, Base
from app.models import device, user, permission
def init_db():
"""创建所有表"""
Base.metadata.create_all(bind=engine)
print("数据库表创建完成")
if __name__ == "__main__":
init_db()
Binary file not shown.
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
"""测试 ONU 状态解析"""
import re
def parse_onu_status(output: str):
"""解析 ONU 状态"""
devices = {}
lines = output.split('\n')
for line in lines:
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
if 'Up' in line:
devices[mac] = 'online'
elif 'Offline' in line:
devices[mac] = 'offline'
return devices
# 测试数据
test_output = """
1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A
1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A
1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
"""
result = parse_onu_status(test_output)
print("解析结果:")
for mac, status in result.items():
print(f" {mac}: {status}")
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIE2TCCAsGgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCYxDjAMBgNVBAoTBWFk
bWluMRQwEgYDVQQDDAtjZXJ0X3drMWVlZjAeFw0yNTEyMTAxMDAzMzVaFw00NTEy
MTAxMDAzMzVaMCYxDjAMBgNVBAoTBWFkbWluMRQwEgYDVQQDDAtjZXJ0X3drMWVl
ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK78ajnNnwnutdZ48l92
hmqa2c8lP1IcpyB7CYVTKurQxSz5iQorYOVhR1UzluSpU8yiPPeFyTRD0pH+DzqG
otc+Alvxnka5DfP1z/P0asogkALJXouRR+YtUY6i1oo5tKlDbIGJNO2aQcOOak9b
YoqSRd9y/pq/bPjte7oww29hGQc03LgbNXmIb9n5NGznWCte1c88NUrTz9Dlpn2H
r3ncOmHpqpzg5NtXughnHsF4YCF+pPIgWlC0C6MtKk0fuJysCY5wpuA620pGL4zO
6QFv920MiWsdVWxcNo0aNQkXKGjEcy3LraXux2k/sH+E0e3GRXGhWYnkrK7i8kg+
V+Nm1YFgyGbY7V3yZ0mXPxb+iMlIvz885ViGsRnKqlR0pN1v8NbmzXPu9EkAO3wi
T/L4fxnETW1hd/ph+AdQ0jEpeyAMRjcl3kMjauOBqfU/THl7L6aUMXB3d05+JK0Y
uTc1nrZ0Qjh/5EG5XyvRSuKNVxpCB1XcAlAaBTuE5art6jQkCJINvFoOjoHZB65K
HJVvfMtMuVr7dLSbYOPHH6YJB2fUijNKoXnclAbxldX3fEALsC/SW7zMFF+3FThg
Seq3OEpNdbkRlQp5sORHeTkWtMO9A60GHvTqDamIqppC4fk71zIRpmEPlfGsOno5
gNhH8+NKH5Cvmfj4s7/S98D/AgMBAAGjEDAOMAwGA1UdEwEB/wQCMAAwDQYJKoZI
hvcNAQELBQADggIBAJLW6zjDKZdkfq00r88lM1IxaxRWmUEPQjINOa0GZH/DyAtd
fgYf35AN+NDEiIiDnJQTR3N8jbX1JySDClEOzv3wLXRiKhWcX8z4HOoieNmMu9lZ
6pf1lEaMX8WNxB475nfOueEEyHegbsbfpmYM/aVAiSxaKb87if7uDnxJeGqK2Ba0
c3LJnQ+H87gcAb84G78KXn/XwjRtwaf+Gy06EzycEnckEeN0vni0pRdw8coSudcX
726Kb+kZ4961G/XxQx4fKX+E4lXkx1CdxPBAws/uOjfciLnuVdT4yp6URXJKRw+/
R+flPtfvHXNDXB8BHj9ul4UJ4oSHGppx0BR80CmWWNDVdaoO+o0WieFOXG2U2PB+
p2pleJzckTDuOh5wIe2GFj9WKZTIn4S7ZA6hS5j5ea4bOX5m6cxXVaS5FPHHfHcN
FyUOEE4f61tv9omsFyl23YD/gmQzotFY+5YPS8DTb1soSzydKQODCq7SobyK8QFo
3FnOw1cnKuWDZbqkZS6TYHFc3doNaH77bBxgSgIBPlJewd98eZZCv+m8/mCOXCbo
+7HOgmOHJ1cOEtweCtgrCc2VNvV0w6RSwcZ+WmUAZMajvyEqikVVQzR2LtOOhOyr
REkkNRAX3PX69GDQLM9QSgGLv0f6nA5uTX9SFj71Iik6YAuUsvIK6uPpn/2B
-----END CERTIFICATE-----
+185
View File
@@ -0,0 +1,185 @@
# H3C ONU设备管理系统 - 环境变量配置示例
# 复制此文件为 .env 并修改相应配置
# ============================================
# 应用基础配置
# ============================================
# 应用环境 (development, production)
APP_ENV=production
# 调试模式 (true/false)
DEBUG=false
# 应用密钥 (使用 openssl rand -hex 32 生成)
SECRET_KEY=your-secret-key-change-in-production
# 时区设置
TZ=Asia/Shanghai
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
# PostgreSQL连接字符串
# 格式: postgresql://username:password@host:port/database
DATABASE_URL=postgresql://h3c_user:your_password@postgres-host:5432/h3c_onu_ms
# 数据库连接池配置
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=40
DATABASE_POOL_RECYCLE=3600
# ============================================
# Redis配置
# ============================================
# Redis连接URL (如果使用外部Redis,修改为外部地址)
REDIS_URL=redis://:redispass@redis:6379/0
# Redis密码 (容器内Redis使用)
REDIS_PASSWORD=redispass
# Redis连接池大小
REDIS_POOL_SIZE=10
# ============================================
# Casdoor认证配置
# ============================================
# Casdoor服务地址
CASDOOR_ENDPOINT=https://casdoor.example.com
# 应用Client ID
CASDOOR_CLIENT_ID=your_casdoor_client_id
# 应用Client Secret
CASDOOR_CLIENT_SECRET=your_casdoor_client_secret
# 应用Certificate
CASDOOR_CERTIFICATE=your_casdoor_certificate
# 组织名称
CASDOOR_ORG_NAME=your_organization
# 应用名称
CASDOOR_APP_NAME=h3c-onu-ms
# ============================================
# SSH连接配置
# ============================================
# SSH连接超时时间(秒)
SSH_TIMEOUT=30
# 最大并发连接数
SSH_MAX_CONNECTIONS=10
# SSH重试次数
SSH_RETRY_COUNT=3
# SSH连接保持时间(秒)
SSH_KEEPALIVE=60
# ============================================
# 任务调度配置
# ============================================
# 自动检查间隔(秒) - 30分钟
CHECK_INTERVAL=1800
# 手动刷新冷却时间(秒) - 5分钟
MANUAL_COOLDOWN=300
# 历史记录保留天数
HISTORY_RETENTION=90
# 批量检查设备数量
BATCH_CHECK_SIZE=100
# ============================================
# 前端配置
# ============================================
# API基础URL (前端访问后端地址)
VITE_API_BASE_URL=http://localhost:8000
# 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
# 应用标题
VITE_APP_TITLE=H3C ONU设备管理系统
# ============================================
# 日志配置
# ============================================
# 日志级别 (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# 日志文件路径
LOG_FILE=/app/logs/app.log
# 日志轮转大小
LOG_ROTATION=10MB
# 日志保留天数
LOG_RETENTION=30
# ============================================
# 邮件通知配置 (可选)
# ============================================
# SMTP服务器
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=your_email@example.com
# SMTP_PASSWORD=your_email_password
# SMTP_FROM=noreply@example.com
# ============================================
# 监控配置
# ============================================
# 健康检查端口
HEALTH_CHECK_PORT=8000
# 监控指标端口
METRICS_PORT=8000
# ============================================
# 安全配置
# ============================================
# CORS允许的域名 (逗号分隔)
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
# 速率限制配置
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_PER_HOUR=1000
# ============================================
# 备份配置
# ============================================
# 数据库备份目录
BACKUP_DIR=/app/backups
# 备份保留天数
BACKUP_RETENTION=30
# 自动备份时间 (cron格式)
BACKUP_SCHEDULE="0 2 * * *"
# ============================================
# 开发环境覆盖配置
# ============================================
# 如果是开发环境,取消注释以下配置
# DEBUG=true
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
# REDIS_URL=redis://localhost:6379/0
# VITE_API_BASE_URL=http://localhost:8000
+104
View File
@@ -0,0 +1,104 @@
services:
# 后端API服务
backend:
build:
context: ../backend
dockerfile: Dockerfile
container_name: h3c-onu-ms-backend
ports:
- "8001:8000"
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- SECRET_KEY=${SECRET_KEY}
- DEBUG=${DEBUG:-false}
volumes:
- ../backend/logs:/app/logs
- ../backend/static:/app/static
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Celery Worker - 处理异步任务
celery-worker:
build:
context: ../backend
dockerfile: Dockerfile
container_name: h3c-onu-ms-celery-worker
command: celery -A app.core.celery_app worker --loglevel=info --concurrency=4
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- SECRET_KEY=${SECRET_KEY}
volumes:
- ../backend/logs:/app/logs
depends_on:
- backend
restart: unless-stopped
# Celery Beat - 定时任务调度
celery-beat:
build:
context: ../backend
dockerfile: Dockerfile
container_name: h3c-onu-ms-celery-beat
command: celery -A app.core.celery_app beat --loglevel=info
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- SECRET_KEY=${SECRET_KEY}
volumes:
- ../backend/logs:/app/logs
- ../backend/celerybeat-schedule:/app/celerybeat-schedule
depends_on:
- backend
restart: unless-stopped
# 前端应用
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
container_name: h3c-onu-ms-frontend
ports:
- "8081:80"
environment:
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
- 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:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
networks:
default:
name: h3c-onu-ms-network
+165
View File
@@ -0,0 +1,165 @@
# H3C ONU设备管理系统 - Nginx站点配置
# 前端静态文件服务
server {
listen 80;
server_name localhost;
# 根目录指向前端构建文件
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip_static on;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 前端路由支持 (Vue Router history模式)
location / {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# API代理到后端
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲区设置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
# 禁用代理缓存
proxy_cache off;
}
# WebSocket支持
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket超时设置
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 静态文件代理 (后端静态文件)
location /static/ {
alias /usr/share/nginx/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 健康检查端点
location /health {
proxy_pass http://backend:8000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
access_log off;
}
# 监控指标端点
location /metrics {
proxy_pass http://backend:8000/metrics;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
access_log off;
}
# API文档
location /docs {
proxy_pass http://backend:8000/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /redoc {
proxy_pass http://backend:8000/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 错误页面
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
# HTTPS配置 (需要SSL证书)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# # SSL证书配置
# ssl_certificate /etc/nginx/ssl/your-domain.com.crt;
# ssl_certificate_key /etc/nginx/ssl/your-domain.com.key;
#
# # SSL优化配置
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
# ssl_prefer_server_ciphers off;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
#
# # HSTS头
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
#
# # 其他配置与HTTP相同
# root /usr/share/nginx/html;
# index index.html;
#
# location / {
# try_files $uri $uri/ /index.html;
# }
#
# location /api/ {
# proxy_pass http://backend:8000;
# # ... 其他代理配置
# }
#
# # 强制HTTP重定向到HTTPS
# # if ($scheme != "https") {
# # return 301 https://$host$request_uri;
# # }
# }
# 重定向HTTP到HTTPS (如果启用HTTPS)
# server {
# listen 80;
# server_name your-domain.com;
# return 301 https://$server_name$request_uri;
# }
+56
View File
@@ -0,0 +1,56 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# 基础配置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 安全头
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;
# 包含站点配置
include /etc/nginx/conf.d/*.conf;
}
+362
View File
@@ -0,0 +1,362 @@
#!/bin/bash
# H3C ONU设备管理系统 - 部署脚本
# 使用方法: ./deploy.sh [环境] [操作]
# 环境: dev, prod (默认: prod)
# 操作: up, down, restart, logs, build (默认: up)
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 默认值
ENV=${1:-prod}
ACTION=${2:-up}
COMPOSE_FILE="docker-compose.yml"
# 根据环境选择配置文件
if [ "$ENV" = "dev" ]; then
COMPOSE_FILE="docker-compose.dev.yml"
echo -e "${BLUE}使用开发环境配置${NC}"
elif [ "$ENV" = "prod" ]; then
COMPOSE_FILE="docker-compose.yml"
echo -e "${BLUE}使用生产环境配置${NC}"
else
echo -e "${RED}错误: 未知环境 '$ENV'${NC}"
echo "可用环境: dev, prod"
exit 1
fi
# 检查Docker Compose文件是否存在
if [ ! -f "$COMPOSE_FILE" ]; then
echo -e "${RED}错误: Docker Compose文件 '$COMPOSE_FILE' 不存在${NC}"
exit 1
fi
# 检查环境变量文件
if [ ! -f ".env" ]; then
echo -e "${YELLOW}警告: .env 文件不存在,从 .env.example 复制${NC}"
if [ -f ".env.example" ]; then
cp .env.example .env
echo -e "${YELLOW}请编辑 .env 文件并设置正确的配置${NC}"
exit 1
else
echo -e "${RED}错误: .env.example 文件也不存在${NC}"
exit 1
fi
fi
# 加载环境变量
set -a
source .env
set +a
# 函数:显示帮助信息
show_help() {
echo "H3C ONU设备管理系统 - 部署脚本"
echo ""
echo "使用方法: $0 [环境] [操作]"
echo ""
echo "环境:"
echo " dev 开发环境"
echo " prod 生产环境 (默认)"
echo ""
echo "操作:"
echo " up 启动服务 (默认)"
echo " down 停止服务"
echo " restart 重启服务"
echo " logs 查看日志"
echo " build 构建镜像"
echo " ps 查看服务状态"
echo " exec 进入容器"
echo " backup 备份数据"
echo " restore 恢复数据"
echo ""
echo "示例:"
echo " $0 prod up # 启动生产环境"
echo " $0 dev logs # 查看开发环境日志"
echo " $0 prod restart # 重启生产环境"
}
# 函数:检查Docker是否运行
check_docker() {
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}错误: Docker未运行或当前用户无权限${NC}"
exit 1
fi
}
# 函数:检查Docker Compose是否安装
check_docker_compose() {
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}错误: Docker Compose未安装${NC}"
exit 1
fi
}
# 函数:启动服务
start_services() {
echo -e "${GREEN}启动服务...${NC}"
docker-compose -f "$COMPOSE_FILE" up -d
# 等待服务启动
echo -e "${BLUE}等待服务启动...${NC}"
sleep 10
# 检查服务状态
check_services_status
}
# 函数:停止服务
stop_services() {
echo -e "${YELLOW}停止服务...${NC}"
docker-compose -f "$COMPOSE_FILE" down
}
# 函数:重启服务
restart_services() {
echo -e "${YELLOW}重启服务...${NC}"
docker-compose -f "$COMPOSE_FILE" restart
}
# 函数:查看日志
show_logs() {
echo -e "${BLUE}查看日志 (Ctrl+C退出)...${NC}"
docker-compose -f "$COMPOSE_FILE" logs -f
}
# 函数:构建镜像
build_images() {
echo -e "${GREEN}构建Docker镜像...${NC}"
docker-compose -f "$COMPOSE_FILE" build --no-cache
# 清理未使用的镜像
echo -e "${BLUE}清理未使用的镜像...${NC}"
docker image prune -f
}
# 函数:查看服务状态
show_status() {
echo -e "${BLUE}服务状态:${NC}"
docker-compose -f "$COMPOSE_FILE" ps
echo -e "\n${BLUE}容器资源使用:${NC}"
docker stats --no-stream $(docker-compose -f "$COMPOSE_FILE" ps -q)
}
# 函数:进入容器
exec_container() {
echo -e "${BLUE}选择要进入的容器:${NC}"
echo "1) backend (后端API)"
echo "2) frontend (前端)"
echo "3) celery-worker (Celery Worker)"
echo "4) celery-beat (Celery Beat)"
echo "5) nginx (Nginx)"
echo "6) redis (Redis)"
read -p "请输入数字 (1-6): " choice
case $choice in
1) CONTAINER="backend" ;;
2) CONTAINER="frontend" ;;
3) CONTAINER="celery-worker" ;;
4) CONTAINER="celery-beat" ;;
5) CONTAINER="nginx" ;;
6) CONTAINER="redis" ;;
*)
echo -e "${RED}无效的选择${NC}"
exit 1
;;
esac
echo -e "${GREEN}进入 $CONTAINER 容器...${NC}"
docker-compose -f "$COMPOSE_FILE" exec "$CONTAINER" sh
}
# 函数:备份数据
backup_data() {
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
echo -e "${GREEN}开始备份数据...${NC}"
# 备份数据库
echo -e "${BLUE}备份数据库...${NC}"
DB_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q backend)
if [ -n "$DB_CONTAINER" ]; then
docker exec "$DB_CONTAINER" pg_dump "$DATABASE_URL" > "$BACKUP_DIR/database.sql"
echo "数据库备份完成: $BACKUP_DIR/database.sql"
else
echo -e "${YELLOW}警告: 数据库容器未运行${NC}"
fi
# 备份Redis数据
echo -e "${BLUE}备份Redis数据...${NC}"
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
if [ -n "$REDIS_CONTAINER" ]; then
docker exec "$REDIS_CONTAINER" redis-cli --rdb /data/dump.rdb
docker cp "$REDIS_CONTAINER:/data/dump.rdb" "$BACKUP_DIR/redis.rdb"
echo "Redis备份完成: $BACKUP_DIR/redis.rdb"
else
echo -e "${YELLOW}警告: Redis容器未运行${NC}"
fi
# 备份配置文件
echo -e "${BLUE}备份配置文件...${NC}"
cp .env "$BACKUP_DIR/"
cp "$COMPOSE_FILE" "$BACKUP_DIR/"
# 创建备份信息文件
echo "备份时间: $(date)" > "$BACKUP_DIR/backup.info"
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
# 压缩备份文件
echo -e "${BLUE}压缩备份文件...${NC}"
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
rm -rf "$BACKUP_DIR"
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}"
}
# 函数:恢复数据
restore_data() {
echo -e "${YELLOW}警告: 恢复数据将覆盖现有数据${NC}"
read -p "请输入要恢复的备份文件路径: " BACKUP_FILE
if [ ! -f "$BACKUP_FILE" ]; then
echo -e "${RED}错误: 备份文件不存在${NC}"
exit 1
fi
# 解压备份文件
BACKUP_DIR="${BACKUP_FILE%.tar.gz}"
echo -e "${BLUE}解压备份文件...${NC}"
tar -xzf "$BACKUP_FILE"
# 停止服务
echo -e "${BLUE}停止服务...${NC}"
docker-compose -f "$COMPOSE_FILE" down
# 恢复数据库
if [ -f "$BACKUP_DIR/database.sql" ]; then
echo -e "${BLUE}恢复数据库...${NC}"
# 这里需要根据实际情况调整数据库恢复命令
# 例如: psql -d database -f backup.sql
echo "数据库恢复命令需要根据实际情况配置"
fi
# 恢复Redis
if [ -f "$BACKUP_DIR/redis.rdb" ]; then
echo -e "${BLUE}恢复Redis数据...${NC}"
# 这里需要根据实际情况调整Redis恢复命令
echo "Redis恢复命令需要根据实际情况配置"
fi
# 恢复配置文件
if [ -f "$BACKUP_DIR/.env" ]; then
echo -e "${BLUE}恢复配置文件...${NC}"
cp "$BACKUP_DIR/.env" .
fi
# 清理临时文件
rm -rf "$BACKUP_DIR"
# 启动服务
echo -e "${BLUE}启动服务...${NC}"
docker-compose -f "$COMPOSE_FILE" up -d
echo -e "${GREEN}数据恢复完成${NC}"
}
# 函数:检查服务状态
check_services_status() {
echo -e "${BLUE}检查服务健康状态...${NC}"
# 检查后端服务
if curl -f http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ 后端服务运行正常${NC}"
else
echo -e "${RED}✗ 后端服务异常${NC}"
fi
# 检查前端服务
if curl -f http://localhost:8080 > /dev/null 2>&1; then
echo -e "${GREEN}✓ 前端服务运行正常${NC}"
else
echo -e "${RED}✗ 前端服务异常${NC}"
fi
# 检查Redis
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
if [ -n "$REDIS_CONTAINER" ] && docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
echo -e "${GREEN}✓ Redis服务运行正常${NC}"
else
echo -e "${RED}✗ Redis服务异常${NC}"
fi
echo -e "\n${GREEN}部署完成!${NC}"
echo -e "前端访问: ${BLUE}http://localhost:8080${NC}"
echo -e "API文档: ${BLUE}http://localhost:8000/docs${NC}"
echo -e "健康检查: ${BLUE}http://localhost:8000/health${NC}"
}
# 主程序
main() {
# 显示标题
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} H3C ONU设备管理系统 - 部署工具${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查依赖
check_docker
check_docker_compose
# 根据操作执行相应命令
case "$ACTION" in
up)
start_services
;;
down)
stop_services
;;
restart)
restart_services
;;
logs)
show_logs
;;
build)
build_images
;;
ps)
show_status
;;
exec)
exec_container
;;
backup)
backup_data
;;
restore)
restore_data
;;
help|--help|-h)
show_help
;;
*)
echo -e "${RED}错误: 未知操作 '$ACTION'${NC}"
show_help
exit 1
;;
esac
}
# 执行主程序
main "$@"
+389
View File
@@ -0,0 +1,389 @@
#!/bin/bash
# H3C ONU设备管理系统 - 数据库初始化脚本
# 用于初始化数据库和创建基础数据
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 加载环境变量
if [ -f "../.env" ]; then
set -a
source ../.env
set +a
else
echo -e "${RED}错误: .env 文件不存在${NC}"
echo "请先复制 .env.example 为 .env 并配置数据库连接"
exit 1
fi
# 检查数据库连接字符串
if [ -z "$DATABASE_URL" ]; then
echo -e "${RED}错误: DATABASE_URL 未设置${NC}"
exit 1
fi
# 从DATABASE_URL解析连接信息
parse_db_url() {
local url="$1"
# 格式: postgresql://username:password@host:port/database
local pattern="postgresql://([^:]+):([^@]+)@([^:]+):([^/]+)/(.+)"
if [[ $url =~ $pattern ]]; then
DB_USER="${BASH_REMATCH[1]}"
DB_PASSWORD="${BASH_REMATCH[2]}"
DB_HOST="${BASH_REMATCH[3]}"
DB_PORT="${BASH_REMATCH[4]}"
DB_NAME="${BASH_REMATCH[5]}"
else
echo -e "${RED}错误: 无法解析DATABASE_URL${NC}"
exit 1
fi
}
parse_db_url "$DATABASE_URL"
# 函数:检查PostgreSQL连接
check_postgres_connection() {
echo -e "${BLUE}检查PostgreSQL连接...${NC}"
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "SELECT 1;" > /dev/null 2>&1; then
echo -e "${GREEN}✓ PostgreSQL连接成功${NC}"
return 0
else
echo -e "${RED}✗ PostgreSQL连接失败${NC}"
return 1
fi
}
# 函数:检查数据库是否存在
check_database_exists() {
echo -e "${BLUE}检查数据库 '$DB_NAME' 是否存在...${NC}"
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then
echo -e "${GREEN}✓ 数据库已存在${NC}"
return 0
else
echo -e "${YELLOW}数据库不存在${NC}"
return 1
fi
}
# 函数:创建数据库
create_database() {
echo -e "${BLUE}创建数据库 '$DB_NAME'...${NC}"
# 创建数据库
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres <<EOF
CREATE DATABASE $DB_NAME;
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ 数据库创建成功${NC}"
else
echo -e "${RED}✗ 数据库创建失败${NC}"
exit 1
fi
}
# 函数:创建数据库用户(如果不存在)
create_database_user() {
echo -e "${BLUE}检查数据库用户 '$DB_USER'...${NC}"
# 检查用户是否存在
USER_EXISTS=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")
if [ "$USER_EXISTS" = "1" ]; then
echo -e "${GREEN}✓ 用户已存在${NC}"
else
echo -e "${BLUE}创建用户 '$DB_USER'...${NC}"
# 需要以postgres用户创建新用户
# 这里假设有postgres用户的密码或者使用trust认证
echo -e "${YELLOW}注意: 需要以postgres用户权限创建新用户${NC}"
echo "请手动执行以下命令:"
echo " psql -h $DB_HOST -p $DB_PORT -U postgres -d postgres"
echo " CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
echo " GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
read -p "是否已手动创建用户? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}跳过用户创建${NC}"
fi
fi
}
# 函数:授予权限
grant_privileges() {
echo -e "${BLUE}授予用户权限...${NC}"
# 授予数据库权限
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres <<EOF
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
EOF
# 授予schema权限
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOF
GRANT ALL ON SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
EOF
echo -e "${GREEN}✓ 权限授予成功${NC}"
}
# 函数:运行数据库迁移
run_migrations() {
echo -e "${BLUE}运行数据库迁移...${NC}"
# 检查后端目录是否存在
if [ ! -d "../../backend" ]; then
echo -e "${YELLOW}警告: 后端目录不存在,跳过迁移${NC}"
return
fi
# 进入后端目录
cd "../../backend"
# 检查Alembic配置
if [ ! -f "alembic.ini" ]; then
echo -e "${YELLOW}警告: alembic.ini 不存在,跳过迁移${NC}"
cd - > /dev/null
return
fi
# 运行迁移
if command -v alembic &> /dev/null; then
# 设置数据库URL环境变量
export DATABASE_URL
# 初始化迁移(如果第一次运行)
if [ ! -d "alembic/versions" ]; then
echo -e "${BLUE}初始化Alembic迁移...${NC}"
alembic init alembic
# 需要手动配置alembic.ini中的sqlalchemy.url
fi
# 创建迁移版本
echo -e "${BLUE}创建迁移版本...${NC}"
alembic revision --autogenerate -m "初始迁移"
# 应用迁移
echo -e "${BLUE}应用迁移...${NC}"
alembic upgrade head
echo -e "${GREEN}✓ 数据库迁移完成${NC}"
else
echo -e "${YELLOW}警告: alembic 命令未找到,跳过迁移${NC}"
echo "请安装: pip install alembic"
fi
# 返回原目录
cd - > /dev/null
}
# 函数:创建基础数据
create_base_data() {
echo -e "${BLUE}创建基础数据...${NC}"
# 检查数据库连接
if ! check_database_exists; then
echo -e "${RED}错误: 数据库不存在,无法创建基础数据${NC}"
return 1
fi
# 创建基础数据SQL
BASE_DATA_SQL=$(cat <<EOF
-- 创建系统配置
INSERT INTO system_config (config_key, config_value, description) VALUES
('check_interval', '1800', '自动检查间隔(秒)'),
('manual_cooldown', '300', '手动刷新冷却时间(秒)'),
('history_retention', '90', '历史记录保留天数'),
('ssh_timeout', '30', 'SSH连接超时时间'),
('ssh_max_connections', '10', 'SSH最大连接数'),
('batch_check_size', '100', '批量检查设备数量')
ON CONFLICT (config_key) DO NOTHING;
-- 创建默认角色
INSERT INTO roles (name, description, is_system) VALUES
('super_admin', '超级管理员', true),
('admin', '管理员', true),
('area_admin', '区域管理员', true),
('school_admin', '学校管理员', true),
('operator', '操作员', true),
('viewer', '查看员', true)
ON CONFLICT (name) DO NOTHING;
-- 创建基础权限
INSERT INTO permissions (name, code, module, description) VALUES
-- 设备模块权限
('设备查看', 'device_view', 'device', '查看设备信息'),
('设备编辑', 'device_edit', 'device', '编辑设备信息'),
('设备删除', 'device_delete', 'device', '删除设备'),
('设备导入', 'device_import', 'device', '导入设备数据'),
('设备检查', 'device_check', 'device', '检查设备状态'),
-- 用户模块权限
('用户查看', 'user_view', 'user', '查看用户信息'),
('用户管理', 'user_manage', 'user', '管理用户'),
('角色管理', 'role_manage', 'user', '管理角色'),
('权限管理', 'permission_manage', 'user', '管理权限'),
-- 系统模块权限
('系统配置', 'system_config', 'system', '系统配置管理'),
('OLT管理', 'olt_manage', 'system', 'OLT设备管理'),
('任务管理', 'task_manage', 'system', '任务配置管理'),
('审核管理', 'audit_manage', 'system', '审核流程管理'),
-- 统计模块权限
('统计查看', 'stats_view', 'stats', '查看统计信息'),
('报表导出', 'report_export', 'stats', '导出报表')
ON CONFLICT (code) DO NOTHING;
-- 为超级管理员角色分配所有权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
-- 为管理员角色分配大部分权限(除了用户管理相关)
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'admin'
AND p.code NOT IN ('user_manage', 'role_manage', 'permission_manage')
ON CONFLICT DO NOTHING;
-- 为区域管理员分配基础权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'area_admin'
AND p.code IN ('device_view', 'device_edit', 'device_check', 'stats_view')
ON CONFLICT DO NOTHING;
-- 为学校管理员分配基础权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'school_admin'
AND p.code IN ('device_view', 'device_edit', 'device_check')
ON CONFLICT DO NOTHING;
-- 为操作员分配基础权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'operator'
AND p.code IN ('device_view', 'device_edit', 'device_check')
ON CONFLICT DO NOTHING;
-- 为查看员分配只读权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'viewer'
AND p.code IN ('device_view', 'stats_view')
ON CONFLICT DO NOTHING;
-- 创建默认系统用户(通过Casdoor同步,这里只是占位)
INSERT INTO users (casdoor_id, username, email, role, is_active) VALUES
('system', '系统用户', 'system@example.com', 'super_admin', true)
ON CONFLICT (casdoor_id) DO NOTHING;
EOF
)
# 执行SQL
echo "$BASE_DATA_SQL" | PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME"
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ 基础数据创建成功${NC}"
else
echo -e "${YELLOW}警告: 基础数据创建可能失败,表可能不存在${NC}"
echo "请先运行数据库迁移"
fi
}
# 函数:显示数据库信息
show_database_info() {
echo -e "\n${GREEN}========================================${NC}"
echo -e "${GREEN} 数据库初始化完成${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}数据库连接信息:${NC}"
echo " 主机: $DB_HOST"
echo " 端口: $DB_PORT"
echo " 数据库: $DB_NAME"
echo " 用户: $DB_USER"
echo ""
echo -e "${BLUE}连接字符串:${NC}"
echo " $DATABASE_URL"
echo ""
echo -e "${BLUE}下一步:${NC}"
echo " 1. 启动后端服务运行数据库迁移"
echo " 2. 配置Casdoor认证"
echo " 3. 导入设备数据"
echo ""
}
# 主程序
main() {
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} H3C ONU设备管理系统 - 数据库初始化${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查PostgreSQL连接
if ! check_postgres_connection; then
echo -e "${RED}请检查PostgreSQL服务是否运行,以及连接信息是否正确${NC}"
exit 1
fi
# 检查数据库是否存在
if check_database_exists; then
echo -e "${YELLOW}数据库已存在,是否重新初始化?${NC}"
read -p "重新初始化将删除现有数据! (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}删除数据库 '$DB_NAME'...${NC}"
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
create_database
else
echo -e "${YELLOW}跳过数据库创建${NC}"
fi
else
create_database
fi
# 创建用户和授予权限
create_database_user
grant_privileges
# 运行迁移(如果后端代码已存在)
run_migrations
# 创建基础数据
create_base_data
# 显示完成信息
show_database_info
}
# 执行主程序
main "$@"
+368
View File
@@ -0,0 +1,368 @@
#!/bin/bash
# H3C ONU设备管理系统 - 系统监控脚本
# 用于监控系统状态、资源使用和性能指标
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 默认值
INTERVAL=${1:-5} # 监控间隔(秒)
DURATION=${2:-0} # 监控时长(0表示无限)
COMPOSE_FILE="docker-compose.yml"
# 加载环境变量
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
# 函数:显示帮助信息
show_help() {
echo "H3C ONU设备管理系统 - 系统监控脚本"
echo ""
echo "使用方法: $0 [间隔] [时长]"
echo ""
echo "参数:"
echo " 间隔 监控间隔(秒,默认: 5)"
echo " 时长 监控总时长(秒,0表示无限,默认: 0)"
echo ""
echo "示例:"
echo " $0 # 每5秒监控一次,无限时长"
echo " $0 10 60 # 每10秒监控一次,持续60秒"
echo ""
}
# 函数:检查Docker Compose服务状态
check_services_status() {
echo -e "${BLUE}=== 服务状态 ===${NC}"
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}错误: docker-compose 未安装${NC}"
return
fi
# 获取服务状态
SERVICES=$(docker-compose -f "$COMPOSE_FILE" ps --services)
for service in $SERVICES; do
STATUS=$(docker-compose -f "$COMPOSE_FILE" ps --filter "name=$service" --format "table {{.Status}}" | tail -1)
CONTAINER_ID=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service")
if [ -n "$CONTAINER_ID" ]; then
# 检查容器是否运行
if docker inspect --format='{{.State.Status}}' "$CONTAINER_ID" | grep -q "running"; then
# 检查健康状态
HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}no healthcheck{{end}}' "$CONTAINER_ID")
if [ "$HEALTH" = "healthy" ]; then
echo -e "${GREEN}$service: 运行中 (健康)${NC}"
elif [ "$HEALTH" = "no healthcheck" ]; then
echo -e "${GREEN}$service: 运行中${NC}"
else
echo -e "${YELLOW}$service: 运行中 ($HEALTH)${NC}"
fi
else
echo -e "${RED}$service: 未运行${NC}"
fi
else
echo -e "${RED}$service: 容器不存在${NC}"
fi
done
}
# 函数:检查系统资源使用
check_system_resources() {
echo -e "\n${BLUE}=== 系统资源 ===${NC}"
# CPU使用率
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)
echo -e "CPU使用率: ${CYAN}${CPU_USAGE}%${NC}"
# 内存使用
MEM_TOTAL=$(free -m | awk '/Mem:/ {print $2}')
MEM_USED=$(free -m | awk '/Mem:/ {print $3}')
MEM_PERCENT=$((MEM_USED * 100 / MEM_TOTAL))
if [ $MEM_PERCENT -lt 70 ]; then
echo -e "内存使用: ${GREEN}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
elif [ $MEM_PERCENT -lt 90 ]; then
echo -e "内存使用: ${YELLOW}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
else
echo -e "内存使用: ${RED}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
fi
# 磁盘使用
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | cut -d'%' -f1)
DISK_TOTAL=$(df -h / | awk 'NR==2 {print $2}')
DISK_USED=$(df -h / | awk 'NR==2 {print $3}')
if [ $DISK_USAGE -lt 70 ]; then
echo -e "磁盘使用: ${GREEN}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
elif [ $DISK_USAGE -lt 90 ]; then
echo -e "磁盘使用: ${YELLOW}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
else
echo -e "磁盘使用: ${RED}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
fi
}
# 函数:检查容器资源使用
check_container_resources() {
echo -e "\n${BLUE}=== 容器资源 ===${NC}"
if ! command -v docker &> /dev/null; then
echo -e "${RED}错误: docker 未安装${NC}"
return
fi
# 获取所有运行中的容器
CONTAINERS=$(docker ps --format "{{.Names}}")
for container in $CONTAINERS; do
# 获取容器资源使用
STATS=$(docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}} {{.MemPerc}}" "$container" 2>/dev/null || echo "0% 0B/0B 0%")
CPU=$(echo "$STATS" | awk '{print $1}')
MEM_USAGE=$(echo "$STATS" | awk '{print $2}')
MEM_PERCENT=$(echo "$STATS" | awk '{print $3}' | cut -d'%' -f1)
# 检查是否属于本项目
if echo "$container" | grep -q "h3c-onu-ms"; then
COLOR="$CYAN"
else
COLOR="$PURPLE"
fi
echo -e "${COLOR}$container${NC}: CPU=${CPU}, 内存=${MEM_USAGE} (${MEM_PERCENT}%)"
done
}
# 函数:检查应用健康状态
check_application_health() {
echo -e "\n${BLUE}=== 应用健康 ===${NC}"
# 检查后端API
if curl -s -f http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ 后端API: 健康${NC}"
# 获取详细健康信息
HEALTH_INFO=$(curl -s http://localhost:8000/health)
echo " 详细信息: $HEALTH_INFO"
else
echo -e "${RED}✗ 后端API: 不可用${NC}"
fi
# 检查前端
if curl -s -f http://localhost:8080 > /dev/null 2>&1; then
echo -e "${GREEN}✓ 前端应用: 可访问${NC}"
else
echo -e "${RED}✗ 前端应用: 不可访问${NC}"
fi
# 检查Redis
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis 2>/dev/null || echo "")
if [ -n "$REDIS_CONTAINER" ]; then
if docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
echo -e "${GREEN}✓ Redis: 运行正常${NC}"
else
echo -e "${RED}✗ Redis: 连接失败${NC}"
fi
fi
# 检查数据库连接
if [ -n "$DATABASE_URL" ]; then
# 简单检查数据库连接
if command -v pg_isready &> /dev/null; then
# 从DATABASE_URL解析主机和端口
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
if pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; then
echo -e "${GREEN}✓ 数据库: 可连接${NC}"
else
echo -e "${RED}✗ 数据库: 连接失败${NC}"
fi
fi
fi
}
# 函数:检查网络连接
check_network() {
echo -e "\n${BLUE}=== 网络连接 ===${NC}"
# 检查互联网连接
if ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1; then
echo -e "${GREEN}✓ 互联网: 可连接${NC}"
else
echo -e "${YELLOW}⚠ 互联网: 连接失败${NC}"
fi
# 检查DNS
if nslookup google.com > /dev/null 2>&1; then
echo -e "${GREEN}✓ DNS: 工作正常${NC}"
else
echo -e "${YELLOW}⚠ DNS: 解析失败${NC}"
fi
# 检查端口监听
echo -e "端口监听:"
PORTS="80 443 8000 8080 5432 6379"
for port in $PORTS; do
if ss -tuln | grep -q ":$port "; then
SERVICE=$(ss -tuln | grep ":$port " | awk '{print $5}' | cut -d':' -f2)
echo -e " ${GREEN}✓ 端口 $port: 被监听 ($SERVICE)${NC}"
else
echo -e " ${YELLOW}⚠ 端口 $port: 未监听${NC}"
fi
done
}
# 函数:检查日志错误
check_log_errors() {
echo -e "\n${BLUE}=== 日志检查 ===${NC}"
# 检查最近错误日志
LOG_FILES="/var/log/syslog /var/log/messages /var/log/docker/*.log ../../backend/logs/*.log"
for log_file in $LOG_FILES; do
if [ -f "$log_file" ]; then
ERROR_COUNT=$(tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | wc -l)
if [ "$ERROR_COUNT" -gt 0 ]; then
echo -e "${YELLOW}$log_file: 最近有 $ERROR_COUNT 个错误${NC}"
# 显示最近3个错误
tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | tail -3 | while read line; do
echo " - $line"
done
fi
fi
done
}
# 函数:检查定时任务
check_scheduled_tasks() {
echo -e "\n${BLUE}=== 定时任务 ===${NC}"
# 检查Celery Beat
BEAT_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-beat 2>/dev/null || echo "")
if [ -n "$BEAT_CONTAINER" ]; then
if docker logs --tail 10 "$BEAT_CONTAINER" 2>/dev/null | grep -q "beat"; then
echo -e "${GREEN}✓ Celery Beat: 运行中${NC}"
else
echo -e "${YELLOW}⚠ Celery Beat: 可能异常${NC}"
fi
fi
# 检查Celery Worker
WORKER_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-worker 2>/dev/null || echo "")
if [ -n "$WORKER_CONTAINER" ]; then
ACTIVE_TASKS=$(docker exec "$WORKER_CONTAINER" celery -A app.core.celery_app inspect active 2>/dev/null | grep -c "worker")
if [ "$ACTIVE_TASKS" -gt 0 ]; then
echo -e "${GREEN}✓ Celery Worker: 有 $ACTIVE_TASKS 个活跃任务${NC}"
else
echo -e "${CYAN} Celery Worker: 无活跃任务${NC}"
fi
fi
}
# 函数:生成监控报告
generate_report() {
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
echo -e "\n${PURPLE}========================================${NC}"
echo -e "${PURPLE} 监控报告 - $timestamp${NC}"
echo -e "${PURPLE}========================================${NC}"
check_services_status
check_system_resources
check_container_resources
check_application_health
check_network
check_log_errors
check_scheduled_tasks
echo -e "\n${PURPLE}========================================${NC}"
}
# 函数:连续监控
continuous_monitoring() {
local start_time=$(date +%s)
local iteration=1
echo -e "${GREEN}开始监控,间隔: ${INTERVAL}${NC}"
if [ "$DURATION" -gt 0 ]; then
echo -e "监控时长: ${DURATION}${NC}"
else
echo -e "监控时长: 无限${NC}"
fi
echo "按 Ctrl+C 停止监控"
echo ""
while true; do
# 检查是否达到监控时长
if [ "$DURATION" -gt 0 ]; then
local current_time=$(date +%s)
local elapsed=$((current_time - start_time))
if [ $elapsed -ge $DURATION ]; then
echo -e "\n${GREEN}监控时长达到,停止监控${NC}"
break
fi
fi
# 清屏并显示报告
clear
echo -e "${BLUE}监控迭代 #$iteration - $(date "+%H:%M:%S")${NC}"
generate_report
# 等待间隔
sleep "$INTERVAL"
iteration=$((iteration + 1))
done
}
# 函数:单次监控
single_monitoring() {
generate_report
}
# 主程序
main() {
# 显示标题
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} H3C ONU设备管理系统 - 系统监控${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查参数
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_help
exit 0
fi
# 检查Docker Compose文件
if [ ! -f "$COMPOSE_FILE" ]; then
echo -e "${YELLOW}警告: $COMPOSE_FILE 不存在,使用默认配置${NC}"
fi
# 选择监控模式
if [ "$DURATION" -eq 0 ] && [ "$INTERVAL" -eq 5 ]; then
# 默认单次监控
single_monitoring
else
# 连续监控
continuous_monitoring
fi
}
# 执行主程序
main "$@"
+32
View File
@@ -0,0 +1,32 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8000:8000"
env_file:
- ./backend/.env
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
celery-worker:
build: ./backend
command: celery -A app.core.celery_app worker --loglevel=info
env_file:
- ./backend/.env
restart: unless-stopped
celery-beat:
build: ./backend
command: celery -A app.core.celery_app beat --loglevel=info
env_file:
- ./backend/.env
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "5173:5173"
restart: unless-stopped
+12
View File
@@ -0,0 +1,12 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>H3C ONU设备管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1778
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "h3c-onu-ms-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.5",
"echarts": "^5.6.0",
"element-plus": "^2.5.0",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup>
</script>
+7
View File
@@ -0,0 +1,7 @@
import request from '../utils/request'
export const getLoginUrl = () => request.get('/auth/login')
export const handleCallback = (code, state) => request.post('/auth/callback', { code, state })
export const getProfile = () => request.get('/auth/profile')
+3
View File
@@ -0,0 +1,3 @@
import request from '../utils/request'
export const triggerCheck = () => request.post('/check/status')
+17
View File
@@ -0,0 +1,17 @@
import request from '../utils/request'
export const getDevices = (params) => request.get('/devices', { params })
export const getDevice = (id) => request.get(`/devices/${id}`)
export const getRegions = () => request.get('/devices/regions')
export const updateDevice = (id, data) => request.put(`/devices/${id}`, data)
export const provisionService = (data) => request.post('/provision/service', data)
export const clearAllStatus = () => request.delete('/devices/status/all')
export const refreshAllStatus = () => request.post('/olt/quick-scan')
export const refreshDevice = (id) => request.post(`/devices/${id}/refresh`)
+13
View File
@@ -0,0 +1,13 @@
import request from '../utils/request'
export const uploadExcel = (formData) => {
return request.post('/import/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
export const downloadTemplate = () => {
return request.get('/import/template', {
responseType: 'blob'
})
}
+7
View File
@@ -0,0 +1,7 @@
import request from '../utils/request'
export const getSummary = () => request.get('/stats/summary')
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
export const getByRegion = () => request.get('/stats/by-region')
+57
View File
@@ -0,0 +1,57 @@
<template>
<el-container>
<el-header>
<div class="header-content">
<h3>H3C ONU 设备管理系统</h3>
<el-button @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-container>
<el-aside width="200px">
<el-menu router>
<el-menu-item index="/dashboard">仪表板</el-menu-item>
<el-menu-item index="/devices">设备列表</el-menu-item>
<el-menu-item index="/import">数据导入</el-menu-item>
<el-menu-item index="/charts">统计图表</el-menu-item>
<el-menu-item index="/olt">OLT管理</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.el-header {
background: #545c64;
color: #fff;
display: flex;
align-items: center;
}
.header-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
h3 {
margin: 0;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+67
View File
@@ -0,0 +1,67 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '../components/Layout.vue'
import { useAuthStore } from '../stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/callback',
name: 'Callback',
component: () => import('../views/Callback.vue')
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue')
},
{
path: '/devices',
name: 'DeviceList',
component: () => import('../views/DeviceList.vue')
},
{
path: '/import',
name: 'ImportData',
component: () => import('../views/ImportData.vue')
},
{
path: '/charts',
name: 'Charts',
component: () => import('../views/Charts.vue')
},
{
path: '/olt',
name: 'OltManage',
component: () => import('../views/OltManage.vue')
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.token) {
next('/login')
} else if (to.path === '/login' && authStore.token) {
next('/dashboard')
} else {
next()
}
})
export default router
+21
View File
@@ -0,0 +1,21 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as authApi from '../api/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '')
const user = ref(null)
const setToken = (newToken) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
const logout = () => {
token.value = ''
user.value = null
localStorage.removeItem('token')
}
return { token, user, setToken, logout }
})
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 30000
})
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default request
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="callback-container">
<el-card>
<div v-if="loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>正在登录...</p>
</div>
<div v-else-if="error">
<p>登录失败: {{ error }}</p>
<el-button @click="$router.push('/login')">返回登录</el-button>
<el-button type="info" @click="showDetails = !showDetails">详细信息</el-button>
<pre v-if="showDetails" class="error-details">{{ errorDetails }}</pre>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { handleCallback } from '../api/auth'
import { Loading } from '@element-plus/icons-vue'
const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const error = ref('')
const errorDetails = ref('')
const showDetails = ref(false)
onMounted(async () => {
const code = new URLSearchParams(window.location.search).get('code')
const state = new URLSearchParams(window.location.search).get('state')
if (!code || !state) {
error.value = '缺少认证参数'
loading.value = false
return
}
try {
const { data } = await handleCallback(code, state)
authStore.setToken(data.access_token)
router.push('/dashboard')
} catch (err) {
error.value = err.message || '登录失败'
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
loading.value = false
}
})
</script>
<style scoped>
.callback-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.error-details {
margin-top: 12px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
font-size: 12px;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="charts-container">
<el-card class="chart-card">
<h3>设备状态趋势</h3>
<div ref="trendChart" style="height: 300px"></div>
</el-card>
<el-card class="chart-card">
<h3>区域设备分布</h3>
<div ref="regionChart" style="height: 300px"></div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import { getTrend, getByRegion } from '../api/stats'
const trendChart = ref(null)
const regionChart = ref(null)
const initTrendChart = async () => {
const { data } = await getTrend(7)
const chart = echarts.init(trendChart.value)
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['在线', '离线'] },
xAxis: { type: 'category', data: data.map(d => d.date) },
yAxis: { type: 'value' },
series: [
{ name: '在线', type: 'line', data: data.map(d => d.online), smooth: true },
{ name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true }
]
})
}
const initRegionChart = async () => {
const { data } = await getByRegion()
const chart = echarts.init(regionChart.value)
chart.setOption({
tooltip: { trigger: 'item' },
legend: { orient: 'vertical', left: 'left' },
series: [{
type: 'pie',
radius: '50%',
data: data.map(d => ({ value: d.total, name: d.region }))
}]
})
}
onMounted(() => {
initTrendChart()
initRegionChart()
})
</script>
<style scoped>
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
}
.chart-card {
margin-bottom: 20px;
}
h3 {
margin-bottom: 20px;
}
</style>
+313
View File
@@ -0,0 +1,313 @@
<template>
<div class="dashboard">
<div class="page-header">
<span class="page-title">统计信息</span>
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
</div>
<!-- 顶部汇总卡片 -->
<el-row :gutter="16" style="margin-bottom: 24px">
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
<div class="stat-card">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-rate" :style="{ color: rateColor(card.rate) }">
{{ card.total > 0 ? card.rate.toFixed(1) + '%' : '-' }}
</div>
<div class="stat-row">
<span class="dot online" />
<span class="count-text">在线 {{ card.online }}</span>
<span class="dot offline" style="margin-left: 12px" />
<span class="count-text">离线 {{ card.offline }}</span>
</div>
<div class="stat-total"> {{ card.total }} </div>
</div>
</el-col>
</el-row>
<!-- 详细列表 -->
<el-row :gutter="16">
<el-col :span="8">
<el-card class="list-card">
<template #header><span class="card-title">城区学校在线率</span></template>
<div class="school-list">
<template v-if="data.urban?.schools?.length">
<div v-for="s in data.urban.schools" :key="s.name" class="school-item">
<div class="school-header">
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
</div>
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
<div class="school-sub">在线 {{ s.online }} / {{ s.total }}</div>
</div>
</template>
<div v-else class="empty-text">暂无数据</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="list-card">
<template #header><span class="card-title">城郊学校在线率</span></template>
<div class="school-list">
<template v-if="data.suburban?.schools?.length">
<div v-for="s in data.suburban.schools" :key="s.name" class="school-item">
<div class="school-header">
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
</div>
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
<div class="school-sub">在线 {{ s.online }} / {{ s.total }}</div>
</div>
</template>
<div v-else class="empty-text">暂无数据</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="list-card">
<template #header><span class="card-title">乡镇在线率</span></template>
<div class="school-list">
<template v-if="data.rural?.towns?.length">
<div v-for="t in data.rural.towns" :key="t.region" class="school-item town-item" @click="openTown(t)">
<div class="school-header">
<span class="school-name town-name">{{ t.region }}</span>
<span class="school-rate" :style="{ color: rateColor(rate(t)) }">
{{ rate(t).toFixed(1) }}%
<el-icon style="vertical-align: -2px; margin-left: 2px"><ArrowRight /></el-icon>
</span>
</div>
<el-progress :percentage="rate(t)" :stroke-width="5" :show-text="false" :color="rateColor(rate(t))" />
<div class="school-sub">在线 {{ t.online }} / {{ t.total }}</div>
</div>
</template>
<div v-else class="empty-text">暂无数据</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 乡镇学校详情 -->
<el-dialog v-model="townVisible" :title="selectedTown?.region + ' — 学校在线率'" width="480px" destroy-on-close>
<div class="school-list" style="max-height: 500px; overflow-y: auto; padding-right: 4px">
<div v-for="s in selectedTown?.schools" :key="s.name" class="school-item">
<div class="school-header">
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
</div>
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
<div class="school-sub">在线 {{ s.online }} / {{ s.total }}</div>
</div>
</div>
<template #footer>
<el-button @click="townVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import request from '../utils/request'
const router = useRouter()
const loading = ref(false)
const data = ref({})
const townVisible = ref(false)
const selectedTown = ref(null)
const rate = (item) => {
if (!item || !item.total) return 0
return (item.online / item.total) * 100
}
const rateColor = (r) => {
if (r >= 90) return '#67c23a'
if (r >= 70) return '#e6a23c'
return '#f56c6c'
}
const summaryCards = computed(() => [
{ key: 'overall', label: '总体在线率', ...(data.value.overall || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.overall) },
{ key: 'urban', label: '城区在线率', ...(data.value.urban || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.urban) },
{ key: 'suburban', label: '城郊在线率', ...(data.value.suburban || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.suburban) },
{ key: 'rural', label: '乡镇在线率', ...(data.value.rural || { total: 0, online: 0, offline: 0 }), rate: rate(data.value.rural) },
])
const openTown = (town) => {
selectedTown.value = town
townVisible.value = true
}
const goToSchool = (schoolName) => {
townVisible.value = false
router.push({ path: '/devices', query: { school_name: schoolName } })
}
const loadData = async () => {
loading.value = true
try {
const { data: d } = await request.get('/stats/dashboard')
data.value = d
} finally {
loading.value = false
}
}
onMounted(loadData)
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid #ebeef5;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.stat-rate {
font-size: 36px;
font-weight: 700;
line-height: 1.1;
margin-bottom: 10px;
}
.stat-row {
display: flex;
align-items: center;
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.dot.online { background: #67c23a; }
.dot.offline { background: #f56c6c; }
.count-text {
font-size: 13px;
color: #606266;
}
.stat-total {
font-size: 12px;
color: #c0c4cc;
margin-top: 4px;
}
.list-card {
height: 100%;
}
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.school-list {
display: flex;
flex-direction: column;
gap: 14px;
max-height: 520px;
overflow-y: auto;
padding-right: 2px;
}
.school-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.town-item {
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.15s;
}
.town-item:hover {
background: #f5f7fa;
}
.school-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.school-name {
font-size: 13px;
color: #303133;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.school-link {
cursor: pointer;
color: #409eff;
}
.school-link:hover {
text-decoration: underline;
}
.town-name {
font-weight: 500;
}
.school-rate {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
display: flex;
align-items: center;
}
.school-sub {
font-size: 11px;
color: #c0c4cc;
}
.empty-text {
text-align: center;
color: #c0c4cc;
font-size: 13px;
padding: 40px 0;
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="device-list">
<el-card>
<el-form inline>
<el-form-item label="区域">
<el-select v-model="filters.region" placeholder="全部区域" clearable @change="search" style="width: 180px">
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable @change="search" style="width: 100px">
<el-option label="在线" value="online" />
<el-option label="离线" value="offline" />
<el-option label="未知" value="unknown" />
</el-select>
</el-form-item>
<el-form-item label="搜索">
<el-input
v-model="filters.keyword"
placeholder="MAC/区域/学校/楼宇/房间号"
clearable
@clear="search"
@keyup.enter="search"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
</el-form-item>
<el-form-item style="margin-left: auto">
<el-button type="danger" plain @click="clearStatus" :loading="clearing">清空状态</el-button>
<el-button type="warning" @click="refreshAllStatus" :loading="refreshing" style="margin-left: 8px">
{{ refreshing ? '更新中...' : '全部更新' }}
</el-button>
</el-form-item>
</el-form>
<el-table :data="devices" border stripe>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="region" label="区域" width="100" />
<el-table-column prop="school_name" label="学校名称" width="180" />
<el-table-column prop="building" label="楼宇" width="130" />
<el-table-column prop="place_type" label="场所类型" width="130" />
<el-table-column prop="room_number" label="房间号" width="100" />
<el-table-column prop="mac_address" label="MAC地址" width="180">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">
{{ formatMac(row.mac_address) }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag
:type="row.status === 'online' ? 'success' : row.status === 'offline' ? 'danger' : 'info'"
size="small"
>
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="distance_m" label="距离" width="90" align="right">
<template #default="{ row }">
{{ row.distance_m ? row.distance_m + 'm' : '-' }}
</template>
</el-table-column>
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip />
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
@current-change="loadDevices"
@size-change="handleSizeChange"
layout="total, sizes, prev, pager, next"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<!-- 检查结果对话框 -->
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="700px" destroy-on-close>
<div v-if="checkInProgress" style="text-align: center; padding: 40px 0; color: #909399">
正在扫描所有 OLT请稍候...
</div>
<div v-else-if="checkResult">
<el-alert
:type="checkResult.success ? 'success' : 'error'"
:title="checkResult.success ? '更新完成' : '更新失败'"
:description="checkResult.error || ''"
:closable="false"
style="margin-bottom: 16px"
/>
<template v-if="checkResult.success">
<div style="display: flex; gap: 24px; margin-bottom: 16px">
<el-statistic title="在线设备" :value="checkResult.total_online || 0" />
<el-statistic title="离线设备" :value="checkResult.total_offline || 0" />
<el-statistic title="失败 OLT" :value="(checkResult.errors || []).length" />
</div>
<el-table :data="checkResult.results || []" border stripe size="small" max-height="300">
<el-table-column prop="olt_location" label="OLT名称" min-width="150" show-overflow-tooltip />
<el-table-column label="在线" width="80" align="center">
<template #default="{ row }">
<el-tag type="success" size="small">{{ row.online || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="离线" width="80" align="center">
<template #default="{ row }">
<el-tag type="danger" size="small">{{ row.offline || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
{{ row.success ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</template>
</div>
<template #footer>
<el-button @click="checkResultVisible = false">关闭</el-button>
<el-button type="primary" @click="checkResultVisible = false; loadDevices()" v-if="!checkInProgress">
刷新列表
</el-button>
</template>
</el-dialog>
<!-- 设备详情对话框 -->
<el-dialog v-model="detailVisible" title="设备详情" width="500px" destroy-on-close>
<el-descriptions :column="2" border>
<el-descriptions-item label="MAC地址" :span="2">
{{ formatMac(selectedDevice.mac_address) }}
</el-descriptions-item>
<el-descriptions-item label="区域">{{ selectedDevice.region }}</el-descriptions-item>
<el-descriptions-item label="学校">{{ selectedDevice.school_name }}</el-descriptions-item>
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '-' }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="距离">
{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '-' }}
</el-descriptions-item>
<el-descriptions-item label="端口" :span="2">
{{ formatPort(selectedDevice) }}
</el-descriptions-item>
<el-descriptions-item label="型号" :span="2">
{{ selectedDevice.model || '-' }}
</el-descriptions-item>
<el-descriptions-item label="所属OLT" :span="2">
{{ selectedDevice.olt_location || '-' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ selectedDevice.notes || '-' }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
<el-button type="primary" @click="openEdit">编辑</el-button>
<el-button type="success" @click="openProvision" :disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id">
业务下发
</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog v-model="editVisible" title="编辑设备信息" width="480px" destroy-on-close>
<el-form :model="editForm" label-width="80px">
<el-form-item label="区域">
<el-input v-model="editForm.region" />
</el-form-item>
<el-form-item label="学校">
<el-input v-model="editForm.school_name" />
</el-form-item>
<el-form-item label="楼宇">
<el-input v-model="editForm.building" />
</el-form-item>
<el-form-item label="房间号">
<el-input v-model="editForm.room_number" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.notes" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
<!-- 业务下发对话框 -->
<el-dialog v-model="provisionVisible" title="业务下发" width="560px" destroy-on-close>
<el-alert
v-if="provisionResult"
:type="provisionResult.success ? 'success' : 'error'"
:title="provisionResult.success ? '下发成功' : '下发失败'"
:description="provisionResult.message"
:closable="false"
style="margin-bottom: 16px"
/>
<el-descriptions :column="2" border v-if="selectedDevice.mac_address">
<el-descriptions-item label="MAC地址">{{ formatMac(selectedDevice.mac_address) }}</el-descriptions-item>
<el-descriptions-item label="端口">{{ formatPort(selectedDevice) }}</el-descriptions-item>
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider>将执行的配置</el-divider>
<el-text type="info" size="small">
<pre style="margin: 0; font-size: 12px; line-height: 1.6">system-view
interface {{ formatPort(selectedDevice) }}
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
port link-type trunk
undo port trunk permit vlan 1
port trunk permit vlan 2000 3000 to 3010 4094
save force</pre>
</el-text>
<template #footer>
<el-button @click="provisionVisible = false; provisionResult = null">关闭</el-button>
<el-button
type="success"
@click="doProvision"
:loading="provisionLoading"
:disabled="selectedDevice.status !== 'online' || !selectedDevice.port_id"
>
确认下发
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import * as deviceApi from '../api/device'
const route = useRoute()
const devices = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const regions = ref([])
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
// 详情对话框
const detailVisible = ref(false)
const selectedDevice = ref({})
const deviceRefreshing = ref(false)
// 业务下发对话框
const provisionVisible = ref(false)
const provisionLoading = ref(false)
const provisionResult = ref(null)
const refreshing = ref(false)
const clearing = ref(false)
// 编辑对话框
const editVisible = ref(false)
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', notes: '' })
const editSaving = ref(false)
// 检查结果对话框
const checkResultVisible = ref(false)
const checkResult = ref(null)
const checkInProgress = ref(false)
// 清空所有设备状态
const clearStatus = async () => {
clearing.value = true
try {
await deviceApi.clearAllStatus()
ElMessage.success('已清空所有设备状态')
loadDevices()
} catch {
ElMessage.error('清空失败')
} finally {
clearing.value = false
}
}
// 刷新所有设备状态
const refreshAllStatus = async () => {
refreshing.value = true
checkResultVisible.value = true
checkInProgress.value = true
checkResult.value = null
try {
const { data } = await deviceApi.refreshAllStatus()
checkResult.value = {
success: true,
total_online: data.total_online,
total_offline: data.total_offline,
results: data.results,
errors: data.errors,
}
} catch (error) {
checkResult.value = { success: false, error: error.response?.data?.detail || '更新失败' }
} finally {
checkInProgress.value = false
refreshing.value = false
}
}
// 格式化 MAC 地址
const formatMac = (mac) => {
if (!mac) return ''
const clean = mac.toUpperCase().replace(/[:-]/g, '')
if (clean.length !== 12) return mac.toLowerCase()
return clean.substring(0, 4).toLowerCase() + '-' +
clean.substring(4, 8).toLowerCase() + '-' +
clean.substring(8, 12).toLowerCase()
}
// 格式化端口显示
const formatPort = (device) => {
if (device.port_id) return `Onu${device.port_id}`
if (!device.slot_number && !device.port_number) return '-'
return `Onu${device.slot_number || '?'}/${device.port_number || '?'}`
}
// 打开详情对话框
const openDetail = (row) => {
selectedDevice.value = { ...row }
provisionResult.value = null
detailVisible.value = true
}
// 单台设备状态更新
const doRefreshDevice = async () => {
deviceRefreshing.value = true
try {
const { data } = await deviceApi.refreshDevice(selectedDevice.value.id)
selectedDevice.value.status = data.status
selectedDevice.value.distance_m = data.distance_m
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
loadDevices()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '更新失败')
} finally {
deviceRefreshing.value = false
}
}
// 打开编辑对话框
const openEdit = () => {
editForm.value = {
region: selectedDevice.value.region || '',
school_name: selectedDevice.value.school_name || '',
building: selectedDevice.value.building || '',
room_number: selectedDevice.value.room_number || '',
notes: selectedDevice.value.notes || '',
}
detailVisible.value = false
editVisible.value = true
}
const saveEdit = async () => {
editSaving.value = true
try {
await deviceApi.updateDevice(selectedDevice.value.id, editForm.value)
ElMessage.success('保存成功')
editVisible.value = false
loadDevices()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '保存失败')
} finally {
editSaving.value = false
}
}
// 打开业务下发对话框
const openProvision = () => {
provisionResult.value = null
provisionVisible.value = true
}
// 执行业务下发
const doProvision = async () => {
provisionLoading.value = true
try {
const { data } = await deviceApi.provisionService({ device_id: selectedDevice.value.id })
provisionResult.value = { success: true, message: data.message }
ElMessage.success(data.message)
} catch (error) {
const msg = error.response?.data?.detail || '下发失败'
provisionResult.value = { success: false, message: msg }
ElMessage.error(msg)
} finally {
provisionLoading.value = false
}
}
// 加载区域列表
const loadRegions = async () => {
const { data } = await deviceApi.getRegions()
regions.value = data
}
const loadDevices = async () => {
const { data } = await deviceApi.getDevices({
skip: (page.value - 1) * pageSize.value,
limit: pageSize.value,
region: filters.value.region || undefined,
keyword: filters.value.keyword || undefined,
school_name: filters.value.school_name || undefined,
status: filters.value.status || undefined,
})
devices.value = data.items
total.value = data.total
}
const search = () => {
page.value = 1
loadDevices()
}
const handleSizeChange = (val) => {
pageSize.value = val
page.value = 1
loadDevices()
}
onMounted(() => {
if (route.query.school_name) {
filters.value.keyword = route.query.school_name
}
loadRegions()
loadDevices()
})
</script>
<style scoped>
.device-list {
padding: 20px;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<div class="import-page">
<el-card>
<div class="header">
<h2>数据导入</h2>
<el-button type="success" @click="handleDownloadTemplate">
<el-icon><Download /></el-icon>
下载模板
</el-button>
</div>
<el-upload
:auto-upload="false"
:on-change="handleFileChange"
accept=".xlsx,.xls"
>
<el-button type="primary">选择文件</el-button>
</el-upload>
<el-button @click="handleUpload" :loading="uploading" style="margin-top: 20px">
开始导入
</el-button>
<div v-if="result" style="margin-top: 20px">
<el-alert
:title="`成功: ${result.success}`"
type="success"
:closable="false"
style="margin-bottom: 10px"
/>
<el-alert
v-if="result.failed && result.failed.length > 0"
:title="`失败: ${result.failed.length}`"
type="error"
:closable="false"
style="margin-bottom: 10px"
/>
<el-table
v-if="result.failed && result.failed.length > 0"
:data="result.failed"
border
style="margin-top: 10px"
max-height="300"
>
<el-table-column label="MAC地址" width="180">
<template #default="{ row }">
{{ row.record?.mac_address || '-' }}
</template>
</el-table-column>
<el-table-column prop="record.school_name" label="学校名称" width="180" />
<el-table-column prop="error" label="失败原因" />
</el-table>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import * as importApi from '../api/import'
const file = ref(null)
const uploading = ref(false)
const result = ref(null)
const handleFileChange = (uploadFile) => {
file.value = uploadFile.raw
}
const handleUpload = async () => {
if (!file.value) {
ElMessage.warning('请选择文件')
return
}
uploading.value = true
const formData = new FormData()
formData.append('file', file.value)
try {
const { data } = await importApi.uploadExcel(formData)
result.value = data
ElMessage.success('导入完成')
} catch (error) {
ElMessage.error(error.response?.data?.detail || '导入失败')
} finally {
uploading.value = false
}
}
const handleDownloadTemplate = async () => {
try {
const response = await importApi.downloadTemplate()
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'onu_import_template.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch {
ElMessage.error('模板下载失败')
}
}
</script>
<style scoped>
.import-page {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="login-container">
<el-card class="login-card">
<h2>H3C ONU 设备管理系统</h2>
<div v-if="!error">
<el-icon class="is-loading" style="font-size: 24px; margin-bottom: 12px"><Loading /></el-icon>
<p>正在跳转到登录页面...</p>
</div>
<div v-else>
<el-alert type="error" :title="error" :closable="false" style="margin-bottom: 16px; text-align: left" />
<el-button type="primary" @click="doRedirect">重试</el-button>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLoginUrl } from '../api/auth'
import { Loading } from '@element-plus/icons-vue'
const error = ref('')
const doRedirect = async () => {
error.value = ''
try {
const { data } = await getLoginUrl()
window.location.href = data.url
} catch (e) {
error.value = e.response?.data?.detail || e.message || '获取登录地址失败,请检查后端服务是否正常'
}
}
onMounted(doRedirect)
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
text-align: center;
padding: 40px;
}
h2 {
margin-bottom: 20px;
color: #333;
}
p { color: #666; margin-bottom: 0; }
</style>
+644
View File
@@ -0,0 +1,644 @@
<template>
<div class="olt-manage">
<el-button type="primary" @click="openAddDialog">添加 OLT 设备</el-button>
<el-upload
:auto-upload="false"
:show-file-list="false"
:on-change="handleImportFile"
accept=".xlsx,.xls"
style="display: inline-block; margin-left: 10px"
>
<el-button type="success" :loading="importing">批量导入</el-button>
</el-upload>
<el-button @click="downloadTemplate">下载模板</el-button>
<el-button type="danger" plain @click="openDuplicateMacs" style="margin-left: 10px">
重复 MAC 地址
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
</el-button>
<el-button type="warning" plain @click="openNewDevices" style="margin-left: 10px">
新增设备
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
</el-button>
<el-button type="danger" plain @click="runLoopbackDetection" :loading="loopDetecting" style="margin-left: 10px">
环路检测
</el-button>
<el-button type="primary" plain @click="runQuickScan" :loading="quickScanning" style="margin-left: 10px">
快速扫描
</el-button>
<el-table :data="devices" style="margin-top: 20px">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="ip_address" label="IP 地址" width="150">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.ip_address }}</el-link>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="location" label="安装位置" width="150" />
<el-table-column prop="slot_command" label="槽位命令" width="150" />
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
<el-table-column label="环路状态" width="110" align="center">
<template #default="{ row }">
<el-tag
v-if="loopStatusMap[row.id]"
type="danger"
size="small"
style="cursor: pointer"
@click="showLoopDetail(row.id)"
>
环路 {{ loopStatusMap[row.id].length }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 快速扫描结果对话框 -->
<el-dialog v-model="quickScanVisible" title="快速扫描结果" width="700px" destroy-on-close>
<div style="margin-bottom: 16px; display: flex; gap: 16px">
<el-statistic title="在线设备" :value="quickScanResult.total_online || 0" />
<el-statistic title="离线设备" :value="quickScanResult.total_offline || 0" />
<el-statistic title="新入库设备" :value="quickScanResult.total_new || 0" />
<el-statistic title="失败 OLT" :value="(quickScanResult.errors || []).length" />
</div>
<el-table :data="quickScanResult.results || []" border size="small" max-height="360">
<el-table-column prop="olt_location" label="OLT" min-width="150" show-overflow-tooltip />
<el-table-column label="在线" width="70" align="center">
<template #default="{ row }">
<el-tag type="success" size="small">{{ row.online }}</el-tag>
</template>
</el-table-column>
<el-table-column label="离线" width="70" align="center">
<template #default="{ row }">
<el-tag type="danger" size="small">{{ row.offline }}</el-tag>
</template>
</el-table-column>
<el-table-column label="新入库" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.new_discovered > 0" type="warning" size="small">{{ row.new_discovered }}</el-tag>
<span v-else style="color: #c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
{{ row.success ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.error || '-' }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="quickScanVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 环路检测结果对话框 -->
<el-dialog v-model="loopVisible" title="环路检测结果" width="860px" destroy-on-close>
<div v-for="item in loopResults" :key="item.olt_id" style="margin-bottom: 16px">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px">
<span style="font-weight: 600">{{ item.olt_location }}</span>
<el-tag v-if="item.error" type="warning" size="small">连接失败</el-tag>
<el-tag v-else-if="item.has_loop" type="danger" size="small">检测到环路 {{ item.loop_interfaces.length }} </el-tag>
<el-tag v-else type="success" size="small">正常</el-tag>
<span v-if="item.error" style="color: #e6a23c; font-size: 12px">{{ item.error }}</span>
</div>
<el-table v-if="item.has_loop" :data="item.loop_interfaces" border size="small">
<el-table-column prop="interface" label="端口" width="140" />
<el-table-column prop="mac_address" label="MAC 地址" width="160">
<template #default="{ row }">{{ row.mac_address || '-' }}</template>
</el-table-column>
<el-table-column prop="region" label="区域" width="100">
<template #default="{ row }">{{ row.region || '-' }}</template>
</el-table-column>
<el-table-column prop="school_name" label="学校" min-width="140">
<template #default="{ row }">{{ row.school_name || '-' }}</template>
</el-table-column>
<el-table-column prop="building" label="楼宇" width="100">
<template #default="{ row }">{{ row.building || '-' }}</template>
</el-table-column>
<el-table-column prop="room_number" label="房间号" width="90">
<template #default="{ row }">{{ row.room_number || '-' }}</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="loopVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 单台 OLT 环路详情对话框 -->
<el-dialog v-model="loopDetailVisible" title="环路详情" width="700px" destroy-on-close>
<el-table :data="loopDetailInterfaces" border size="small">
<el-table-column prop="interface" label="端口" width="140" />
<el-table-column prop="mac_address" label="MAC 地址" width="160">
<template #default="{ row }">{{ row.mac_address || '-' }}</template>
</el-table-column>
<el-table-column prop="region" label="区域" width="100">
<template #default="{ row }">{{ row.region || '-' }}</template>
</el-table-column>
<el-table-column prop="school_name" label="学校" min-width="140">
<template #default="{ row }">{{ row.school_name || '-' }}</template>
</el-table-column>
<el-table-column prop="building" label="楼宇" width="100">
<template #default="{ row }">{{ row.building || '-' }}</template>
</el-table-column>
<el-table-column prop="room_number" label="房间号" width="90">
<template #default="{ row }">{{ row.room_number || '-' }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="loopDetailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 添加/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑 OLT 设备' : '添加 OLT 设备'" width="500px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item label="IP 地址">
<el-input v-model="form.ip_address" :disabled="isEdit" />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="不修改请留空" />
</el-form-item>
<el-form-item label="安装位置">
<el-input v-model="form.location" />
</el-form-item>
<el-form-item label="槽位命令">
<el-input v-model="form.slot_command" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveDevice">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="detailVisible" title="OLT 设备详情" width="500px" destroy-on-close>
<el-descriptions :column="2" border>
<el-descriptions-item label="IP 地址" :span="2">{{ selectedDevice.ip_address }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ selectedDevice.username }}</el-descriptions-item>
<el-descriptions-item label="安装位置">{{ selectedDevice.location || '-' }}</el-descriptions-item>
<el-descriptions-item label="槽位命令">{{ selectedDevice.slot_command || '-' }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedDevice.description || '-' }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="success" :loading="scanning" @click="scanOlt">扫描发现设备</el-button>
<el-button type="warning" :loading="discovering" @click="discoverOlt">扫描并入库</el-button>
<el-button type="primary" @click="openEdit">编辑</el-button>
<el-button type="danger" @click="confirmDelete">删除</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 扫描结果对话框 -->
<el-dialog v-model="scanVisible" title="扫描结果" width="800px" destroy-on-close>
<div style="margin-bottom: 12px">
<el-tag>共发现 {{ scanResult.total }} </el-tag>
<el-tag type="warning" style="margin-left: 8px">新设备 {{ scanResult.new }} </el-tag>
<el-tag v-if="scanResult.duplicates?.length" type="danger" style="margin-left: 8px">
重复 MAC {{ scanResult.duplicates.length }}
</el-tag>
</div>
<el-table :data="scanResult.devices" max-height="400">
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'online' ? 'success' : 'danger'" size="small">
{{ row.status === 'online' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="distance_m" label="距离" width="90">
<template #default="{ row }">
{{ row.distance_m || '-' }}
</template>
</el-table-column>
<el-table-column label="端口" width="120">
<template #default="{ row }">
{{ row.port_id || '-' }}
</template>
</el-table-column>
<el-table-column prop="loid" label="LOID" width="80" />
<el-table-column prop="model" label="型号" />
<el-table-column label="是否新设备" width="100">
<template #default="{ row }">
<el-tag v-if="row.is_new" type="warning" size="small">新发现</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button type="warning" :loading="discovering" @click="discoverOlt">确认入库新设备</el-button>
<el-button @click="scanVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 重复 MAC 地址对话框 -->
<el-dialog v-model="dupVisible" title="重复 MAC 地址" width="800px" destroy-on-close>
<div style="margin-bottom: 12px; color: #909399; font-size: 13px">
以下 MAC 地址在 OLT 上出现在多个端口可能是设备更换端口后旧记录未清除对离线的旧端口执行"清除"可恢复其默认配置
</div>
<el-table :data="dupList" max-height="450" empty-text="暂无重复 MAC 记录">
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
<el-table-column label="出现的端口" min-width="280">
<template #default="{ row }">
<div v-for="p in row.ports" :key="p.port_id" style="display: inline-flex; align-items: center; margin-right: 8px; margin-bottom: 4px">
<el-tag
:type="p.status === 'online' ? 'success' : 'info'"
size="small"
>
{{ p.port_id }} ({{ p.status === 'online' ? '在线' : '离线' }})
</el-tag>
<el-button
v-if="p.status !== 'online'"
type="danger"
size="small"
text
:loading="clearingPort === `${row.id}-${p.port_id}`"
style="margin-left: 2px; padding: 0 4px"
@click="clearPort(row, p.port_id)"
>清除</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="最后发现" width="160">
<template #default="{ row }">
{{ row.last_seen_at ? new Date(row.last_seen_at).toLocaleString() : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="danger" size="small" text @click="deleteDupRecord(row.id)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="dupVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 新增设备对话框 -->
<el-dialog v-model="newDevVisible" title="新增设备" width="900px" destroy-on-close>
<div style="margin-bottom: 12px; color: #909399; font-size: 13px">
以下设备是 OLT 扫描新发现的请补全区域和学校信息后确认入库
</div>
<el-table :data="newDevList" max-height="450" empty-text="暂无新增设备">
<el-table-column prop="mac_address" label="MAC 地址" width="160" />
<el-table-column prop="port_id" label="端口" width="100" />
<el-table-column prop="model" label="型号" width="160" />
<el-table-column label="区域 *" width="130">
<template #default="{ row }">
<el-input v-model="row.region" size="small" placeholder="必填" />
</template>
</el-table-column>
<el-table-column label="学校 *" width="150">
<template #default="{ row }">
<el-input v-model="row.school_name" size="small" placeholder="必填" />
</template>
</el-table-column>
<el-table-column label="楼宇" width="110">
<template #default="{ row }">
<el-input v-model="row.building" size="small" />
</template>
</el-table-column>
<el-table-column label="房间号" width="100">
<template #default="{ row }">
<el-input v-model="row.room_number" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button type="primary" size="small" text :loading="savingDev === row.id" @click="saveNewDevice(row)">保存</el-button>
<el-button type="info" size="small" text @click="dismissNewDevice(row.id)">忽略</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="newDevVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '../utils/request'
const devices = ref([])
const dialogVisible = ref(false)
const detailVisible = ref(false)
const scanVisible = ref(false)
const dupVisible = ref(false)
const isEdit = ref(false)
const selectedDevice = ref({})
const scanning = ref(false)
const discovering = ref(false)
const importing = ref(false)
const scanResult = ref({ total: 0, new: 0, devices: [], duplicates: [] })
const dupList = ref([])
const duplicateCount = ref(0)
const clearingPort = ref('')
const newDevList = ref([])
const newDeviceCount = ref(0)
const newDevVisible = ref(false)
const savingDev = ref(null)
const loopDetecting = ref(false)
const loopVisible = ref(false)
const loopResults = ref([])
const loopStatusMap = ref({}) // olt_id -> loop_interfaces[]
const loopDetailVisible = ref(false)
const loopDetailInterfaces = ref([])
const quickScanning = ref(false)
const quickScanVisible = ref(false)
const quickScanResult = ref({ total_online: 0, total_offline: 0, total_new: 0, results: [], errors: [] })
const form = ref({ ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' })
const fetchDevices = async () => {
const { data } = await request.get('/olt/devices')
devices.value = data
}
const fetchDuplicateCount = async () => {
try {
const { data } = await request.get('/olt/duplicate-macs')
duplicateCount.value = data.length
} catch {}
}
const openAddDialog = () => {
isEdit.value = false
form.value = { ip_address: '', username: '', password: '', slot_command: 'display onu slot', location: '', description: '' }
dialogVisible.value = true
}
const openDetail = (row) => {
selectedDevice.value = { ...row }
detailVisible.value = true
}
const openEdit = () => {
detailVisible.value = false
isEdit.value = true
form.value = {
ip_address: selectedDevice.value.ip_address,
username: selectedDevice.value.username,
password: '',
slot_command: selectedDevice.value.slot_command || 'display onu slot',
location: selectedDevice.value.location || '',
description: selectedDevice.value.description || ''
}
dialogVisible.value = true
}
const saveDevice = async () => {
const payload = { ...form.value }
if (!payload.password) {
delete payload.password
}
if (isEdit.value) {
await request.put(`/olt/devices/${form.value.ip_address}`, payload)
ElMessage.success('更新成功')
} else {
await request.post('/olt/devices', payload)
ElMessage.success('保存成功')
}
dialogVisible.value = false
fetchDevices()
}
const confirmDelete = async () => {
try {
await ElMessageBox.confirm('确定要删除此 OLT 设备吗?', '确认删除', { type: 'warning' })
await request.delete(`/olt/devices/${selectedDevice.value.ip_address}`)
ElMessage.success('删除成功')
detailVisible.value = false
fetchDevices()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.detail || '删除失败')
}
}
}
const scanOlt = async () => {
scanning.value = true
try {
const { data } = await request.post(`/check/scan/${selectedDevice.value.id}`)
scanResult.value = data
detailVisible.value = false
scanVisible.value = true
fetchDuplicateCount()
fetchNewDeviceCount()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '扫描失败')
} finally {
scanning.value = false
}
}
const discoverOlt = async () => {
discovering.value = true
try {
const { data } = await request.post(`/check/discover/${selectedDevice.value.id}`)
const dupMsg = data.duplicate_macs > 0 ? `,重复 MAC ${data.duplicate_macs}` : ''
ElMessage.success(`扫描完成:在线 ${data.online},离线 ${data.offline},新入库 ${data.new_discovered}${dupMsg}`)
scanVisible.value = false
detailVisible.value = false
fetchDuplicateCount()
fetchNewDeviceCount()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '入库失败')
} finally {
discovering.value = false
}
}
const openDuplicateMacs = async () => {
try {
const { data } = await request.get('/olt/duplicate-macs')
dupList.value = data
duplicateCount.value = data.length
dupVisible.value = true
} catch (error) {
ElMessage.error('获取重复 MAC 失败')
}
}
const clearPort = async (row, portId) => {
try {
await ElMessageBox.confirm(
`确定要清除端口 Onu${portId} 的配置吗?此操作将通过 SSH 执行 default 命令,不可撤销。`,
'确认清除',
{ type: 'warning', confirmButtonText: '确认清除', confirmButtonClass: 'el-button--danger' }
)
} catch {
return
}
clearingPort.value = `${row.id}-${portId}`
try {
const { data } = await request.post(`/olt/duplicate-macs/${row.id}/clear-port`, { port_id: portId })
ElMessage.success(data.message)
if (data.remaining_ports.length === 0) {
dupList.value = dupList.value.filter(r => r.id !== row.id)
} else {
row.ports = data.remaining_ports
}
duplicateCount.value = dupList.value.length
} catch (error) {
ElMessage.error(error.response?.data?.detail || '清除失败')
} finally {
clearingPort.value = ''
}
}
const deleteDupRecord = async (id) => {
try {
await request.delete(`/olt/duplicate-macs/${id}`)
dupList.value = dupList.value.filter(r => r.id !== id)
duplicateCount.value = dupList.value.length
ElMessage.success('已清除')
} catch (error) {
ElMessage.error('清除失败')
}
}
const fetchNewDeviceCount = async () => {
try {
const { data } = await request.get('/olt/new-devices')
newDeviceCount.value = data.length
} catch {}
}
const openNewDevices = async () => {
try {
const { data } = await request.get('/olt/new-devices')
newDevList.value = data.map(d => ({ ...d, region: d.region || '', school_name: d.school_name || '', building: d.building || '', room_number: d.room_number || '' }))
newDeviceCount.value = data.length
newDevVisible.value = true
} catch {
ElMessage.error('获取新增设备失败')
}
}
const saveNewDevice = async (row) => {
if (!row.region || !row.school_name) {
ElMessage.warning('区域和学校为必填项')
return
}
savingDev.value = row.id
try {
await request.put(`/olt/new-devices/${row.id}`, {
region: row.region,
school_name: row.school_name,
building: row.building,
room_number: row.room_number,
})
ElMessage.success('已保存')
newDevList.value = newDevList.value.filter(d => d.id !== row.id)
newDeviceCount.value = newDevList.value.length
} catch (error) {
ElMessage.error(error.response?.data?.detail || '保存失败')
} finally {
savingDev.value = null
}
}
const dismissNewDevice = async (id) => {
try {
await request.delete(`/olt/new-devices/${id}`)
newDevList.value = newDevList.value.filter(d => d.id !== id)
newDeviceCount.value = newDevList.value.length
ElMessage.success('已忽略')
} catch {
ElMessage.error('操作失败')
}
}
const runQuickScan = async () => {
quickScanning.value = true
try {
const { data } = await request.post('/olt/quick-scan')
quickScanResult.value = data
quickScanVisible.value = true
} catch (error) {
ElMessage.error(error.response?.data?.detail || '快速扫描失败')
} finally {
quickScanning.value = false
}
}
const runLoopbackDetection = async () => {
loopDetecting.value = true
try {
const { data } = await request.post('/olt/loopback-detection')
loopResults.value = data
// 更新列表中的环路标志
const map = {}
for (const item of data) {
if (item.has_loop && item.loop_interfaces.length > 0) {
map[item.olt_id] = item.loop_interfaces
}
}
loopStatusMap.value = map
loopVisible.value = true
} catch (error) {
ElMessage.error(error.response?.data?.detail || '环路检测失败')
} finally {
loopDetecting.value = false
}
}
const showLoopDetail = (oltId) => {
loopDetailInterfaces.value = loopStatusMap.value[oltId] || []
loopDetailVisible.value = true
}
const handleImportFile = async (uploadFile) => {
importing.value = true
const formData = new FormData()
formData.append('file', uploadFile.raw)
try {
const { data } = await request.post('/olt/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
const failMsg = data.failed?.length ? `${data.failed.length} 条失败` : ''
ElMessage.success(`成功导入 ${data.success}${failMsg}`)
fetchDevices()
} catch (error) {
ElMessage.error(error.response?.data?.detail || '导入失败')
} finally {
importing.value = false
}
}
const handleImportSuccess = () => {
ElMessage.success('导入成功')
fetchDevices()
}
const downloadTemplate = () => {
window.open('/api/olt/template', '_blank')
}
onMounted(() => {
fetchDevices()
fetchDuplicateCount()
fetchNewDeviceCount()
})
</script>
<style scoped>
.olt-manage {
padding: 20px;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python
import sys
import os
# 切换到项目根目录
os.chdir('/home/v6ole/pyproject/H3ConuMS2')
# 设置 PYTHONPATH
sys.path.insert(0, '/home/v6ole/pyproject/H3ConuMS2/backend')
# 设置环境变量
os.environ['SECRET_KEY'] = 'your-secret-key-change-this'
os.environ['DATABASE_URL'] = 'postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms'
os.environ['REDIS_URL'] = 'redis://:redis_c4FFJQ@10.10.10.14:6379'
os.environ['CASDOOR_ENDPOINT'] = 'https://casdoor.dhdx.fun'
os.environ['CASDOOR_CLIENT_ID'] = '2289912424bcea5f9859'
os.environ['CASDOOR_CLIENT_SECRET'] = 'cfa97de106762c8293901a8daef11e04f1b10043'
os.environ['CASDOOR_REDIRECT_URL'] = 'http://10.10.10.14:5173/callback'
os.environ['CASDOOR_ORG_NAME'] = 'dahua'
os.environ['CASDOOR_APP_NAME'] = 'H3ConuMS'
os.environ['CASDOOR_CERTIFICATE'] = 'token_jwt_key.pem'
# 加载 celery app
from celery_worker import celery_app
# 运行 worker
if __name__ == '__main__':
celery_app.worker_main(['worker', '--loglevel=info'])
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# 快速启动脚本
echo "=== H3C ONU 设备管理系统 ==="
echo ""
# 检查虚拟环境
if [ ! -d "backend/venv" ]; then
echo "创建虚拟环境..."
cd backend && python3 -m venv venv && cd ..
fi
# 激活虚拟环境
source backend/venv/bin/activate
# 检查依赖
if ! python -c "import fastapi" 2>/dev/null; then
echo "安装依赖..."
pip install -r backend/requirements.txt
fi
# 初始化数据库
echo "初始化数据库..."
python backend/scripts/init_db.py
echo ""
echo "✅ 初始化完成!"
echo ""
echo "启动服务:"
echo " 后端: cd backend && uvicorn app.main:app --reload"
echo " 前端: cd frontend && npm run dev"
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# 启动开发环境
echo "启动后端服务..."
cd backend
source venv/bin/activate 2>/dev/null || 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
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
cd /home/v6ole/pyproject/H3ConuMS2
export PYTHONPATH=/home/v6ole/pyproject/H3ConuMS2/backend
# 设置环境变量
export SECRET_KEY="your-secret-key-change-this"
export DATABASE_URL="postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms"
export REDIS_URL="redis://:redis_c4FFJQ@10.10.10.14:6379"
export CASDOOR_ENDPOINT="https://casdoor.dhdx.fun"
export CASDOOR_CLIENT_ID="2289912424bcea5f9859"
export CASDOOR_CLIENT_SECRET="cfa97de106762c8293901a8daef11e04f1b10043"
export CASDOOR_REDIRECT_URL="http://10.10.10.14:5173/callback"
export CASDOOR_ORG_NAME="dahua"
export CASDOOR_APP_NAME="H3ConuMS"
export CASDOOR_CERTIFICATE="token_jwt_key.pem"
exec /home/v6ole/pyproject/H3ConuMS2/backend/venv/bin/celery -A celery_worker.celery worker --loglevel=info
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# 启动前端服务
echo "启动前端服务..."
cd frontend
npm install
npm run dev
View File
+22
View File
@@ -0,0 +1,22 @@
# H3C ONU设备下发上网业务步骤
1. 先查看需要下发业务的设备MAC在OLT的哪个口下面,需要进入OLT对应的槽位,如slot 1
`display onu slot 1 | include 1484-778e-a860`
• OLT返回内容
`1484-778e-a860 4 12414 Onu1/0/2:4 WA6520H-EGPON/A 106/ Up N/A`
2. 进入系统配置
`system-view`
3. 进入端口设备的端口
`interface Onu1/0/2:4`
4. 配置ONU
```
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
port link-type trunk
undo port trunk permit vlan 1
port trunk permit vlan 2000 3000 to 3010 4094
```
5. 保存(输入save force后需要稍等一下才会出现Saved the current configuration to mainboard device successfully.
```
[16OLT-Onu1/0/2:4]save force
Validating file. Please wait...
Saved the current configuration to mainboard device successfully.
```

Some files were not shown because too many files have changed in this diff Show More