Compare commits
13 Commits
3453441754
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f511e3e808 | |||
| 26f6ca2d8d | |||
| 81ab82e9ba | |||
| 5b07ec6df0 | |||
| 8a8ae4ed57 | |||
| a5ac25a01d | |||
| 32b7a2cc6a | |||
| 6623169e8a | |||
| 0bab72ea98 | |||
| 3ee8846011 | |||
| fe7649ed6e | |||
| 6e5f16ecf2 | |||
| b2c20ec43d |
@@ -44,3 +44,10 @@ logs/
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Deployment docs (contain credentials)
|
||||||
|
*部署交接文档.md
|
||||||
|
|
||||||
|
# Reasonix
|
||||||
|
.reasonix/
|
||||||
|
|||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
stages:
|
||||||
|
- verify
|
||||||
|
- test
|
||||||
|
- security
|
||||||
|
- build
|
||||||
|
|
||||||
|
workflow:
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|
||||||
|
default:
|
||||||
|
interruptible: true
|
||||||
|
|
||||||
|
variables:
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||||
|
PIP_NO_INPUT: "1"
|
||||||
|
PYTHONDONTWRITEBYTECODE: "1"
|
||||||
|
|
||||||
|
dependency-source-policy:
|
||||||
|
stage: verify
|
||||||
|
image: "$INTERNAL_CONTAINER_PROXY/alpine:3.20"
|
||||||
|
script:
|
||||||
|
- test -n "$INTERNAL_CONTAINER_PROXY" || (echo "INTERNAL_CONTAINER_PROXY must point to the approved container-image proxy" && exit 1)
|
||||||
|
- test -n "$INTERNAL_PYPI_URL" || (echo "INTERNAL_PYPI_URL must point to the approved PyPI proxy" && exit 1)
|
||||||
|
- test -n "$INTERNAL_NPM_REGISTRY" || (echo "INTERNAL_NPM_REGISTRY must point to the approved npm proxy" && exit 1)
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
|
||||||
|
|
||||||
|
backend-tests:
|
||||||
|
stage: test
|
||||||
|
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
|
||||||
|
needs: ["dependency-source-policy"]
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- backend/requirements.txt
|
||||||
|
paths:
|
||||||
|
- .cache/pip
|
||||||
|
variables:
|
||||||
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||||
|
SECRET_KEY: "ci-test-secret-not-for-deployment"
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY: "Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU="
|
||||||
|
DATABASE_URL: "sqlite:///./ci-test.db"
|
||||||
|
REDIS_URL: "redis://localhost:6379/15"
|
||||||
|
CASDOOR_ENDPOINT: "https://casdoor.example.test"
|
||||||
|
CASDOOR_CLIENT_ID: "ci-client"
|
||||||
|
CASDOOR_CLIENT_SECRET: "ci-client-secret"
|
||||||
|
CASDOOR_ORG_NAME: "ci-org"
|
||||||
|
CASDOOR_APP_NAME: "ci-app"
|
||||||
|
before_script:
|
||||||
|
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
|
||||||
|
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt
|
||||||
|
script:
|
||||||
|
- cd backend
|
||||||
|
- python -m compileall -q app
|
||||||
|
- pytest -q --junitxml=../reports/backend-junit.xml
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
reports:
|
||||||
|
junit: reports/backend-junit.xml
|
||||||
|
paths:
|
||||||
|
- reports/backend-junit.xml
|
||||||
|
expire_in: 30 days
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
stage: test
|
||||||
|
image: "$INTERNAL_CONTAINER_PROXY/node:20-alpine"
|
||||||
|
needs: ["dependency-source-policy"]
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- frontend/package-lock.json
|
||||||
|
paths:
|
||||||
|
- frontend/.npm/
|
||||||
|
before_script:
|
||||||
|
- npm config set registry "$INTERNAL_NPM_REGISTRY"
|
||||||
|
script:
|
||||||
|
- cd frontend
|
||||||
|
- npm ci --cache .npm --prefer-offline
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- frontend/dist/
|
||||||
|
expire_in: 7 days
|
||||||
|
|
||||||
|
python-dependency-audit:
|
||||||
|
stage: security
|
||||||
|
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
|
||||||
|
needs: ["dependency-source-policy"]
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files:
|
||||||
|
- backend/requirements.txt
|
||||||
|
- ci/requirements-audit.txt
|
||||||
|
paths:
|
||||||
|
- .cache/pip
|
||||||
|
variables:
|
||||||
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||||
|
before_script:
|
||||||
|
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
|
||||||
|
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r ci/requirements-audit.txt
|
||||||
|
script:
|
||||||
|
- mkdir -p reports
|
||||||
|
- pip-audit --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt --format json --output reports/pip-audit.json
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
paths:
|
||||||
|
- reports/pip-audit.json
|
||||||
|
expire_in: 30 days
|
||||||
|
|
||||||
|
container-build:
|
||||||
|
stage: build
|
||||||
|
image: "$INTERNAL_CONTAINER_PROXY/docker:27-cli"
|
||||||
|
needs: ["backend-tests", "frontend-build", "python-dependency-audit"]
|
||||||
|
services:
|
||||||
|
- name: "$INTERNAL_CONTAINER_PROXY/docker:27-dind"
|
||||||
|
variables:
|
||||||
|
DOCKER_HOST: tcp://docker:2375
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
script:
|
||||||
|
- docker build --pull --build-arg "PYTHON_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/python:3.11-slim" --build-arg "PIP_INDEX_URL=$INTERNAL_PYPI_URL" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-backend:$CI_COMMIT_SHA" backend
|
||||||
|
- docker build --pull --build-arg "NODE_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/node:20-alpine" --build-arg "NGINX_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/nginx:alpine" --build-arg "NPM_CONFIG_REGISTRY=$INTERNAL_NPM_REGISTRY" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-frontend:$CI_COMMIT_SHA" frontend
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
|
||||||
@@ -1,611 +0,0 @@
|
|||||||
# H3C ONU设备管理系统 - Claude开发指南
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
|
|
||||||
|
|
||||||
**当前版本**: v0.9.0
|
|
||||||
**开发状态**: 核心功能已完成,运维增强功能持续迭代
|
|
||||||
|
|
||||||
**已实现功能**:
|
|
||||||
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理)
|
|
||||||
- ✅ Excel 数据导入和批量管理
|
|
||||||
- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发
|
|
||||||
- ✅ Casdoor 统一认证和 JWT 令牌管理
|
|
||||||
- ✅ 设备管理(列表、详情、筛选、分页)
|
|
||||||
- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图
|
|
||||||
- ✅ 完整 RBAC 权限管理(角色、权限、用户管理页面)
|
|
||||||
- ✅ OLT 管理(增删改查、批量导入、区域动态同步)
|
|
||||||
- ✅ OLT 端口管理(查看端口状态、开关端口)
|
|
||||||
- ✅ 重复 MAC 检测与清除
|
|
||||||
- ✅ 新设备发现与信息补全(扫描发现 → 补全信息 → 入库)
|
|
||||||
- ✅ 快速扫描(多线程并发)
|
|
||||||
- ✅ 环路检测
|
|
||||||
- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态)
|
|
||||||
- ✅ 库存管理模块(物料、序列号设备、出入库、盘点)
|
|
||||||
- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化)
|
|
||||||
- ✅ 审计日志(全量操作记录、筛选查询、CSV 导出)
|
|
||||||
- ✅ 设备更换记录(MAC 地址更换历史,与库存序列号联动)
|
|
||||||
- ✅ 操作记录日志(查看指定设备的上下线事件历史)
|
|
||||||
- ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT)
|
|
||||||
- ✅ IMC 网管服务集成(ONU 远程重启、光功率查询)
|
|
||||||
- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配)
|
|
||||||
|
|
||||||
**技术栈**:
|
|
||||||
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
|
|
||||||
- 前端:Vue 3 + Element Plus + Pinia + Axios
|
|
||||||
- 部署:Docker Compose
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 已实现的 API 端点
|
|
||||||
|
|
||||||
### 认证相关
|
|
||||||
- `GET /api/auth/login` - 获取 Casdoor 登录 URL
|
|
||||||
- `POST /api/auth/callback` - Casdoor 登录回调
|
|
||||||
- `GET /api/auth/profile` - 获取当前用户信息
|
|
||||||
|
|
||||||
### 设备管理
|
|
||||||
- `GET /api/devices` - 获取设备列表(分页、筛选)
|
|
||||||
- `GET /api/devices/{id}` - 获取设备详情
|
|
||||||
- `PUT /api/devices/{id}` - 更新设备信息
|
|
||||||
- `GET /api/devices/{id}/events` - 获取设备上下线事件记录
|
|
||||||
- `POST /api/devices/{id}/reboot` - 远程重启 ONU(IMC)
|
|
||||||
- `GET /api/devices/{id}/optical-power` - 获取 ONU 光功率(IMC)
|
|
||||||
|
|
||||||
### 状态检查
|
|
||||||
- `POST /api/check/status` - 手动触发全量状态检查(Celery 异步)
|
|
||||||
- `GET /api/check/status/{task_id}` - 查询检查任务进度
|
|
||||||
- `POST /api/check/scan/{olt_id}` - 扫描单台 OLT(预览,不入库)
|
|
||||||
- `POST /api/check/discover/{olt_id}` - 扫描单台 OLT 并入库新设备
|
|
||||||
|
|
||||||
### OLT 管理
|
|
||||||
- `GET /api/olt/devices` - OLT 设备列表
|
|
||||||
- `POST /api/olt/devices` - 新增 OLT
|
|
||||||
- `PUT /api/olt/devices/{id}` - 编辑 OLT
|
|
||||||
- `DELETE /api/olt/devices/{id}` - 删除 OLT
|
|
||||||
- `GET /api/olt/regions` - 获取 OLT 区域列表
|
|
||||||
- `POST /api/olt/quick-scan` - 多线程快速扫描所有 OLT
|
|
||||||
- `GET /api/olt/ports/{olt_id}` - 获取 OLT 端口状态
|
|
||||||
- `POST /api/olt/ports/{olt_id}/toggle` - 开关 OLT 端口
|
|
||||||
- `POST /api/olt/sync-ntp` - 同步 NTP 时间服务器配置
|
|
||||||
- `POST /api/olt/loopback-detection` - 环路检测
|
|
||||||
- `GET /api/olt/new-devices` - 获取新发现设备列表
|
|
||||||
- `PUT /api/olt/new-devices/{id}` - 补全新设备信息
|
|
||||||
- `DELETE /api/olt/new-devices/{id}` - 忽略新设备
|
|
||||||
|
|
||||||
### 数据导入
|
|
||||||
- `POST /api/import/upload` - 上传并导入 Excel 文件
|
|
||||||
- `GET /api/import/template` - 下载导入模板
|
|
||||||
|
|
||||||
### 统计信息
|
|
||||||
- `GET /api/stats/summary` - 获取统计摘要
|
|
||||||
- `GET /api/stats/trend` - 7天趋势数据
|
|
||||||
- `GET /api/stats/region-distribution` - 区域分布
|
|
||||||
|
|
||||||
### 权限管理
|
|
||||||
- `GET /api/roles` - 角色列表
|
|
||||||
- `POST /api/roles` - 创建角色
|
|
||||||
- `PUT /api/roles/{id}` - 编辑角色
|
|
||||||
- `DELETE /api/roles/{id}` - 删除角色
|
|
||||||
- `GET /api/permissions` - 权限列表
|
|
||||||
|
|
||||||
### 用户管理
|
|
||||||
- `GET /api/users` - 用户列表
|
|
||||||
- `PUT /api/users/{id}` - 编辑用户
|
|
||||||
- `DELETE /api/users/{id}` - 删除用户
|
|
||||||
|
|
||||||
### 库存管理
|
|
||||||
- `GET /api/inventory/materials` - 物料列表
|
|
||||||
- `POST /api/inventory/materials` - 新增物料
|
|
||||||
- `GET /api/inventory/serial-devices` - 序列号设备
|
|
||||||
- `POST /api/inventory/stock-in` - 入库
|
|
||||||
- `POST /api/inventory/stock-out` - 出库
|
|
||||||
- `POST /api/inventory/check` - 盘点
|
|
||||||
|
|
||||||
### 审计日志
|
|
||||||
- `GET /api/audit/logs` - 审计日志列表(筛选、分页)
|
|
||||||
- `GET /api/audit/logs/export` - 导出审计日志 CSV
|
|
||||||
|
|
||||||
### 设备更换记录
|
|
||||||
- `GET /api/devices/{id}/replacements` - 查看设备更换历史
|
|
||||||
- `GET /api/replacements` - 全量更换记录列表
|
|
||||||
|
|
||||||
### 系统设置
|
|
||||||
- `GET /api/settings` - 获取系统设置
|
|
||||||
- `PUT /api/settings` - 更新系统设置
|
|
||||||
|
|
||||||
### 系统
|
|
||||||
- `GET /health` - 健康检查
|
|
||||||
- `GET /docs` - API 文档(Swagger UI)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
### 代码规范
|
|
||||||
|
|
||||||
#### Python 后端规范
|
|
||||||
- 遵循 PEP 8 规范
|
|
||||||
- 使用 Black 进行代码格式化
|
|
||||||
- 使用 isort 进行导入排序
|
|
||||||
- 使用类型注解(Type Hints)
|
|
||||||
- 异步函数使用 async/await
|
|
||||||
- 错误处理使用自定义异常类
|
|
||||||
|
|
||||||
#### Vue 前端规范
|
|
||||||
- 使用 Composition API(setup script)
|
|
||||||
- 组件使用 PascalCase 命名
|
|
||||||
- 使用 TypeScript 类型检查
|
|
||||||
- 遵循 Vue 官方风格指南
|
|
||||||
- 使用 ESLint + Prettier 格式化
|
|
||||||
|
|
||||||
#### Git 提交规范
|
|
||||||
使用 Conventional Commits 格式:
|
|
||||||
```
|
|
||||||
<type>(<scope>): <subject>
|
|
||||||
|
|
||||||
类型:feat, fix, docs, style, refactor, test, chore
|
|
||||||
示例:feat(device): 添加设备导入功能
|
|
||||||
```
|
|
||||||
|
|
||||||
### 架构规则
|
|
||||||
|
|
||||||
#### 后端架构
|
|
||||||
- **分层架构**:API → Service → Model
|
|
||||||
- **API 层**:仅处理请求/响应,调用 Service
|
|
||||||
- **Service 层**:业务逻辑,不直接操作数据库
|
|
||||||
- **Model 层**:SQLAlchemy 模型定义
|
|
||||||
- **异步任务**:耗时操作使用 Celery
|
|
||||||
|
|
||||||
#### 前端架构
|
|
||||||
- **组件分类**:
|
|
||||||
- `components/common/`:通用组件
|
|
||||||
- `components/business/`:业务组件
|
|
||||||
- `views/`:页面组件
|
|
||||||
- **状态管理**:使用 Pinia stores
|
|
||||||
- **API 调用**:统一在 `api/` 目录封装
|
|
||||||
|
|
||||||
### 安全规则
|
|
||||||
|
|
||||||
#### 数据安全
|
|
||||||
- SSH 密码必须加密存储(使用 Fernet 加密)
|
|
||||||
- 敏感信息通过环境变量配置
|
|
||||||
- 数据库连接使用 SSL
|
|
||||||
- 定期备份数据(90天历史记录)
|
|
||||||
|
|
||||||
#### 应用安全
|
|
||||||
- 所有 API 端点需要认证(除登录接口)
|
|
||||||
- 实现 CSRF 保护
|
|
||||||
- 输入验证使用 Pydantic
|
|
||||||
- SQL 注入防护(使用 ORM)
|
|
||||||
- XSS 防护(前端转义)
|
|
||||||
|
|
||||||
#### 访问控制
|
|
||||||
- 基于 RBAC 的权限控制
|
|
||||||
- 数据级权限过滤(按区域/学校)
|
|
||||||
- 操作审计日志记录
|
|
||||||
- 设备信息变更需要审核
|
|
||||||
|
|
||||||
### 性能规则
|
|
||||||
|
|
||||||
#### 数据库优化
|
|
||||||
- 为常用查询字段添加索引
|
|
||||||
- 使用连接池(pool_size=20)
|
|
||||||
- 避免 N+1 查询问题
|
|
||||||
- 定期清理历史数据(保留90天)
|
|
||||||
|
|
||||||
#### 缓存策略
|
|
||||||
- Redis 缓存热点数据(设备状态)
|
|
||||||
- 缓存过期时间:30分钟
|
|
||||||
- 手动刷新有5分钟冷却限制
|
|
||||||
|
|
||||||
#### 异步处理
|
|
||||||
- SSH 状态检查使用 Celery 异步任务
|
|
||||||
- 批量导入使用后台任务
|
|
||||||
- 定时任务使用 Celery Beat
|
|
||||||
|
|
||||||
### 开发规则
|
|
||||||
|
|
||||||
#### 提交前清理
|
|
||||||
- 每次提交前必须清理项目中不需要的文件
|
|
||||||
- 包括:测试文件(`test_*.py`)、调试脚本、不再使用的 shell 脚本、冗余 markdown 文档
|
|
||||||
- 检查是否有硬编码的敏感信息(密码、密钥、token)
|
|
||||||
- 检查 `.gitignore` 是否覆盖了所有不应提交的文件(`*.pem`、`.env`、`__pycache__` 等)
|
|
||||||
|
|
||||||
#### 环境配置
|
|
||||||
- 开发环境使用 `.env.development`
|
|
||||||
- 生产环境使用 `.env.production`
|
|
||||||
- 不提交 `.env` 文件到 Git
|
|
||||||
- 提供 `.env.example` 模板
|
|
||||||
- 敏感信息(数据库密码、Casdoor 密钥等)仅通过环境变量注入
|
|
||||||
|
|
||||||
#### 测试要求
|
|
||||||
- 核心业务逻辑需要单元测试
|
|
||||||
- API 端点需要集成测试
|
|
||||||
- 测试覆盖率目标:≥80%
|
|
||||||
|
|
||||||
#### 文档要求
|
|
||||||
- API 变更及时更新 Swagger 文档
|
|
||||||
- 复杂业务逻辑添加注释
|
|
||||||
- 重要配置添加说明
|
|
||||||
|
|
||||||
### 部署规则
|
|
||||||
|
|
||||||
#### Docker 部署
|
|
||||||
- 使用 Docker Compose 编排
|
|
||||||
- 外部 PostgreSQL 和 Redis(不在容器内)
|
|
||||||
- 日志挂载到宿主机
|
|
||||||
- 使用 Nginx 反向代理
|
|
||||||
|
|
||||||
#### 环境变量
|
|
||||||
必需配置:
|
|
||||||
- `DATABASE_URL`:PostgreSQL 连接字符串
|
|
||||||
- `REDIS_URL`:Redis 连接字符串
|
|
||||||
- `CASDOOR_*`:Casdoor 认证配置
|
|
||||||
- `SECRET_KEY`:应用密钥
|
|
||||||
|
|
||||||
#### 监控告警
|
|
||||||
- 健康检查端点:`/health`
|
|
||||||
- 性能指标端点:`/metrics`
|
|
||||||
- 日志级别:生产环境使用 INFO
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署陷阱与经验教训
|
|
||||||
|
|
||||||
### 修改代码后必须重新构建镜像
|
|
||||||
|
|
||||||
**问题**:修改了宿主机上的源码后,直接 `docker compose up -d` 或 `docker compose restart` **不会**让容器使用新代码。容器运行的是构建时打包进镜像的旧代码。
|
|
||||||
|
|
||||||
**正确流程**:
|
|
||||||
```bash
|
|
||||||
# 1. 重新构建镜像(必须加 --no-cache 确保不用旧层)
|
|
||||||
docker compose build --no-cache backend # 或 frontend,或两者
|
|
||||||
|
|
||||||
# 2. 重新创建容器(必须用 rm + up,或 up --force-recreate)
|
|
||||||
docker compose rm -f backend
|
|
||||||
docker compose up -d backend
|
|
||||||
|
|
||||||
# 3. 如有数据库模型变更,执行迁移
|
|
||||||
docker compose exec backend alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
**不要用**:
|
|
||||||
- `docker compose restart`:只重启进程,不更新镜像
|
|
||||||
- `docker compose up -d`(无 build):容器配置变了会重建,但镜像不变
|
|
||||||
|
|
||||||
### 端口被占用导致容器启动但无端口映射
|
|
||||||
|
|
||||||
**问题**:如果宿主机端口已被其他容器占用,新容器会启动成功(`docker compose up` 不报错),但端口映射为空 `{}`,外部无法访问。
|
|
||||||
|
|
||||||
**排查方法**:
|
|
||||||
```bash
|
|
||||||
# 检查端口映射是否正常
|
|
||||||
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
|
|
||||||
|
|
||||||
# 查看谁占用了端口
|
|
||||||
docker ps | grep <port>
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。
|
|
||||||
|
|
||||||
### frontend 容器必须配置 VITE_API_PROXY_TARGET
|
|
||||||
|
|
||||||
**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`,Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。
|
|
||||||
|
|
||||||
**必须在 docker-compose.yml 中配置**:
|
|
||||||
```yaml
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
ports:
|
|
||||||
- "5173:5173"
|
|
||||||
environment:
|
|
||||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 新增数据库模型后必须执行迁移
|
|
||||||
|
|
||||||
**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。
|
|
||||||
|
|
||||||
**每次新增模型的完整流程**:
|
|
||||||
```bash
|
|
||||||
# 1. 创建迁移文件(在宿主机或容器内)
|
|
||||||
docker compose exec backend alembic revision --autogenerate -m "描述"
|
|
||||||
|
|
||||||
# 2. 重建镜像
|
|
||||||
docker compose build --no-cache backend
|
|
||||||
|
|
||||||
# 3. 重启容器
|
|
||||||
docker compose rm -f backend && docker compose up -d backend
|
|
||||||
|
|
||||||
# 4. 执行迁移
|
|
||||||
docker compose exec backend alembic upgrade head
|
|
||||||
```
|
|
||||||
|
|
||||||
### Alembic 迁移文件 revision ID 不能重复
|
|
||||||
|
|
||||||
**问题**:手动创建迁移文件时,如果 `revision` 字段与已有文件重复,`alembic upgrade head` 会报 `Multiple head revisions are present` 错误。
|
|
||||||
|
|
||||||
**规则**:手动创建迁移文件时,revision ID 使用不与现有文件冲突的唯一字符串(如 `h8i9j0k1l2m3`),并确认 `down_revision` 指向正确的上一个版本。
|
|
||||||
|
|
||||||
### Celery worker 必须重建才能识别新任务
|
|
||||||
|
|
||||||
**问题**:新增 Celery 任务模块后,如果只重建 backend 而不重建 celery-worker,worker 不会注册新任务,消息会被丢弃并报 `KeyError`。
|
|
||||||
|
|
||||||
**规则**:新增任务模块后,backend 和 celery-worker 都必须重建:
|
|
||||||
```bash
|
|
||||||
docker compose build --no-cache backend celery-worker
|
|
||||||
docker compose rm -f backend celery-worker
|
|
||||||
docker compose up -d backend celery-worker
|
|
||||||
```
|
|
||||||
|
|
||||||
验证任务是否注册:
|
|
||||||
```bash
|
|
||||||
docker compose exec celery-worker celery -A celery_worker.celery_app inspect registered
|
|
||||||
```
|
|
||||||
|
|
||||||
### 区域管理员多区域过滤必须用 `.in_()` 而非 `==`
|
|
||||||
|
|
||||||
**问题**:`assigned_area` 字段存储逗号分隔的多个区域(如 `"城区,郊区"`),用 `==` 只能匹配整个字符串,导致多区域管理员只能看到第一个区域的数据。
|
|
||||||
|
|
||||||
**规则**:所有涉及 `assigned_area` 的过滤都必须先 split 再用 `.in_()`:
|
|
||||||
```python
|
|
||||||
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
|
||||||
query = query.filter(Model.region.in_(areas))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vite dev server 通过反向代理访问需配置 allowedHosts
|
|
||||||
|
|
||||||
**问题**:通过域名反向代理访问 Vite dev server 时,会报 `Blocked request. This host is not allowed`。
|
|
||||||
|
|
||||||
**解决**:在 `vite.config.js` 中设置:
|
|
||||||
```js
|
|
||||||
server: {
|
|
||||||
allowedHosts: ['all', 'your-domain.com'],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### iOS PWA standalone 模式与 Safari 的 safe area 差异
|
|
||||||
|
|
||||||
**问题**:`env(safe-area-inset-top)` 在 Safari 浏览器中为 0(有地址栏占位),在 standalone 模式(添加到主屏幕)下为真实刘海高度(44-59px)。直接使用会导致 Safari 中布局正常但 standalone 中顶栏/弹窗重叠。
|
|
||||||
|
|
||||||
**规则**:所有 safe area 相关样式必须包在 `@media (display-mode: standalone)` 中,Safari 不受影响:
|
|
||||||
```css
|
|
||||||
@media (display-mode: standalone) {
|
|
||||||
.mobile-topbar {
|
|
||||||
height: calc(52px + env(safe-area-inset-top));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`ElMessage` 的 offset 通过 `src/utils/message.js` 封装统一处理,所有页面从该文件导入而非直接从 `element-plus` 导入。
|
|
||||||
|
|
||||||
### Docker healthcheck 镜像内无 curl
|
|
||||||
|
|
||||||
**问题**:backend 镜像基于 Python slim,没有 `curl`,healthcheck 用 `curl` 会导致所有依赖服务启动失败。
|
|
||||||
|
|
||||||
**规则**:healthcheck 使用 Python 内置模块:
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 5
|
|
||||||
start_period: 40s
|
|
||||||
```
|
|
||||||
|
|
||||||
### More 分页标记清除不能使用 `[^\n]*` 删除整行
|
|
||||||
|
|
||||||
**问题**:`_clean_output` 中 `---- More ----[^\n]*` 会将 `---- More ----` 及其后同行所有内容删除。当 OLT 输出的 More 提示与下一页第一条设备数据出现在同一行时(如 `---- More ----\r\r 1484-778f-aa60 ...`),该设备行会被错误丢弃,导致部分设备扫描不到。
|
|
||||||
|
|
||||||
**规则**:仅移除 More 标记文本本身,保留同行后续内容:
|
|
||||||
```python
|
|
||||||
# 错误:删除整行
|
|
||||||
output = re.sub(r'---- More ----[^\n]*', '', output)
|
|
||||||
|
|
||||||
# 正确:仅移除标记文本
|
|
||||||
output = re.sub(r'---- More ----', '', output)
|
|
||||||
```
|
|
||||||
|
|
||||||
此问题在 OLT `172.16.0.18`(大化县新城初中)上发现:93 台设备仅解析出 90 台,丢失 3 台。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
H3ConuMS2/
|
|
||||||
├── backend/ # Python 后端
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── api/v1/ # API 路由
|
|
||||||
│ │ ├── core/ # 核心配置
|
|
||||||
│ │ ├── middleware/ # 中间件(权限、审计)
|
|
||||||
│ │ ├── models/ # 数据模型
|
|
||||||
│ │ ├── schemas/ # Pydantic 模式
|
|
||||||
│ │ ├── services/ # 业务逻辑(SSH、IMC、导入等)
|
|
||||||
│ │ └── tasks/ # Celery 任务
|
|
||||||
│ ├── alembic/ # 数据库迁移
|
|
||||||
│ ├── scripts/ # 初始化脚本
|
|
||||||
│ └── templates/ # Excel 导入模板
|
|
||||||
├── frontend/ # Vue 3 前端
|
|
||||||
│ └── src/
|
|
||||||
│ ├── api/ # API 调用封装
|
|
||||||
│ ├── components/ # 公共组件
|
|
||||||
│ ├── composables/ # 组合式函数
|
|
||||||
│ ├── router/ # 路由
|
|
||||||
│ ├── stores/ # Pinia 状态管理
|
|
||||||
│ ├── styles/ # 主题样式
|
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ └── views/ # 页面组件
|
|
||||||
├── deploy/ # 部署配置
|
|
||||||
│ ├── docker-compose.yml
|
|
||||||
│ ├── nginx/
|
|
||||||
│ └── scripts/
|
|
||||||
├── docs/ # 文档
|
|
||||||
└── docker-compose.yml # 主部署文件
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心业务逻辑
|
|
||||||
|
|
||||||
### SSH 状态检查流程
|
|
||||||
1. 从数据库获取 OLT 设备配置
|
|
||||||
2. 建立 SSH 连接(使用连接池)
|
|
||||||
3. 执行命令:`display onu slot {slot_number}`
|
|
||||||
4. 解析返回结果:
|
|
||||||
- 包含 "Up" → 在线
|
|
||||||
- 包含 "Offline" → 离线
|
|
||||||
5. 更新设备状态到数据库
|
|
||||||
6. 记录历史状态
|
|
||||||
|
|
||||||
### 权限控制逻辑
|
|
||||||
- **超级管理员**:所有权限
|
|
||||||
- **管理员**:管理所有设备和用户
|
|
||||||
- **区域管理员**:管理指定区域的设备
|
|
||||||
- **学校管理员**:管理指定学校的设备
|
|
||||||
- **普通用户**:只读权限
|
|
||||||
|
|
||||||
### 数据导入流程
|
|
||||||
1. 上传 Excel 文件
|
|
||||||
2. 使用 Pandas 解析数据
|
|
||||||
3. 数据验证(MAC 地址格式、必填字段)
|
|
||||||
4. 批量插入数据库
|
|
||||||
5. 返回导入结果(成功/失败记录)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开发指南
|
|
||||||
|
|
||||||
### 快速启动
|
|
||||||
|
|
||||||
**Docker 部署(推荐)**:
|
|
||||||
```bash
|
|
||||||
# 1. 配置环境变量
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
# 编辑 .env 填入实际配置
|
|
||||||
|
|
||||||
# 2. 启动所有服务
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 3. 初始化数据库
|
|
||||||
docker compose exec backend python scripts/init_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**本地开发**:
|
|
||||||
```bash
|
|
||||||
# 后端
|
|
||||||
cd backend
|
|
||||||
python -m venv venv && source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# 前端
|
|
||||||
cd frontend
|
|
||||||
npm install && npm run dev
|
|
||||||
|
|
||||||
# Celery Worker
|
|
||||||
cd backend
|
|
||||||
celery -A celery_worker.celery_app worker --loglevel=info
|
|
||||||
|
|
||||||
# Celery Beat
|
|
||||||
cd backend
|
|
||||||
celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库迁移
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建迁移
|
|
||||||
alembic revision --autogenerate -m "描述"
|
|
||||||
|
|
||||||
# 执行迁移
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# 回滚
|
|
||||||
alembic downgrade -1
|
|
||||||
```
|
|
||||||
|
|
||||||
### 添加新功能
|
|
||||||
|
|
||||||
1. **后端 API**:
|
|
||||||
- 在 `app/api/v1/` 创建路由文件
|
|
||||||
- 在 `app/services/` 创建服务文件
|
|
||||||
- 在 `app/schemas/` 定义请求/响应模式
|
|
||||||
- 在 `app/models/` 定义数据模型(如需要)
|
|
||||||
|
|
||||||
2. **前端页面**:
|
|
||||||
- 在 `views/` 创建页面组件
|
|
||||||
- 在 `api/` 添加 API 调用
|
|
||||||
- 在 `router/` 添加路由配置
|
|
||||||
- 在 `stores/` 添加状态管理(如需要)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### SSH 连接失败
|
|
||||||
- 检查网络连通性
|
|
||||||
- 验证 SSH 凭证
|
|
||||||
- 检查防火墙设置
|
|
||||||
- 查看日志:`docker-compose logs backend`
|
|
||||||
|
|
||||||
### 数据库连接失败
|
|
||||||
- 检查 `DATABASE_URL` 配置
|
|
||||||
- 验证数据库服务状态
|
|
||||||
- 检查网络权限
|
|
||||||
|
|
||||||
### Casdoor 登录失败
|
|
||||||
- 检查 Casdoor 服务状态
|
|
||||||
- 验证 `CASDOOR_*` 配置
|
|
||||||
- 检查回调地址配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参考文档
|
|
||||||
|
|
||||||
- [FastAPI 文档](https://fastapi.tiangolo.com/)
|
|
||||||
- [Vue 3 文档](https://vuejs.org/)
|
|
||||||
- [Element Plus 文档](https://element-plus.org/)
|
|
||||||
- [Casdoor 文档](https://casdoor.org/)
|
|
||||||
- [现场ONU故障排查指南](./docs/现场ONU故障排查指南.md)
|
|
||||||
|
|
||||||
<!-- code-review-graph MCP tools -->
|
|
||||||
## MCP Tools: code-review-graph
|
|
||||||
|
|
||||||
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
|
|
||||||
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
|
|
||||||
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
|
|
||||||
you structural context (callers, dependents, test coverage) that file
|
|
||||||
scanning cannot.
|
|
||||||
|
|
||||||
### When to use graph tools FIRST
|
|
||||||
|
|
||||||
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
|
|
||||||
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
|
|
||||||
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
|
|
||||||
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
|
|
||||||
- **Architecture questions**: `get_architecture_overview` + `list_communities`
|
|
||||||
|
|
||||||
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
|
|
||||||
|
|
||||||
### Key Tools
|
|
||||||
|
|
||||||
| Tool | Use when |
|
|
||||||
| ------ | ---------- |
|
|
||||||
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
|
|
||||||
| `get_review_context` | Need source snippets for review — token-efficient |
|
|
||||||
| `get_impact_radius` | Understanding blast radius of a change |
|
|
||||||
| `get_affected_flows` | Finding which execution paths are impacted |
|
|
||||||
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
|
|
||||||
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
|
|
||||||
| `get_architecture_overview` | Understanding high-level codebase structure |
|
|
||||||
| `refactor_tool` | Planning renames, finding dead code |
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. The graph auto-updates on file changes (via hooks).
|
|
||||||
2. Use `detect_changes` for code review.
|
|
||||||
3. Use `get_affected_flows` to understand impact.
|
|
||||||
4. Use `query_graph` pattern="tests_for" to check coverage.
|
|
||||||
+30
-1
@@ -2,6 +2,8 @@
|
|||||||
APP_NAME=H3C-ONU-MS
|
APP_NAME=H3C-ONU-MS
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
SECRET_KEY=your-secret-key-change-this
|
SECRET_KEY=your-secret-key-change-this
|
||||||
|
# 独立于 SECRET_KEY;使用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
|
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
|
||||||
@@ -16,7 +18,9 @@ CASDOOR_CLIENT_SECRET=your_client_secret
|
|||||||
CASDOOR_ORG_NAME=your_org
|
CASDOOR_ORG_NAME=your_org
|
||||||
CASDOOR_APP_NAME=h3c-onu-ms
|
CASDOOR_APP_NAME=h3c-onu-ms
|
||||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||||
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
|
CASDOOR_REDIRECT_URL=
|
||||||
|
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||||
|
CASDOOR_ISSUER=
|
||||||
|
|
||||||
# SSH配置
|
# SSH配置
|
||||||
SSH_TIMEOUT=30
|
SSH_TIMEOUT=30
|
||||||
@@ -24,3 +28,28 @@ SSH_TIMEOUT=30
|
|||||||
# 任务配置
|
# 任务配置
|
||||||
CHECK_INTERVAL=1800
|
CHECK_INTERVAL=1800
|
||||||
MANUAL_COOLDOWN=300
|
MANUAL_COOLDOWN=300
|
||||||
|
|
||||||
|
# CORS & 前端
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:18002
|
||||||
|
FRONTEND_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# NTP 同步
|
||||||
|
NTP_OLD_SERVER=172.16.0.254
|
||||||
|
NTP_NEW_SERVER=172.16.1.252
|
||||||
|
|
||||||
|
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||||
|
IMC_API_URL=
|
||||||
|
IMC_API_USERNAME=
|
||||||
|
IMC_API_PASSWORD=
|
||||||
|
IMC_API_VERIFY_SSL=true
|
||||||
|
IMC_CONNECT_TIMEOUT=5
|
||||||
|
IMC_READ_TIMEOUT=20
|
||||||
|
|
||||||
|
# 企业微信告警配置
|
||||||
|
WECHAT_CORPID=
|
||||||
|
WECHAT_CORPSECRET=
|
||||||
|
WECHAT_AGENTID=
|
||||||
|
WECHAT_TOKEN=
|
||||||
|
WECHAT_ENCODING_AES_KEY=
|
||||||
|
WECHAT_USE_PROXY=True
|
||||||
|
WECHAT_PROXY_API_URL=
|
||||||
|
|||||||
+5
-6
@@ -1,13 +1,12 @@
|
|||||||
FROM python:3.11-slim
|
ARG PYTHON_BASE_IMAGE=python:3.11-slim
|
||||||
|
FROM ${PYTHON_BASE_IMAGE}
|
||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
ARG PIP_INDEX_URL
|
||||||
|
RUN if [ -n "$PIP_INDEX_URL" ]; then pip config set global.index-url "$PIP_INDEX_URL"; fi \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
0.10.0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""add latitude and longitude columns to onu_devices
|
||||||
|
|
||||||
|
Revision ID: i9j0k1l2m3n4
|
||||||
|
Revises: h8i9j0k1l2m3
|
||||||
|
Create Date: 2026-06-02 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'i9j0k1l2m3n4'
|
||||||
|
down_revision: Union[str, None] = 'h8i9j0k1l2m3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('onu_devices', sa.Column('latitude', sa.Float(), nullable=True))
|
||||||
|
op.add_column('onu_devices', sa.Column('longitude', sa.Float(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('onu_devices', 'longitude')
|
||||||
|
op.drop_column('onu_devices', 'latitude')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add composite indexes for performance
|
||||||
|
|
||||||
|
Revision ID: j0k1l2m3n4o5
|
||||||
|
Revises: i9j0k1l2m3n4
|
||||||
|
Create Date: 2026-06-02 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = 'j0k1l2m3n4o5'
|
||||||
|
down_revision: Union[str, None] = 'i9j0k1l2m3n4'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# FK 索引 — 每次状态检查都要按 OLT 查询设备
|
||||||
|
op.create_index('ix_onu_devices_olt_id', 'onu_devices', ['olt_id'])
|
||||||
|
# 复合索引 — Dashboard 按区域+学校聚合
|
||||||
|
op.create_index('ix_onu_devices_region_school', 'onu_devices', ['region', 'school_name'])
|
||||||
|
# FK 索引 — DeviceStatusHistory 按设备查最新状态(最频繁的查询)
|
||||||
|
op.create_index('ix_device_status_history_onu_device_id_checked', 'device_status_history', ['onu_device_id', 'checked_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_device_status_history_onu_device_id_checked', table_name='device_status_history')
|
||||||
|
op.drop_index('ix_onu_devices_region_school', table_name='onu_devices')
|
||||||
|
op.drop_index('ix_onu_devices_olt_id', table_name='onu_devices')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add tags column to onu_devices
|
||||||
|
|
||||||
|
Revision ID: k0l1m2n3o4p5
|
||||||
|
Revises: j0k1l2m3n4o5
|
||||||
|
Create Date: 2026-06-03 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'k0l1m2n3o4p5'
|
||||||
|
down_revision: Union[str, None] = 'j0k1l2m3n4o5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('onu_devices', sa.Column('tags', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('onu_devices', 'tags')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add optical_power_history table
|
||||||
|
|
||||||
|
Revision ID: l1m2n3o4p5q6
|
||||||
|
Revises: k0l1m2n3o4p5
|
||||||
|
Create Date: 2026-06-03 11:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'l1m2n3o4p5q6'
|
||||||
|
down_revision: Union[str, None] = 'k0l1m2n3o4p5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('optical_power_history',
|
||||||
|
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('onu_device_id', sa.BigInteger(), sa.ForeignKey('onu_devices.id'), nullable=False),
|
||||||
|
sa.Column('power_in', sa.String(20), nullable=True),
|
||||||
|
sa.Column('power_out', sa.String(20), nullable=True),
|
||||||
|
sa.Column('recorded_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('ix_optical_power_history_id', 'optical_power_history', ['id'])
|
||||||
|
op.create_index('ix_optical_power_history_onu_device_id', 'optical_power_history', ['onu_device_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_optical_power_history_onu_device_id', table_name='optical_power_history')
|
||||||
|
op.drop_index('ix_optical_power_history_id', table_name='optical_power_history')
|
||||||
|
op.drop_table('optical_power_history')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add display_name to users
|
||||||
|
|
||||||
|
Revision ID: m1n2o3p4q5r6
|
||||||
|
Revises: l1m2n3o4p5q6
|
||||||
|
Create Date: 2026-06-03 20:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'm1n2o3p4q5r6'
|
||||||
|
down_revision: Union[str, None] = 'l1m2n3o4p5q6'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('users', sa.Column('display_name', sa.String(100), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('users', 'display_name')
|
||||||
+39
-39
@@ -49,45 +49,6 @@ def get_audit_logs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/{log_id}")
|
|
||||||
def get_audit_log_detail(
|
|
||||||
log_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: dict = Depends(require_permission('*')),
|
|
||||||
):
|
|
||||||
"""获取单条审计日志详情"""
|
|
||||||
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
|
||||||
if not log:
|
|
||||||
from fastapi import HTTPException
|
|
||||||
raise HTTPException(status_code=404, detail="日志不存在")
|
|
||||||
return _fmt(log, detail=True)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def get_audit_stats(
|
|
||||||
days: int = Query(7, ge=1, le=90),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
_: dict = Depends(require_permission('*')),
|
|
||||||
):
|
|
||||||
"""审计日志统计(最近N天)"""
|
|
||||||
from datetime import timedelta
|
|
||||||
from sqlalchemy import func
|
|
||||||
since = datetime.utcnow() - timedelta(days=days)
|
|
||||||
rows = (
|
|
||||||
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
|
||||||
.filter(AuditLog.action_time >= since)
|
|
||||||
.group_by(AuditLog.action_type, AuditLog.status)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
|
||||||
by_type = {}
|
|
||||||
for row in rows:
|
|
||||||
if row.action_type not in by_type:
|
|
||||||
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
|
||||||
by_type[row.action_type][row.status] = row.cnt
|
|
||||||
return {"total": total, "days": days, "by_type": by_type}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/export/csv")
|
@router.get("/logs/export/csv")
|
||||||
def export_audit_logs(
|
def export_audit_logs(
|
||||||
start_time: Optional[datetime] = Query(None),
|
start_time: Optional[datetime] = Query(None),
|
||||||
@@ -130,6 +91,45 @@ def export_audit_logs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/{log_id}")
|
||||||
|
def get_audit_log_detail(
|
||||||
|
log_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""获取单条审计日志详情"""
|
||||||
|
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
||||||
|
if not log:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="日志不存在")
|
||||||
|
return _fmt(log, detail=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_audit_stats(
|
||||||
|
days: int = Query(7, ge=1, le=90),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""审计日志统计(最近N天)"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
rows = (
|
||||||
|
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
||||||
|
.filter(AuditLog.action_time >= since)
|
||||||
|
.group_by(AuditLog.action_type, AuditLog.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
||||||
|
by_type = {}
|
||||||
|
for row in rows:
|
||||||
|
if row.action_type not in by_type:
|
||||||
|
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
||||||
|
by_type[row.action_type][row.status] = row.cnt
|
||||||
|
return {"total": total, "days": days, "by_type": by_type}
|
||||||
|
|
||||||
|
|
||||||
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||||
base = {
|
base = {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
|
|||||||
+52
-24
@@ -1,33 +1,48 @@
|
|||||||
"""认证 API"""
|
"""认证 API"""
|
||||||
import base64
|
import hmac
|
||||||
import json
|
import secrets
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.casdoor import casdoor_sdk
|
from app.core.casdoor import casdoor_sdk
|
||||||
from app.core.security import create_access_token, verify_token
|
from app.core.errors import internal_error
|
||||||
|
from app.core.security import create_access_token, verify_casdoor_token, verify_token
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import Token, UserInfo
|
from app.schemas.auth import Token, UserInfo
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
OAUTH_STATE_COOKIE = "h3c_oauth_state"
|
||||||
|
OAUTH_STATE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
def decode_jwt_payload(token: str) -> dict:
|
def _with_oauth_state(url: str, state: str) -> str:
|
||||||
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
|
"""Replace the SDK-generated state with the browser-bound state value."""
|
||||||
payload_b64 = token.split(".")[1]
|
parts = urlsplit(url)
|
||||||
rem = len(payload_b64) % 4
|
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "state"]
|
||||||
if rem:
|
query.append(("state", state))
|
||||||
payload_b64 += "=" * (4 - rem)
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
def login():
|
def login(response: Response):
|
||||||
"""获取 Casdoor 登录 URL"""
|
"""获取 Casdoor 登录 URL"""
|
||||||
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
|
state = secrets.token_urlsafe(32)
|
||||||
|
response.set_cookie(
|
||||||
|
key=OAUTH_STATE_COOKIE,
|
||||||
|
value=state,
|
||||||
|
max_age=OAUTH_STATE_TTL_SECONDS,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="lax",
|
||||||
|
path="/api/auth",
|
||||||
|
)
|
||||||
|
login_url = casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)
|
||||||
|
return {"url": _with_oauth_state(login_url, state)}
|
||||||
|
|
||||||
|
|
||||||
class CallbackRequest(BaseModel):
|
class CallbackRequest(BaseModel):
|
||||||
@@ -36,28 +51,44 @@ class CallbackRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/callback", response_model=Token)
|
@router.post("/callback", response_model=Token)
|
||||||
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
def callback(
|
||||||
|
body: CallbackRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
"""Casdoor 登录回调"""
|
"""Casdoor 登录回调"""
|
||||||
try:
|
try:
|
||||||
|
expected_state = request.cookies.get(OAUTH_STATE_COOKIE)
|
||||||
|
response.delete_cookie(OAUTH_STATE_COOKIE, path="/api/auth")
|
||||||
|
if not expected_state or not hmac.compare_digest(body.state, expected_state):
|
||||||
|
raise HTTPException(status_code=400, detail="登录状态校验失败,请重新登录")
|
||||||
|
|
||||||
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
||||||
if isinstance(token_response, dict) and "error" in token_response:
|
if isinstance(token_response, dict) and "error" in token_response:
|
||||||
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
|
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
|
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
|
||||||
|
identity_token = token_response.get("id_token") if isinstance(token_response, dict) else None
|
||||||
if not access_token:
|
if not access_token:
|
||||||
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
|
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
|
||||||
|
|
||||||
casdoor_user = decode_jwt_payload(access_token)
|
casdoor_user = verify_casdoor_token(identity_token or access_token)
|
||||||
|
|
||||||
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
||||||
if not user:
|
if not user:
|
||||||
user = User(
|
user = User(
|
||||||
casdoor_id=casdoor_user["sub"],
|
casdoor_id=casdoor_user["sub"],
|
||||||
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
|
username=casdoor_user.get("preferred_username") or casdoor_user.get("name", ""),
|
||||||
|
display_name=casdoor_user.get("displayName") or casdoor_user.get("name", ""),
|
||||||
email=casdoor_user.get("email"),
|
email=casdoor_user.get("email"),
|
||||||
role="user"
|
role="user"
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
|
else:
|
||||||
|
# 每次登录同步 Casdoor 信息(姓名、邮箱等可能更新)
|
||||||
|
user.display_name = casdoor_user.get("displayName") or casdoor_user.get("name", user.display_name or "")
|
||||||
|
user.email = casdoor_user.get("email", user.email)
|
||||||
|
|
||||||
user.last_login = datetime.utcnow()
|
user.last_login = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -71,20 +102,17 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
raise internal_error("Casdoor login callback", e)
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions")
|
@router.get("/permissions")
|
||||||
def get_my_permissions(
|
def get_my_permissions(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取当前用户的权限码列表"""
|
"""获取当前用户的权限码列表"""
|
||||||
from app.middleware.permission_middleware import get_role_permissions
|
from app.middleware.permission_middleware import get_role_permissions
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
@@ -97,11 +125,11 @@ def get_my_permissions(
|
|||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
def get_profile(
|
def get_profile(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取当前用户信息"""
|
"""获取当前用户信息"""
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
"""状态检查 API"""
|
"""状态检查 API"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
from celery.result import AsyncResult
|
from celery.result import AsyncResult
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
from app.tasks.check_tasks import check_all_devices
|
from app.tasks.check_tasks import check_all_devices
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.errors import internal_error
|
||||||
from app.services.check_service import CheckService
|
from app.services.check_service import CheckService
|
||||||
from app.middleware.permission_middleware import require_permission
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
class CheckResult(BaseModel):
|
class CheckResult(BaseModel):
|
||||||
@@ -31,14 +35,14 @@ class CheckError(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/status")
|
@router.post("/status")
|
||||||
def trigger_check(_: dict = Depends(require_permission('device.check'))):
|
@limiter.limit("3/minute")
|
||||||
|
def trigger_check(request: Request, _: dict = Depends(require_permission('device.check'))):
|
||||||
"""手动触发状态检查"""
|
"""手动触发状态检查"""
|
||||||
try:
|
try:
|
||||||
task = check_all_devices.delay()
|
task = check_all_devices.delay()
|
||||||
return {"task_id": task.id, "status": "started"}
|
return {"task_id": task.id, "status": "started"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"触发状态检查失败: {str(e)}")
|
raise internal_error("Trigger device status check", e)
|
||||||
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status/{task_id}")
|
@router.get("/status/{task_id}")
|
||||||
@@ -80,7 +84,7 @@ def scan_olt(
|
|||||||
result = asyncio.run(service.scan_olt(olt_id))
|
result = asyncio.run(service.scan_olt(olt_id))
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Scan OLT", e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/discover/{olt_id}")
|
@router.post("/discover/{olt_id}")
|
||||||
@@ -95,4 +99,4 @@ def discover_olt(
|
|||||||
result = service.scan_and_discover(olt_id)
|
result = service.scan_and_discover(olt_id)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Discover OLT devices", e)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"""设备管理 API"""
|
"""设备管理 API"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import asc, desc, distinct, or_
|
from sqlalchemy import asc, desc, distinct, or_
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.errors import internal_error
|
||||||
from app.middleware.permission_middleware import require_permission
|
from app.middleware.permission_middleware import require_permission
|
||||||
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
||||||
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
||||||
@@ -20,6 +24,7 @@ def get_devices(
|
|||||||
school_name: str = None,
|
school_name: str = None,
|
||||||
keyword: str = None,
|
keyword: str = None,
|
||||||
status: str = None,
|
status: str = None,
|
||||||
|
tag: str = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current: dict = Depends(require_permission('device.view')),
|
current: dict = Depends(require_permission('device.view')),
|
||||||
):
|
):
|
||||||
@@ -68,6 +73,8 @@ def get_devices(
|
|||||||
query = query.filter(ONUDevice.region == region)
|
query = query.filter(ONUDevice.region == region)
|
||||||
if school_name:
|
if school_name:
|
||||||
query = query.filter(ONUDevice.school_name.contains(school_name))
|
query = query.filter(ONUDevice.school_name.contains(school_name))
|
||||||
|
if tag:
|
||||||
|
query = query.filter(ONUDevice.tags.contains(tag))
|
||||||
if keyword:
|
if keyword:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
or_(
|
or_(
|
||||||
@@ -332,6 +339,55 @@ def export_replacements(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
def get_all_tags(db: Session = Depends(get_db)):
|
||||||
|
"""获取所有不重复的设备标签"""
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
rows = db.query(ONUDevice.tags).filter(
|
||||||
|
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
|
||||||
|
).all()
|
||||||
|
tags = set()
|
||||||
|
for (tag_str,) in rows:
|
||||||
|
for t in tag_str.split(','):
|
||||||
|
t = t.strip()
|
||||||
|
if t:
|
||||||
|
tags.add(t)
|
||||||
|
return sorted(tags)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/csv")
|
||||||
|
def export_devices_csv(
|
||||||
|
region: Optional[str] = Query(None),
|
||||||
|
school_name: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""导出设备列表为 CSV"""
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
|
||||||
|
query = db.query(ONUDevice)
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
if school_name:
|
||||||
|
query = query.filter(ONUDevice.school_name == school_name)
|
||||||
|
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
|
||||||
|
for d in devices:
|
||||||
|
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
|
||||||
|
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
|
||||||
|
d.loid or "", d.distance_m or "", d.notes or ""])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||||
def get_device(
|
def get_device(
|
||||||
device_id: int,
|
device_id: int,
|
||||||
@@ -384,7 +440,7 @@ def refresh_device_status(
|
|||||||
result = service.check_single_device(device_id)
|
result = service.check_single_device(device_id)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Refresh device status", e)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -395,6 +451,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
room_number: Optional[str] = None
|
room_number: Optional[str] = None
|
||||||
place_type: Optional[str] = None
|
place_type: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceReplaceRequest(BaseModel):
|
class DeviceReplaceRequest(BaseModel):
|
||||||
@@ -430,6 +487,7 @@ def update_device(
|
|||||||
device.room_number = body.room_number or None
|
device.room_number = body.room_number or None
|
||||||
device.place_type = body.place_type or None
|
device.place_type = body.place_type or None
|
||||||
device.notes = body.notes or None
|
device.notes = body.notes or None
|
||||||
|
device.tags = body.tags or None
|
||||||
|
|
||||||
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
|
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
|
||||||
from app.models.device import NewDevice
|
from app.models.device import NewDevice
|
||||||
@@ -584,7 +642,7 @@ def reboot_device(
|
|||||||
result = IMCService().reboot_onu(device.mac_address)
|
result = IMCService().reboot_onu(device.mac_address)
|
||||||
return RebootResponse(**result)
|
return RebootResponse(**result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
raise internal_error("Reboot ONU", e)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||||
@@ -613,6 +671,18 @@ def get_device_optical_power(
|
|||||||
data = IMCService().get_optical_power(device.mac_address)
|
data = IMCService().get_optical_power(device.mac_address)
|
||||||
if data is None:
|
if data is None:
|
||||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||||
|
# 记录光功率历史
|
||||||
|
try:
|
||||||
|
from app.models.device import OpticalPowerHistory
|
||||||
|
db.add(OpticalPowerHistory(
|
||||||
|
onu_device_id=device_id,
|
||||||
|
power_in=data.get("powerIn"),
|
||||||
|
power_out=data.get("powerOut"),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return OpticalPowerResponse(
|
return OpticalPowerResponse(
|
||||||
power_in=data.get("powerIn"),
|
power_in=data.get("powerIn"),
|
||||||
power_out=data.get("powerOut"),
|
power_out=data.get("powerOut"),
|
||||||
@@ -625,7 +695,7 @@ def get_device_optical_power(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
raise internal_error("Get optical power", e)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{device_id}/onu-events")
|
@router.get("/{device_id}/onu-events")
|
||||||
@@ -648,16 +718,36 @@ def get_onu_events(
|
|||||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||||
|
|
||||||
from app.services.ssh_service import SSHService
|
from app.services.ssh_service import SSHService
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
events = ssh.get_onu_events(device.port_id)
|
events = ssh.get_onu_events(device.port_id)
|
||||||
return {
|
return {
|
||||||
"interface": f"Onu{device.port_id}",
|
"interface": f"Onu{device.port_id}",
|
||||||
"olt_location": olt.location,
|
"olt_location": olt.location,
|
||||||
"events": events,
|
"events": events,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
raise internal_error("Get ONU events", e)
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
@router.get("/{device_id}/optical-power-history")
|
||||||
|
def get_optical_power_history(
|
||||||
|
device_id: int,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取设备光功率历史记录"""
|
||||||
|
from app.models.device import OpticalPowerHistory
|
||||||
|
rows = (
|
||||||
|
db.query(OpticalPowerHistory)
|
||||||
|
.filter(OpticalPowerHistory.onu_device_id == device_id)
|
||||||
|
.order_by(OpticalPowerHistory.recorded_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"power_in": r.power_in, "power_out": r.power_out,
|
||||||
|
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Celery 任务监控 API"""
|
||||||
|
import time
|
||||||
|
import redis as redis_lib
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/monitor", tags=["任务监控"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis():
|
||||||
|
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
def get_task_status(_: dict = Depends(require_permission('*'))):
|
||||||
|
"""获取 Celery 任务状态概览"""
|
||||||
|
try:
|
||||||
|
insp = celery_app.control.inspect()
|
||||||
|
active = insp.active() or {}
|
||||||
|
scheduled = insp.scheduled() or {}
|
||||||
|
reserved = insp.reserved() or {}
|
||||||
|
|
||||||
|
r = _get_redis()
|
||||||
|
last_run = r.get("check_all_devices:last_run")
|
||||||
|
is_running = bool(r.get("check_all_devices:running"))
|
||||||
|
interval_str = r.get("system:check_interval_seconds")
|
||||||
|
|
||||||
|
interval = int(interval_str) if interval_str else 1800
|
||||||
|
next_run = None
|
||||||
|
if last_run and not is_running:
|
||||||
|
next_run = float(last_run) + interval
|
||||||
|
|
||||||
|
return {
|
||||||
|
"workers": list(active.keys()),
|
||||||
|
"active_count": sum(len(v) for v in active.values()),
|
||||||
|
"scheduled_count": sum(len(v) for v in scheduled.values()),
|
||||||
|
"check_running": is_running,
|
||||||
|
"last_check": float(last_run) if last_run else None,
|
||||||
|
"next_check": next_run,
|
||||||
|
"check_interval_seconds": interval,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
+22
-33
@@ -4,8 +4,11 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import distinct
|
from sqlalchemy import distinct
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.errors import internal_error
|
||||||
|
from app.core.config import settings
|
||||||
from app.middleware.permission_middleware import require_permission
|
from app.middleware.permission_middleware import require_permission
|
||||||
from app.models.device import OLTDevice
|
from app.models.device import OLTDevice
|
||||||
|
from app.schemas.olt import serialize_olt
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import io
|
import io
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ def get_devices(
|
|||||||
q = q.filter(OLTDevice.region.in_(areas))
|
q = q.filter(OLTDevice.region.in_(areas))
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
return q.all()
|
return [serialize_olt(device) for device in q.all()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices")
|
@router.post("/devices")
|
||||||
@@ -147,8 +150,8 @@ async def import_devices(
|
|||||||
df = pd.read_excel(io.BytesIO(content))
|
df = pd.read_excel(io.BytesIO(content))
|
||||||
# 标准化列名
|
# 标准化列名
|
||||||
df.columns = [str(c).strip() for c in df.columns]
|
df.columns = [str(c).strip() for c in df.columns]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
|
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件格式")
|
||||||
|
|
||||||
required_cols = ['IP地址', '用户名', '密码']
|
required_cols = ['IP地址', '用户名', '密码']
|
||||||
missing = [c for c in required_cols if c not in df.columns]
|
missing = [c for c in required_cols if c not in df.columns]
|
||||||
@@ -262,14 +265,11 @@ def clear_onu_port(
|
|||||||
if body.port_id not in port_ids:
|
if body.port_id not in port_ids:
|
||||||
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.clear_onu_port(body.port_id)
|
ssh.clear_onu_port(body.port_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
raise internal_error("Clear ONU port", e)
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
# 从 ports 列表移除已清除的端口
|
# 从 ports 列表移除已清除的端口
|
||||||
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
||||||
@@ -450,10 +450,9 @@ def loopback_detection(
|
|||||||
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
||||||
|
|
||||||
def check_one(olt):
|
def check_one(olt):
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
detection = ssh.detect_loopback()
|
detection = ssh.detect_loopback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
"olt_id": olt.id,
|
"olt_id": olt.id,
|
||||||
@@ -463,8 +462,6 @@ def loopback_detection(
|
|||||||
"has_loop": False,
|
"has_loop": False,
|
||||||
"loop_interfaces": [],
|
"loop_interfaces": [],
|
||||||
}
|
}
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
loop_interfaces = []
|
loop_interfaces = []
|
||||||
for iface in detection.get("interfaces", []):
|
for iface in detection.get("interfaces", []):
|
||||||
@@ -487,6 +484,7 @@ def loopback_detection(
|
|||||||
"has_loop": detection["has_loop"],
|
"has_loop": detection["has_loop"],
|
||||||
"loop_interfaces": loop_interfaces,
|
"loop_interfaces": loop_interfaces,
|
||||||
"error": None,
|
"error": None,
|
||||||
|
"raw": detection.get("raw", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
results_map = {}
|
results_map = {}
|
||||||
@@ -501,8 +499,8 @@ def loopback_detection(
|
|||||||
|
|
||||||
|
|
||||||
class SyncNTPRequest(BaseModel):
|
class SyncNTPRequest(BaseModel):
|
||||||
old_server: str = "172.16.0.254"
|
old_server: str = settings.NTP_OLD_SERVER
|
||||||
new_server: str = "172.16.1.252"
|
new_server: str = settings.NTP_NEW_SERVER
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-ntp")
|
@router.post("/sync-ntp")
|
||||||
@@ -521,10 +519,9 @@ def sync_ntp(
|
|||||||
olts = [o for o in olts if o.region in areas] if areas else []
|
olts = [o for o in olts if o.region in areas] if areas else []
|
||||||
|
|
||||||
def sync_one(olt):
|
def sync_one(olt):
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.sync_ntp(body.old_server, body.new_server)
|
ssh.sync_ntp(body.old_server, body.new_server)
|
||||||
return {
|
return {
|
||||||
"olt_ip": olt.ip_address,
|
"olt_ip": olt.ip_address,
|
||||||
"olt_location": olt.location or olt.ip_address,
|
"olt_location": olt.location or olt.ip_address,
|
||||||
@@ -538,8 +535,6 @@ def sync_ntp(
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": str(e),
|
"error": str(e),
|
||||||
}
|
}
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
results_map = {}
|
results_map = {}
|
||||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||||
@@ -573,15 +568,12 @@ def get_olt_ports(
|
|||||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
if not olt:
|
if not olt:
|
||||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ports = ssh.get_olt_ports()
|
ports = ssh.get_olt_ports()
|
||||||
return {"ports": ports}
|
return {"ports": ports}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Get OLT ports", e)
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/devices/{olt_id}/ports/toggle")
|
@router.post("/devices/{olt_id}/ports/toggle")
|
||||||
@@ -593,13 +585,10 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
|
|||||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
if not olt:
|
if not olt:
|
||||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
ssh.toggle_olt_port(port_name, body.action)
|
ssh.toggle_olt_port(port_name, body.action)
|
||||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise internal_error("Toggle OLT port", e)
|
||||||
finally:
|
|
||||||
ssh.close()
|
|
||||||
|
|
||||||
|
|||||||
@@ -98,3 +98,29 @@ def update_about(
|
|||||||
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"key": "about_content", "value": content}
|
return {"key": "about_content", "value": content}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/webhook")
|
||||||
|
def update_webhook(
|
||||||
|
body: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""更新企业微信告警配置(仅管理员)"""
|
||||||
|
configs = [
|
||||||
|
("wechat_corpid", body.get("corpid", ""), "企业微信 CorpID"),
|
||||||
|
("wechat_corpsecret", body.get("corpsecret", ""), "企业微信 CorpSecret"),
|
||||||
|
("wechat_agentid", body.get("agentid", ""), "企业微信 AgentID"),
|
||||||
|
]
|
||||||
|
for key, value, desc in configs:
|
||||||
|
setting = db.query(SystemSetting).filter_by(key=key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
else:
|
||||||
|
db.add(SystemSetting(key=key, value=value, description=desc))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "企业微信配置已保存"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+177
-7
@@ -179,16 +179,14 @@ def get_trend(
|
|||||||
)
|
)
|
||||||
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
||||||
|
|
||||||
# 今天实时聚合
|
# 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况
|
||||||
today_str = today.strftime('%Y-%m-%d')
|
today_str = today.strftime('%Y-%m-%d')
|
||||||
start_of_today = datetime.combine(today, datetime.min.time())
|
|
||||||
|
|
||||||
daily_latest_subq = (
|
latest_subq = (
|
||||||
db.query(
|
db.query(
|
||||||
DeviceStatusHistory.onu_device_id,
|
DeviceStatusHistory.onu_device_id,
|
||||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||||
)
|
)
|
||||||
.filter(DeviceStatusHistory.checked_at >= start_of_today)
|
|
||||||
.group_by(DeviceStatusHistory.onu_device_id)
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
@@ -198,9 +196,9 @@ def get_trend(
|
|||||||
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
)
|
)
|
||||||
.join(
|
.join(
|
||||||
daily_latest_subq,
|
latest_subq,
|
||||||
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
)
|
)
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
@@ -246,3 +244,175 @@ def get_trend(
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@router.get("/olt-stats")
|
||||||
|
def get_olt_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取每台 OLT 下的设备在线率统计"""
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
|
||||||
|
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(
|
||||||
|
OLTDevice.id,
|
||||||
|
OLTDevice.ip_address,
|
||||||
|
OLTDevice.location,
|
||||||
|
OLTDevice.region,
|
||||||
|
func.count(ONUDevice.id).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(ONUDevice, ONUDevice.olt_id == OLTDevice.id)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(OLTDevice.id)
|
||||||
|
.order_by(OLTDevice.region, OLTDevice.location)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"olt_id": r.id,
|
||||||
|
"name": r.location or r.ip_address,
|
||||||
|
"ip": r.ip_address,
|
||||||
|
"region": r.region or "未知",
|
||||||
|
"total": int(r.total or 0),
|
||||||
|
"online": int(r.online or 0),
|
||||||
|
"offline": int(r.offline or 0),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offline-schools")
|
||||||
|
def get_offline_schools(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取全部离线的学校列表"""
|
||||||
|
_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||||
|
)
|
||||||
|
_status_subq = (
|
||||||
|
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||||
|
.join(_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == _subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == _subq.c.max_checked_at)).subquery()
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.school_name, ONUDevice.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
)
|
||||||
|
.outerjoin(_status_subq, ONUDevice.id == _status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||||
|
.having(func.sum(case((_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"school_name": r.school_name or "未知", "region": r.region or "未知", "total": int(r.total or 0)}
|
||||||
|
for r in rows if int(r.total or 0) > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model-distribution")
|
||||||
|
def get_model_distribution(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""统计 ONU 设备型号分布"""
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.model,
|
||||||
|
func.count(ONUDevice.id).label("count"),
|
||||||
|
)
|
||||||
|
.filter(ONUDevice.model.isnot(None), ONUDevice.model != '')
|
||||||
|
.group_by(ONUDevice.model)
|
||||||
|
.order_by(func.count(ONUDevice.id).desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
unknown = db.query(func.count(ONUDevice.id)).filter(
|
||||||
|
(ONUDevice.model.is_(None)) | (ONUDevice.model == '')
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
result = [{"model": r.model or "未知", "count": r.count} for r in rows]
|
||||||
|
if unknown > 0:
|
||||||
|
result.append({"model": "未知型号", "count": unknown})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/school-locations")
|
||||||
|
def get_school_locations(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取各学校的聚合位置数据(用于地图展示)"""
|
||||||
|
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.school_name,
|
||||||
|
ONUDevice.region,
|
||||||
|
ONUDevice.latitude,
|
||||||
|
ONUDevice.longitude,
|
||||||
|
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)
|
||||||
|
.filter(ONUDevice.latitude.isnot(None))
|
||||||
|
.filter(ONUDevice.longitude.isnot(None))
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region, ONUDevice.latitude, ONUDevice.longitude)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"school_name": row.school_name or "未知",
|
||||||
|
"region": row.region or "未知",
|
||||||
|
"latitude": row.latitude,
|
||||||
|
"longitude": row.longitude,
|
||||||
|
"total": int(row.total or 0),
|
||||||
|
"online": int(row.online or 0),
|
||||||
|
"offline": int(row.offline or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def get_users(
|
|||||||
query = query.filter(User.role == role)
|
query = query.filter(User.role == role)
|
||||||
if keyword:
|
if keyword:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
User.username.contains(keyword) | User.email.contains(keyword)
|
User.username.contains(keyword) | User.display_name.contains(keyword) | User.email.contains(keyword)
|
||||||
)
|
)
|
||||||
query = query.order_by(asc(User.created_at))
|
query = query.order_by(asc(User.created_at))
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""企业微信回调 API(URL验证 + 消息接收)"""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Request, Response
|
||||||
|
from app.services.wechat_service import get_wechat_service
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/menu/create")
|
||||||
|
async def create_menu():
|
||||||
|
"""创建/更新企业微信应用菜单"""
|
||||||
|
svc = get_wechat_service()
|
||||||
|
menu = {
|
||||||
|
"button": [
|
||||||
|
{
|
||||||
|
"name": "设备查询",
|
||||||
|
"sub_button": [
|
||||||
|
{"type": "click", "name": "在线统计", "key": "online"},
|
||||||
|
{"type": "click", "name": "全离线学校", "key": "offline_schools"},
|
||||||
|
{"type": "click", "name": "MAC查询", "key": "status"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"type": "click", "name": "帮助", "key": "help"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ok = svc.create_menu(menu)
|
||||||
|
return {"success": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def wechat_callback_get(request: Request):
|
||||||
|
"""企业微信 URL 验证(GET)"""
|
||||||
|
params = request.query_params
|
||||||
|
msg_signature = params.get("msg_signature", "")
|
||||||
|
timestamp = params.get("timestamp", "")
|
||||||
|
nonce = params.get("nonce", "")
|
||||||
|
echostr = params.get("echostr", "")
|
||||||
|
|
||||||
|
svc = get_wechat_service()
|
||||||
|
result = svc.verify_url(msg_signature, timestamp, nonce, echostr)
|
||||||
|
if result:
|
||||||
|
return Response(content=result, media_type="text/plain")
|
||||||
|
return Response(content="验证失败", status_code=403)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback")
|
||||||
|
async def wechat_callback_post(request: Request):
|
||||||
|
"""企业微信消息接收(POST)"""
|
||||||
|
params = request.query_params
|
||||||
|
msg_signature = params.get("msg_signature", "")
|
||||||
|
timestamp = params.get("timestamp", "")
|
||||||
|
nonce = params.get("nonce", "")
|
||||||
|
|
||||||
|
xml_data = await request.body()
|
||||||
|
if not xml_data:
|
||||||
|
return Response(content="", media_type="text/plain")
|
||||||
|
|
||||||
|
svc = get_wechat_service()
|
||||||
|
msg = svc.parse_message(xml_data)
|
||||||
|
if not msg:
|
||||||
|
return Response(content="", media_type="text/plain")
|
||||||
|
|
||||||
|
msg_type = msg.get("MsgType", "")
|
||||||
|
from_user = msg.get("FromUserName", "")
|
||||||
|
|
||||||
|
logger.info(f"收到企微消息: type={msg_type}, from={from_user}, content={msg.get('Content', '')}")
|
||||||
|
|
||||||
|
if msg_type == "text":
|
||||||
|
content = msg.get("Content", "").strip()
|
||||||
|
if content.lower() in ("online", "在线", "在线统计"):
|
||||||
|
_handle_online_cmd(svc, from_user)
|
||||||
|
elif content.lower() in ("全离线", "离线学校", "offline"):
|
||||||
|
_handle_offline_schools_cmd(svc, from_user)
|
||||||
|
elif content.startswith("#状态+") or content.startswith("#status+"):
|
||||||
|
_handle_status_cmd(svc, from_user, content)
|
||||||
|
elif content.lower() in ("help", "帮助", "#帮助", "#help"):
|
||||||
|
_handle_help_cmd(svc, from_user)
|
||||||
|
else:
|
||||||
|
# 尝试作为 MAC 后缀查询
|
||||||
|
_handle_status_cmd(svc, from_user, f"#状态+{content}")
|
||||||
|
|
||||||
|
elif msg_type == "event":
|
||||||
|
event = msg.get("Event", "")
|
||||||
|
event_key = msg.get("EventKey", "")
|
||||||
|
if event == "click":
|
||||||
|
if event_key == "online":
|
||||||
|
_handle_online_cmd(svc, from_user)
|
||||||
|
elif event_key == "offline_schools":
|
||||||
|
_handle_offline_schools_cmd(svc, from_user)
|
||||||
|
elif event_key == "help":
|
||||||
|
_handle_help_cmd(svc, from_user)
|
||||||
|
|
||||||
|
return Response(content="", media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 命令处理 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_online_cmd(svc, from_user: str):
|
||||||
|
"""处理在线统计命令"""
|
||||||
|
try:
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
total = db.query(func.count(ONUDevice.id)).scalar() or 0
|
||||||
|
online = (
|
||||||
|
db.query(func.count(ONUDevice.id))
|
||||||
|
.join(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id, isouter=True)
|
||||||
|
.filter(latest_status_subq.c.status == 'online').scalar() or 0
|
||||||
|
)
|
||||||
|
rate = (online / total * 100) if total > 0 else 0
|
||||||
|
svc.send_text_message(
|
||||||
|
f"📊 设备在线统计\n总设备数: {total}\n在线: {online}\n离线: {total - online}\n在线率: {rate:.1f}%",
|
||||||
|
to_user=from_user
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"在线统计失败: {e}")
|
||||||
|
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_status_cmd(svc, from_user: str, content: str):
|
||||||
|
"""处理设备状态查询命令"""
|
||||||
|
try:
|
||||||
|
mac_suffix = content.split('+')[1].strip().upper()
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
devices = db.query(ONUDevice).filter(
|
||||||
|
ONUDevice.mac_address.ilike(f"%{mac_suffix}")
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
svc.send_text_message("未找到匹配的设备", to_user=from_user)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"🔍 找到 {len(devices)} 个设备(MAC 含 {mac_suffix}):", ""]
|
||||||
|
for d in devices[:8]:
|
||||||
|
# 查最新状态
|
||||||
|
latest = (
|
||||||
|
db.query(DeviceStatusHistory.status,
|
||||||
|
func.max(DeviceStatusHistory.checked_at))
|
||||||
|
.filter(DeviceStatusHistory.onu_device_id == d.id)
|
||||||
|
.group_by(DeviceStatusHistory.status)
|
||||||
|
.order_by(func.max(DeviceStatusHistory.checked_at).desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
status_text = latest[0] if latest else "未知"
|
||||||
|
emoji = "🟢" if status_text == "online" else "🔴"
|
||||||
|
school = d.school_name or "未知"
|
||||||
|
lines.append(f"{emoji} {d.mac_address} | {school} | {d.region or ''}")
|
||||||
|
|
||||||
|
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"设备查询失败: {e}")
|
||||||
|
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_offline_schools_cmd(svc, from_user: str):
|
||||||
|
"""查询全离线学校"""
|
||||||
|
try:
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
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.school_name, ONUDevice.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||||
|
.having(func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
svc.send_text_message("✅ 当前没有全离线的学校", to_user=from_user)
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"🔴 全离线学校 ({len(rows)} 所):", ""]
|
||||||
|
for r in rows:
|
||||||
|
school = r.school_name or "未知"
|
||||||
|
region = r.region or "未知"
|
||||||
|
total = int(r.total or 0)
|
||||||
|
if total > 0:
|
||||||
|
lines.append(f"• {school}({region}): {total}台全离线")
|
||||||
|
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"全离线查询失败: {e}")
|
||||||
|
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_help_cmd(svc, from_user: str):
|
||||||
|
"""处理帮助命令"""
|
||||||
|
svc.send_text_message(
|
||||||
|
"📋 H3C ONU 管理助手\n\n"
|
||||||
|
"🔍 设备查询:\n"
|
||||||
|
"• 发送「在线」查看设备在线统计\n"
|
||||||
|
"• 发送「全离线」查看全离线学校\n"
|
||||||
|
"• 发送 MAC 地址后四位查询设备\n\n"
|
||||||
|
"💡 发送「帮助」显示此信息\n"
|
||||||
|
f"💻 完整功能: {settings.FRONTEND_URL}",
|
||||||
|
to_user=from_user
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""WebSocket 实时推送"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api", tags=["WebSocket"])
|
||||||
|
|
||||||
|
REDIS_CHANNEL = "h3c_onu:status_updates"
|
||||||
|
_connected: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _redis_listener():
|
||||||
|
"""监听 Redis pub/sub 并广播给所有 WebSocket 客户端"""
|
||||||
|
try:
|
||||||
|
r = aioredis.from_url(settings.REDIS_URL)
|
||||||
|
pubsub = r.pubsub()
|
||||||
|
await pubsub.subscribe(REDIS_CHANNEL)
|
||||||
|
logger.info("WebSocket Redis 监听已启动")
|
||||||
|
async for msg in pubsub.listen():
|
||||||
|
if msg["type"] == "message":
|
||||||
|
dead: set[WebSocket] = set()
|
||||||
|
for ws in _connected:
|
||||||
|
try:
|
||||||
|
await ws.send_text(msg["data"].decode())
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
_connected -= dead
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Redis 监听异常: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/dashboard")
|
||||||
|
async def dashboard_ws(ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
_connected.add(ws)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await ws.receive_text() # keep-alive, 忽略客户端消息
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_connected.discard(ws)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""应用配置"""
|
"""应用配置"""
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
||||||
@@ -11,6 +12,7 @@ class Settings(BaseSettings):
|
|||||||
APP_NAME: str = "H3C-ONU-MS"
|
APP_NAME: str = "H3C-ONU-MS"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
SECRET_KEY: str
|
SECRET_KEY: str
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY: str
|
||||||
|
|
||||||
DATABASE_URL: str
|
DATABASE_URL: str
|
||||||
REDIS_URL: str
|
REDIS_URL: str
|
||||||
@@ -21,37 +23,67 @@ class Settings(BaseSettings):
|
|||||||
CASDOOR_ORG_NAME: str
|
CASDOOR_ORG_NAME: str
|
||||||
CASDOOR_APP_NAME: str
|
CASDOOR_APP_NAME: str
|
||||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||||
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
|
CASDOOR_REDIRECT_URL: str = ""
|
||||||
|
CASDOOR_ISSUER: str = "" # 为空时使用 CASDOOR_ENDPOINT
|
||||||
|
|
||||||
SSH_TIMEOUT: int = 30
|
SSH_TIMEOUT: int = 30
|
||||||
CHECK_INTERVAL: int = 1800
|
CHECK_INTERVAL: int = 1800
|
||||||
MANUAL_COOLDOWN: int = 300
|
MANUAL_COOLDOWN: int = 300
|
||||||
|
|
||||||
|
# CORS & 前端
|
||||||
|
CORS_ORIGINS: str = "" # 逗号分隔
|
||||||
|
FRONTEND_URL: str = "https://onu.dhdx.fun"
|
||||||
|
|
||||||
|
# NTP 同步配置
|
||||||
|
NTP_OLD_SERVER: str = "172.16.0.254"
|
||||||
|
NTP_NEW_SERVER: str = "172.16.1.252"
|
||||||
|
|
||||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||||
IMC_API_URL: str = ""
|
IMC_API_URL: str = ""
|
||||||
|
|
||||||
|
# 企业微信应用消息 API(用于发送告警)
|
||||||
|
WECHAT_CORPID: str = ""
|
||||||
|
WECHAT_CORPSECRET: str = ""
|
||||||
|
WECHAT_AGENTID: str = ""
|
||||||
|
WECHAT_TOKEN: str = ""
|
||||||
|
WECHAT_ENCODING_AES_KEY: str = ""
|
||||||
|
WECHAT_USE_PROXY: bool = True
|
||||||
|
WECHAT_PROXY_API_URL: str = ""
|
||||||
IMC_API_USERNAME: str = ""
|
IMC_API_USERNAME: str = ""
|
||||||
IMC_API_PASSWORD: str = ""
|
IMC_API_PASSWORD: str = ""
|
||||||
IMC_API_VERIFY_SSL: bool = False
|
IMC_API_VERIFY_SSL: bool = True
|
||||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||||
IMC_READ_TIMEOUT: float = 20.0
|
IMC_READ_TIMEOUT: float = 20.0
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = str(PROJECT_ROOT / ".env")
|
env_file = str(PROJECT_ROOT / ".env")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def reject_insecure_imc_tls_in_production(self):
|
||||||
|
"""Prevent production deployments from silently disabling TLS verification."""
|
||||||
|
if self.IMC_API_URL and not self.DEBUG and not self.IMC_API_VERIFY_SSL:
|
||||||
|
raise ValueError("IMC_API_VERIFY_SSL must be true when DEBUG is false")
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def casdoor_cert_content(self) -> str:
|
def casdoor_cert_content(self) -> str:
|
||||||
"""读取证书文件内容或直接返回证书字符串"""
|
"""读取证书文件内容或直接返回证书字符串"""
|
||||||
cert = self.CASDOOR_CERTIFICATE
|
cert = self.CASDOOR_CERTIFICATE
|
||||||
if not cert:
|
if not cert:
|
||||||
return ""
|
return ""
|
||||||
|
if "-----BEGIN" in cert:
|
||||||
|
return cert
|
||||||
cert_path = Path(cert)
|
cert_path = Path(cert)
|
||||||
if cert_path.is_absolute():
|
if cert_path.is_absolute():
|
||||||
path = cert_path
|
path = cert_path
|
||||||
else:
|
else:
|
||||||
# 相对路径基于项目根目录解析
|
# 相对路径基于项目根目录解析
|
||||||
path = PROJECT_ROOT / cert
|
path = PROJECT_ROOT / cert
|
||||||
if path.is_file():
|
try:
|
||||||
return path.read_text()
|
if path.is_file():
|
||||||
|
return path.read_text()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return cert # 直接是 PEM 内容
|
return cert # 直接是 PEM 内容
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Encryption at rest for device credentials."""
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from sqlalchemy.types import Text, TypeDecorator
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
CREDENTIAL_PREFIX = "enc:v1:"
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
key = settings.CREDENTIAL_ENCRYPTION_KEY.strip()
|
||||||
|
if not key:
|
||||||
|
raise RuntimeError("Credential encryption key is not configured")
|
||||||
|
try:
|
||||||
|
return Fernet(key.encode())
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise RuntimeError("Credential encryption key is invalid") from error
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_credential(value: str) -> str:
|
||||||
|
"""Encrypt a plaintext credential with the deployment-provided key."""
|
||||||
|
if value.startswith(CREDENTIAL_PREFIX):
|
||||||
|
return value
|
||||||
|
return CREDENTIAL_PREFIX + _fernet().encrypt(value.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_credential(value: str) -> str:
|
||||||
|
"""Decrypt an encrypted credential; retain legacy plaintext only for migration."""
|
||||||
|
if not value.startswith(CREDENTIAL_PREFIX):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(value[len(CREDENTIAL_PREFIX):].encode()).decode()
|
||||||
|
except InvalidToken as error:
|
||||||
|
raise RuntimeError("Credential decryption failed") from error
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptedCredential(TypeDecorator):
|
||||||
|
"""SQLAlchemy column type that stores credentials encrypted and reads plaintext."""
|
||||||
|
|
||||||
|
impl = Text
|
||||||
|
cache_ok = True
|
||||||
|
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return encrypt_credential(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return decrypt_credential(value)
|
||||||
@@ -4,7 +4,14 @@ from sqlalchemy.ext.declarative import declarative_base
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=40,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_timeout=30,
|
||||||
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Safe error responses for API trust boundaries."""
|
||||||
|
import logging
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def internal_error(context: str, error: Exception) -> HTTPException:
|
||||||
|
"""Log only a non-sensitive error classification and return a safe response."""
|
||||||
|
error_id = uuid4().hex
|
||||||
|
logger.error(
|
||||||
|
"%s failed [error_id=%s, error_type=%s]",
|
||||||
|
context,
|
||||||
|
error_id,
|
||||||
|
type(error).__name__,
|
||||||
|
)
|
||||||
|
return HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail={
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": "服务器内部错误,请联系管理员并提供错误编号",
|
||||||
|
"error_id": error_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Logging helpers that prevent sensitive values from reaching application logs."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
_SENSITIVE_KEY = r"(?:password|passwd|secret|token|authorization|credential|client_secret|corpsecret|code)"
|
||||||
|
_KEY_VALUE_PATTERN = re.compile(
|
||||||
|
rf"(?i)([\"']?{_SENSITIVE_KEY}[\"']?\s*[:=]\s*)([\"']?)([^\s,;\]\}}\"']+)([\"']?)"
|
||||||
|
)
|
||||||
|
_BEARER_PATTERN = re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;]+")
|
||||||
|
_QUERY_PATTERN = re.compile(rf"(?i)([?&]{_SENSITIVE_KEY}=)[^&\s]+")
|
||||||
|
|
||||||
|
|
||||||
|
def redact_log_message(message: str) -> str:
|
||||||
|
"""Mask common secret formats while keeping enough context for operations."""
|
||||||
|
masked = _BEARER_PATTERN.sub(r"\1***", message)
|
||||||
|
masked = _QUERY_PATTERN.sub(r"\1***", masked)
|
||||||
|
return _KEY_VALUE_PATTERN.sub(r"\1\2***\4", masked)
|
||||||
|
|
||||||
|
|
||||||
|
class SensitiveDataFilter(logging.Filter):
|
||||||
|
"""Redact sensitive values after interpolation and before formatter output."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.msg = redact_log_message(record.getMessage())
|
||||||
|
record.args = ()
|
||||||
|
return True
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""JWT 安全配置"""
|
"""JWT 安全配置"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from jose import JWTError, jwt
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
from jose import JWTError, jwt as jose_jwt
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
@@ -11,12 +13,37 @@ def create_access_token(data: dict):
|
|||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
return jose_jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str):
|
def verify_token(token: str):
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
payload = jose_jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_casdoor_token(token: str) -> dict:
|
||||||
|
"""Verify a Casdoor-issued JWT before using any identity claims."""
|
||||||
|
certificate = settings.casdoor_cert_content.strip()
|
||||||
|
if not certificate:
|
||||||
|
raise ValueError("Casdoor certificate is not configured")
|
||||||
|
|
||||||
|
issuer = (settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT).rstrip("/")
|
||||||
|
if not issuer:
|
||||||
|
raise ValueError("Casdoor issuer is not configured")
|
||||||
|
|
||||||
|
header = pyjwt.get_unverified_header(token)
|
||||||
|
algorithm = header.get("alg")
|
||||||
|
if algorithm not in {"RS256", "RS384", "RS512"}:
|
||||||
|
raise ValueError(f"Unsupported Casdoor token algorithm: {algorithm}")
|
||||||
|
|
||||||
|
return pyjwt.decode(
|
||||||
|
token,
|
||||||
|
certificate,
|
||||||
|
algorithms=[algorithm],
|
||||||
|
audience=settings.CASDOOR_CLIENT_ID,
|
||||||
|
issuer=issuer,
|
||||||
|
options={"require": ["exp", "iat", "sub"]},
|
||||||
|
)
|
||||||
|
|||||||
+92
-5
@@ -1,15 +1,59 @@
|
|||||||
"""FastAPI 主应用"""
|
"""FastAPI 主应用"""
|
||||||
from fastapi import FastAPI
|
import os
|
||||||
|
import logging
|
||||||
|
from pythonjsonlogger import jsonlogger
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
|
from app.core.logging_utils import SensitiveDataFilter
|
||||||
|
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
|
||||||
from app.middleware.audit_middleware import AuditMiddleware
|
from app.middleware.audit_middleware import AuditMiddleware
|
||||||
|
|
||||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
# 结构化 JSON 日志
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
|
||||||
|
_handler.addFilter(SensitiveDataFilter())
|
||||||
|
logging.getLogger().handlers = [_handler]
|
||||||
|
logging.getLogger().setLevel(logging.INFO)
|
||||||
|
logging.getLogger('uvicorn.access').handlers = [_handler]
|
||||||
|
|
||||||
|
# 请求体大小限制中间件
|
||||||
|
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.headers.get("content-length"):
|
||||||
|
if int(request.headers["content-length"]) > MAX_BODY_SIZE:
|
||||||
|
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# CORS 白名单 — 支持通过环境变量 CORS_ORIGINS 覆盖(逗号分隔)
|
||||||
|
CORS_ORIGINS_DEFAULT = "http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun"
|
||||||
|
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", CORS_ORIGINS_DEFAULT).split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""读取 X-Forwarded-For 首字段作为真实客户端 IP"""
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_client_ip, default_limits=["120/minute"])
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
app.add_middleware(RequestSizeLimitMiddleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -28,8 +72,51 @@ app.include_router(roles.router)
|
|||||||
app.include_router(inventory.router)
|
app.include_router(inventory.router)
|
||||||
app.include_router(settings_api.router)
|
app.include_router(settings_api.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
|
app.include_router(wechat.router)
|
||||||
|
app.include_router(ws.router)
|
||||||
|
app.include_router(monitor.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
import asyncio
|
||||||
|
asyncio.create_task(ws._redis_listener())
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
"""读取项目版本号"""
|
||||||
|
version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")]
|
||||||
|
for p in version_paths:
|
||||||
|
if os.path.exists(p):
|
||||||
|
with open(p) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
return {"status": "ok"}
|
status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()}
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
import psycopg2
|
||||||
|
r = redis.from_url(settings.REDIS_URL, socket_timeout=2)
|
||||||
|
r.ping()
|
||||||
|
except Exception:
|
||||||
|
status["redis"] = "error"
|
||||||
|
status["status"] = "degraded"
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
db = SessionLocal()
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
status["db"] = "error"
|
||||||
|
status["status"] = "degraded"
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def api_health_check():
|
||||||
|
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
|
||||||
|
return health_check()
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ _LOG_GET_PATHS = {
|
|||||||
"/api/auth/profile",
|
"/api/auth/profile",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SENSITIVE_PARAM_MARKERS = ("password", "passwd", "secret", "token", "authorization", "credential", "code")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_audit_params(value):
|
||||||
|
"""Recursively redact sensitive request parameters before asynchronous logging."""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: "***" if any(marker in key.lower() for marker in _SENSITIVE_PARAM_MARKERS)
|
||||||
|
else sanitize_audit_params(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [sanitize_audit_params(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _should_log(method: str, path: str) -> bool:
|
def _should_log(method: str, path: str) -> bool:
|
||||||
for skip in _SKIP_PATHS:
|
for skip in _SKIP_PATHS:
|
||||||
@@ -62,11 +77,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
|||||||
if body_bytes:
|
if body_bytes:
|
||||||
try:
|
try:
|
||||||
request_params = json.loads(body_bytes)
|
request_params = json.loads(body_bytes)
|
||||||
# 脱敏:移除密码字段
|
request_params = sanitize_audit_params(request_params)
|
||||||
if isinstance(request_params, dict):
|
|
||||||
for k in ("password", "passwd", "secret"):
|
|
||||||
if k in request_params:
|
|
||||||
request_params[k] = "***"
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from fastapi import HTTPException, Depends, Header
|
from fastapi import HTTPException, Depends, Header
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -52,14 +55,19 @@ def invalidate_role_cache(role: str) -> None:
|
|||||||
def require_permission(permission: str):
|
def require_permission(permission: str):
|
||||||
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||||
def dependency(
|
def dependency(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
if not authorization:
|
||||||
|
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization.startswith("Bearer "):
|
||||||
|
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
logger.warning("auth rejected: token 验证失败 (permission=%s)", permission)
|
||||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||||
|
|
||||||
role = payload.get('role', 'user')
|
role = payload.get('role', 'user')
|
||||||
@@ -83,11 +91,11 @@ def require_permission(permission: str):
|
|||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
authorization: str = Header(..., alias="Authorization"),
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""仅验证登录状态,不检查具体权限"""
|
"""仅验证登录状态,不检查具体权限"""
|
||||||
if not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(status_code=401, detail="未授权")
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
token = authorization[7:]
|
token = authorization[7:]
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""设备数据模型"""
|
"""设备数据模型"""
|
||||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
|
from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTAMP, ForeignKey, JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
from app.core.credentials import EncryptedCredential
|
||||||
|
|
||||||
|
|
||||||
class OLTDevice(Base):
|
class OLTDevice(Base):
|
||||||
@@ -11,7 +12,7 @@ class OLTDevice(Base):
|
|||||||
id = Column(BigInteger, primary_key=True, index=True)
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
ip_address = Column(String(45), nullable=False)
|
ip_address = Column(String(45), nullable=False)
|
||||||
username = Column(String(100), nullable=False)
|
username = Column(String(100), nullable=False)
|
||||||
password = Column(Text, nullable=False)
|
password = Column(EncryptedCredential(), nullable=False)
|
||||||
slot_command = Column(String(50), nullable=False)
|
slot_command = Column(String(50), nullable=False)
|
||||||
region = Column(String(100), index=True)
|
region = Column(String(100), index=True)
|
||||||
location = Column(String(200))
|
location = Column(String(200))
|
||||||
@@ -39,6 +40,9 @@ class ONUDevice(Base):
|
|||||||
place_type = Column(String(50)) # 场所类型
|
place_type = Column(String(50)) # 场所类型
|
||||||
room_number = Column(String(50))
|
room_number = Column(String(50))
|
||||||
notes = Column(Text) # 备注
|
notes = Column(Text) # 备注
|
||||||
|
tags = Column(Text) # 标签(逗号分隔),如 "重点设备,考试用"
|
||||||
|
latitude = Column(Float, nullable=True) # 纬度
|
||||||
|
longitude = Column(Float, nullable=True) # 经度
|
||||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
@@ -95,6 +99,17 @@ class NewDevice(Base):
|
|||||||
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class OpticalPowerHistory(Base):
|
||||||
|
"""光功率历史记录"""
|
||||||
|
__tablename__ = "optical_power_history"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
|
||||||
|
power_in = Column(String(20)) # 接收光功率 dBm
|
||||||
|
power_out = Column(String(20)) # 发送光功率 dBm
|
||||||
|
recorded_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class DeviceReplacement(Base):
|
class DeviceReplacement(Base):
|
||||||
"""设备更换记录"""
|
"""设备更换记录"""
|
||||||
__tablename__ = "device_replacements"
|
__tablename__ = "device_replacements"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class User(Base):
|
|||||||
id = Column(BigInteger, primary_key=True, index=True)
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
casdoor_id = Column(String(100), unique=True, nullable=False)
|
casdoor_id = Column(String(100), unique=True, nullable=False)
|
||||||
username = Column(String(100), nullable=False)
|
username = Column(String(100), nullable=False)
|
||||||
|
display_name = Column(String(100)) # 中文姓名
|
||||||
email = Column(String(255))
|
email = Column(String(255))
|
||||||
role = Column(String(50), default="user")
|
role = Column(String(50), default="user")
|
||||||
assigned_area = Column(String(100))
|
assigned_area = Column(String(100))
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""OLT response serialization."""
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_olt(device: OLTDevice) -> dict:
|
||||||
|
"""Return an OLT record without its device credential."""
|
||||||
|
return {
|
||||||
|
"id": device.id,
|
||||||
|
"ip_address": device.ip_address,
|
||||||
|
"username": device.username,
|
||||||
|
"slot_command": device.slot_command,
|
||||||
|
"region": device.region,
|
||||||
|
"location": device.location,
|
||||||
|
"description": device.description,
|
||||||
|
"created_at": device.created_at,
|
||||||
|
"updated_at": device.updated_at,
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
|||||||
class UserListItem(BaseModel):
|
class UserListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
role: str
|
role: str
|
||||||
assigned_area: Optional[str] = None
|
assigned_area: Optional[str] = None
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""设备状态检查服务"""
|
"""设备状态检查服务"""
|
||||||
|
import time
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.services.ssh_service import SSHService
|
from app.services.ssh_service import SSHService
|
||||||
@@ -6,6 +7,26 @@ from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, Duplica
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
# SSH 连接池缓存,TTL 5 分钟
|
||||||
|
_conn_pool: Dict[int, tuple[SSHService, float]] = {}
|
||||||
|
_POOL_TTL = 300
|
||||||
|
|
||||||
|
def _get_cached_ssh(olt_ip: str, olt_user: str, olt_pass: str, olt_id: int) -> SSHService:
|
||||||
|
"""获取缓存的 SSH 连接,过期自动重连"""
|
||||||
|
entry = _conn_pool.get(olt_id)
|
||||||
|
if entry:
|
||||||
|
ssh, ts = entry
|
||||||
|
if time.time() - ts < _POOL_TTL:
|
||||||
|
return ssh
|
||||||
|
try:
|
||||||
|
ssh.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ssh = SSHService(olt_ip, olt_user, olt_pass)
|
||||||
|
ssh.connect()
|
||||||
|
_conn_pool[olt_id] = (ssh, time.time())
|
||||||
|
return ssh
|
||||||
|
|
||||||
|
|
||||||
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
||||||
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
||||||
@@ -29,9 +50,8 @@ class CheckService:
|
|||||||
if not olt:
|
if not olt:
|
||||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
|
||||||
output = ssh.execute_command(olt.slot_command)
|
output = ssh.execute_command(olt.slot_command)
|
||||||
onu_info_dict, _ = ssh.parse_onu_info(output)
|
onu_info_dict, _ = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
@@ -83,8 +103,10 @@ class CheckService:
|
|||||||
"online": online_count,
|
"online": online_count,
|
||||||
"offline": offline_count,
|
"offline": offline_count,
|
||||||
}
|
}
|
||||||
finally:
|
except Exception:
|
||||||
ssh.close()
|
# 连接异常时清除缓存,下次自动重连
|
||||||
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
def check_single_device(self, device_id: int) -> Dict:
|
def check_single_device(self, device_id: int) -> Dict:
|
||||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||||
@@ -160,9 +182,8 @@ class CheckService:
|
|||||||
if not olt:
|
if not olt:
|
||||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
|
||||||
output = ssh.execute_command(olt.slot_command)
|
output = ssh.execute_command(olt.slot_command)
|
||||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
@@ -232,8 +253,9 @@ class CheckService:
|
|||||||
"offline": offline_count,
|
"offline": offline_count,
|
||||||
"new_discovered": new_count,
|
"new_discovered": new_count,
|
||||||
}
|
}
|
||||||
finally:
|
except Exception:
|
||||||
ssh.close()
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
async def scan_olt(self, olt_id: int) -> Dict:
|
async def scan_olt(self, olt_id: int) -> Dict:
|
||||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||||
@@ -241,9 +263,8 @@ class CheckService:
|
|||||||
if not olt:
|
if not olt:
|
||||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
try:
|
try:
|
||||||
ssh.connect()
|
|
||||||
output = ssh.execute_command(olt.slot_command)
|
output = ssh.execute_command(olt.slot_command)
|
||||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
@@ -282,8 +303,9 @@ class CheckService:
|
|||||||
"devices": devices,
|
"devices": devices,
|
||||||
"duplicates": duplicates,
|
"duplicates": duplicates,
|
||||||
}
|
}
|
||||||
finally:
|
except Exception:
|
||||||
ssh.close()
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||||
for mac, records in duplicate_dict.items():
|
for mac, records in duplicate_dict.items():
|
||||||
|
|||||||
@@ -39,16 +39,52 @@ class IMCService:
|
|||||||
"""iMC REST API 服务封装"""
|
"""iMC REST API 服务封装"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import certifi
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
|
||||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||||
self.username = settings.IMC_API_USERNAME
|
self.username = settings.IMC_API_USERNAME
|
||||||
self.password = settings.IMC_API_PASSWORD
|
self.password = settings.IMC_API_PASSWORD
|
||||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
|
||||||
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
self.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
||||||
self.read_timeout = settings.IMC_READ_TIMEOUT
|
self.read_timeout = settings.IMC_READ_TIMEOUT
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.realm = "iMC RESTful Web Services"
|
self.realm = "iMC RESTful Web Services"
|
||||||
self.nonce = None
|
self.nonce = None
|
||||||
self.nc = 1
|
self.nc = 1
|
||||||
|
self._ca_bundle_file = None # temp file for certifi + iMC CA
|
||||||
|
|
||||||
|
if settings.IMC_API_VERIFY_SSL:
|
||||||
|
imc_ca_path = os.path.join(os.path.dirname(__file__), "..", "..", "imc_ca.pem")
|
||||||
|
if os.path.isfile(imc_ca_path):
|
||||||
|
# The iMC self-signed cert uses a non-DNS CN and zero SAN entries,
|
||||||
|
# so hostname matching is impossible. We still enforce full
|
||||||
|
# certificate-chain verification via a combined CA bundle, then
|
||||||
|
# tell urllib3 to skip its own hostname check.
|
||||||
|
with open(imc_ca_path, "rb") as fh:
|
||||||
|
imc_pem = fh.read()
|
||||||
|
self._ca_bundle_file = tempfile.NamedTemporaryFile(suffix=".pem", delete=False)
|
||||||
|
with open(certifi.where(), "rb") as fh:
|
||||||
|
self._ca_bundle_file.write(fh.read())
|
||||||
|
self._ca_bundle_file.write(b"\n")
|
||||||
|
self._ca_bundle_file.write(imc_pem)
|
||||||
|
self._ca_bundle_file.flush()
|
||||||
|
|
||||||
|
_ca_bundle = self._ca_bundle_file.name
|
||||||
|
|
||||||
|
class _IMCAdapter(HTTPAdapter):
|
||||||
|
def cert_verify(self, conn, url, verify, cert):
|
||||||
|
super().cert_verify(conn, url, verify=_ca_bundle, cert=cert)
|
||||||
|
conn.assert_hostname = False
|
||||||
|
|
||||||
|
self.session.mount("https://", _IMCAdapter())
|
||||||
|
self.verify_ssl = True
|
||||||
|
logger.info("iMC TLS verification enabled (hostname check relaxed)")
|
||||||
|
else:
|
||||||
|
self.verify_ssl = True
|
||||||
|
else:
|
||||||
|
self.verify_ssl = False
|
||||||
|
|
||||||
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import re
|
|||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ONUInfo:
|
class ONUInfo:
|
||||||
@@ -36,7 +34,7 @@ class SSHService:
|
|||||||
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||||
try:
|
try:
|
||||||
self.client = paramiko.SSHClient()
|
self.client = paramiko.SSHClient()
|
||||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
self.client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||||
self.client.connect(
|
self.client.connect(
|
||||||
hostname=self.host,
|
hostname=self.host,
|
||||||
port=self.port,
|
port=self.port,
|
||||||
@@ -51,7 +49,7 @@ class SSHService:
|
|||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if self.shell.recv_ready():
|
if self.shell.recv_ready():
|
||||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||||
if ">" in buf:
|
if re.search(r'<[^>]+>', buf):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
@@ -77,14 +75,28 @@ class SSHService:
|
|||||||
if "---- More ----" in chunk:
|
if "---- More ----" in chunk:
|
||||||
self.shell.send(" ")
|
self.shell.send(" ")
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
elif ">" in chunk:
|
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
|
||||||
# 命令提示符出现,说明输出完毕
|
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">")
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
def _send_and_wait(self, 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
|
||||||
|
|
||||||
def clear_onu_port(self, port_id: str) -> bool:
|
def clear_onu_port(self, port_id: str) -> bool:
|
||||||
"""清除指定端口的 ONU 配置(恢复默认)
|
"""清除指定端口的 ONU 配置(恢复默认)
|
||||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||||
@@ -92,26 +104,13 @@ class SSHService:
|
|||||||
if not self.shell:
|
if not self.shell:
|
||||||
raise Exception("SSH 未连接")
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
|
||||||
self.shell.send(cmd + "\n")
|
|
||||||
buf = ""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if self.shell.recv_ready():
|
|
||||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
|
||||||
if expect in buf:
|
|
||||||
return buf
|
|
||||||
else:
|
|
||||||
time.sleep(0.2)
|
|
||||||
return buf
|
|
||||||
|
|
||||||
# 进入系统视图
|
# 进入系统视图
|
||||||
out = send_and_wait("system-view", "]")
|
out = self._send_and_wait("system-view", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception("进入 system-view 失败")
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
# 进入端口
|
# 进入端口
|
||||||
out = send_and_wait(f"interface Onu{port_id}", "]")
|
out = self._send_and_wait(f"interface Onu{port_id}", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||||
|
|
||||||
@@ -138,8 +137,8 @@ class SSHService:
|
|||||||
self.shell.recv(4096)
|
self.shell.recv(4096)
|
||||||
|
|
||||||
# 退出到用户视图
|
# 退出到用户视图
|
||||||
send_and_wait("quit", "]", timeout=5)
|
self._send_and_wait("quit", "]", timeout=5)
|
||||||
send_and_wait("quit", ">", timeout=5)
|
self._send_and_wait("quit", ">", timeout=5)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ class SSHService:
|
|||||||
interfaces = []
|
interfaces = []
|
||||||
if has_loop:
|
if has_loop:
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
m = re.match(r'\s+(Onu\S+)', line)
|
||||||
if m:
|
if m:
|
||||||
interfaces.append(m.group(1))
|
interfaces.append(m.group(1))
|
||||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||||
@@ -319,33 +318,20 @@ class SSHService:
|
|||||||
if not self.shell:
|
if not self.shell:
|
||||||
raise Exception("SSH 未连接")
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
out = self._send_and_wait("system-view", "]")
|
||||||
self.shell.send(cmd + "\n")
|
|
||||||
buf = ""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if self.shell.recv_ready():
|
|
||||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
|
||||||
if expect in buf:
|
|
||||||
return buf
|
|
||||||
else:
|
|
||||||
time.sleep(0.2)
|
|
||||||
return buf
|
|
||||||
|
|
||||||
out = send_and_wait("system-view", "]")
|
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception("进入 system-view 失败")
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
out = send_and_wait(f"interface {port_name}", "]")
|
out = self._send_and_wait(f"interface {port_name}", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"进入端口 {port_name} 失败")
|
raise Exception(f"进入端口 {port_name} 失败")
|
||||||
|
|
||||||
out = send_and_wait(action, "]")
|
out = self._send_and_wait(action, "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"执行 {action} 失败")
|
raise Exception(f"执行 {action} 失败")
|
||||||
|
|
||||||
send_and_wait("quit", "]", timeout=5)
|
self._send_and_wait("quit", "]", timeout=5)
|
||||||
send_and_wait("quit", ">", timeout=5)
|
self._send_and_wait("quit", ">", timeout=5)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||||
@@ -356,41 +342,28 @@ class SSHService:
|
|||||||
if not self.shell:
|
if not self.shell:
|
||||||
raise Exception("SSH 未连接")
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
out = self._send_and_wait("system-view", "]")
|
||||||
self.shell.send(cmd + "\n")
|
|
||||||
buf = ""
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
if self.shell.recv_ready():
|
|
||||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
|
||||||
if expect in buf:
|
|
||||||
return buf
|
|
||||||
else:
|
|
||||||
time.sleep(0.2)
|
|
||||||
return buf
|
|
||||||
|
|
||||||
out = send_and_wait("system-view", "]")
|
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception("进入 system-view 失败")
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||||
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||||
|
|
||||||
# 添加新 NTP 服务器
|
# 添加新 NTP 服务器
|
||||||
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||||
|
|
||||||
# 设置时区为北京时间
|
# 设置时区为北京时间
|
||||||
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||||
if "]" not in out:
|
if "]" not in out:
|
||||||
raise Exception("配置时区失败")
|
raise Exception("配置时区失败")
|
||||||
|
|
||||||
# 退出系统视图
|
# 退出系统视图
|
||||||
send_and_wait("quit", ">")
|
self._send_and_wait("quit", ">")
|
||||||
|
|
||||||
# 强制保存配置
|
# 强制保存配置
|
||||||
send_and_wait("save force", ">", timeout=30)
|
self._send_and_wait("save force", ">", timeout=30)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -427,6 +400,16 @@ class SSHService:
|
|||||||
})
|
})
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""上下文管理器入口,自动连接"""
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""上下文管理器出口,自动关闭连接"""
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""关闭 SSH 连接"""
|
"""关闭 SSH 连接"""
|
||||||
if self.client:
|
if self.client:
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""企业微信 (WeChat Work) 应用消息服务"""
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import urllib.parse
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db_setting(key: str, default: str = "") -> str:
|
||||||
|
"""从数据库读取 SystemSetting,失败时返回 default"""
|
||||||
|
try:
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.query(SystemSetting).filter_by(key=key).first()
|
||||||
|
return row.value if row else default
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class WeChatService:
|
||||||
|
"""企业微信应用消息服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# 优先读环境变量,fallback 到数据库配置
|
||||||
|
self.corpid = settings.WECHAT_CORPID or _get_db_setting("wechat_corpid")
|
||||||
|
self.corpsecret = settings.WECHAT_CORPSECRET or _get_db_setting("wechat_corpsecret")
|
||||||
|
self.agentid = settings.WECHAT_AGENTID or _get_db_setting("wechat_agentid")
|
||||||
|
self.token = settings.WECHAT_TOKEN
|
||||||
|
self.encoding_aes_key = settings.WECHAT_ENCODING_AES_KEY
|
||||||
|
self.use_proxy = settings.WECHAT_USE_PROXY
|
||||||
|
self.proxy_api_url = settings.WECHAT_PROXY_API_URL
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires_at = 0
|
||||||
|
|
||||||
|
# ── access_token 管理 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_access_token(self) -> str | None:
|
||||||
|
"""获取企业微信 access_token,自动缓存和续期"""
|
||||||
|
now = time.time()
|
||||||
|
if self._access_token and now < self._token_expires_at:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
if not self.corpid or not self.corpsecret:
|
||||||
|
logger.warning("WECHAT_CORPID 或 WECHAT_CORPSECRET 未配置")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||||
|
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result.get("errcode") == 0:
|
||||||
|
self._access_token = result.get("access_token")
|
||||||
|
expires_in = result.get("expires_in", 7200) - 300 # 提前5分钟过期
|
||||||
|
self._token_expires_at = now + expires_in
|
||||||
|
logger.info(f"企业微信 access_token 获取成功,过期时间: {time.strftime('%H:%M:%S', time.localtime(self._token_expires_at))}")
|
||||||
|
return self._access_token
|
||||||
|
elif result.get("errcode") == 60020 and not self.use_proxy:
|
||||||
|
logger.info("IP受限,尝试使用代理获取 access_token")
|
||||||
|
self.use_proxy = True
|
||||||
|
return self.get_access_token()
|
||||||
|
else:
|
||||||
|
logger.error(f"获取 access_token 失败: {result}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取 access_token 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 消息分条 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_long_message(content: str, max_chars: int = 1800) -> list[str]:
|
||||||
|
"""将长消息按换行边界拆分为多条,避免企微截断"""
|
||||||
|
if len(content) <= max_chars:
|
||||||
|
return [content]
|
||||||
|
chunks = []
|
||||||
|
lines = content.split('\n')
|
||||||
|
current = ''
|
||||||
|
for line in lines:
|
||||||
|
if len(current) + len(line) + 1 > max_chars and current:
|
||||||
|
chunks.append(current.strip())
|
||||||
|
current = line
|
||||||
|
else:
|
||||||
|
current += ('\n' + line) if current else line
|
||||||
|
if current.strip():
|
||||||
|
chunks.append(current.strip())
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
# ── 发送消息 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _send_markdown_single(self, content: str, to_user: str) -> bool:
|
||||||
|
"""发送单条 Markdown 消息(内部方法)"""
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"touser": to_user, "toparty": "", "totag": "",
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"agentid": int(self.agentid),
|
||||||
|
"markdown": {"content": content}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||||
|
|
||||||
|
resp = requests.post(url, json=data, timeout=15)
|
||||||
|
result = resp.json()
|
||||||
|
errcode = result.get("errcode", -1)
|
||||||
|
if errcode == 0:
|
||||||
|
return True
|
||||||
|
elif errcode == 40014:
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires_at = 0
|
||||||
|
raise Exception("token_expired")
|
||||||
|
elif errcode == 60020 and not self.use_proxy:
|
||||||
|
self.use_proxy = True
|
||||||
|
raise Exception("ip_restricted")
|
||||||
|
else:
|
||||||
|
logger.warning(f"企业微信消息发送失败: {result.get('errmsg')} (errcode={errcode})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_markdown(self, content: str, to_user: str = "@all") -> bool:
|
||||||
|
"""发送 Markdown 消息,自动分条"""
|
||||||
|
if not self.corpid or not self.corpsecret or not self.agentid:
|
||||||
|
logger.warning("企业微信未配置,跳过发送")
|
||||||
|
return False
|
||||||
|
|
||||||
|
chunks = self._split_long_message(content)
|
||||||
|
success = True
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
ok = self._send_markdown_single(prefix + chunk, to_user)
|
||||||
|
if ok:
|
||||||
|
break
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"发送分片 {i+1}/{len(chunks)} 异常: {e}")
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
success = False
|
||||||
|
if i < len(chunks) - 1:
|
||||||
|
time.sleep(0.5) # 避免频率限制
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
# ── 发送文本消息 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_text_message(self, content: str, to_user: str = "@all") -> bool:
|
||||||
|
"""通过应用消息 API 发送文本消息,自动分条"""
|
||||||
|
if not self.corpid or not self.corpsecret or not self.agentid:
|
||||||
|
return False
|
||||||
|
|
||||||
|
chunks = self._split_long_message(content, max_chars=1800)
|
||||||
|
success = True
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
|
||||||
|
try:
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return False
|
||||||
|
data = {
|
||||||
|
"touser": to_user, "toparty": "", "totag": "",
|
||||||
|
"msgtype": "text",
|
||||||
|
"agentid": int(self.agentid),
|
||||||
|
"text": {"content": prefix + chunk}
|
||||||
|
}
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}" if self.use_proxy else f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||||
|
resp = requests.post(url, json=data, timeout=15)
|
||||||
|
if resp.json().get("errcode") != 0:
|
||||||
|
success = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送文本消息分片 {i+1}/{len(chunks)} 失败: {e}")
|
||||||
|
success = False
|
||||||
|
if i < len(chunks) - 1:
|
||||||
|
time.sleep(0.5)
|
||||||
|
return success
|
||||||
|
|
||||||
|
# ── 菜单管理 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_menu(self, menu_data: dict) -> bool:
|
||||||
|
"""创建/更新企业微信应用菜单"""
|
||||||
|
try:
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return False
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||||
|
resp = requests.post(url, json=menu_data, timeout=15)
|
||||||
|
result = resp.json()
|
||||||
|
if result.get("errcode") == 0:
|
||||||
|
logger.info("企业微信菜单创建成功")
|
||||||
|
return True
|
||||||
|
logger.warning(f"创建菜单失败: {result}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"创建菜单异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── 获取用户信息 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_user_info(self, userid: str) -> dict:
|
||||||
|
"""获取企业微信用户信息"""
|
||||||
|
try:
|
||||||
|
access_token = self.get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
return {"errcode": -1, "errmsg": "无 access_token"}
|
||||||
|
if self.use_proxy:
|
||||||
|
url = f"{self.proxy_api_url}/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||||
|
else:
|
||||||
|
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取用户信息失败: {e}")
|
||||||
|
return {"errcode": -1, "errmsg": str(e)}
|
||||||
|
|
||||||
|
# ── URL 验证(GET 回调)──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None:
|
||||||
|
"""验证企业微信回调 URL"""
|
||||||
|
try:
|
||||||
|
echostr = urllib.parse.unquote(echostr)
|
||||||
|
temp_list = [self.token, timestamp, nonce, echostr]
|
||||||
|
temp_list.sort()
|
||||||
|
temp_str = ''.join(temp_list)
|
||||||
|
hash_str = hashlib.sha1(temp_str.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
if hash_str != msg_signature:
|
||||||
|
logger.error(f"URL验证签名不匹配: expected={msg_signature}, got={hash_str}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.encoding_aes_key:
|
||||||
|
return self._decrypt_echostr(echostr)
|
||||||
|
return echostr
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"URL验证异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _decrypt_echostr(self, echostr: str) -> str | None:
|
||||||
|
"""解密 echostr"""
|
||||||
|
try:
|
||||||
|
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||||
|
encrypted = base64.b64decode(echostr)
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||||
|
decrypted = cipher.decrypt(encrypted)
|
||||||
|
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
|
||||||
|
content = decrypted[16:]
|
||||||
|
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
xml_content = content[4:xml_len + 4]
|
||||||
|
received_id = content[xml_len + 4:].decode('utf-8')
|
||||||
|
if received_id != self.corpid:
|
||||||
|
logger.error(f"企业ID验证失败: {received_id} != {self.corpid}")
|
||||||
|
return None
|
||||||
|
return xml_content.decode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解密echostr失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 消息解密(POST 回调)─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_message(self, xml_data: bytes) -> dict | None:
|
||||||
|
"""解析企业微信回调的加密 XML 消息"""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_data)
|
||||||
|
msg = {child.tag: child.text for child in root}
|
||||||
|
|
||||||
|
if 'Encrypt' in msg:
|
||||||
|
decrypted = self._decrypt_message(msg['Encrypt'])
|
||||||
|
decrypted_root = ET.fromstring(decrypted)
|
||||||
|
msg = {child.tag: child.text for child in decrypted_root}
|
||||||
|
|
||||||
|
return msg
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析消息失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _decrypt_message(self, encrypted_msg: str) -> str:
|
||||||
|
"""解密企业微信推送的消息"""
|
||||||
|
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||||
|
encrypted = base64.b64decode(encrypted_msg)
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||||
|
decrypted = cipher.decrypt(encrypted)
|
||||||
|
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
|
||||||
|
content = decrypted[16:]
|
||||||
|
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||||
|
xml_content = content[4:xml_len + 4]
|
||||||
|
received_id = content[xml_len + 4:].decode('utf-8')
|
||||||
|
if received_id != self.corpid:
|
||||||
|
raise Exception(f"企业ID验证失败: {received_id} != {self.corpid}")
|
||||||
|
return xml_content.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级单例和便捷函数
|
||||||
|
_service: WeChatService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_wechat_service() -> WeChatService:
|
||||||
|
global _service
|
||||||
|
if _service is None:
|
||||||
|
_service = WeChatService()
|
||||||
|
return _service
|
||||||
|
|
||||||
|
|
||||||
|
def send_wechat_markdown(content: str) -> bool:
|
||||||
|
"""便捷函数:发送企业微信 Markdown 消息"""
|
||||||
|
return get_wechat_service().send_markdown(content)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""告警相关 Celery 任务"""
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
|
||||||
|
def check_school_offline_alerts():
|
||||||
|
"""
|
||||||
|
检查是否有学校全部离线(在线率为 0%),如有则发送企业微信告警。
|
||||||
|
该任务在每次全量状态检查完成后异步调用。
|
||||||
|
"""
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||||
|
from app.services.wechat_service import send_wechat_markdown
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 每台设备最新状态的子查询
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按学校聚合在线率,只查询在线数为 0 的学校
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
ONUDevice.school_name,
|
||||||
|
ONUDevice.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||||
|
.having(
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {"alerted": False, "reason": "没有全离线的学校"}
|
||||||
|
|
||||||
|
# 构造告警消息
|
||||||
|
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
lines = [
|
||||||
|
"## <font color=\"warning\">[告警] 学校全部离线</font>",
|
||||||
|
f"> 检查时间:{now_str}",
|
||||||
|
"> 以下学校所有设备均处于离线状态:",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
school = row.school_name or "未知"
|
||||||
|
region = row.region or "未知"
|
||||||
|
total = int(row.total or 0)
|
||||||
|
if total > 0:
|
||||||
|
lines.append(f"- **{school}**({region}): {total} 台设备全离线")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
send_wechat_markdown(content)
|
||||||
|
return {"alerted": True, "schools": len(rows)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"alerted": False, "error": "告警任务失败", "error_type": type(e).__name__}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
"""审计日志 Celery 任务"""
|
"""审计日志 Celery 任务"""
|
||||||
import traceback
|
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
|
|
||||||
@@ -57,6 +56,6 @@ def cleanup_audit_logs_task():
|
|||||||
deleted = cleanup_old_logs(db)
|
deleted = cleanup_old_logs(db)
|
||||||
return {'success': True, 'deleted': deleted}
|
return {'success': True, 'deleted': deleted}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
return {'success': False, 'error': '审计日志任务失败', 'error_type': type(e).__name__}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""状态检查任务"""
|
"""状态检查任务"""
|
||||||
import time
|
import time
|
||||||
import traceback
|
|
||||||
import redis as redis_lib
|
import redis as redis_lib
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import func, case
|
from sqlalchemy import func, case
|
||||||
@@ -86,17 +85,29 @@ def check_all_devices(self):
|
|||||||
errors.append({
|
errors.append({
|
||||||
'olt_id': olt.id,
|
'olt_id': olt.id,
|
||||||
'olt_name': olt.location or olt.ip_address,
|
'olt_name': olt.location or olt.ip_address,
|
||||||
'error': str(e)
|
'error': 'OLT 状态检查失败',
|
||||||
|
'error_type': type(e).__name__,
|
||||||
})
|
})
|
||||||
results.append({
|
results.append({
|
||||||
'olt_id': olt.id,
|
'olt_id': olt.id,
|
||||||
'olt_name': olt.location or olt.ip_address,
|
'olt_name': olt.location or olt.ip_address,
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': 'OLT 状态检查失败',
|
||||||
|
'error_type': type(e).__name__,
|
||||||
})
|
})
|
||||||
|
|
||||||
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||||
|
|
||||||
|
# 通知 WebSocket 客户端状态已更新
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
r.publish("h3c_onu:status_updates", _json.dumps({
|
||||||
|
"type": "check_complete", "total_online": total_online,
|
||||||
|
"total_offline": total_offline, "total_olts": total
|
||||||
|
}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'total_olts': total,
|
'total_olts': total,
|
||||||
@@ -108,8 +119,8 @@ def check_all_devices(self):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e),
|
'error': '设备状态检查任务失败',
|
||||||
'traceback': traceback.format_exc()
|
'error_type': type(e).__name__,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
# 任务完成后记录时间、清除运行标记
|
# 任务完成后记录时间、清除运行标记
|
||||||
@@ -171,7 +182,7 @@ def aggregate_daily_snapshot():
|
|||||||
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
|
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
return {'success': False, 'error': '设备状态检查任务失败', 'error_type': type(e).__name__}
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||||
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
from app.tasks import alert_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
|
||||||
__all__ = ['celery_app']
|
__all__ = ['celery_app']
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIC+TCCAeGgAwIBAgIEHno+KjANBgkqhkiG9w0BAQsFADAtMQwwCgYDVQQLDANS
|
||||||
|
JkQxHTAbBgNVBAMTFGlNQyBEZXZlbG9wbWVudCBUZWFtMB4XDTE5MDExOTA2Mzkx
|
||||||
|
OVoXDTM5MDExNDA2MzkxOVowLTEMMAoGA1UECwwDUiZEMR0wGwYDVQQDExRpTUMg
|
||||||
|
RGV2ZWxvcG1lbnQgVGVhbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||||
|
AIVGUvmtUD6Gx0AQLi+/voDIuSERGLyTLcQQOBmWVWLfxr0i1PGxuGODylN+30Ud
|
||||||
|
pBY1faRijvUZUWN51dxjXMRLnHUecBuPVyWOv1jPFMd1jcc7KmQ4KY6s43t1cC2R
|
||||||
|
pBcfyBhVmN+tArhcbDJb6MUuN1P5ZGi3E1vor9EY0Gt8TvJ/GgmqgWwlveAC0soH
|
||||||
|
yTTk8UU9m2OikbaZ0dKgQbshna4wrSWw9iyJ5Bao4rZgus3hGZUnznWpPD0/jHC4
|
||||||
|
lI3znCKQPAsnJculZcK/8dm1UYKFx7C3L5z4SnzsLY+rTYXvmGfb2x06wKkkJTIt
|
||||||
|
oepc1U5U/jNlUVbNdA9yhP0CAwEAAaMhMB8wHQYDVR0OBBYEFGErff6AvZVRmgqK
|
||||||
|
DLQxMBGOr0ObMA0GCSqGSIb3DQEBCwUAA4IBAQA9HhuDvrVI4ICyddy5g4DM+bv4
|
||||||
|
NaKoBS0Y5nhANbN/0f0J0Zj32OYtbsJlUZViFQ42LIR7b37x6OgMXRjHJIDg3q+9
|
||||||
|
tkH5S8Q5xmb5Tqq0WcOWQmg0o1OjW5iJNAfKiTffGtc5DjmrOBuCf1P23G3polRB
|
||||||
|
34QxGgnRygEDKy5aYumaXyiL1yMgDLkZ2adg2lWvsBSpvmgzMF5H75Spq4WvK60Z
|
||||||
|
o3EyCpB5ZS4SxwF3JH+LdpsyCc+UsyYW4/v/FVidtCp+nNTgsTA1yI8vcPVyPxPQ
|
||||||
|
SvVFIvuLYpo9cbHLcmQ95LBKjMbhPVHMzsBRVd85xKVjBspRMWeUp7F94W8V
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -14,7 +14,12 @@ paramiko==3.4.0
|
|||||||
pandas==2.1.4
|
pandas==2.1.4
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
cryptography==42.0.0
|
cryptography==42.0.0
|
||||||
|
pycryptodome==3.20.0
|
||||||
|
slowapi==0.1.9
|
||||||
|
python-json-logger==2.0.7
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.25.0
|
||||||
casdoor==1.18.0
|
casdoor==1.18.0
|
||||||
aiohttp>=3.9.0
|
aiohttp==3.9.5
|
||||||
PyJWT>=2.8.0
|
PyJWT==2.8.0
|
||||||
requests>=2.31.0
|
requests==2.31.0
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""One-time migration that encrypts legacy plaintext OLT credentials."""
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
from app.core.credentials import CREDENTIAL_PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(batch_size: int = 100) -> int:
|
||||||
|
"""Encrypt every legacy credential and return the number of migrated records."""
|
||||||
|
db = SessionLocal()
|
||||||
|
migrated = 0
|
||||||
|
try:
|
||||||
|
devices = db.query(OLTDevice).yield_per(batch_size)
|
||||||
|
for device in devices:
|
||||||
|
if device.password.startswith(CREDENTIAL_PREFIX):
|
||||||
|
continue
|
||||||
|
# Reading a legacy row yields plaintext. Mark it dirty so the column type encrypts it on flush.
|
||||||
|
device.password = device.password
|
||||||
|
flag_modified(device, "password")
|
||||||
|
migrated += 1
|
||||||
|
db.commit()
|
||||||
|
return migrated
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"Migrated {migrate()} OLT credential(s).")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""pytest fixtures"""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
TEST_DB_URL = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session():
|
||||||
|
engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""数据导入服务测试"""
|
||||||
|
import io
|
||||||
|
import pytest
|
||||||
|
from app.services.import_service import ImportService
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportService:
|
||||||
|
def test_validate_mac(self, db_session):
|
||||||
|
svc = ImportService(db_session)
|
||||||
|
valid = [{"mac_address": "1484-778f-aa60", "region": "城区", "school_name": "测试学校"}]
|
||||||
|
result = svc.validate_data(valid)
|
||||||
|
assert len(result["valid"]) == 1
|
||||||
|
assert len(result["invalid"]) == 0
|
||||||
|
|
||||||
|
def test_invalid_mac_rejected(self, db_session):
|
||||||
|
svc = ImportService(db_session)
|
||||||
|
data = [{"mac_address": "invalid", "region": "城区", "school_name": "测试学校"}]
|
||||||
|
result = svc.validate_data(data)
|
||||||
|
assert len(result["invalid"]) > 0
|
||||||
|
|
||||||
|
def test_missing_required_fields(self, db_session):
|
||||||
|
svc = ImportService(db_session)
|
||||||
|
# MAC format valid but empty region/school may or may not be rejected
|
||||||
|
# depending on validation rules — just verify it doesn't crash
|
||||||
|
data = [{"mac_address": "1484-778f-aa60", "region": "", "school_name": ""}]
|
||||||
|
result = svc.validate_data(data)
|
||||||
|
assert "valid" in result or "invalid" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanValue:
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
from app.services.import_service import clean_value
|
||||||
|
assert clean_value(" test ") == "test"
|
||||||
|
|
||||||
|
def test_none_returns_empty(self):
|
||||||
|
from app.services.import_service import clean_value
|
||||||
|
assert clean_value(None) == ''
|
||||||
|
|
||||||
|
def test_nan_returns_empty(self):
|
||||||
|
from app.services.import_service import clean_value
|
||||||
|
import math
|
||||||
|
assert clean_value(float('nan')) == ''
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Security regression tests for P0 remediations."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.core.errors import internal_error
|
||||||
|
from app.core.credentials import CREDENTIAL_PREFIX, decrypt_credential, encrypt_credential
|
||||||
|
from app.core.logging_utils import redact_log_message
|
||||||
|
from app.core.security import verify_casdoor_token
|
||||||
|
from app.middleware.audit_middleware import sanitize_audit_params
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_disabled_imc_tls_verification():
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="IMC_API_VERIFY_SSL"):
|
||||||
|
Settings(
|
||||||
|
SECRET_KEY="test-secret",
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY="Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU=",
|
||||||
|
DATABASE_URL="sqlite:///./test.db",
|
||||||
|
REDIS_URL="redis://localhost:6379/15",
|
||||||
|
CASDOOR_ENDPOINT="https://casdoor.example.test",
|
||||||
|
CASDOOR_CLIENT_ID="test-client",
|
||||||
|
CASDOOR_CLIENT_SECRET="test-client-secret",
|
||||||
|
CASDOOR_ORG_NAME="test-org",
|
||||||
|
CASDOOR_APP_NAME="test-app",
|
||||||
|
IMC_API_URL="https://imc.example.test",
|
||||||
|
IMC_API_VERIFY_SSL=False,
|
||||||
|
DEBUG=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_params_redacts_nested_sensitive_values():
|
||||||
|
params = {
|
||||||
|
"corpsecret": "corp-secret",
|
||||||
|
"profile": {"access_token": "access-token", "name": "operator"},
|
||||||
|
"items": [{"password": "device-password"}],
|
||||||
|
"normal": "kept",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert sanitize_audit_params(params) == {
|
||||||
|
"corpsecret": "***",
|
||||||
|
"profile": {"access_token": "***", "name": "operator"},
|
||||||
|
"items": [{"password": "***"}],
|
||||||
|
"normal": "kept",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_error_does_not_expose_exception_text():
|
||||||
|
exception = internal_error("test operation", RuntimeError("database password=should-not-leak"))
|
||||||
|
|
||||||
|
assert isinstance(exception, HTTPException)
|
||||||
|
assert exception.status_code == 500
|
||||||
|
assert exception.detail["code"] == "INTERNAL_ERROR"
|
||||||
|
assert "should-not-leak" not in str(exception.detail)
|
||||||
|
assert exception.detail["error_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_redaction_masks_common_credential_formats():
|
||||||
|
message = "Authorization: Bearer abc.def password=hunter2&access_token=token-value corpsecret: corp-secret"
|
||||||
|
redacted = redact_log_message(message)
|
||||||
|
|
||||||
|
assert "abc.def" not in redacted
|
||||||
|
assert "hunter2" not in redacted
|
||||||
|
assert "token-value" not in redacted
|
||||||
|
assert "corp-secret" not in redacted
|
||||||
|
|
||||||
|
|
||||||
|
def test_olt_credential_encryption_round_trip(monkeypatch):
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
encrypted = encrypt_credential("olt-device-password")
|
||||||
|
|
||||||
|
assert encrypted.startswith(CREDENTIAL_PREFIX)
|
||||||
|
assert "olt-device-password" not in encrypted
|
||||||
|
assert decrypt_credential(encrypted) == "olt-device-password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_olt_response_never_contains_device_password():
|
||||||
|
from app.schemas.olt import serialize_olt
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
|
||||||
|
device = OLTDevice(
|
||||||
|
id=1,
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
username="operator",
|
||||||
|
password="olt-device-password",
|
||||||
|
slot_command="display onu slot",
|
||||||
|
region="城区",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = serialize_olt(device)
|
||||||
|
assert "password" not in response
|
||||||
|
assert "olt-device-password" not in str(response)
|
||||||
|
|
||||||
|
|
||||||
|
def test_olt_password_is_encrypted_in_database(monkeypatch):
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
session = sessionmaker(bind=engine)()
|
||||||
|
session.add(
|
||||||
|
OLTDevice(
|
||||||
|
id=1,
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
username="operator",
|
||||||
|
password="olt-device-password",
|
||||||
|
slot_command="display onu slot",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
stored_password = session.execute(text("SELECT password FROM olt_devices")).scalar_one()
|
||||||
|
assert stored_password.startswith(CREDENTIAL_PREFIX)
|
||||||
|
assert "olt-device-password" not in stored_password
|
||||||
|
|
||||||
|
session.expire_all()
|
||||||
|
assert session.query(OLTDevice).one().password == "olt-device-password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_casdoor_token_requires_expected_signature_and_claims(monkeypatch):
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
public_pem = private_key.public_key().public_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
).decode()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "CASDOOR_CERTIFICATE", public_pem)
|
||||||
|
monkeypatch.setattr(settings, "CASDOOR_ENDPOINT", "https://casdoor.example.test")
|
||||||
|
monkeypatch.setattr(settings, "CASDOOR_ISSUER", "")
|
||||||
|
monkeypatch.setattr(settings, "CASDOOR_CLIENT_ID", "h3c-client")
|
||||||
|
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"sub": "user-1",
|
||||||
|
"iss": "https://casdoor.example.test",
|
||||||
|
"aud": "h3c-client",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=5),
|
||||||
|
},
|
||||||
|
private_key,
|
||||||
|
algorithm="RS256",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert verify_casdoor_token(token)["sub"] == "user-1"
|
||||||
|
|
||||||
|
invalid_token = jwt.encode(
|
||||||
|
{
|
||||||
|
"sub": "user-1",
|
||||||
|
"iss": "https://casdoor.example.test",
|
||||||
|
"aud": "other-client",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(minutes=5),
|
||||||
|
},
|
||||||
|
private_key,
|
||||||
|
algorithm="RS256",
|
||||||
|
)
|
||||||
|
with pytest.raises(jwt.InvalidAudienceError):
|
||||||
|
verify_casdoor_token(invalid_token)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""SSH 输出解析测试"""
|
||||||
|
import re
|
||||||
|
import pytest
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_svc():
|
||||||
|
return SSHService("10.0.0.1", "admin", "pass")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOnuInfo:
|
||||||
|
def test_single_online(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||||
|
Port MAC Status OAM State LOID Model Distance
|
||||||
|
0/0/1 1484-778f-aa60 Up OAM_Up test_loid H3C_ET704 1234m
|
||||||
|
"""
|
||||||
|
onu_dict, unknown = svc.parse_onu_info(output)
|
||||||
|
assert len(onu_dict) == 1
|
||||||
|
assert "1484-778f-aa60" in onu_dict
|
||||||
|
assert onu_dict["1484-778f-aa60"].status == "online"
|
||||||
|
|
||||||
|
def test_mixed_online_offline(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||||
|
Port MAC Status OAM State LOID Model Distance
|
||||||
|
0/0/1 1484-778f-aa60 Up OAM_Up loid_a H3C_ET704 500m
|
||||||
|
0/0/2 1484-778f-bb70 Down OAM_Down loid_b Unknown <1000m
|
||||||
|
"""
|
||||||
|
onu_dict, unknown = svc.parse_onu_info(output)
|
||||||
|
mac_a = "1484-778f-aa60"
|
||||||
|
mac_b = "1484-778f-bb70"
|
||||||
|
assert onu_dict[mac_a].status == "online"
|
||||||
|
assert onu_dict[mac_b].status == "offline"
|
||||||
|
|
||||||
|
def test_more_marker_removal(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||||
|
Port MAC Status OAM State LOID Model Distance
|
||||||
|
---- More ----
|
||||||
|
0/0/1 1484-778f-aa60 Up OAM_Up loid H3C_ET704 500m
|
||||||
|
---- More ----
|
||||||
|
0/0/2 1484-778f-bb70 Up OAM_Up loid2 H3C_ET704 800m
|
||||||
|
"""
|
||||||
|
onu_dict, _ = svc.parse_onu_info(output)
|
||||||
|
assert len(onu_dict) == 2
|
||||||
|
|
||||||
|
def test_more_inline_with_device_line(self):
|
||||||
|
"""More 标记与下一条设备数据同行时,不应丢弃该行"""
|
||||||
|
svc = _make_svc()
|
||||||
|
output = """
|
||||||
|
---- More ---- 1484-778f-aa60 Up OAM_Up loid H3C_ET704 500m
|
||||||
|
"""
|
||||||
|
onu_dict, _ = svc.parse_onu_info(output)
|
||||||
|
mac = "1484-778f-aa60"
|
||||||
|
assert mac in onu_dict
|
||||||
|
|
||||||
|
def test_empty_output(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
onu_dict, unknown = svc.parse_onu_info("")
|
||||||
|
assert len(onu_dict) == 0
|
||||||
|
|
||||||
|
def test_header_only(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = " Flags: S-Switched L-Loopback N-Not exist U-Up D-Down\n Port MAC Status"
|
||||||
|
onu_dict, _ = svc.parse_onu_info(output)
|
||||||
|
assert len(onu_dict) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanOutput:
|
||||||
|
def test_strips_ansi_codes(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
cleaned = svc._clean_output("\x1b[37D\x1b[K 1484-778f-aa60 Up")
|
||||||
|
assert "\x1b[37D" not in cleaned
|
||||||
|
assert "\x1b[K" not in cleaned
|
||||||
|
assert "1484-778f-aa60" in cleaned
|
||||||
|
|
||||||
|
def test_removes_more_marker(self):
|
||||||
|
svc = _make_svc()
|
||||||
|
output = "---- More ----\n1484-778f-aa60 Up"
|
||||||
|
cleaned = svc._clean_output(output)
|
||||||
|
assert "---- More ----" not in cleaned
|
||||||
|
assert "1484-778f-aa60" in cleaned
|
||||||
|
|
||||||
|
def test_preserves_device_line_after_more(self):
|
||||||
|
"""More 标记同行后续设备数据不应被删除"""
|
||||||
|
svc = _make_svc()
|
||||||
|
output = "---- More ----\r\r 1484-778f-aa60 Up"
|
||||||
|
cleaned = svc._clean_output(output)
|
||||||
|
assert "1484-778f-aa60" in cleaned
|
||||||
|
assert "---- More ----" not in cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectLoopback:
|
||||||
|
"""环路检测输出解析测试"""
|
||||||
|
|
||||||
|
def _parse(self, output: str):
|
||||||
|
"""模拟 detect_loopback 中的解析逻辑"""
|
||||||
|
has_loop = "Loop is detected on following interfaces" in output
|
||||||
|
interfaces = []
|
||||||
|
if has_loop:
|
||||||
|
for line in output.splitlines():
|
||||||
|
m = re.match(r'\s+(Onu\S+)', line)
|
||||||
|
if m:
|
||||||
|
interfaces.append(m.group(1))
|
||||||
|
return has_loop, interfaces
|
||||||
|
|
||||||
|
def test_no_loop(self):
|
||||||
|
output = """
|
||||||
|
Loopback detection is enabled.
|
||||||
|
Loopback detection interval is 30 second(s).
|
||||||
|
No loopback is detected.
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert not has_loop
|
||||||
|
assert interfaces == []
|
||||||
|
|
||||||
|
def test_has_loop_single(self):
|
||||||
|
output = """
|
||||||
|
Loopback detection is enabled.
|
||||||
|
Loopback detection interval is 30 second(s).
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1"]
|
||||||
|
|
||||||
|
def test_has_loop_multiple(self):
|
||||||
|
output = """
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
Onu1/0/2:3
|
||||||
|
Onu2/0/5:10
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1", "Onu1/0/2:3", "Onu2/0/5:10"]
|
||||||
|
|
||||||
|
def test_has_loop_with_extra_whitespace(self):
|
||||||
|
"""接口行有多余空白字符"""
|
||||||
|
output = """
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1"]
|
||||||
|
|
||||||
|
def test_no_false_positive_on_prompt(self):
|
||||||
|
"""确保设备提示符不被误识别为接口"""
|
||||||
|
output = """
|
||||||
|
Loop is detected on following interfaces:
|
||||||
|
Onu1/0/1:1
|
||||||
|
<H3C_Device>
|
||||||
|
"""
|
||||||
|
has_loop, interfaces = self._parse(output)
|
||||||
|
assert has_loop
|
||||||
|
assert interfaces == ["Onu1/0/1:1"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArvxqOc2fCe611njyX3aG
|
||||||
|
aprZzyU/UhynIHsJhVMq6tDFLPmJCitg5WFHVTOW5KlTzKI894XJNEPSkf4POoai
|
||||||
|
1z4CW/GeRrkN8/XP8/RqyiCQAslei5FH5i1RjqLWijm0qUNsgYk07ZpBw45qT1ti
|
||||||
|
ipJF33L+mr9s+O17ujDDb2EZBzTcuBs1eYhv2fk0bOdYK17Vzzw1StPP0OWmfYev
|
||||||
|
edw6YemqnODk21e6CGcewXhgIX6k8iBaULQLoy0qTR+4nKwJjnCm4DrbSkYvjM7p
|
||||||
|
AW/3bQyJax1VbFw2jRo1CRcoaMRzLcutpe7HaT+wf4TR7cZFcaFZieSsruLySD5X
|
||||||
|
42bVgWDIZtjtXfJnSZc/Fv6IyUi/PzzlWIaxGcqqVHSk3W/w1ubNc+70SQA7fCJP
|
||||||
|
8vh/GcRNbWF3+mH4B1DSMSl7IAxGNyXeQyNq44Gp9T9MeXsvppQxcHd3Tn4krRi5
|
||||||
|
NzWetnRCOH/kQblfK9FK4o1XGkIHVdwCUBoFO4Tlqu3qNCQIkg28Wg6OgdkHrkoc
|
||||||
|
lW98y0y5Wvt0tJtg48cfpgkHZ9SKM0qhedyUBvGV1fd8QAuwL9JbvMwUX7cVOGBJ
|
||||||
|
6rc4Sk11uRGVCnmw5Ed5ORa0w70DrQYe9OoNqYiqmkLh+TvXMhGmYQ+V8aw6ejmA
|
||||||
|
2Efz40ofkK+Z+Pizv9L3wP8CAwEAAQ==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# CI-only security tooling. Keep this file pinned so the scan itself is reproducible.
|
||||||
|
pip-audit==2.7.3
|
||||||
+47
-14
@@ -13,10 +13,17 @@ DEBUG=false
|
|||||||
|
|
||||||
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
# OLT SSH 密码加密密钥;请通过受控密钥服务或受限环境变量注入,切勿提交至仓库
|
||||||
|
# 生成示例:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# 时区设置
|
# 时区设置
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# 宿主机发布端口。并行验证时可改为未占用端口,确认后再切换为正式端口。
|
||||||
|
BACKEND_PORT=8001
|
||||||
|
FRONTEND_PORT=18062
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 数据库配置 (PostgreSQL)
|
# 数据库配置 (PostgreSQL)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -65,6 +72,11 @@ CASDOOR_ORG_NAME=your_organization
|
|||||||
# 应用名称
|
# 应用名称
|
||||||
CASDOOR_APP_NAME=h3c-onu-ms
|
CASDOOR_APP_NAME=h3c-onu-ms
|
||||||
|
|
||||||
|
# Casdoor 回调地址(部署后改为实际域名)
|
||||||
|
CASDOOR_REDIRECT_URL=
|
||||||
|
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||||
|
CASDOOR_ISSUER=
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# SSH连接配置
|
# SSH连接配置
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -98,20 +110,35 @@ HISTORY_RETENTION=90
|
|||||||
BATCH_CHECK_SIZE=100
|
BATCH_CHECK_SIZE=100
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 前端配置
|
# 前端 & CORS 配置
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# API基础URL (前端访问后端地址)
|
# 前端访问地址
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
FRONTEND_URL=https://your-domain.com
|
||||||
|
|
||||||
# Casdoor前端配置
|
# CORS允许的域名 (逗号分隔)
|
||||||
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
|
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||||
VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id
|
|
||||||
VITE_CASDOOR_ORG_NAME=your_organization
|
|
||||||
VITE_CASDOOR_APP_NAME=h3c-onu-ms
|
|
||||||
|
|
||||||
# 应用标题
|
# ============================================
|
||||||
VITE_APP_TITLE=H3C ONU设备管理系统
|
# NTP 同步配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# NTP 旧服务器 IP
|
||||||
|
NTP_OLD_SERVER=172.16.0.254
|
||||||
|
# NTP 新服务器 IP
|
||||||
|
NTP_NEW_SERVER=172.16.1.252
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# iMC 网管 API 配置(ONU 远程重启/光功率查询)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
IMC_API_URL=
|
||||||
|
IMC_API_USERNAME=
|
||||||
|
IMC_API_PASSWORD=
|
||||||
|
# 本地认证不需要 SSL 验证
|
||||||
|
IMC_API_VERIFY_SSL=true
|
||||||
|
IMC_CONNECT_TIMEOUT=5
|
||||||
|
IMC_READ_TIMEOUT=20
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 日志配置
|
# 日志配置
|
||||||
@@ -154,9 +181,6 @@ METRICS_PORT=8000
|
|||||||
# 安全配置
|
# 安全配置
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# CORS允许的域名 (逗号分隔)
|
|
||||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
|
||||||
|
|
||||||
# 速率限制配置
|
# 速率限制配置
|
||||||
RATE_LIMIT_PER_MINUTE=60
|
RATE_LIMIT_PER_MINUTE=60
|
||||||
RATE_LIMIT_PER_HOUR=1000
|
RATE_LIMIT_PER_HOUR=1000
|
||||||
@@ -168,6 +192,15 @@ RATE_LIMIT_PER_HOUR=1000
|
|||||||
# 数据库备份目录
|
# 数据库备份目录
|
||||||
BACKUP_DIR=/app/backups
|
BACKUP_DIR=/app/backups
|
||||||
|
|
||||||
|
# 企业微信告警配置
|
||||||
|
WECHAT_CORPID=
|
||||||
|
WECHAT_CORPSECRET=
|
||||||
|
WECHAT_AGENTID=
|
||||||
|
WECHAT_TOKEN=
|
||||||
|
WECHAT_ENCODING_AES_KEY=
|
||||||
|
WECHAT_USE_PROXY=True
|
||||||
|
WECHAT_PROXY_API_URL=
|
||||||
|
|
||||||
# 备份保留天数
|
# 备份保留天数
|
||||||
BACKUP_RETENTION=30
|
BACKUP_RETENTION=30
|
||||||
|
|
||||||
@@ -182,4 +215,4 @@ BACKUP_SCHEDULE="0 2 * * *"
|
|||||||
# DEBUG=true
|
# DEBUG=true
|
||||||
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
|
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
|
||||||
# REDIS_URL=redis://localhost:6379/0
|
# REDIS_URL=redis://localhost:6379/0
|
||||||
# VITE_API_BASE_URL=http://localhost:8000
|
# VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
|||||||
+39
-11
@@ -6,7 +6,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: h3c-onu-ms-backend
|
container_name: h3c-onu-ms-backend
|
||||||
ports:
|
ports:
|
||||||
- "8001:8000"
|
- "${BACKEND_PORT:-8001}:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
@@ -16,8 +16,25 @@ services:
|
|||||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
||||||
|
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||||
- DEBUG=${DEBUG:-false}
|
- DEBUG=${DEBUG:-false}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
|
||||||
|
- NTP_OLD_SERVER=${NTP_OLD_SERVER:-172.16.0.254}
|
||||||
|
- NTP_NEW_SERVER=${NTP_NEW_SERVER:-172.16.1.252}
|
||||||
|
- IMC_API_URL=${IMC_API_URL:-}
|
||||||
|
- IMC_API_USERNAME=${IMC_API_USERNAME:-}
|
||||||
|
- IMC_API_PASSWORD=${IMC_API_PASSWORD:-}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
- ../backend/static:/app/static
|
- ../backend/static:/app/static
|
||||||
@@ -45,7 +62,16 @@ services:
|
|||||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -68,33 +94,35 @@ services:
|
|||||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend/logs:/app/logs
|
- ../backend/logs:/app/logs
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# 前端应用
|
# 前端应用(生产模式:nginx 静态文件服务)
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ../frontend
|
context: ../frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: h3c-onu-ms-frontend
|
container_name: h3c-onu-ms-frontend
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "${FRONTEND_PORT:-18062}:80"
|
||||||
environment:
|
|
||||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
|
|
||||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
|
||||||
- VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT}
|
|
||||||
- VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID}
|
|
||||||
- VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME}
|
|
||||||
- VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:5173"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name onu.dhdx.fun;
|
||||||
|
index index.html;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $server_name;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $http_connection;
|
||||||
|
access_log /www/sites/onu.dhdx.fun/log/access.log main;
|
||||||
|
error_log /www/sites/onu.dhdx.fun/log/error.log;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge {
|
||||||
|
allow all;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scheme = http) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssl_certificate /www/sites/onu.dhdx.fun/ssl/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/sites/onu.dhdx.fun/ssl/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.3 TLSv1.2 TLSv1.1 TLSv1;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
error_page 497 https://$host$request_uri;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000";
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# 前端(生产 nginx 容器,端口 18062)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:18062;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 后端 API(frp 隧道 → 本机后端 /api/ws/dashboard WebSocket)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:18060;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,12 +216,31 @@ backup_data() {
|
|||||||
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
||||||
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
||||||
|
|
||||||
|
# 验证数据库备份
|
||||||
|
echo -e "${BLUE}验证数据库备份...${NC}"
|
||||||
|
if [ -f "$BACKUP_DIR/database.sql" ]; then
|
||||||
|
SQL_SIZE=$(wc -c < "$BACKUP_DIR/database.sql")
|
||||||
|
if [ "$SQL_SIZE" -lt 100 ]; then
|
||||||
|
echo -e "${RED}错误: 数据库备份文件过小 ($SQL_SIZE bytes),可能备份失败${NC}"
|
||||||
|
elif head -1 "$BACKUP_DIR/database.sql" | grep -qiE "^(--|SET|CREATE|COPY|INSERT|ALTER)"; then
|
||||||
|
echo -e "${GREEN}数据库备份验证通过 ($SQL_SIZE bytes)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}警告: 数据库备份格式异常,请检查${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# 压缩备份文件
|
# 压缩备份文件
|
||||||
echo -e "${BLUE}压缩备份文件...${NC}"
|
echo -e "${BLUE}压缩备份文件...${NC}"
|
||||||
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
||||||
rm -rf "$BACKUP_DIR"
|
rm -rf "$BACKUP_DIR"
|
||||||
|
|
||||||
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}"
|
# 清理旧备份(保留最近 30 天)
|
||||||
|
echo -e "${BLUE}清理旧备份(保留30天)...${NC}"
|
||||||
|
find backups/ -name "*.tar.gz" -mtime +30 -delete 2>/dev/null
|
||||||
|
find backups/ -name "*.tar.gz" -mtime +30 -exec echo " 删除: {}" \; 2>/dev/null
|
||||||
|
|
||||||
|
BACKUP_COUNT=$(find backups/ -name "*.tar.gz" | wc -l)
|
||||||
|
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz (现存 ${BACKUP_COUNT} 个备份)${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 函数:恢复数据
|
# 函数:恢复数据
|
||||||
|
|||||||
+3
-21
@@ -17,7 +17,7 @@ services:
|
|||||||
|
|
||||||
celery-worker:
|
celery-worker:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms
|
command: celery -A celery_worker.celery_app worker -B --loglevel=info -Q h3c_onu_ms --schedule=/tmp/celerybeat-schedule
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
volumes:
|
volumes:
|
||||||
@@ -27,23 +27,5 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
celery-beat:
|
# frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
|
||||||
build: ./backend
|
# 部署命令见 .claude/rules/07-remote-operations.md
|
||||||
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
|
||||||
env_file:
|
|
||||||
- ./backend/.env
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
ports:
|
|
||||||
- "18002:5173"
|
|
||||||
environment:
|
|
||||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# CI 实施说明
|
||||||
|
|
||||||
|
本仓库提供 `.gitlab-ci.yml`,用于满足 C1 项目最小的代码更新、构建、测试和依赖安全检查能力。流水线在合并请求、分支更新和标签创建时运行;受保护分支应在 GitLab 中配置为仅允许通过流水线和评审合入。
|
||||||
|
|
||||||
|
## 发布前配置
|
||||||
|
|
||||||
|
在 GitLab 项目的 CI/CD 变量中配置以下**受保护且掩码**变量:
|
||||||
|
|
||||||
|
| 变量 | 用途 | 要求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `INTERNAL_PYPI_URL` | 企业统一 PyPI 代理地址 | 仅允许访问经批准的代理仓库;不得配置为个人镜像。 |
|
||||||
|
| `INTERNAL_NPM_REGISTRY` | 企业统一 npm 代理地址 | 仅允许访问经批准的代理仓库;不得提交认证令牌到仓库。 |
|
||||||
|
| `INTERNAL_CONTAINER_PROXY` | 企业统一容器镜像代理地址(不含末尾 `/`) | Runner、构建基础镜像和 Docker-in-Docker 均从该代理拉取镜像。 |
|
||||||
|
|
||||||
|
流水线会在变量缺失时失败,避免从非受控公共源下载依赖或基础镜像。若代理需要认证,应使用 GitLab 受保护变量或 Runner 的受控凭据注入机制,不得将账号、口令或 token 写入 `.gitlab-ci.yml`、`requirements.txt`、`package-lock.json`、Dockerfile 或日志。
|
||||||
|
|
||||||
|
## 流水线内容与门禁
|
||||||
|
|
||||||
|
1. `dependency-source-policy`:验证统一依赖源已配置。
|
||||||
|
2. `backend-tests`:安装锁定的 Python 依赖,执行语法检查和 pytest,归档 JUnit 报告。
|
||||||
|
3. `frontend-build`:通过 `npm ci` 使用锁文件构建前端,归档静态构建产物。
|
||||||
|
4. `python-dependency-audit`:执行 Python 第三方组件漏洞扫描,归档 JSON 报告;发现可识别漏洞时失败。
|
||||||
|
5. `container-build`:仅构建带 commit SHA 标签的后端与前端镜像,验证 Dockerfile 可构建;Python、npm 与基础镜像均从企业代理获得,本 job 不推送镜像。
|
||||||
|
|
||||||
|
容器构建依赖 Docker-in-Docker runner。若当前 Runner 未授权特权容器,应由平台管理员提供隔离的构建 Runner;不得为了通过流水线而取消镜像构建或将 Docker Socket 暴露给不受信任的 job。
|
||||||
|
|
||||||
|
## 仍需由平台完成的事项
|
||||||
|
|
||||||
|
- 在 GitLab 中开启合并请求评审与成功流水线门禁,并保护 `main`/发布分支。
|
||||||
|
- 增加平台 SAST、前端依赖/SCA、许可证扫描和镜像扫描 job,并把报告关联到统一制品库中的制品元数据。
|
||||||
|
- 将镜像推送至统一制品库,采用不可变版本(发布 tag + commit SHA)并记录制品、测试、扫描和部署关联;该动作需要制品库地址与发布权限,未在本次整改中写入。
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# H3C ONU 设备管理系统研发规范整改计划
|
||||||
|
|
||||||
|
## 1. 审查基线
|
||||||
|
|
||||||
|
- 审查日期:2026-07-28
|
||||||
|
- 审查版本:`5b07ec6df0448bd43966260b1b7b70532c1d552b`(`main`)
|
||||||
|
- 审查范围:Vue/Vite 前端、FastAPI 后端、PostgreSQL/Redis/Celery、Docker Compose 与仓库交付物。
|
||||||
|
- 目标等级:**C1(待项目负责人确认)**。未分类项目按 C1 基线审查,依据《总体规范》2.1。
|
||||||
|
- 说明:本计划只将原文标明 C1/C2/C3/C4 或“【强制】”的内容标为规范要求;其余安全加固按工程风险处置。
|
||||||
|
|
||||||
|
## 2. 整改优先级与工作包
|
||||||
|
|
||||||
|
### P0:上线前阻断项(安全)
|
||||||
|
|
||||||
|
| 编号 | 整改项 | 主要证据 | 验收标准 | 依据 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| SEC-01 | 删除日志中的令牌片段,并建立统一日志脱敏器。脱敏范围至少覆盖 `Authorization`、token、password、secret、corpsecret、客户端凭据及请求体嵌套字段。 | `backend/app/middleware/permission_middleware.py:70` 写入 token 前 20 位;审计中间件仅脱敏三个精确字段名。 | 自动化测试证明日志中不出现任一敏感值或其可复用片段。 | 《安全分册》3.1.1【强制】 |
|
||||||
|
| SEC-02 | 停用 iMC 调用中的 MD5 Digest 实现,优先接入 iMC 支持的 Digest SHA-256、OAuth 或受控网关认证;如设备仅支持 MD5,须形成例外审批、隔离边界和替代改造计划。 | `backend/app/services/imc_service.py:82-87` 使用 `hashlib.md5`。 | 代码与扫描结果不再出现 MD5;或已具备批准的临时例外和退役日期。 | 《安全分册》3.1.2【强制】 |
|
||||||
|
| SEC-03 | 修复“关于”页存储型 XSS:Markdown 渲染后使用白名单 HTML 清洗,限制 URL 协议;为所有 `v-html` 建立受信来源约束。 | `frontend/src/views/About.vue:14,30` 将管理员可写 Markdown 直接插入 DOM。 | 恶意 `script`、事件属性和 `javascript:` URL 均不能执行;新增前端测试。 | 《安全分册》3.2.1.4【强制】 |
|
||||||
|
| SEC-04 | 认证回调验证 Casdoor 返回 JWT 的签名、发行者、受众、有效期和 nonce/state;失败默认拒绝。 | `backend/app/api/v1/auth.py:18-24,50` 明确“不验签”后使用 `sub` 建立本地会话。 | 伪造、过期、错误 issuer/audience 的令牌均被拒绝;通过真实 OIDC 回调回归。 | 《安全分册》2.1.10、2.2.2;《总体规范》4.5.1(C1) |
|
||||||
|
| SEC-05 | 生产环境强制 TLS 校验,禁止 `IMC_API_VERIFY_SSL=false` 默认值;为内部 CA 配置证书链。 | `backend/app/core/config.py:51` 与两份 `.env.example` 默认关闭校验。 | 非开发环境启动时拒绝关闭 TLS 校验;HTTPS 证书错误请求失败。 | 《安全分册》2.2.4、2.3.1 |
|
||||||
|
| SEC-06 | 统一异常处理:对外返回错误码与通用信息,内部以脱敏日志记录关联 ID;禁止透出异常文本、堆栈和上游响应正文。 | 多处 `detail=str(e)`,如 `devices.py:442`、`check.py:87,102`、`auth.py:82`;任务结果含 traceback。 | 500 响应不含路径、凭据、堆栈或上游报文;异常日志可按关联 ID 检索。 | 《安全分册》3.2.2.1【强制】 |
|
||||||
|
| SEC-07 | 对需要复用的 OLT SSH 密码实施加密(密钥由受控密钥服务或部署密钥注入),完成历史数据迁移、轮换和审计。 | `backend/app/models/device.py:14` 原以明文 `Text` 存储设备密码。 | 数据库备份、查询和审计日志均无明文密码;迁移可回滚、轮换可执行。 | 工程安全整改;密钥方案待确认(规范未指定具体 KMS 实现)。 |
|
||||||
|
|
||||||
|
### P1:C1 研发交付链整改
|
||||||
|
|
||||||
|
| 编号 | 整改项 | 验收标准 | 依据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ENG-01 | 在内部研发平台配置 CI:后端依赖安装、pytest、前端 `npm ci && npm run build`、SAST、依赖/SCA 扫描、镜像构建。为失败设置合入门禁并归档报告。 | 每次代码更新均有可追溯构建、单测和安全扫描结果;阻断级问题不可合入或发布。 | 《总体规范》4.5.1、4.5.3(C1);《流水线分册》2.3.2、3.2 |
|
||||||
|
| ENG-02 | 补齐测试策略、用例、测试报告与缺陷闭环;优先增加认证验签、权限隔离、审计脱敏、文件上传、XSS、iMC TLS、OLT 密码加密迁移的自动化测试。 | 测试计划、评审记录、执行报告、缺陷清单齐全;安全整改均有回归用例。 | 《总体规范》4.5.1、4.6、5.5.7(C1);《测试管理分册》3.2-3.5、6 |
|
||||||
|
| ENG-03 | 将第三方依赖改为可复现、可审核来源:固定后端所有直接依赖版本,使用内部统一制品库代理,生成依赖清单与许可证/漏洞扫描报告。 | `requirements` 无无上限版本;构建只从批准镜像/依赖源获取依赖,扫描报告可追溯。 | 《制品管理分册》4.1(C1);《总体规范》4.5.5(C1) |
|
||||||
|
| ENG-04 | 建立版本、制品与部署追溯:镜像标签包含应用版本与 commit ID;测试/生产只从统一制品库拉取已扫描制品,保留版本、测试、部署与回退记录。 | 任一生产版本能反查 commit、制品、扫描/测试结果和部署记录;禁止可变标签发布。 | 《制品管理分册》2.5、2.6、3.1(C1);《部署管理分册》2.2-2.3(C1) |
|
||||||
|
| ENG-05 | 补齐 C1 最小交付物:需求编号及变更记录、概要设计、安全设计/威胁分析、测试计划/报告、部署实施与回退方案;建立需求—用例—代码/制品—版本追踪表。 | 文档受版本控制,且每次发布可对应需求、测试与制品版本。 | 《总体规范》4.1、4.2、4.3、4.4(C1);《代码管理分册》2.3(C1) |
|
||||||
|
| ENG-06 | 在远端仓库核验并固化治理配置:仓库管理员不超过 3 人、最小权限、离职回收、主干保护、发布 tag;补全仓库描述中的项目编号、项目名称和子项目名称。 | 导出远端权限、保护分支和 tag 证据;README 与根 `.gitignore` 保持合规。 | 《代码管理分册》2.2-3.1(C1) |
|
||||||
|
|
||||||
|
### P2:运行与可维护性改进
|
||||||
|
|
||||||
|
| 编号 | 整改项 | 验收标准 | 属性 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| OPS-01 | 统一部署脚本与实际 Compose 服务、端口和健康检查;补充发布前检查、回退演练、备份恢复验证。 | 部署、备份和恢复在预发环境演练成功,并记录证据。 | 规范要求(部署材料,C1) |
|
||||||
|
| OPS-02 | Docker 构建使用固定基础镜像摘要、非 root 用户、最小镜像和健康检查;为容器设置资源与网络边界。 | 镜像安全扫描通过,运行身份和暴露端口可审计。 | 工程建议 |
|
||||||
|
| OPS-03 | 清理 Pydantic/SQLAlchemy 弃用用法,增加 lint/format/type-check;建立代码所有者与评审清单。 | CI 中无新增高等级质量问题,弃用告警清零。 | 工程建议 |
|
||||||
|
|
||||||
|
## 3. 实施顺序与里程碑
|
||||||
|
|
||||||
|
1. **M0:范围确认(0.5 天)**:确认项目等级、数据分级、iMC 支持的认证方式、内部制品库/CI/密钥服务和生产发布窗口。
|
||||||
|
2. **M1:安全止血(3-5 天)**:完成 SEC-01 至 SEC-06,补充回归测试;SEC-07 先输出密钥与迁移设计,禁止新增明文凭据。
|
||||||
|
3. **M2:凭据迁移与测试(3-5 天)**:实施 SEC-07,完成历史数据加密、密钥轮换演练和核心接口测试。
|
||||||
|
4. **M3:研发交付链(3-5 天)**:实施 ENG-01 至 ENG-04,接入内部流水线、制品库、SAST/SCA 与可追溯发布。
|
||||||
|
5. **M4:文档与上线验收(2-3 天)**:实施 ENG-05、ENG-06、OPS-01,完成预发演练、测试报告、部署审批材料和复审。
|
||||||
|
|
||||||
|
## 4. 当前证据缺口与需确认事项
|
||||||
|
|
||||||
|
- 未获得远端仓库的成员、权限、保护分支、合并评审和流水线运行记录,不能据此断言其合规性。
|
||||||
|
- 未获得研发云、制品库、SAST/SCA、测试管理、缺陷管理和生产部署平台的证据。
|
||||||
|
- 本地测试尝试因当前运行环境未安装 `paramiko` 而在收集阶段中断;前端构建因工作环境没有 npm 未执行。应由 CI 使用锁定工具链和依赖后重跑。
|
||||||
|
- 需项目负责人确认:项目 C 级、数据分级、iMC 接口可支持的认证算法、密钥托管产品和发布窗口。
|
||||||
|
|
||||||
|
## 4.1 SEC-07 发布前操作(待发布授权)
|
||||||
|
|
||||||
|
1. 在受控密钥服务中生成并保管 `CREDENTIAL_ENCRYPTION_KEY`;不得写入仓库、镜像、部署脚本或审计日志。
|
||||||
|
2. 备份数据库并完成恢复演练;记录备份版本和操作人。
|
||||||
|
3. 将密钥仅注入 backend、celery-worker、celery-beat 运行环境,部署新版代码后执行 `python scripts/migrate_olt_credentials.py`。
|
||||||
|
4. 以数据库管理员账户核验 `olt_devices.password` 全部为 `enc:v1:` 前缀;通过应用的 OLT 扫描、重启和端口管理回归测试确认可解密使用。
|
||||||
|
5. 如迁移异常,先停止后续发布,使用已验证的数据库备份回退;密钥泄露时按应急流程轮换密钥并重新加密所有凭据。
|
||||||
|
|
||||||
|
## 4.2 SEC-02 iMC 认证能力核验(2026-07-28)
|
||||||
|
|
||||||
|
- 已在部署服务器上对 iMC REST 接口发起未认证的只读请求。服务端返回 `401` 及 `Digest realm="iMC RESTful Web Services", qop="auth"`,未声明 `algorithm` 参数;现网项目的 iMC 客户端亦按 MD5 Digest 计算认证摘要。
|
||||||
|
- 受控浏览器因 iMC 使用不受信任的 TLS 证书而拒绝建立连接,未绕过证书校验;因此不能把“浏览器能访问”作为验收证据。
|
||||||
|
- 目前未发现 Digest SHA-256、OAuth/OIDC 或令牌认证的可用证据。SEC-02 不能以修改客户端代码的方式单独关闭:须向 iMC 厂商/平台管理员取得当前版本 REST API 的认证能力说明并确认升级路径。
|
||||||
|
- 若确认该版本仅支持 MD5 Digest,则上线前应提交安全例外审批,至少限定 iMC 为受控内网目标、使用专用最小权限账户、启用有效 TLS 证书与证书校验、禁止记录 `Authorization`/nonce/响应敏感数据,并明确 iMC 升级或网关替代方案的责任人和退役日期。
|
||||||
|
|
||||||
|
## 4.3 ENG-01、ENG-03 当前落实情况(2026-07-28)
|
||||||
|
|
||||||
|
- 已新增 GitLab CI 基线:统一依赖源检查、后端单元测试与 JUnit 报告、前端 `npm ci` 构建、Python 组件漏洞扫描和容器构建校验。详见 `docs/CI实施说明.md`。
|
||||||
|
- 已锁定后端直接依赖中原本使用下限约束的 `aiohttp`、`PyJWT`、`requests`;前端已存在 `package-lock.json`,流水线与 Docker 构建均使用 `npm ci`。
|
||||||
|
- Dockerfile 不再固定第三方镜像站;CI 通过 `INTERNAL_PYPI_URL`、`INTERNAL_NPM_REGISTRY`、`INTERNAL_CONTAINER_PROXY` 三个受保护变量接入企业代理。变量、受保护 Runner、合并门禁和统一制品库推送尚需由平台管理员配置后才能验收。
|
||||||
|
- 本地环境没有可用的 Docker、pytest 及项目运行依赖;已通过 Python 语法编译与 `git diff --check`,完整测试、镜像构建、依赖扫描和 GitLab CI Lint 均待在配置完成的内部流水线执行。
|
||||||
|
|
||||||
|
## 5. 下一项可执行动作
|
||||||
|
|
||||||
|
由 GitLab/制品库管理员先配置 `INTERNAL_PYPI_URL`、`INTERNAL_NPM_REGISTRY`、`INTERNAL_CONTAINER_PROXY` 与隔离的 Docker Runner;随后将本整改分支推送到 GitLab,确认 VerifyCI 全绿并将 `main` 设置为“合并请求评审 + 成功流水线”门禁。SEC-02 保持例外审批依赖,SEC-07 仍待密钥服务与发布窗口确认。
|
||||||
+24
-4
@@ -1,12 +1,32 @@
|
|||||||
FROM node:18-alpine
|
# Stage 1: Build
|
||||||
|
ARG NODE_BASE_IMAGE=node:20-alpine
|
||||||
|
ARG NGINX_BASE_IMAGE=nginx:alpine
|
||||||
|
FROM ${NODE_BASE_IMAGE} AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
ARG NPM_CONFIG_REGISTRY
|
||||||
|
RUN if [ -n "$NPM_CONFIG_REGISTRY" ]; then npm config set registry "$NPM_CONFIG_REGISTRY"; fi \
|
||||||
|
&& npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 5173
|
# Build for production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
# Stage 2: Serve with nginx
|
||||||
|
FROM ${NGINX_BASE_IMAGE} AS serve
|
||||||
|
|
||||||
|
# Remove default nginx config
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy built files from build stage
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression for text-based assets
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript
|
||||||
|
application/json application/javascript application/xml+rss
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Cache static assets with content hash names (Vite output)
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback - all routes serve index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint for docker
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,11 @@ export const getSummary = () => request.get('/stats/summary')
|
|||||||
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
||||||
|
|
||||||
export const getByRegion = () => request.get('/stats/by-region')
|
export const getByRegion = () => request.get('/stats/by-region')
|
||||||
|
|
||||||
|
export const getOltStats = () => request.get('/stats/olt-stats')
|
||||||
|
|
||||||
|
export const getModelDistribution = () => request.get('/stats/model-distribution')
|
||||||
|
|
||||||
|
export const getOfflineSchools = () => request.get('/stats/offline-schools')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
<div v-if="authStore.user" class="sidebar-user">
|
||||||
|
<div class="sidebar-user-avatar">{{ (authStore.user.display_name || authStore.user.username || '?')[0] }}</div>
|
||||||
|
<div class="sidebar-user-info">
|
||||||
|
<div class="sidebar-user-name">{{ authStore.user.display_name || authStore.user.username }}</div>
|
||||||
|
<div class="sidebar-user-role">{{ roleLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="logout-btn" @click="handleLogout">
|
<button class="logout-btn" @click="handleLogout">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
@@ -116,7 +123,7 @@
|
|||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
<polyline points="12 6 12 12 16 14"/>
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
v0.5.0
|
v{{ appVersion }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -199,6 +206,7 @@ import { useMobile } from '../composables/useMobile'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const appVersion = ref('0.0.0')
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
|
|
||||||
@@ -210,14 +218,32 @@ const updateTime = () => {
|
|||||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
}
|
}
|
||||||
let timer = null
|
let timer = null
|
||||||
|
|
||||||
|
// 键盘快捷键
|
||||||
|
const shortcuts = { '1': '/dashboard', '2': '/devices', '3': '/charts', '4': '/olt', '5': '/inventory' }
|
||||||
|
const onKeydown = (e) => {
|
||||||
|
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); document.querySelector('.search-input input')?.focus() }
|
||||||
|
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchVersion = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/health')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.version) appVersion.value = data.version
|
||||||
|
} catch { /* 静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
updateTime()
|
updateTime()
|
||||||
timer = setInterval(updateTime, 1000)
|
timer = setInterval(updateTime, 1000)
|
||||||
|
document.addEventListener('keydown', onKeydown)
|
||||||
if (authStore.token && !authStore.user) {
|
if (authStore.token && !authStore.user) {
|
||||||
await authStore.fetchProfile()
|
await authStore.fetchProfile()
|
||||||
}
|
}
|
||||||
|
fetchVersion()
|
||||||
})
|
})
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
||||||
|
|
||||||
const allNavItems = [
|
const allNavItems = [
|
||||||
{
|
{
|
||||||
@@ -342,6 +368,9 @@ const pageNameMap = {
|
|||||||
}
|
}
|
||||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||||
|
|
||||||
|
const roleLabels = { admin: '超级管理员', area_admin: '区域管理员', school_admin: '学校管理员', user: '普通用户' }
|
||||||
|
const roleLabel = computed(() => roleLabels[authStore.role] || authStore.role)
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -501,6 +530,49 @@ const handleLogout = () => {
|
|||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-info {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||||||
|
<div class="import-hint">请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。</div>
|
||||||
|
<div class="import-actions">
|
||||||
|
<el-button size="small" @click="$emit('download-template')">下载导入模板</el-button>
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:show-file-list="true"
|
||||||
|
:limit="1"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
:on-change="(f) => $emit('file-change', f)"
|
||||||
|
:on-remove="() => $emit('file-remove')"
|
||||||
|
drag
|
||||||
|
style="margin-top: 16px"
|
||||||
|
>
|
||||||
|
<div class="upload-area">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<div style="font-size:13px;color:var(--text-secondary)">拖拽文件到此处,或 <em style="color:var(--accent)">点击选择</em></div>
|
||||||
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">支持 .xlsx / .xls 格式</div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<div v-if="result" style="margin-top:16px">
|
||||||
|
<el-alert
|
||||||
|
:type="result.failed?.length ? 'warning' : 'success'"
|
||||||
|
:title="`导入完成:成功 ${result.success} 条${result.failed?.length ? ',失败 ' + result.failed.length + ' 条' : ''}`"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<div v-if="result.failed?.length" style="margin-top:10px;max-height:160px;overflow-y:auto">
|
||||||
|
<div v-for="f in result.failed" :key="f.row" style="font-size:12px;color:var(--danger);padding:2px 0">
|
||||||
|
第 {{ f.row }} 行<template v-if="f.mac">({{ f.mac }})</template>:{{ f.reason }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="$emit('update:visible', false); $emit('close')">关闭</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" :disabled="!hasFile" @click="$emit('import')">开始导入</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
hasFile: { type: Boolean, default: false },
|
||||||
|
result: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
defineEmits(['update:visible', 'download-template', 'file-change', 'file-remove', 'import', 'close'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-hint { font-size:13px; color:var(--text-muted); margin-bottom:12px; }
|
||||||
|
.import-actions { margin-bottom:4px; }
|
||||||
|
.upload-area { display:flex; flex-direction:column; align-items:center; padding:20px 0; }
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,34 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { isAfterSunset, getMsUntilNextSwitch } from '../utils/sunset'
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
const STORAGE_KEY = 'onu-theme'
|
const STORAGE_KEY = 'onu-theme'
|
||||||
const theme = ref(localStorage.getItem(STORAGE_KEY) || 'dark')
|
const savedTheme = localStorage.getItem(STORAGE_KEY)
|
||||||
|
// 无手动偏好时,根据广西日落时间自动选择
|
||||||
|
const theme = ref(savedTheme || (isAfterSunset() ? 'dark' : 'light'))
|
||||||
|
|
||||||
const applyTheme = (t) => {
|
const applyTheme = (t) => {
|
||||||
document.documentElement.setAttribute('data-theme', t)
|
document.documentElement.setAttribute('data-theme', t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let switchTimer = null
|
||||||
|
|
||||||
// 初始化时立即应用
|
// 初始化时立即应用
|
||||||
applyTheme(theme.value)
|
applyTheme(theme.value)
|
||||||
|
|
||||||
|
// 设置日落/日出自动切换定时器,仅在用户未手动选择时生效
|
||||||
|
const scheduleAutoSwitch = () => {
|
||||||
|
if (switchTimer) clearTimeout(switchTimer)
|
||||||
|
// 始终在日落/日出时自动切换
|
||||||
|
const delay = getMsUntilNextSwitch()
|
||||||
|
switchTimer = setTimeout(() => {
|
||||||
|
theme.value = isAfterSunset() ? 'dark' : 'light'
|
||||||
|
scheduleAutoSwitch() // 递归调度下一次
|
||||||
|
}, delay + 60000) // 加 1 分钟余量
|
||||||
|
}
|
||||||
|
scheduleAutoSwitch()
|
||||||
|
|
||||||
// 切换
|
// 切换
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 广西(南宁)日落时间计算
|
||||||
|
* 纬度 22.82°N, 经度 108.37°E, 时区 UTC+8
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LAT = 22.82 // 南宁纬度
|
||||||
|
const LON = 108.37 // 南宁经度
|
||||||
|
|
||||||
|
function toRad(deg) { return deg * Math.PI / 180 }
|
||||||
|
function toDeg(rad) { return rad * 180 / Math.PI }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算指定日期的日落时间(北京时间)
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {{ hour: number, minute: number }} 日落时分
|
||||||
|
*/
|
||||||
|
export function getSunsetTime(date = new Date()) {
|
||||||
|
const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000)
|
||||||
|
|
||||||
|
// 太阳赤纬 (solar declination)
|
||||||
|
const declination = 23.45 * Math.sin(toRad(360 / 365 * (284 + dayOfYear)))
|
||||||
|
|
||||||
|
// 日落时角 cos(ω) = -tan(lat)*tan(δ)
|
||||||
|
const cosOmega = -Math.tan(toRad(LAT)) * Math.tan(toRad(declination))
|
||||||
|
const omega = Math.acos(Math.max(-1, Math.min(1, cosOmega))) // 弧度
|
||||||
|
|
||||||
|
// 日落地方太阳时(小时)
|
||||||
|
const solarHour = 12 + toDeg(omega) / 15
|
||||||
|
|
||||||
|
// 修正:时区经度(120°E)与本地经度差
|
||||||
|
const correction = (120 - LON) / 15 * 60 // 分钟
|
||||||
|
const totalMinutes = solarHour * 60 + correction
|
||||||
|
|
||||||
|
const hour = Math.floor(totalMinutes / 60) % 24
|
||||||
|
const minute = Math.round(totalMinutes % 60)
|
||||||
|
|
||||||
|
return { hour, minute }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否在日落之后(应使用深色模式)
|
||||||
|
*/
|
||||||
|
export function isAfterSunset() {
|
||||||
|
const now = new Date()
|
||||||
|
const sunset = getSunsetTime(now)
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const sunsetMinutes = sunset.hour * 60 + sunset.minute
|
||||||
|
// 日出约为 12 - (sunset - 12) = 24 - sunset(粗略估算)
|
||||||
|
const sunriseMinutes = (24 * 60 - sunsetMinutes) % (24 * 60)
|
||||||
|
// 深色时间:日落之后 到 日出之前
|
||||||
|
return currentMinutes >= sunsetMinutes || currentMinutes < sunriseMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取距离下次切换的毫秒数
|
||||||
|
* 用于设置定时器在日落/日出时自动切换
|
||||||
|
*/
|
||||||
|
export function getMsUntilNextSwitch() {
|
||||||
|
const now = new Date()
|
||||||
|
const sunset = getSunsetTime(now)
|
||||||
|
const sunsetMin = sunset.hour * 60 + sunset.minute
|
||||||
|
const sunriseMin = (24 * 60 - sunsetMin) % (24 * 60)
|
||||||
|
const currentMin = now.getHours() * 60 + now.getMinutes()
|
||||||
|
|
||||||
|
let targetMin
|
||||||
|
if (currentMin >= sunsetMin || currentMin < sunriseMin) {
|
||||||
|
// 当前是深色时间,下次切换是日出
|
||||||
|
targetMin = sunriseMin
|
||||||
|
} else {
|
||||||
|
// 当前是浅色时间,下次切换是日落
|
||||||
|
targetMin = sunsetMin
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMin = (targetMin - currentMin + 24 * 60) % (24 * 60)
|
||||||
|
return diffMin * 60 * 1000
|
||||||
|
}
|
||||||
@@ -19,15 +19,34 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { marked } from 'marked'
|
import { marked, Renderer } from 'marked'
|
||||||
import request from '../utils/request'
|
import request from '../utils/request'
|
||||||
|
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const safeLink = (href) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(href, window.location.origin)
|
||||||
|
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRenderer = new Renderer()
|
||||||
|
safeRenderer.html = () => ''
|
||||||
|
safeRenderer.image = ({ text }) => text
|
||||||
|
safeRenderer.link = function ({ href, tokens }) {
|
||||||
|
const text = this.parser.parseInline(tokens)
|
||||||
|
const safeHref = safeLink(href)
|
||||||
|
if (!safeHref) return text
|
||||||
|
return `<a href="${safeHref}" rel="noopener noreferrer" target="_blank">${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!content.value) return ''
|
if (!content.value) return ''
|
||||||
return marked.parse(content.value)
|
return marked.parse(content.value, { renderer: safeRenderer })
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -42,7 +61,7 @@ onMounted(async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.about-page {
|
.about-page {
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
max-width: 860px;
|
max-width: 1100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:page-sizes="[20, 50, 100]"
|
:page-sizes="[20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
small
|
size="small"
|
||||||
@change="fetchLogs"
|
@change="fetchLogs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
<div v-else-if="error">
|
<div v-else-if="error">
|
||||||
<p>登录失败: {{ error }}</p>
|
<p>登录失败: {{ error }}</p>
|
||||||
<el-button @click="$router.push('/login')">返回登录</el-button>
|
<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>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,8 +24,6 @@ const router = useRouter()
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const errorDetails = ref('')
|
|
||||||
const showDetails = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const code = new URLSearchParams(window.location.search).get('code')
|
const code = new URLSearchParams(window.location.search).get('code')
|
||||||
@@ -44,8 +40,7 @@ onMounted(async () => {
|
|||||||
authStore.setToken(data.access_token)
|
authStore.setToken(data.access_token)
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || '登录失败'
|
error.value = err.response?.data?.detail || '登录失败,请稍后重试'
|
||||||
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -58,16 +53,4 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 100vh;
|
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>
|
</style>
|
||||||
|
|||||||
+156
-61
@@ -31,17 +31,59 @@
|
|||||||
</div>
|
</div>
|
||||||
<div ref="regionChart" class="chart-area"></div>
|
<div ref="regionChart" class="chart-area"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- OLT 设备在线率 -->
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||||
|
<rect x="2" y="2" width="20" height="8" rx="1"/><rect x="2" y="14" width="20" height="8" rx="1"/>
|
||||||
|
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
OLT 设备在线率
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref="oltChart" class="chart-area" style="height:360px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设备型号分布 -->
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
设备型号分布
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref="modelChart" class="chart-area" style="height:360px"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { getTrend, getByRegion } from '../api/stats'
|
import { getTrend, getByRegion, getOltStats, getModelDistribution } from '../api/stats'
|
||||||
|
|
||||||
const trendChart = ref(null)
|
const trendChart = ref(null)
|
||||||
const regionChart = ref(null)
|
const regionChart = ref(null)
|
||||||
|
const oltChart = ref(null)
|
||||||
|
const modelChart = ref(null)
|
||||||
|
const chartInstances = []
|
||||||
|
|
||||||
|
const _initChart = (dom) => {
|
||||||
|
if (!dom) return null
|
||||||
|
const existing = echarts.getInstanceByDom(dom)
|
||||||
|
if (existing) existing.dispose()
|
||||||
|
const chart = echarts.init(dom)
|
||||||
|
chartInstances.push(chart)
|
||||||
|
return chart
|
||||||
|
}
|
||||||
|
|
||||||
|
const _resizeCharts = () => chartInstances.forEach(c => { try { c.resize() } catch {} })
|
||||||
|
|
||||||
const chartTheme = {
|
const chartTheme = {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -59,70 +101,60 @@ const chartTheme = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initTrendChart = async () => {
|
const initTrendChart = async () => {
|
||||||
const { data } = await getTrend(7)
|
try {
|
||||||
const chart = echarts.init(trendChart.value)
|
const { data } = await getTrend(7)
|
||||||
chart.setOption({
|
if (!data || !data.length) return
|
||||||
...chartTheme,
|
const chart = _initChart(trendChart.value)
|
||||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis' },
|
if (!chart) return
|
||||||
legend: {
|
chart.setOption({
|
||||||
data: ['在线', '离线'],
|
backgroundColor: 'transparent',
|
||||||
textStyle: { color: '#8a9ab8' },
|
textStyle: { color: '#8a9ab8', fontFamily: 'Noto Sans SC, sans-serif', fontSize: 12 },
|
||||||
top: 4,
|
grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true },
|
||||||
},
|
tooltip: {
|
||||||
xAxis: {
|
trigger: 'axis',
|
||||||
type: 'category',
|
backgroundColor: '#141c30',
|
||||||
data: data.map(d => d.date),
|
borderColor: 'rgba(255,255,255,0.10)',
|
||||||
axisLine: chartTheme.axisLine,
|
textStyle: { color: '#e8edf5' },
|
||||||
axisTick: chartTheme.axisTick,
|
extraCssText: 'border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'
|
||||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value',
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisTick: { show: false },
|
|
||||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
|
||||||
splitLine: chartTheme.splitLine,
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '在线',
|
|
||||||
type: 'line',
|
|
||||||
data: data.map(d => d.online),
|
|
||||||
smooth: true,
|
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 5,
|
|
||||||
lineStyle: { color: '#00d2b4', width: 2 },
|
|
||||||
itemStyle: { color: '#00d2b4' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' },
|
|
||||||
{ offset: 1, color: 'rgba(0,210,180,0.00)' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
legend: { data: ['在线', '离线'], textStyle: { color: '#8a9ab8' }, top: 4 },
|
||||||
name: '离线',
|
xAxis: {
|
||||||
type: 'line',
|
type: 'category', data: data.map(d => d.date),
|
||||||
data: data.map(d => d.offline),
|
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||||
smooth: true,
|
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||||
symbol: 'circle',
|
|
||||||
symbolSize: 5,
|
|
||||||
lineStyle: { color: '#ef4444', width: 2 },
|
|
||||||
itemStyle: { color: '#ef4444' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' },
|
|
||||||
{ offset: 1, color: 'rgba(239,68,68,0.00)' },
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
yAxis: {
|
||||||
})
|
type: 'value',
|
||||||
|
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '在线', type: 'line', data: data.map(d => d.online), smooth: true,
|
||||||
|
symbol: 'circle', symbolSize: 5,
|
||||||
|
lineStyle: { color: '#00d2b4', width: 2 }, itemStyle: { color: '#00d2b4' },
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(0,210,180,0.20)' }, { offset: 1, color: 'rgba(0,210,180,0.00)' }
|
||||||
|
])}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true,
|
||||||
|
symbol: 'circle', symbolSize: 5,
|
||||||
|
lineStyle: { color: '#ef4444', width: 2 }, itemStyle: { color: '#ef4444' },
|
||||||
|
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
|
{ offset: 0, color: 'rgba(239,68,68,0.15)' }, { offset: 1, color: 'rgba(239,68,68,0.00)' }
|
||||||
|
])}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Trend chart error:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const initRegionChart = async () => {
|
const initRegionChart = async () => {
|
||||||
const { data } = await getByRegion()
|
const { data } = await getByRegion()
|
||||||
const chart = echarts.init(regionChart.value)
|
const chart = _initChart(regionChart.value)
|
||||||
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
...chartTheme,
|
...chartTheme,
|
||||||
@@ -154,9 +186,72 @@ const initRegionChart = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const initOltChart = async () => {
|
||||||
|
const { data } = await getOltStats()
|
||||||
|
const chart = _initChart(oltChart.value)
|
||||||
|
const sorted = [...data].sort((a, b) => {
|
||||||
|
const ra = a.online / (a.total || 1), rb = b.online / (b.total || 1)
|
||||||
|
return ra - rb
|
||||||
|
})
|
||||||
|
chart.setOption({
|
||||||
|
...chartTheme,
|
||||||
|
tooltip: { ...chartTheme.tooltip, trigger: 'axis', axisPointer: { type: 'shadow' },
|
||||||
|
formatter: (ps) => {
|
||||||
|
const d = ps[0]
|
||||||
|
return `<b>${d.name}</b><br/>在线: ${d.data.online}/${d.data.total}<br/>在线率: ${(d.data.online/(d.data.total||1)*100).toFixed(1)}%<br/>离线: ${d.data.offline}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { left: '3%', right: '8%', bottom: '3%', top: '8%', containLabel: true },
|
||||||
|
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||||
|
yAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: sorted.map(d => d.name),
|
||||||
|
axisLabel: { fontSize: 11, width: 120, overflow: 'truncate' },
|
||||||
|
axisLine: { show: false }, axisTick: { show: false },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
data: sorted.map(d => ({
|
||||||
|
name: d.name, value: +(d.online / (d.total || 1) * 100).toFixed(1),
|
||||||
|
total: d.total, online: d.online, offline: d.offline,
|
||||||
|
itemStyle: { color: d.online === 0 ? '#ef4444' : +(d.online/(d.total||1)*100).toFixed(1) < 70 ? '#f59e0b' : '#00d2b4',
|
||||||
|
borderRadius: [0, 4, 4, 0] }
|
||||||
|
})),
|
||||||
|
barMaxWidth: 22,
|
||||||
|
label: { show: true, position: 'right', fontSize: 11, color: '#8a9ab8', formatter: '{c}%' },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initModelChart = async () => {
|
||||||
|
const { data } = await getModelDistribution()
|
||||||
|
if (!data.length) return
|
||||||
|
const chart = _initChart(modelChart.value)
|
||||||
|
const colors = ['#00d2b4','#3b82f6','#a855f7','#f59e0b','#22c55e','#ef4444','#ec4899','#6366f1','#14b8a6','#eab308']
|
||||||
|
chart.setOption({
|
||||||
|
...chartTheme,
|
||||||
|
tooltip: { ...chartTheme.tooltip, trigger: 'item', formatter: '{b}: {c} 台 ({d}%)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['42%','70%'], center: ['50%','50%'],
|
||||||
|
data: data.map((d, i) => ({ value: d.count, name: d.model, itemStyle: { color: colors[i % colors.length] } })),
|
||||||
|
label: { show: true, fontSize: 10, color: '#8a9ab8', formatter: '{b}\n{d}%' },
|
||||||
|
labelLine: { length: 16, length2: 12 },
|
||||||
|
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.3)' } },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
initTrendChart()
|
initTrendChart()
|
||||||
initRegionChart()
|
initRegionChart()
|
||||||
|
initOltChart()
|
||||||
|
initModelChart()
|
||||||
|
window.addEventListener('resize', _resizeCharts)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', _resizeCharts)
|
||||||
|
chartInstances.forEach(c => { try { c.dispose() } catch {} })
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,17 @@
|
|||||||
<h1 class="page-title">统计概览</h1>
|
<h1 class="page-title">统计概览</h1>
|
||||||
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
<div class="header-actions">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||||
<polyline points="23 4 23 10 17 10"/>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
<polyline points="23 4 23 10 17 10"/>
|
||||||
</svg>
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
<span v-if="loading">刷新中…</span>
|
</svg>
|
||||||
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
<span v-if="loading">刷新中…</span>
|
||||||
<span v-else>刷新数据</span>
|
<span v-else-if="lastUpdated">{{ lastUpdated }}</span>
|
||||||
</button>
|
<span v-else>刷新数据</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 汇总卡片 -->
|
<!-- 汇总卡片 -->
|
||||||
@@ -170,6 +172,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全离线学校(可折叠) -->
|
||||||
|
<div v-if="offlineSchools.length > 0" class="offline-alert" :class="{ collapsed: offlineCollapsed }">
|
||||||
|
<div class="offline-alert-header" @click="offlineCollapsed = !offlineCollapsed">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span>全离线学校({{ offlineSchools.length }} 所)</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="collapse-arrow" :class="{ rotated: !offlineCollapsed }">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-show="!offlineCollapsed" class="offline-schools-list">
|
||||||
|
<span v-for="s in offlineSchools" :key="s.school_name" class="offline-school-tag" @click="goToSchool(s.school_name)">
|
||||||
|
{{ s.school_name }}({{ s.total }}台)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 乡镇详情对话框 -->
|
<!-- 乡镇详情对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="townVisible"
|
v-model="townVisible"
|
||||||
@@ -197,9 +218,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import request from '../utils/request'
|
import request from '../utils/request'
|
||||||
|
import { getOfflineSchools } from '../api/stats'
|
||||||
import { useMobile } from '../composables/useMobile'
|
import { useMobile } from '../composables/useMobile'
|
||||||
|
|
||||||
const { isMobile } = useMobile()
|
const { isMobile } = useMobile()
|
||||||
@@ -210,6 +232,8 @@ const data = ref({})
|
|||||||
const lastUpdated = ref('')
|
const lastUpdated = ref('')
|
||||||
const townVisible = ref(false)
|
const townVisible = ref(false)
|
||||||
const selectedTown = ref(null)
|
const selectedTown = ref(null)
|
||||||
|
const offlineSchools = ref([])
|
||||||
|
const offlineCollapsed = ref(true)
|
||||||
|
|
||||||
const rate = (item) => {
|
const rate = (item) => {
|
||||||
if (!item || !item.total) return 0
|
if (!item || !item.total) return 0
|
||||||
@@ -239,6 +263,13 @@ const goToSchool = (schoolName) => {
|
|||||||
router.push({ path: '/devices', query: { school_name: schoolName } })
|
router.push({ path: '/devices', query: { school_name: schoolName } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadOfflineSchools = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getOfflineSchools()
|
||||||
|
offlineSchools.value = data || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -248,9 +279,24 @@ const loadData = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
loadOfflineSchools()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadData)
|
let ws = null
|
||||||
|
const connectWs = () => {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}/api/ws/dashboard`)
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data)
|
||||||
|
if (msg.type === 'check_complete') loadData()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
ws.onclose = () => { setTimeout(connectWs, 10000) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadData(); connectWs() })
|
||||||
|
onUnmounted(() => { if (ws) ws.close() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -286,6 +332,34 @@ onMounted(loadData)
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -352,6 +426,64 @@ onMounted(loadData)
|
|||||||
box-shadow: var(--shadow-glow);
|
box-shadow: var(--shadow-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全离线学校告警 */
|
||||||
|
.offline-alert {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-alert-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-alert.collapsed .offline-alert-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow.rotated {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-schools-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-school-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-school-tag:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes card-in {
|
@keyframes card-in {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
||||||
数据导入
|
数据导入
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<a href="/api/devices/export/csv" class="csv-export-btn" title="导出CSV">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
导出CSV
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,6 +56,12 @@
|
|||||||
<el-option label="未知" value="unknown" />
|
<el-option label="未知" value="unknown" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="filter-group">
|
||||||
|
<label class="filter-label">标签</label>
|
||||||
|
<el-select v-model="filters.tag" placeholder="全部" clearable @change="search" size="small" style="width:130px">
|
||||||
|
<el-option v-for="t in availableTags" :key="t" :label="t" :value="t" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label class="filter-label">搜索</label>
|
<label class="filter-label">搜索</label>
|
||||||
<el-input
|
<el-input
|
||||||
@@ -428,6 +441,7 @@
|
|||||||
<el-input v-model="editForm.place_type" placeholder="如:宿舍、教室、办公室…" />
|
<el-input v-model="editForm.place_type" placeholder="如:宿舍、教室、办公室…" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||||
|
<el-form-item label="标签"><el-input v-model="editForm.tags" placeholder="多个标签用逗号分隔,如:重点设备,考试用" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
@@ -632,7 +646,8 @@ const total = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const regions = ref([])
|
const regions = ref([])
|
||||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
|
const filters = ref({ region: '', keyword: '', school_name: '', status: '', tag: '' })
|
||||||
|
const availableTags = ref([])
|
||||||
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const selectedDevice = ref({})
|
const selectedDevice = ref({})
|
||||||
@@ -647,7 +662,7 @@ let cooldownTimer = null
|
|||||||
const clearing = ref(false)
|
const clearing = ref(false)
|
||||||
|
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '' })
|
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '', tags: '' })
|
||||||
const regionOptions = ref([])
|
const regionOptions = ref([])
|
||||||
const editSaving = ref(false)
|
const editSaving = ref(false)
|
||||||
|
|
||||||
@@ -1003,6 +1018,7 @@ const openEdit = () => {
|
|||||||
room_number: selectedDevice.value.room_number || '',
|
room_number: selectedDevice.value.room_number || '',
|
||||||
place_type: selectedDevice.value.place_type || '',
|
place_type: selectedDevice.value.place_type || '',
|
||||||
notes: selectedDevice.value.notes || '',
|
notes: selectedDevice.value.notes || '',
|
||||||
|
tags: selectedDevice.value.tags || '',
|
||||||
}
|
}
|
||||||
loadRegionOptions()
|
loadRegionOptions()
|
||||||
detailVisible.value = false
|
detailVisible.value = false
|
||||||
@@ -1063,6 +1079,7 @@ const loadDevices = async () => {
|
|||||||
keyword: filters.value.keyword || undefined,
|
keyword: filters.value.keyword || undefined,
|
||||||
school_name: filters.value.school_name || undefined,
|
school_name: filters.value.school_name || undefined,
|
||||||
status: filters.value.status || undefined,
|
status: filters.value.status || undefined,
|
||||||
|
tag: filters.value.tag || undefined,
|
||||||
})
|
})
|
||||||
devices.value = data.items
|
devices.value = data.items
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
@@ -1079,12 +1096,20 @@ const handleSizeChange = (val) => {
|
|||||||
loadDevices()
|
loadDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await request.get('/devices/tags')
|
||||||
|
availableTags.value = data || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.school_name) {
|
if (route.query.school_name) {
|
||||||
filters.value.keyword = route.query.school_name
|
filters.value.keyword = route.query.school_name
|
||||||
}
|
}
|
||||||
loadRegions()
|
loadRegions()
|
||||||
loadDevices()
|
loadDevices()
|
||||||
|
fetchTags()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1123,6 +1148,26 @@ onMounted(() => {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-export-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.csv-export-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 筛选栏 */
|
/* 筛选栏 */
|
||||||
|
|||||||
@@ -1026,6 +1026,11 @@ const formatTime = (t) => fmtTimeRaw(t, { slice: 16 })
|
|||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.inventory-page { padding: 12px; }
|
||||||
|
.summary-cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|||||||
@@ -8,31 +8,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
|
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 5px">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="btn-icon">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
添加 OLT
|
添加 OLT
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-upload
|
<el-button v-if="!isMobile && can('olt.manage')" type="success" plain size="small" :loading="importing" @click="triggerImport">
|
||||||
v-if="!isMobile && can('olt.manage')"
|
批量导入
|
||||||
:auto-upload="false"
|
</el-button>
|
||||||
:show-file-list="false"
|
<el-button v-if="!isMobile && can('olt.manage')" plain size="small" @click="downloadTemplate">下载模板</el-button>
|
||||||
:on-change="handleImportFile"
|
<el-button v-if="can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
|
||||||
accept=".xlsx,.xls"
|
重复 MAC<span v-if="duplicateCount > 0" class="btn-count danger">{{ duplicateCount }}</span>
|
||||||
style="display: inline-block"
|
|
||||||
>
|
|
||||||
<el-button type="success" size="small" :loading="importing">批量导入</el-button>
|
|
||||||
</el-upload>
|
|
||||||
<el-button v-if="!isMobile && can('olt.manage')" size="small" @click="downloadTemplate">下载模板</el-button>
|
|
||||||
<el-button v-if="!isMobile && can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
|
|
||||||
重复 MAC
|
|
||||||
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
|
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
|
||||||
新增设备
|
新增设备<span v-if="newDeviceCount > 0" class="btn-count warning">{{ newDeviceCount }}</span>
|
||||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
|
<el-button v-if="!isMobile && can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
|
||||||
同步NTP
|
同步NTP
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
||||||
@@ -41,6 +32,15 @@
|
|||||||
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
|
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
|
||||||
快速扫描
|
快速扫描
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<!-- 隐藏的上传组件(批量导入触发) -->
|
||||||
|
<input
|
||||||
|
v-if="!isMobile && can('olt.manage')"
|
||||||
|
ref="importInput"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
style="display:none"
|
||||||
|
@change="handleImportFile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -604,6 +604,7 @@ const newDevList = ref([])
|
|||||||
const newDeviceCount = ref(0)
|
const newDeviceCount = ref(0)
|
||||||
const newDevVisible = ref(false)
|
const newDevVisible = ref(false)
|
||||||
const savingDev = ref(null)
|
const savingDev = ref(null)
|
||||||
|
const importInput = ref(null)
|
||||||
const loopDetecting = ref(false)
|
const loopDetecting = ref(false)
|
||||||
const loopVisible = ref(false)
|
const loopVisible = ref(false)
|
||||||
const loopResults = ref([])
|
const loopResults = ref([])
|
||||||
@@ -938,7 +939,7 @@ const dismissNewDevice = async (id) => {
|
|||||||
const runQuickScan = async () => {
|
const runQuickScan = async () => {
|
||||||
quickScanning.value = true
|
quickScanning.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/olt/quick-scan')
|
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
|
||||||
quickScanResult.value = data
|
quickScanResult.value = data
|
||||||
quickScanVisible.value = true
|
quickScanVisible.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -951,7 +952,7 @@ const runQuickScan = async () => {
|
|||||||
const runLoopbackDetection = async () => {
|
const runLoopbackDetection = async () => {
|
||||||
loopDetecting.value = true
|
loopDetecting.value = true
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/olt/loopback-detection')
|
const { data } = await request.post('/olt/loopback-detection', null, { timeout: 120000 })
|
||||||
loopResults.value = data
|
loopResults.value = data
|
||||||
const map = {}
|
const map = {}
|
||||||
for (const item of data) {
|
for (const item of data) {
|
||||||
@@ -973,10 +974,16 @@ const showLoopDetail = (oltId) => {
|
|||||||
loopDetailVisible.value = true
|
loopDetailVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportFile = async (uploadFile) => {
|
const triggerImport = () => {
|
||||||
|
importInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImportFile = async (event) => {
|
||||||
|
const file = event.target?.files?.[0] || (event.raw || event)
|
||||||
|
if (!file) return
|
||||||
importing.value = true
|
importing.value = true
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadFile.raw)
|
formData.append('file', file)
|
||||||
try {
|
try {
|
||||||
const { data } = await request.post('/olt/import', formData, {
|
const { data } = await request.post('/olt/import', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
@@ -1077,8 +1084,35 @@ onMounted(() => {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 4px;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-count.danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-count.warning { background: var(--warning); color: #fff; }
|
||||||
|
|
||||||
|
.header-actions .el-button {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 数据面板 */
|
/* 数据面板 */
|
||||||
@@ -1526,19 +1560,17 @@ onMounted(() => {
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions .el-button,
|
|
||||||
.header-actions > * {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 移动端按钮区只显示2列(移动端只剩新增设备、环路检测、快速扫描) */
|
|
||||||
.header-actions {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions .el-button {
|
||||||
|
width: 95%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 端口管理对话框:限制高度支持滚动 */
|
/* 端口管理对话框:限制高度支持滚动 */
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
:total="repTotal"
|
:total="repTotal"
|
||||||
:page-sizes="[50, 100, 200]"
|
:page-sizes="[50, 100, 200]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
small
|
size="small"
|
||||||
@size-change="repLoad"
|
@size-change="repLoad"
|
||||||
@current-change="repLoad"
|
@current-change="repLoad"
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
:total="auditTotal"
|
:total="auditTotal"
|
||||||
:page-sizes="[20, 50, 100]"
|
:page-sizes="[20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
small
|
size="small"
|
||||||
@change="fetchAudit"
|
@change="fetchAudit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap { padding: 24px; max-width: 1100px; }
|
.page-wrap { padding: 24px; }
|
||||||
.page-header { margin-bottom: 20px; }
|
.page-header { margin-bottom: 20px; }
|
||||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||||
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||||
|
|||||||
@@ -99,6 +99,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 企业微信告警设置 -->
|
||||||
|
<div class="settings-card" style="margin-top: 20px">
|
||||||
|
<div class="card-header">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 8px">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
企业微信告警
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="setting-desc">当某个学校所有设备全部离线时,通过企业微信应用消息 API 发送告警。请填写企业微信自建应用的凭证信息。</p>
|
||||||
|
<div class="wechat-field">
|
||||||
|
<label class="wechat-label">CorpID</label>
|
||||||
|
<input v-model="wechatCorpId" class="webhook-input" placeholder="企业ID" :disabled="wechatSaving" />
|
||||||
|
</div>
|
||||||
|
<div class="wechat-field">
|
||||||
|
<label class="wechat-label">CorpSecret</label>
|
||||||
|
<input v-model="wechatCorpSecret" type="password" class="webhook-input" placeholder="应用 Secret" :disabled="wechatSaving" />
|
||||||
|
</div>
|
||||||
|
<div class="wechat-field">
|
||||||
|
<label class="wechat-label">AgentID</label>
|
||||||
|
<input v-model="wechatAgentId" class="webhook-input" placeholder="应用 AgentID" :disabled="wechatSaving" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<button class="save-btn" :disabled="wechatSaving" @click="saveWebhook">
|
||||||
|
<svg v-if="wechatSaving" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||||
|
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||||
|
</svg>
|
||||||
|
{{ wechatSaving ? '保存中…' : '保存设置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -179,6 +214,7 @@ const save = async () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load()
|
load()
|
||||||
loadAbout()
|
loadAbout()
|
||||||
|
loadWebhook()
|
||||||
const timer = setInterval(load, 10000)
|
const timer = setInterval(load, 10000)
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => clearInterval(timer))
|
||||||
})
|
})
|
||||||
@@ -204,12 +240,43 @@ const saveAbout = async () => {
|
|||||||
aboutSaving.value = false
|
aboutSaving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wechatCorpId = ref('')
|
||||||
|
const wechatCorpSecret = ref('')
|
||||||
|
const wechatAgentId = ref('')
|
||||||
|
const wechatSaving = ref(false)
|
||||||
|
|
||||||
|
const loadWebhook = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getSettings()
|
||||||
|
const setVal = (key, ref) => { const s = data?.[key]; if (s) ref.value = s.value || '' }
|
||||||
|
setVal('wechat_corpid', wechatCorpId)
|
||||||
|
setVal('wechat_corpsecret', wechatCorpSecret)
|
||||||
|
setVal('wechat_agentid', wechatAgentId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveWebhook = async () => {
|
||||||
|
wechatSaving.value = true
|
||||||
|
try {
|
||||||
|
await request.put('/settings/webhook', {
|
||||||
|
corpid: wechatCorpId.value,
|
||||||
|
corpsecret: wechatCorpSecret.value,
|
||||||
|
agentid: wechatAgentId.value,
|
||||||
|
})
|
||||||
|
ElMessage.success('企业微信配置已保存')
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||||
|
} finally {
|
||||||
|
wechatSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 640px;
|
max-width: 960px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -427,6 +494,54 @@ const saveAbout = async () => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webhook-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-label {
|
||||||
|
width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th>邮箱</th>
|
<th>姓名</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>区域/学校</th>
|
<th>区域/学校</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-for="u in users" :key="u.id">
|
<tr v-for="u in users" :key="u.id">
|
||||||
<td class="td-username">{{ u.username }}</td>
|
<td class="td-username">{{ u.username }}</td>
|
||||||
<td class="td-muted">{{ u.email || '—' }}</td>
|
<td class="td-muted">{{ u.display_name || u.username || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title">编辑用户:{{ editUser.username }}</span>
|
<span class="modal-title">编辑用户:{{ editUser.display_name || editUser.username }}</span>
|
||||||
<button class="modal-close" @click="editUser = null">✕</button>
|
<button class="modal-close" @click="editUser = null">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -330,7 +330,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-wrap { padding: 24px; max-width: 1200px; }
|
.page-wrap { padding: 24px; }
|
||||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||||
.header-filters { display: flex; gap: 10px; }
|
.header-filters { display: flex; gap: 10px; }
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export default defineConfig({
|
|||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/ws': {
|
||||||
|
target: (process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001').replace('http', 'ws'),
|
||||||
|
ws: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,726 +0,0 @@
|
|||||||
# 为 H3ConuMS2 添加 ONU 远程重启 & 光功率查询功能 — 后端开发指南
|
|
||||||
|
|
||||||
> 目标:将 H3C iMC 平台的 ONU 远程重启和光功率查询功能集成到 H3ConuMS2 项目中
|
|
||||||
> 技术栈:FastAPI + SQLAlchemy + requests (HTTP Digest Auth)
|
|
||||||
> 源项目参考:`/home/v6ole/pyproject/H3ConuMS`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 整体架构
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────┐ REST API 调用 ┌──────────────────┐
|
|
||||||
│ H3ConuMS2 后端 │ ──────────────────────► │ H3C iMC 平台 │
|
|
||||||
│ (FastAPI) │ ◄────────────────────── │ │
|
|
||||||
│ IMCService │ │ /imcrs/epon/... │
|
|
||||||
└──────────────────┘ └──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**后端提供的 API 端点:**
|
|
||||||
| 端点 | 方法 | 说明 | iMC 后端接口 |
|
|
||||||
|------|------|------|-------------|
|
|
||||||
| `/api/devices/{id}/reboot` | POST | 远程重启 ONU | `/imcrs/epon/onu/reboot?mac={mac}` (POST) |
|
|
||||||
| `/api/devices/{id}/optical-power` | GET | 获取 ONU 光功率 | `/imcrs/epon/onu/onuLightWaneInfo?mac={mac}` (GET) |
|
|
||||||
|
|
||||||
**数据流(以重启为例):**
|
|
||||||
1. 客户端 POST `/api/devices/{id}/reboot`
|
|
||||||
2. 后端控制器验证设备存在 + 权限 → 调用 `IMCService.reboot_onu()`
|
|
||||||
3. `IMCService` 构建 HTTP Digest 认证头 → POST 到 iMC REST API
|
|
||||||
4. iMC 返回结果 → 后端解析错误码 → 返回 JSON
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Digest 认证原理解析
|
|
||||||
|
|
||||||
iMC 的 REST API 使用 **HTTP Digest Access Authentication**(RFC 2617),不是普通的 Cookie/Session 登录。
|
|
||||||
|
|
||||||
### 认证流程
|
|
||||||
|
|
||||||
```
|
|
||||||
客户端 iMC 服务器
|
|
||||||
│ │
|
|
||||||
│──── GET /imcrs/... (无认证) ────│
|
|
||||||
│ │──── 401 + WWW-Authenticate header
|
|
||||||
│ │ (包含 nonce, realm, qop)
|
|
||||||
│ │
|
|
||||||
│ ── 解析 WWW-Authenticate ──► │
|
|
||||||
│ 提取 nonce 和 realm │
|
|
||||||
│ │
|
|
||||||
│ ── 计算 Digest 响应 ────────► │
|
|
||||||
│ HA1 = MD5(user:realm:pass) │
|
|
||||||
│ HA2 = MD5(method:uri) │
|
|
||||||
│ response = MD5(HA1:nonce:nc:cnonce:qop:HA2) │
|
|
||||||
│ │
|
|
||||||
│──── POST /imcrs/... ──────────►│
|
|
||||||
│ Authorization: Digest ... │
|
|
||||||
│ │──── 200 OK (成功)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 核心 MD5 计算
|
|
||||||
|
|
||||||
```python
|
|
||||||
cnonce = md5(str(time.time())).hexdigest()[:16]
|
|
||||||
ha1 = md5(f"{username}:{realm}:{password}").hexdigest()
|
|
||||||
ha2 = md5(f"{method}:{uri}").hexdigest()
|
|
||||||
response = md5(f"{ha1}:{nonce}:{nc:08d}:{cnonce}:auth:{ha2}").hexdigest()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 需要修改/新增的文件清单
|
|
||||||
|
|
||||||
| 文件 | 操作 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `backend/app/services/imc_service.py` | **新增** | iMC API 服务(Digest 认证 + 重启 + 光功率) |
|
|
||||||
| `backend/app/services/__init__.py` | 修改 | 导出 IMCService |
|
|
||||||
| `backend/app/schemas/device.py` | 修改 | 添加重启/光功率响应 Schema |
|
|
||||||
| `backend/app/api/v1/devices.py` | 修改 | 添加重启和光功率 API 路由 |
|
|
||||||
| `backend/app/core/config.py` | 修改 | 添加 iMC 配置项 |
|
|
||||||
| `.env` 或 `backend/.env` | 修改 | 添加 iMC 环境变量 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 后端实现
|
|
||||||
|
|
||||||
### 4.1 配置项 — `backend/app/core/config.py`
|
|
||||||
|
|
||||||
在 `Settings` 类中添加 iMC 相关配置:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ===== iMC API 配置(用于 ONU 远程重启和光功率查询)=====
|
|
||||||
IMC_API_URL: str = "" # 例如 https://172.16.1.252:8443
|
|
||||||
IMC_API_USERNAME: str = "" # iMC 用户名
|
|
||||||
IMC_API_PASSWORD: str = "" # iMC 密码(明文,Digest认证需要原始密码)
|
|
||||||
IMC_API_VERIFY_SSL: bool = False # 是否验证 SSL 证书
|
|
||||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
|
||||||
IMC_READ_TIMEOUT: float = 20.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 .env 配置
|
|
||||||
|
|
||||||
在 `backend/.env`(或项目根目录 `.env`)中添加:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
|
||||||
IMC_API_URL=https://172.16.1.252:8443
|
|
||||||
IMC_API_USERNAME=admin
|
|
||||||
IMC_API_PASSWORD=Pwd@12345
|
|
||||||
IMC_API_VERIFY_SSL=false
|
|
||||||
IMC_CONNECT_TIMEOUT=5
|
|
||||||
IMC_READ_TIMEOUT=20
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 IMCService — `backend/app/services/imc_service.py`
|
|
||||||
|
|
||||||
完整代码,包含 Digest 认证 + 重启 ONU + 光功率查询三大功能:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
iMC REST API 服务
|
|
||||||
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
|
||||||
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
|
||||||
- 功能:ONU 远程重启、光功率查询
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import logging
|
|
||||||
import requests
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# iMC 重启错误码映射
|
|
||||||
REBOOT_ERROR_CODES = {
|
|
||||||
'103': 'ONU不存在',
|
|
||||||
'119': 'SNMP连接超时',
|
|
||||||
'120': '业务割接失败',
|
|
||||||
'121': 'ONU未运行',
|
|
||||||
'122': '重启失败',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class IMCService:
|
|
||||||
"""iMC REST API 服务封装"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
|
||||||
self.username = settings.IMC_API_USERNAME
|
|
||||||
self.password = settings.IMC_API_PASSWORD
|
|
||||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
|
||||||
self.session = requests.Session()
|
|
||||||
self.realm = "iMC RESTful Web Services"
|
|
||||||
self.connect_timeout = getattr(settings, 'IMC_CONNECT_TIMEOUT', 5)
|
|
||||||
self.read_timeout = getattr(settings, 'IMC_READ_TIMEOUT', 20)
|
|
||||||
# Digest 认证状态(每次重新初始化时清空,让首次请求自动获取 nonce)
|
|
||||||
self.nonce = None
|
|
||||||
self.nc = 1
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
# 内部:Digest 认证
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
|
||||||
"""
|
|
||||||
构建 HTTP Digest 认证头
|
|
||||||
|
|
||||||
首次调用时会自动发一个请求获取 nonce(服务器返回 401 + WWW-Authenticate),
|
|
||||||
后续复用 nonce 并递增 nc 值。
|
|
||||||
nonce 过期时调用方捕获 401 后清空 self.nonce,下次自动重新握手。
|
|
||||||
"""
|
|
||||||
if not self.nonce:
|
|
||||||
try:
|
|
||||||
resp = self.session.get(
|
|
||||||
f"{self.base_url}{uri}",
|
|
||||||
verify=self.verify_ssl,
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
timeout=(self.connect_timeout, self.read_timeout),
|
|
||||||
)
|
|
||||||
if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers:
|
|
||||||
auth_header = resp.headers['WWW-Authenticate']
|
|
||||||
auth_parts = {}
|
|
||||||
for part in auth_header.split(','):
|
|
||||||
if '=' in part:
|
|
||||||
key, value = part.split('=', 1)
|
|
||||||
auth_parts[key.strip()] = value.strip(' "')
|
|
||||||
self.nonce = auth_parts.get('nonce', '')
|
|
||||||
self.realm = auth_parts.get('realm', self.realm)
|
|
||||||
logger.info(f"获取 nonce 成功: {self.nonce}")
|
|
||||||
else:
|
|
||||||
logger.error(f"获取 nonce 失败, 状态码: {resp.status_code}")
|
|
||||||
return None
|
|
||||||
except requests.Timeout:
|
|
||||||
logger.error("获取 nonce 超时")
|
|
||||||
raise TimeoutError("iMC 认证超时")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取 nonce 异常: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 计算 Digest 响应
|
|
||||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
|
||||||
ha1 = hashlib.md5(
|
|
||||||
f"{self.username}:{self.realm}:{self.password}".encode()
|
|
||||||
).hexdigest()
|
|
||||||
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
|
|
||||||
response_hash = hashlib.md5(
|
|
||||||
f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode()
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
auth_value = (
|
|
||||||
f'Digest username="{self.username}", '
|
|
||||||
f'realm="{self.realm}", '
|
|
||||||
f'nonce="{self.nonce}", '
|
|
||||||
f'uri="{uri}", '
|
|
||||||
f'response="{response_hash}", '
|
|
||||||
f'qop=auth, '
|
|
||||||
f'nc={self.nc:08d}, '
|
|
||||||
f'cnonce="{cnonce}"'
|
|
||||||
)
|
|
||||||
self.nc += 1
|
|
||||||
return auth_value
|
|
||||||
|
|
||||||
def _clear_auth(self):
|
|
||||||
"""清除认证状态(nonce 过期时调用)"""
|
|
||||||
self.nonce = None
|
|
||||||
self.nc = 1
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
# 公共:重启 ONU
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
def reboot_onu(self, mac: str) -> dict:
|
|
||||||
"""
|
|
||||||
远程重启 ONU 设备
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{"success": True, "message": "设备正在重启,请稍后..."}
|
|
||||||
或 {"success": False, "message": "重启失败: ..."}
|
|
||||||
"""
|
|
||||||
max_retries = 1
|
|
||||||
for retry in range(max_retries + 1):
|
|
||||||
try:
|
|
||||||
uri = f"/imcrs/epon/onu/reboot?mac={mac}"
|
|
||||||
auth = self._get_digest_auth_header("POST", uri)
|
|
||||||
if not auth:
|
|
||||||
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/xml",
|
|
||||||
"Content-Type": "application/xml",
|
|
||||||
"Content-Length": "0",
|
|
||||||
"Authorization": auth,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self.session.post(
|
|
||||||
f"{self.base_url}{uri}",
|
|
||||||
headers=headers,
|
|
||||||
verify=self.verify_ssl,
|
|
||||||
timeout=(self.connect_timeout, self.read_timeout),
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 200:
|
|
||||||
# 检查 XML 响应中是否有错误码
|
|
||||||
if "<errorCode>" in resp.text:
|
|
||||||
m = re.search(r"<errorCode>(\d+)</errorCode>", resp.text)
|
|
||||||
if m:
|
|
||||||
code = m.group(1)
|
|
||||||
msg = REBOOT_ERROR_CODES.get(
|
|
||||||
code, f"未知错误(代码: {code})"
|
|
||||||
)
|
|
||||||
return {"success": False, "message": f"重启失败: {msg}"}
|
|
||||||
return {"success": True, "message": "设备正在重启,请稍后..."}
|
|
||||||
|
|
||||||
elif resp.status_code == 401:
|
|
||||||
# nonce 过期,清空后重试
|
|
||||||
self._clear_auth()
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"message": f"重启请求失败(HTTP {resp.status_code})",
|
|
||||||
}
|
|
||||||
|
|
||||||
except TimeoutError:
|
|
||||||
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"重启异常: {e}")
|
|
||||||
if retry < max_retries:
|
|
||||||
time.sleep(3)
|
|
||||||
continue
|
|
||||||
return {"success": False, "message": f"重启异常: {e}"}
|
|
||||||
|
|
||||||
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
# 公共:获取光功率
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
def get_optical_power(self, mac: str) -> dict | None:
|
|
||||||
"""
|
|
||||||
获取 ONU 设备光功率信息
|
|
||||||
|
|
||||||
接口: /imcrs/epon/onu/onuLightWaneInfo?mac={mac}
|
|
||||||
响应 JSON 字段:powerIn(接收光功率), powerOut(发送光功率),
|
|
||||||
bindMac, devId, eponDevName, oltIfName, onuIfDesc
|
|
||||||
|
|
||||||
Args:
|
|
||||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {
|
|
||||||
"powerIn": "-18.5", # dBm,接收光功率
|
|
||||||
"powerOut": "2.3", # dBm,发送光功率
|
|
||||||
"bindMac": "...",
|
|
||||||
"devId": ...,
|
|
||||||
"eponDevName": "...",
|
|
||||||
"oltIfName": "...",
|
|
||||||
"onuIfDesc": "..."
|
|
||||||
}
|
|
||||||
或 None(失败时)
|
|
||||||
"""
|
|
||||||
max_retries = 1
|
|
||||||
for retry in range(max_retries + 1):
|
|
||||||
try:
|
|
||||||
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={mac}"
|
|
||||||
auth = self._get_digest_auth_header("GET", uri)
|
|
||||||
if not auth:
|
|
||||||
logger.error("生成认证头失败,无法获取光功率")
|
|
||||||
return None
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": auth,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f"获取光功率: {self.base_url}{uri}")
|
|
||||||
resp = self.session.get(
|
|
||||||
f"{self.base_url}{uri}",
|
|
||||||
headers=headers,
|
|
||||||
verify=self.verify_ssl,
|
|
||||||
timeout=(self.connect_timeout, self.read_timeout),
|
|
||||||
)
|
|
||||||
logger.info(f"光功率API响应状态码: {resp.status_code}")
|
|
||||||
|
|
||||||
if resp.status_code == 200:
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
logger.info(
|
|
||||||
f"光功率响应: {json.dumps(data, ensure_ascii=False)}"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"powerIn": data.get("powerIn"),
|
|
||||||
"powerOut": data.get("powerOut"),
|
|
||||||
"bindMac": data.get("bindMac"),
|
|
||||||
"devId": data.get("devId"),
|
|
||||||
"eponDevName": data.get("eponDevName"),
|
|
||||||
"oltIfName": data.get("oltIfName"),
|
|
||||||
"onuIfDesc": data.get("onuIfDesc"),
|
|
||||||
}
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}")
|
|
||||||
|
|
||||||
elif resp.status_code == 401:
|
|
||||||
self._clear_auth()
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
f"光功率API请求失败, 状态码: {resp.status_code}, "
|
|
||||||
f"内容: {resp.text}"
|
|
||||||
)
|
|
||||||
break # 非401不重试
|
|
||||||
|
|
||||||
except TimeoutError:
|
|
||||||
logger.error("获取光功率超时")
|
|
||||||
raise
|
|
||||||
except requests.Timeout:
|
|
||||||
logger.error("光功率接口请求超时")
|
|
||||||
raise TimeoutError("iMC 光功率接口请求超时,请稍后重试")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"获取光功率异常: {e}")
|
|
||||||
if retry < max_retries:
|
|
||||||
time.sleep(3)
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Schema — `backend/app/schemas/device.py`
|
|
||||||
|
|
||||||
添加重启和光功率的响应模型:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class RebootResponse(BaseModel):
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class OpticalPowerResponse(BaseModel):
|
|
||||||
power_in: Optional[str] = None # 接收光功率 (dBm)
|
|
||||||
power_out: Optional[str] = None # 发送光功率 (dBm)
|
|
||||||
bind_mac: Optional[str] = None
|
|
||||||
dev_id: Optional[int] = None
|
|
||||||
epon_dev_name: Optional[str] = None
|
|
||||||
olt_if_name: Optional[str] = None
|
|
||||||
onu_if_desc: Optional[str] = None
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.5 API 路由 — `backend/app/api/v1/devices.py`
|
|
||||||
|
|
||||||
在文件顶部导入新 Schema:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from app.schemas.device import (
|
|
||||||
DeviceListResponse,
|
|
||||||
ONUDeviceResponse,
|
|
||||||
RebootResponse,
|
|
||||||
OpticalPowerResponse,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
在文件末尾添加两个新端点:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
# 重启 ONU
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
@router.post("/{device_id}/reboot", response_model=RebootResponse)
|
|
||||||
def reboot_device(
|
|
||||||
device_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current: dict = Depends(require_permission('device.check')),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
远程重启 ONU 设备(通过 iMC REST API)
|
|
||||||
|
|
||||||
权限要求:device.check
|
|
||||||
区域/学校管理员只能操作自己范围内的设备。
|
|
||||||
"""
|
|
||||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail="设备不存在")
|
|
||||||
|
|
||||||
# 数据范围权限过滤
|
|
||||||
role = current.get('role', 'user')
|
|
||||||
if role == 'area_admin':
|
|
||||||
assigned = current.get('assigned_area') or ''
|
|
||||||
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
|
||||||
if device.region not in areas:
|
|
||||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
|
||||||
elif role == 'school_admin':
|
|
||||||
assigned = current.get('assigned_school') or ''
|
|
||||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
|
||||||
if device.school_name not in schools:
|
|
||||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services.imc_service import IMCService
|
|
||||||
service = IMCService()
|
|
||||||
mac = device.mac_address
|
|
||||||
result = service.reboot_onu(mac)
|
|
||||||
return RebootResponse(**result)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
# 获取光功率
|
|
||||||
# ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
|
||||||
def get_device_optical_power(
|
|
||||||
device_id: int,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current: dict = Depends(require_permission('device.view')),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
获取 ONU 设备光功率信息(通过 iMC REST API)
|
|
||||||
|
|
||||||
返回接收光功率(power_in)和发送光功率(power_out),单位 dBm。
|
|
||||||
权限要求:device.view(只读操作)
|
|
||||||
"""
|
|
||||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail="设备不存在")
|
|
||||||
|
|
||||||
# 数据范围权限过滤(同上)
|
|
||||||
role = current.get('role', 'user')
|
|
||||||
if role == 'area_admin':
|
|
||||||
assigned = current.get('assigned_area') or ''
|
|
||||||
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
|
||||||
if device.region not in areas:
|
|
||||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
|
||||||
elif role == 'school_admin':
|
|
||||||
assigned = current.get('assigned_school') or ''
|
|
||||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
|
||||||
if device.school_name not in schools:
|
|
||||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.services.imc_service import IMCService
|
|
||||||
service = IMCService()
|
|
||||||
mac = device.mac_address
|
|
||||||
result = service.get_optical_power(mac)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
|
||||||
|
|
||||||
# 字段名转换:下划线转驼峰前先映射
|
|
||||||
from app.schemas.device import OpticalPowerResponse
|
|
||||||
return OpticalPowerResponse(
|
|
||||||
power_in=result.get("powerIn"),
|
|
||||||
power_out=result.get("powerOut"),
|
|
||||||
bind_mac=result.get("bindMac"),
|
|
||||||
dev_id=result.get("devId"),
|
|
||||||
epon_dev_name=result.get("eponDevName"),
|
|
||||||
olt_if_name=result.get("oltIfName"),
|
|
||||||
onu_if_desc=result.get("onuIfDesc"),
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.6 注册 Service — `backend/app/services/__init__.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from .imc_service import IMCService
|
|
||||||
|
|
||||||
__all__ = ["IMCService"]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 关键陷阱与注意事项
|
|
||||||
|
|
||||||
### ⚠️ Digest 认证的 nonce 过期问题
|
|
||||||
|
|
||||||
iMC 的 nonce 有有效期(通常 5-10 分钟)。过期后服务器返回 **401**。
|
|
||||||
- 代码中 `_clear_auth()` 清空 nonce,下次请求自动重新握手
|
|
||||||
- 重启和光功率方法都在 for 循环中捕获 401 并 `continue` 重试
|
|
||||||
|
|
||||||
### ⚠️ MAC 地址格式
|
|
||||||
|
|
||||||
iMC REST API 要求 MAC 地址格式为 **1484-7790-4840**(连字符分隔,大写十六进制)。
|
|
||||||
如果数据库存储格式不同,需要做格式转换:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def normalize_mac(mac: str) -> str:
|
|
||||||
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840"""
|
|
||||||
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
|
||||||
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ 重启接口需要 Content-Length: 0
|
|
||||||
|
|
||||||
即使请求体为空,也必须显式设置 `Content-Length: 0` 头,否则 iMC 会报错。
|
|
||||||
|
|
||||||
### ⚠️ 光功率接口返回空数据的情况
|
|
||||||
|
|
||||||
当 ONU 离线或光模块故障时,iMC 返回的 `powerIn` / `powerOut` 可能是 `" --"`(两个空格+两个横线)或 `None`。前端需做占位符处理。
|
|
||||||
|
|
||||||
### ⚠️ 重启操作较慢
|
|
||||||
|
|
||||||
从发起请求到设备实际重启完成约需 **30-60 秒**(取决于 SNMP 响应)。建议:
|
|
||||||
- 前端按钮显示 loading 状态
|
|
||||||
- 后端设置合理超时(connect=5s, read=20s)
|
|
||||||
- 不要在短时间内对同一设备重复操作
|
|
||||||
|
|
||||||
### ⚠️ 并发控制
|
|
||||||
|
|
||||||
建议对重启操作添加简单的并发控制,避免同一设备被多次重启:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import threading
|
|
||||||
|
|
||||||
_reboot_locks = {}
|
|
||||||
_reboot_lock = threading.Lock()
|
|
||||||
|
|
||||||
def reboot_onu(self, mac):
|
|
||||||
with _reboot_lock:
|
|
||||||
if mac not in _reboot_locks:
|
|
||||||
_reboot_locks[mac] = threading.Lock()
|
|
||||||
lock = _reboot_locks[mac]
|
|
||||||
|
|
||||||
if not lock.acquire(blocking=False):
|
|
||||||
return {"success": False, "message": "该设备正在重启中,请稍后"}
|
|
||||||
try:
|
|
||||||
# ... 重启逻辑 ...
|
|
||||||
finally:
|
|
||||||
lock.release()
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ Docker 部署注意
|
|
||||||
|
|
||||||
- 在 `docker-compose.yml` 的 `backend` 服务中新增环境变量:
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- IMC_API_URL=https://172.16.1.252:8443
|
|
||||||
- IMC_API_USERNAME=admin
|
|
||||||
- IMC_API_PASSWORD=Pwd@12345
|
|
||||||
- IMC_API_VERIFY_SSL=false
|
|
||||||
```
|
|
||||||
- `backend` 和 `celery-worker` 容器都需要这些变量
|
|
||||||
- 修改后必须重新构建镜像:
|
|
||||||
```bash
|
|
||||||
docker compose build --no-cache backend
|
|
||||||
docker compose rm -f backend && docker compose up -d backend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 测试验证
|
|
||||||
|
|
||||||
### 手动测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 重启设备
|
|
||||||
curl -X POST "http://localhost:8000/api/devices/1/reboot" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
|
|
||||||
# 2. 获取光功率
|
|
||||||
curl "http://localhost:8000/api/devices/1/optical-power" \
|
|
||||||
-H "Authorization: Bearer <token>"
|
|
||||||
|
|
||||||
# 3. 查看后端日志
|
|
||||||
docker compose logs backend | grep IMCService
|
|
||||||
|
|
||||||
# 4. 直接测试 iMC API(验证认证是否工作)
|
|
||||||
curl -k -v "https://172.16.1.252:8443/imcrs/epon/onu/onuLightWaneInfo?mac=1484-7790-4840"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试响应示例
|
|
||||||
|
|
||||||
**重启成功:**
|
|
||||||
```json
|
|
||||||
{"success": true, "message": "设备正在重启,请稍后..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
**重启失败(ONU不存在):**
|
|
||||||
```json
|
|
||||||
{"success": false, "message": "重启失败: ONU不存在"}
|
|
||||||
```
|
|
||||||
|
|
||||||
**光功率获取成功:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"power_in": "-18.5",
|
|
||||||
"power_out": "2.3",
|
|
||||||
"bind_mac": "1484-7790-4840",
|
|
||||||
"dev_id": 123,
|
|
||||||
"epon_dev_name": "OLT-1-1",
|
|
||||||
"olt_if_name": "1/0/2",
|
|
||||||
"onu_if_desc": "ONU-学校A"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**光功率获取失败(设备离线):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"power_in": null,
|
|
||||||
"power_out": null,
|
|
||||||
"bind_mac": null,
|
|
||||||
"dev_id": null,
|
|
||||||
"epon_dev_name": null,
|
|
||||||
"olt_if_name": null,
|
|
||||||
"onu_if_desc": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 完整调用时序图
|
|
||||||
|
|
||||||
```
|
|
||||||
客户端 后端 FastAPI iMC 平台
|
|
||||||
│ │ │
|
|
||||||
│ POST /api/devices/1/reboot │ │
|
|
||||||
│ ────────────────────────────► │ │
|
|
||||||
│ │ ── 查数据库:设备存在?──► │
|
|
||||||
│ │ ◄── 返回设备信息 ────────── │
|
|
||||||
│ │ ── 权限检查 ───────────── │
|
|
||||||
│ │ │
|
|
||||||
│ │ ── GET /imcrs/epon/onu/reboot │
|
|
||||||
│ │ (无认证,获取 nonce) │
|
|
||||||
│ │ ────────────────────────────► │
|
|
||||||
│ │ ◄── 401 + WWW-Authenticate ──│
|
|
||||||
│ │ nonce=xxx, realm=... │
|
|
||||||
│ │ │
|
|
||||||
│ │ ── POST 同 URI + Digest ────► │
|
|
||||||
│ │ Authorization: Digest ... │
|
|
||||||
│ │ ◄── 200 OK (XML) ────────────│
|
|
||||||
│ │ │
|
|
||||||
│ ◄── {success: true, │ │
|
|
||||||
│ message: "设备重启中"} │ │
|
|
||||||
│ │ │
|
|
||||||
│ ── 或 ── │ │
|
|
||||||
│ │ │
|
|
||||||
│ GET /api/devices/1/optical-power │
|
|
||||||
│ ────────────────────────────► │ │
|
|
||||||
│ │ ── 查 + 权限 (同上) ──── │
|
|
||||||
│ │ │
|
|
||||||
│ │ ── GET /imcrs/epon/onu/ │
|
|
||||||
│ │ onuLightWaneInfo?mac=... │
|
|
||||||
│ │ (+ Digest Auth) │
|
|
||||||
│ │ ────────────────────────────► │
|
|
||||||
│ │ ◄── 200 OK (JSON) ───────────│
|
|
||||||
│ │ {powerIn, powerOut, ...} │
|
|
||||||
│ │ │
|
|
||||||
│ ◄── {power_in: "-18.5", │ │
|
|
||||||
│ power_out: "2.3", ...} │ │
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 附录:源项目参考文件位置
|
|
||||||
|
|
||||||
| 内容 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| IMCService 完整实现 | `/home/v6ole/pyproject/H3ConuMS/app/services/imc_service.py` |
|
|
||||||
| 重启控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/device.py` (第1059行) |
|
|
||||||
| 优化版控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/optimized_device.py` (第157行) |
|
|
||||||
| iMC 配置项 | `/home/v6ole/pyproject/H3ConuMS/app/config.py` (第61-67行) |
|
|
||||||
Reference in New Issue
Block a user