Compare commits
20 Commits
bba319f8af
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b07ec6df0 | |||
| 8a8ae4ed57 | |||
| a5ac25a01d | |||
| 32b7a2cc6a | |||
| 6623169e8a | |||
| 0bab72ea98 | |||
| 3ee8846011 | |||
| fe7649ed6e | |||
| 6e5f16ecf2 | |||
| b2c20ec43d | |||
| 3453441754 | |||
| b5d542a725 | |||
| bd341df9de | |||
| 5aadbc78c6 | |||
| eaabebbceb | |||
| d222978ae4 | |||
| dcb2435514 | |||
| f1f8518985 | |||
| dfa8fa62a8 | |||
| 381ea7085d |
@@ -0,0 +1,73 @@
|
|||||||
|
# 代码规范
|
||||||
|
|
||||||
|
## Python 后端规范
|
||||||
|
|
||||||
|
- 遵循 PEP 8 规范
|
||||||
|
- 使用 Black 进行代码格式化
|
||||||
|
- 使用 isort 进行导入排序
|
||||||
|
- 使用类型注解(Type Hints)
|
||||||
|
- 异步函数使用 async/await
|
||||||
|
- 错误处理使用自定义异常类
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```python
|
||||||
|
from typing import List, Optional
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
async def get_devices(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100
|
||||||
|
) -> List[Device]:
|
||||||
|
"""获取设备列表"""
|
||||||
|
try:
|
||||||
|
devices = await device_service.get_all(skip, limit)
|
||||||
|
return devices
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vue 前端规范
|
||||||
|
|
||||||
|
- 使用 Composition API(setup script)
|
||||||
|
- 组件使用 PascalCase 命名
|
||||||
|
- 使用 TypeScript 类型检查
|
||||||
|
- 遵循 Vue 官方风格指南
|
||||||
|
- 使用 ESLint + Prettier 格式化
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import type { Device } from '@/types'
|
||||||
|
|
||||||
|
const devices = ref<Device[]>([])
|
||||||
|
|
||||||
|
const fetchDevices = async () => {
|
||||||
|
// 实现逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDevices()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
使用 Conventional Commits 格式:
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
类型:
|
||||||
|
- feat: 新功能
|
||||||
|
- fix: 修复bug
|
||||||
|
- docs: 文档更新
|
||||||
|
- style: 代码格式
|
||||||
|
- refactor: 重构
|
||||||
|
- test: 测试
|
||||||
|
- chore: 构建/工具
|
||||||
|
|
||||||
|
示例:
|
||||||
|
feat(device): 添加设备导入功能
|
||||||
|
fix(auth): 修复登录token过期问题
|
||||||
|
```
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 架构规则
|
||||||
|
|
||||||
|
## 后端架构
|
||||||
|
|
||||||
|
### 分层架构
|
||||||
|
- **API 层**:仅处理请求/响应,调用 Service
|
||||||
|
- **Service 层**:业务逻辑,不直接操作数据库
|
||||||
|
- **Model 层**:SQLAlchemy 模型定义
|
||||||
|
|
||||||
|
### 目录结构
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── api/v1/ # API 路由
|
||||||
|
├── services/ # 业务逻辑
|
||||||
|
├── models/ # 数据模型
|
||||||
|
├── schemas/ # Pydantic 模式
|
||||||
|
├── core/ # 核心配置
|
||||||
|
├── tasks/ # Celery 任务
|
||||||
|
└── utils/ # 工具函数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 异步任务
|
||||||
|
- 耗时操作使用 Celery
|
||||||
|
- SSH 状态检查使用异步任务
|
||||||
|
- 批量导入使用后台任务
|
||||||
|
|
||||||
|
## 前端架构
|
||||||
|
|
||||||
|
### 组件分类
|
||||||
|
- `components/common/`:通用组件(Layout、Table、Form)
|
||||||
|
- `components/business/`:业务组件(Device、User)
|
||||||
|
- `views/`:页面组件
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
- 使用 Pinia stores
|
||||||
|
- 按模块划分:auth、device、user、system
|
||||||
|
|
||||||
|
### API 调用
|
||||||
|
- 统一在 `api/` 目录封装
|
||||||
|
- 使用 Axios 拦截器处理认证和错误
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 安全规则
|
||||||
|
|
||||||
|
## 数据安全
|
||||||
|
|
||||||
|
- SSH 密码必须加密存储(使用 Fernet 加密)
|
||||||
|
- 敏感信息通过环境变量配置
|
||||||
|
- 数据库连接使用 SSL
|
||||||
|
- 定期备份数据(90天历史记录)
|
||||||
|
|
||||||
|
**密码加密示例**:
|
||||||
|
```python
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
def encrypt_password(password: str, key: bytes) -> str:
|
||||||
|
f = Fernet(key)
|
||||||
|
return f.encrypt(password.encode()).decode()
|
||||||
|
|
||||||
|
def decrypt_password(encrypted: str, key: bytes) -> str:
|
||||||
|
f = Fernet(key)
|
||||||
|
return f.decrypt(encrypted.encode()).decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应用安全
|
||||||
|
|
||||||
|
- 所有 API 端点需要认证(除登录接口)
|
||||||
|
- 实现 CSRF 保护
|
||||||
|
- 输入验证使用 Pydantic
|
||||||
|
- SQL 注入防护(使用 ORM)
|
||||||
|
- XSS 防护(前端转义)
|
||||||
|
- 实现速率限制
|
||||||
|
|
||||||
|
## 访问控制
|
||||||
|
|
||||||
|
- 基于 RBAC 的权限控制
|
||||||
|
- 数据级权限过滤(按区域/学校)
|
||||||
|
- 操作审计日志记录
|
||||||
|
- 设备信息变更需要审核
|
||||||
|
|
||||||
|
**权限级别**:
|
||||||
|
- 超级管理员:所有权限
|
||||||
|
- 管理员:管理所有设备和用户
|
||||||
|
- 区域管理员:管理指定区域的设备
|
||||||
|
- 学校管理员:管理指定学校的设备
|
||||||
|
- 普通用户:只读权限
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 性能规则
|
||||||
|
|
||||||
|
## 数据库优化
|
||||||
|
|
||||||
|
- 为常用查询字段添加索引(mac_address, region, school_name)
|
||||||
|
- 使用连接池(pool_size=20, max_overflow=40)
|
||||||
|
- 避免 N+1 查询问题
|
||||||
|
- 定期清理历史数据(保留90天)
|
||||||
|
- 使用 select_related/joinedload 预加载关联数据
|
||||||
|
|
||||||
|
**索引示例**:
|
||||||
|
```python
|
||||||
|
class ONUDevice(Base):
|
||||||
|
__tablename__ = "onu_devices"
|
||||||
|
|
||||||
|
mac_address = Column(String(17), index=True)
|
||||||
|
region = Column(String(100), index=True)
|
||||||
|
school_name = Column(String(200), index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 缓存策略
|
||||||
|
|
||||||
|
- Redis 缓存热点数据(设备状态)
|
||||||
|
- 缓存过期时间:30分钟
|
||||||
|
- 手动刷新有5分钟冷却限制
|
||||||
|
- 使用缓存键命名规范:`device:status:{device_id}`
|
||||||
|
|
||||||
|
## 异步处理
|
||||||
|
|
||||||
|
- SSH 状态检查使用 Celery 异步任务
|
||||||
|
- 批量导入使用后台任务
|
||||||
|
- 定时任务使用 Celery Beat(每30分钟)
|
||||||
|
- 分批处理大量设备(每批100个)
|
||||||
|
|
||||||
|
## 前端优化
|
||||||
|
|
||||||
|
- 组件懒加载
|
||||||
|
- 图片懒加载
|
||||||
|
- 虚拟滚动(大列表)
|
||||||
|
- 防抖和节流
|
||||||
|
- 资源压缩和 CDN
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 开发规则
|
||||||
|
|
||||||
|
## 环境配置
|
||||||
|
|
||||||
|
- 开发环境使用 `.env.development`
|
||||||
|
- 生产环境使用 `.env.production`
|
||||||
|
- 不提交 `.env` 文件到 Git
|
||||||
|
- 提供 `.env.example` 模板
|
||||||
|
|
||||||
|
**必需环境变量**:
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||||
|
REDIS_URL=redis://host:6379/0
|
||||||
|
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||||
|
CASDOOR_CLIENT_ID=your_client_id
|
||||||
|
CASDOOR_CLIENT_SECRET=your_client_secret
|
||||||
|
SECRET_KEY=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
- 核心业务逻辑需要单元测试
|
||||||
|
- API 端点需要集成测试
|
||||||
|
- 测试覆盖率目标:≥80%
|
||||||
|
- 使用 pytest(后端)和 vitest(前端)
|
||||||
|
|
||||||
|
**测试示例**:
|
||||||
|
```python
|
||||||
|
def test_device_status_check():
|
||||||
|
device = create_test_device()
|
||||||
|
result = check_device_status(device)
|
||||||
|
assert result.status in ["online", "offline"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档要求
|
||||||
|
|
||||||
|
- API 变更及时更新 Swagger 文档
|
||||||
|
- 复杂业务逻辑添加注释
|
||||||
|
- 重要配置添加说明
|
||||||
|
- 保持 README 和设计文档同步
|
||||||
|
|
||||||
|
## 开发流程
|
||||||
|
|
||||||
|
1. 创建功能分支:`git checkout -b feature/xxx`
|
||||||
|
2. 开发并提交代码
|
||||||
|
3. 运行测试:`pytest` / `npm run test`
|
||||||
|
4. 提交 PR 并等待审核
|
||||||
|
5. 合并到主分支
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 部署规则
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
- 使用 Docker Compose 编排
|
||||||
|
- 外部 PostgreSQL 和 Redis(不在容器内)
|
||||||
|
- 日志挂载到宿主机
|
||||||
|
- 使用 Nginx 反向代理
|
||||||
|
|
||||||
|
**服务列表**:
|
||||||
|
- backend: FastAPI 应用
|
||||||
|
- celery-worker: Celery 工作进程
|
||||||
|
- celery-beat: Celery 定时任务
|
||||||
|
- frontend: Vue 静态文件(Nginx)
|
||||||
|
- nginx: 反向代理
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
**后端必需配置**:
|
||||||
|
```bash
|
||||||
|
DATABASE_URL # PostgreSQL 连接
|
||||||
|
REDIS_URL # Redis 连接
|
||||||
|
CASDOOR_* # Casdoor 认证配置
|
||||||
|
SECRET_KEY # 应用密钥
|
||||||
|
SSH_TIMEOUT=30 # SSH 超时时间
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端必需配置**:
|
||||||
|
```bash
|
||||||
|
VITE_API_BASE_URL # 后端 API 地址
|
||||||
|
VITE_CASDOOR_* # Casdoor 前端配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 监控告警
|
||||||
|
|
||||||
|
- 健康检查端点:`GET /health`
|
||||||
|
- 性能指标端点:`GET /metrics`
|
||||||
|
- 日志级别:生产环境使用 INFO
|
||||||
|
- 日志轮转:10MB 每个文件,保留30天
|
||||||
|
|
||||||
|
## 部署流程
|
||||||
|
|
||||||
|
1. 配置环境变量(`.env` 文件)
|
||||||
|
2. 构建镜像:`docker-compose build`
|
||||||
|
3. 启动服务:`docker-compose up -d`
|
||||||
|
4. 运行数据库迁移:`docker-compose exec backend alembic upgrade head`
|
||||||
|
5. 检查服务状态:`docker-compose ps`
|
||||||
|
6. 查看日志:`docker-compose logs -f`
|
||||||
|
|
||||||
|
## 备份策略
|
||||||
|
|
||||||
|
- 数据库每日自动备份
|
||||||
|
- 备份保留30天
|
||||||
|
- 重要操作前手动备份
|
||||||
|
- 定期测试恢复流程
|
||||||
+37
-160
@@ -1,176 +1,53 @@
|
|||||||
# ---> Python
|
# Python
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.Python
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# UV
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
#uv.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
env/
|
||||||
env.bak/
|
*.egg-info/
|
||||||
venv.bak/
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
# Spyder project settings
|
# Node
|
||||||
.spyderproject
|
node_modules/
|
||||||
.spyproject
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# Rope project settings
|
# Environment
|
||||||
.ropeproject
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
# mkdocs documentation
|
# IDE
|
||||||
/site
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
# mypy
|
# Logs
|
||||||
.mypy_cache/
|
logs/
|
||||||
.dmypy.json
|
*.log
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
# Database
|
||||||
.pyre/
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
# pytype static type analyzer
|
# Keys
|
||||||
.pytype/
|
*.pem
|
||||||
|
|
||||||
# Cython debug symbols
|
# Temp
|
||||||
cython_debug/
|
/tmp/
|
||||||
|
*.tmp
|
||||||
|
# Added by code-review-graph
|
||||||
|
.code-review-graph/
|
||||||
|
|
||||||
# PyCharm
|
# Claude Code
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
.claude/
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
.mcp.json
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
CLAUDE.md
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
# Deployment docs (contain credentials)
|
||||||
.ruff_cache/
|
*部署交接文档.md
|
||||||
|
|
||||||
# PyPI configuration file
|
|
||||||
.pypirc
|
|
||||||
|
|
||||||
|
# Reasonix
|
||||||
|
.reasonix/
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
|
||||||
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
H3ConuMS-v2
|
|
||||||
Copyright (C) 2026 v6ole
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
|
|
||||||
@@ -1,2 +1,148 @@
|
|||||||
# H3ConuMS-v2
|
# H3C ONU 设备管理系统
|
||||||
|
|
||||||
|
基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控和管理大规模 ONU 设备的在线状态。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 设备监控与管理
|
||||||
|
- H3C OLT 设备 SSH 连接和状态查询
|
||||||
|
- ONU 设备在线状态监控(支持 4000+ 设备)
|
||||||
|
- 定时自动状态检查(可配置间隔,最小 5 分钟)
|
||||||
|
- 手动刷新功能(5 分钟冷却限制)
|
||||||
|
- 设备信息编辑(区域、学校、楼宇、场所类型、备注)
|
||||||
|
- 设备 MAC 地址更换,保留完整变更历史
|
||||||
|
- OLT 扫描新发现设备自动入库流程
|
||||||
|
|
||||||
|
### 数据管理
|
||||||
|
- Excel 批量导入设备信息
|
||||||
|
- 设备状态历史记录(保留 30 天)
|
||||||
|
- 每日状态快照(用于趋势图)
|
||||||
|
- 重复 MAC 地址检测
|
||||||
|
|
||||||
|
### 统计分析
|
||||||
|
- 实时在线率统计
|
||||||
|
- 7 天趋势折线图
|
||||||
|
- 区域分布饼图
|
||||||
|
|
||||||
|
### 权限管理
|
||||||
|
- Casdoor 统一认证(OIDC)
|
||||||
|
- 基于 RBAC 的多级权限控制(管理员 / 区域管理员 / 学校管理员 / 普通用户)
|
||||||
|
- 区域 / 学校级别的数据隔离
|
||||||
|
- 角色权限精细配置
|
||||||
|
|
||||||
|
### 运维功能
|
||||||
|
- 用户操作审计日志(保留 90 天)
|
||||||
|
- 业务下发(ONU 服务配置)
|
||||||
|
- 库存序列号管理
|
||||||
|
- 关于页面(支持 Markdown,管理员可编辑)
|
||||||
|
- 登录过期自动跳转
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 后端 | Python · FastAPI · SQLAlchemy · Celery · Redis · Paramiko · Pandas |
|
||||||
|
| 前端 | Vue 3 · Element Plus · ECharts · marked |
|
||||||
|
| 数据库 | PostgreSQL |
|
||||||
|
| 认证 | Casdoor(OIDC) |
|
||||||
|
| 部署 | Docker Compose · Nginx |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Docker 20.10+
|
||||||
|
- Docker Compose 2.0+
|
||||||
|
- 外部 PostgreSQL 数据库
|
||||||
|
- 外部 Redis 服务
|
||||||
|
- Casdoor 认证服务器
|
||||||
|
|
||||||
|
### 启动步骤
|
||||||
|
|
||||||
|
1. 克隆项目
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd H3ConuMS2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 配置环境变量
|
||||||
|
```bash
|
||||||
|
cp deploy/.env.example deploy/.env
|
||||||
|
# 编辑 deploy/.env,填写数据库、Redis、Casdoor 等配置
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动服务
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 运行数据库迁移
|
||||||
|
```bash
|
||||||
|
docker compose exec backend alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 访问系统
|
||||||
|
- 前端界面:http://localhost:8080
|
||||||
|
- API 文档:http://localhost:8000/docs
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
H3ConuMS2/
|
||||||
|
├── backend/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # API 路由
|
||||||
|
│ │ ├── core/ # 配置、数据库、Celery
|
||||||
|
│ │ ├── middleware/ # 权限、审计中间件
|
||||||
|
│ │ ├── models/ # 数据模型
|
||||||
|
│ │ ├── services/ # 业务逻辑
|
||||||
|
│ │ └── tasks/ # Celery 定时任务
|
||||||
|
│ ├── alembic/ # 数据库迁移
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 调用
|
||||||
|
│ │ ├── components/ # 公共组件
|
||||||
|
│ │ ├── router/ # 路由配置
|
||||||
|
│ │ ├── views/ # 页面组件
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── deploy/ # 部署配置(docker-compose、nginx、.env)
|
||||||
|
└── about.md # 系统介绍(关于页面内容)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 定时任务
|
||||||
|
|
||||||
|
| 任务 | 执行时间 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 设备状态检查 | 每 5 分钟触发,内部节流 | 轮询所有 OLT 采集 ONU 状态 |
|
||||||
|
| 每日快照聚合 | 每天凌晨 1:00 | 生成趋势图数据 |
|
||||||
|
| 审计日志清理 | 每天凌晨 2:00 | 删除 90 天前的审计记录 |
|
||||||
|
| 状态历史清理 | 每天凌晨 3:00 | 删除 30 天前的状态历史 |
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### Casdoor
|
||||||
|
1. 在 Casdoor 中创建应用
|
||||||
|
2. 配置回调地址:`http://your-domain/api/auth/callback`
|
||||||
|
3. 在 `.env` 中填写 Client ID、Client Secret 和 Certificate
|
||||||
|
|
||||||
|
### OLT 设备
|
||||||
|
通过系统界面添加 OLT 设备,配置 SSH 连接信息(IP、用户名、密码)和插槽命令。
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v0.8.0
|
||||||
|
- 新增审计日志功能
|
||||||
|
- 新增关于页面(Markdown 编辑,管理员可配置)
|
||||||
|
- 新增登录过期自动跳转提示
|
||||||
|
- 设备编辑支持场所类型字段,区域改为下拉快速填充
|
||||||
|
- MAC 地址统一格式为 `xxxx-xxxx-xxxx`(小写)
|
||||||
|
- 更换设备 MAC 时自动清理 OLT 新发现列表中的冲突记录
|
||||||
|
- 状态历史自动清理(保留 30 天)
|
||||||
|
|
||||||
|
### v0.7.x 及以前
|
||||||
|
- 基础设备监控、OLT 管理、权限系统、Excel 导入、库存管理等核心功能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**:生产环境部署前请修改默认密码和密钥,并配置适当的防火墙规则。
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 应用配置
|
||||||
|
APP_NAME=H3C-ONU-MS
|
||||||
|
DEBUG=false
|
||||||
|
SECRET_KEY=your-secret-key-change-this
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
|
# Casdoor配置
|
||||||
|
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||||
|
CASDOOR_CLIENT_ID=your_client_id
|
||||||
|
CASDOOR_CLIENT_SECRET=your_client_secret
|
||||||
|
CASDOOR_ORG_NAME=your_org
|
||||||
|
CASDOOR_APP_NAME=h3c-onu-ms
|
||||||
|
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||||
|
CASDOOR_REDIRECT_URL=
|
||||||
|
|
||||||
|
# SSH配置
|
||||||
|
SSH_TIMEOUT=30
|
||||||
|
|
||||||
|
# 任务配置
|
||||||
|
CHECK_INTERVAL=1800
|
||||||
|
MANUAL_COOLDOWN=300
|
||||||
|
|
||||||
|
# CORS & 前端
|
||||||
|
CORS_ORIGINS=http://localhost:5173,http://localhost:18002
|
||||||
|
FRONTEND_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# NTP 同步
|
||||||
|
NTP_OLD_SERVER=172.16.0.254
|
||||||
|
NTP_NEW_SERVER=172.16.1.252
|
||||||
|
|
||||||
|
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||||
|
IMC_API_URL=
|
||||||
|
IMC_API_USERNAME=
|
||||||
|
IMC_API_PASSWORD=
|
||||||
|
IMC_API_VERIFY_SSL=false
|
||||||
|
IMC_CONNECT_TIMEOUT=5
|
||||||
|
IMC_READ_TIMEOUT=20
|
||||||
|
|
||||||
|
# 企业微信告警配置
|
||||||
|
WECHAT_CORPID=
|
||||||
|
WECHAT_CORPSECRET=
|
||||||
|
WECHAT_AGENTID=
|
||||||
|
WECHAT_TOKEN=
|
||||||
|
WECHAT_ENCODING_AES_KEY=
|
||||||
|
WECHAT_USE_PROXY=True
|
||||||
|
WECHAT_PROXY_API_URL=
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 使用清华镜像源
|
||||||
|
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
|
||||||
|
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.10.0
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models import device
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""add location to olt_devices
|
||||||
|
|
||||||
|
Revision ID: 32c84173ef05
|
||||||
|
Revises: 5c24b07e0f9c
|
||||||
|
Create Date: 2026-04-02 10:52:19.883293
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '32c84173ef05'
|
||||||
|
down_revision: Union[str, None] = '5c24b07e0f9c'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.add_column('olt_devices', sa.Column('location', sa.String(length=200), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('olt_devices', 'location')
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
|
||||||
|
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""add port_id to onu_devices
|
||||||
|
|
||||||
|
Revision ID: 3e108afa9bba
|
||||||
|
Revises: add_onu_extended_fields
|
||||||
|
Create Date: 2026-04-02 20:30:23.475918
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '3e108afa9bba'
|
||||||
|
down_revision: Union[str, None] = 'add_onu_extended_fields'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.add_column('onu_devices', sa.Column('port_id', sa.String(length=20), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('onu_devices', 'port_id')
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
|
||||||
|
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""add place_type and notes fields
|
||||||
|
|
||||||
|
Revision ID: 5c24b07e0f9c
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-01 17:51:21.451465
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '5c24b07e0f9c'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('role_permissions')
|
||||||
|
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_table('permissions')
|
||||||
|
op.add_column('onu_devices', sa.Column('place_type', sa.String(length=50), nullable=True))
|
||||||
|
op.drop_column('onu_devices', 'location_type')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('onu_devices', sa.Column('location_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
|
||||||
|
op.drop_column('onu_devices', 'place_type')
|
||||||
|
op.create_table('role_permissions',
|
||||||
|
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('permission_id', sa.BIGINT(), autoincrement=False, nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], name=op.f('role_permissions_permission_id_fkey')),
|
||||||
|
sa.PrimaryKeyConstraint('role', 'permission_id', name=op.f('role_permissions_pkey'))
|
||||||
|
)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('casdoor_id', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('role', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('assigned_area', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('assigned_school', sa.VARCHAR(length=200), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('last_login', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), autoincrement=False, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')),
|
||||||
|
sa.UniqueConstraint('casdoor_id', name=op.f('users_casdoor_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||||
|
op.create_table('permissions',
|
||||||
|
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('module', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('permissions_pkey')),
|
||||||
|
sa.UniqueConstraint('code', name=op.f('permissions_code_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""rebuild users, permissions, role_permissions tables
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 3e108afa9bba
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, None] = '3e108afa9bba'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
insp = sa.inspect(conn)
|
||||||
|
existing = insp.get_table_names()
|
||||||
|
|
||||||
|
# 创建 permissions 表(如不存在)
|
||||||
|
if 'permissions' not in existing:
|
||||||
|
op.create_table(
|
||||||
|
'permissions',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('module', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建 users 表(如不存在)
|
||||||
|
if 'users' not in existing:
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('casdoor_id', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('role', sa.String(length=50), nullable=True, server_default='user'),
|
||||||
|
sa.Column('assigned_area', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('assigned_school', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=True, server_default='true'),
|
||||||
|
sa.Column('last_login', postgresql.TIMESTAMP(), nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('casdoor_id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_users_id', 'users', ['id'], unique=False)
|
||||||
|
|
||||||
|
# 创建 role_permissions 关联表(如不存在)
|
||||||
|
if 'role_permissions' not in existing:
|
||||||
|
op.create_table(
|
||||||
|
'role_permissions',
|
||||||
|
sa.Column('role', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('permission_id', sa.BigInteger(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id']),
|
||||||
|
sa.PrimaryKeyConstraint('role', 'permission_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 插入默认权限数据(跳过已存在的)
|
||||||
|
permissions_table = sa.table(
|
||||||
|
'permissions',
|
||||||
|
sa.column('name', sa.String),
|
||||||
|
sa.column('code', sa.String),
|
||||||
|
sa.column('module', sa.String),
|
||||||
|
sa.column('description', sa.Text),
|
||||||
|
)
|
||||||
|
op.bulk_insert(permissions_table, [
|
||||||
|
{'name': '查看设备', 'code': 'device.view', 'module': 'device', 'description': '查看设备列表和详情'},
|
||||||
|
{'name': '触发检查', 'code': 'device.check', 'module': 'device', 'description': '手动触发设备状态检查'},
|
||||||
|
{'name': '导入设备', 'code': 'device.import', 'module': 'device', 'description': '通过Excel导入设备数据'},
|
||||||
|
{'name': '编辑设备', 'code': 'device.edit', 'module': 'device', 'description': '编辑设备信息'},
|
||||||
|
{'name': '删除设备', 'code': 'device.delete', 'module': 'device', 'description': '删除设备记录'},
|
||||||
|
{'name': '查看OLT', 'code': 'olt.view', 'module': 'olt', 'description': '查看OLT设备列表'},
|
||||||
|
{'name': '管理OLT', 'code': 'olt.manage', 'module': 'olt', 'description': '添加、编辑、删除OLT设备'},
|
||||||
|
{'name': '查看用户', 'code': 'user.view', 'module': 'user', 'description': '查看用户列表'},
|
||||||
|
{'name': '管理用户', 'code': 'user.manage', 'module': 'user', 'description': '修改用户角色和权限'},
|
||||||
|
{'name': '系统管理', 'code': 'system.admin', 'module': 'system', 'description': '系统级管理操作'},
|
||||||
|
])
|
||||||
|
|
||||||
|
# 插入默认角色权限
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO role_permissions (role, permission_id)
|
||||||
|
SELECT 'area_admin', id FROM permissions WHERE code IN ('device.view', 'device.check', 'olt.view')
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO role_permissions (role, permission_id)
|
||||||
|
SELECT 'school_admin', id FROM permissions WHERE code IN ('device.view')
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO role_permissions (role, permission_id)
|
||||||
|
SELECT 'user', id FROM permissions WHERE code IN ('device.view')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('role_permissions')
|
||||||
|
op.drop_index('ix_users_id', table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_table('permissions')
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""添加距离、LOID、型号等扩展字段
|
||||||
|
|
||||||
|
Revision ID: add_onu_extended_fields
|
||||||
|
Revises: 32c84173ef05
|
||||||
|
Create Date: 2026-04-02
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = 'add_onu_extended_fields'
|
||||||
|
down_revision = '32c84173ef05'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ONUDevice 表新增字段
|
||||||
|
op.add_column('onu_devices', sa.Column('loid', sa.String(50), nullable=True))
|
||||||
|
op.add_column('onu_devices', sa.Column('model', sa.String(100), nullable=True))
|
||||||
|
op.add_column('onu_devices', sa.Column('distance_m', sa.Integer, nullable=True))
|
||||||
|
|
||||||
|
# DeviceStatusHistory 表新增字段
|
||||||
|
op.add_column('device_status_history', sa.Column('distance_m', sa.Integer, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('device_status_history', 'distance_m')
|
||||||
|
op.drop_column('onu_devices', 'distance_m')
|
||||||
|
op.drop_column('onu_devices', 'model')
|
||||||
|
op.drop_column('onu_devices', 'loid')
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add olt.port_manage, olt.discover, olt.loopback permissions
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'b2c3d4e5f6a7'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO permissions (name, code, module, description)
|
||||||
|
VALUES
|
||||||
|
('端口管理', 'olt.port_manage', 'olt', '查看和切换OLT端口状态'),
|
||||||
|
('扫描入库', 'olt.discover', 'olt', '扫描OLT并将新设备写入数据库'),
|
||||||
|
('环路检测', 'olt.loopback', 'olt', '对OLT执行环路检测')
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.execute("DELETE FROM permissions WHERE code IN ('olt.port_manage', 'olt.discover', 'olt.loopback')")
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""add inventory management tables and permissions
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = 'c3d4e5f6a7b8'
|
||||||
|
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 物料分类表
|
||||||
|
op.create_table(
|
||||||
|
'material_categories',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 物料主数据表
|
||||||
|
op.create_table(
|
||||||
|
'materials',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('category_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('model', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('specification', sa.Text(), nullable=True),
|
||||||
|
sa.Column('brand', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('unit', sa.String(length=20), server_default='个', nullable=True),
|
||||||
|
sa.Column('safe_quantity', sa.Integer(), server_default='0', nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['category_id'], ['material_categories.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_materials_id', 'materials', ['id'], unique=False)
|
||||||
|
|
||||||
|
# 库存批次表
|
||||||
|
op.create_table(
|
||||||
|
'inventory_batches',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('batch_no', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('available_quantity', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('supplier', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('purchase_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('purchase_price', sa.Numeric(10, 2), nullable=True),
|
||||||
|
sa.Column('expiry_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('location', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), server_default='in_stock', nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 序列号设备表
|
||||||
|
op.create_table(
|
||||||
|
'serial_devices',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('batch_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('serial_no', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('mac_address', sa.String(length=17), nullable=True),
|
||||||
|
sa.Column('asset_no', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), server_default='in_stock', nullable=True),
|
||||||
|
sa.Column('current_location', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('installed_info', postgresql.JSONB(), nullable=True),
|
||||||
|
sa.Column('onu_device_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
|
||||||
|
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||||
|
sa.ForeignKeyConstraint(['onu_device_id'], ['onu_devices.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('serial_no'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_serial_devices_mac_address', 'serial_devices', ['mac_address'], unique=False)
|
||||||
|
op.create_index('ix_serial_devices_status', 'serial_devices', ['status'], unique=False)
|
||||||
|
|
||||||
|
# 出入库记录表
|
||||||
|
op.create_table(
|
||||||
|
'inventory_transactions',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('transaction_no', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('transaction_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('batch_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('serial_device_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('from_status', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('to_status', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('operator_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('project_name', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('installation_info', postgresql.JSONB(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
|
||||||
|
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||||
|
sa.ForeignKeyConstraint(['operator_id'], ['users.id']),
|
||||||
|
sa.ForeignKeyConstraint(['serial_device_id'], ['serial_devices.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('transaction_no'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 盘点记录表
|
||||||
|
op.create_table(
|
||||||
|
'inventory_checks',
|
||||||
|
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('check_no', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('check_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('checker_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('material_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('batch_id', sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column('book_quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('actual_quantity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('difference', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('reason', sa.Text(), nullable=True),
|
||||||
|
sa.Column('adjusted', sa.Boolean(), server_default='false', nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('now()'), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['batch_id'], ['inventory_batches.id']),
|
||||||
|
sa.ForeignKeyConstraint(['checker_id'], ['users.id']),
|
||||||
|
sa.ForeignKeyConstraint(['material_id'], ['materials.id']),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('check_no'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 新增库存管理权限
|
||||||
|
permissions_table = sa.table(
|
||||||
|
'permissions',
|
||||||
|
sa.column('name', sa.String),
|
||||||
|
sa.column('code', sa.String),
|
||||||
|
sa.column('module', sa.String),
|
||||||
|
sa.column('description', sa.Text),
|
||||||
|
)
|
||||||
|
op.bulk_insert(permissions_table, [
|
||||||
|
{'name': '查看库存', 'code': 'inventory.view', 'module': 'inventory', 'description': '查看库存列表和统计'},
|
||||||
|
{'name': '管理物料', 'code': 'inventory.manage', 'module': 'inventory', 'description': '创建、编辑、删除物料'},
|
||||||
|
{'name': '出入库操作', 'code': 'inventory.transaction', 'module': 'inventory', 'description': '执行采购入库、领用出库、退库操作'},
|
||||||
|
{'name': '库存盘点', 'code': 'inventory.check', 'module': 'inventory', 'description': '创建盘点单并执行库存调整'},
|
||||||
|
{'name': '库存报表', 'code': 'inventory.report', 'module': 'inventory', 'description': '查看库存统计报表'},
|
||||||
|
])
|
||||||
|
|
||||||
|
# 默认给 area_admin 查看权限
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO role_permissions (role, permission_id)
|
||||||
|
SELECT 'area_admin', id FROM permissions WHERE code = 'inventory.view'
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 插入默认物料分类
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO material_categories (name, code, description) VALUES
|
||||||
|
('ONU设备', 'ONU', '光网络单元设备'),
|
||||||
|
('OLT设备', 'OLT', '光线路终端设备'),
|
||||||
|
('交换机', 'SWITCH', '网络交换机'),
|
||||||
|
('防火墙', 'FIREWALL', '网络防火墙设备'),
|
||||||
|
('上网行为管理', 'BEHAVIOR', '上网行为管理设备'),
|
||||||
|
('光模块及配件', 'ACCESSORY', '光模块、跳线等配件')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('inventory_checks')
|
||||||
|
op.drop_table('inventory_transactions')
|
||||||
|
op.drop_index('ix_serial_devices_status', table_name='serial_devices')
|
||||||
|
op.drop_index('ix_serial_devices_mac_address', table_name='serial_devices')
|
||||||
|
op.drop_table('serial_devices')
|
||||||
|
op.drop_table('inventory_batches')
|
||||||
|
op.drop_index('ix_materials_id', table_name='materials')
|
||||||
|
op.drop_table('materials')
|
||||||
|
op.drop_table('material_categories')
|
||||||
|
op.execute("""
|
||||||
|
DELETE FROM role_permissions WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE module = 'inventory'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("DELETE FROM permissions WHERE module = 'inventory'")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add region to olt_devices
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: c3d4e5f6a7b8
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'd4e5f6a7b8c9'
|
||||||
|
down_revision = 'c3d4e5f6a7b8'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('olt_devices', sa.Column('region', sa.String(100), nullable=True))
|
||||||
|
op.create_index('ix_olt_devices_region', 'olt_devices', ['region'])
|
||||||
|
# 默认将现有 OLT 设置为城区
|
||||||
|
op.execute("UPDATE olt_devices SET region = '城区' WHERE region IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('ix_olt_devices_region', 'olt_devices')
|
||||||
|
op.drop_column('olt_devices', 'region')
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add device_daily_snapshots table
|
||||||
|
|
||||||
|
Revision ID: e5f6a7b8c9d0
|
||||||
|
Revises: d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'e5f6a7b8c9d0'
|
||||||
|
down_revision = 'd4e5f6a7b8c9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'device_daily_snapshots',
|
||||||
|
sa.Column('id', sa.BigInteger(), primary_key=True, index=True),
|
||||||
|
sa.Column('snapshot_date', sa.String(10), nullable=False),
|
||||||
|
sa.Column('total', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('online', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('offline', sa.Integer(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('now()')),
|
||||||
|
)
|
||||||
|
op.create_index('ix_device_daily_snapshots_snapshot_date', 'device_daily_snapshots', ['snapshot_date'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('ix_device_daily_snapshots_snapshot_date', 'device_daily_snapshots')
|
||||||
|
op.drop_table('device_daily_snapshots')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add system_settings table
|
||||||
|
|
||||||
|
Revision ID: f6a7b8c9d0e1
|
||||||
|
Revises: e5f6a7b8c9d0
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'f6a7b8c9d0e1'
|
||||||
|
down_revision = 'e5f6a7b8c9d0'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'system_settings',
|
||||||
|
sa.Column('key', sa.String(100), primary_key=True),
|
||||||
|
sa.Column('value', sa.Text(), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('now()')),
|
||||||
|
)
|
||||||
|
# 插入默认值
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO system_settings (key, value, description)
|
||||||
|
VALUES ('check_interval_seconds', '1800', '定时检查间隔(秒),最小 300(5分钟)')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('system_settings')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add audit_logs table
|
||||||
|
|
||||||
|
Revision ID: g7h8i9j0k1l2
|
||||||
|
Revises: f6a7b8c9d0e1
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
revision = 'g7h8i9j0k1l2'
|
||||||
|
down_revision = 'f6a7b8c9d0e1'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'audit_logs',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.String(100), nullable=False),
|
||||||
|
sa.Column('username', sa.String(100), nullable=False),
|
||||||
|
sa.Column('user_role', sa.String(50)),
|
||||||
|
sa.Column('action_time', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('action_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('action_subtype', sa.String(50)),
|
||||||
|
sa.Column('ip_address', sa.String(45)),
|
||||||
|
sa.Column('user_agent', sa.Text()),
|
||||||
|
sa.Column('request_method', sa.String(10)),
|
||||||
|
sa.Column('request_path', sa.String(500)),
|
||||||
|
sa.Column('status', sa.String(20), nullable=False),
|
||||||
|
sa.Column('status_code', sa.Integer()),
|
||||||
|
sa.Column('resource_type', sa.String(50)),
|
||||||
|
sa.Column('resource_id', sa.String(100)),
|
||||||
|
sa.Column('resource_name', sa.String(200)),
|
||||||
|
sa.Column('description', sa.Text(), nullable=False),
|
||||||
|
sa.Column('request_params', JSONB()),
|
||||||
|
sa.Column('response_data', JSONB()),
|
||||||
|
sa.Column('error_message', sa.Text()),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
)
|
||||||
|
op.create_index('idx_audit_logs_action_time', 'audit_logs', ['action_time'])
|
||||||
|
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'])
|
||||||
|
op.create_index('idx_audit_logs_action_type', 'audit_logs', ['action_type'])
|
||||||
|
op.create_index('idx_audit_logs_resource_type', 'audit_logs', ['resource_type'])
|
||||||
|
op.create_index('idx_audit_logs_status', 'audit_logs', ['status'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_audit_logs_status', 'audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_resource_type', 'audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_action_type', 'audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_user_id', 'audit_logs')
|
||||||
|
op.drop_index('idx_audit_logs_action_time', 'audit_logs')
|
||||||
|
op.drop_table('audit_logs')
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""add device_replacements table
|
||||||
|
|
||||||
|
Revision ID: h8i9j0k1l2m3
|
||||||
|
Revises: g7h8i9j0k1l2
|
||||||
|
Create Date: 2026-04-05
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'h8i9j0k1l2m3'
|
||||||
|
down_revision = 'g7h8i9j0k1l2'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
'device_replacements',
|
||||||
|
sa.Column('id', sa.BigInteger(), primary_key=True),
|
||||||
|
sa.Column('onu_device_id', sa.BigInteger(), sa.ForeignKey('onu_devices.id'), nullable=False),
|
||||||
|
sa.Column('old_mac', sa.String(17), nullable=False),
|
||||||
|
sa.Column('new_mac', sa.String(17), nullable=False),
|
||||||
|
sa.Column('reason', sa.Text()),
|
||||||
|
sa.Column('operator_id', sa.String(100)),
|
||||||
|
sa.Column('operator_name', sa.String(100)),
|
||||||
|
sa.Column('replaced_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(), nullable=False, server_default=sa.text('now()')),
|
||||||
|
)
|
||||||
|
op.create_index('idx_device_replacements_onu_device_id', 'device_replacements', ['onu_device_id'])
|
||||||
|
op.create_index('idx_device_replacements_old_mac', 'device_replacements', ['old_mac'])
|
||||||
|
op.create_index('idx_device_replacements_new_mac', 'device_replacements', ['new_mac'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index('idx_device_replacements_new_mac', 'device_replacements')
|
||||||
|
op.drop_index('idx_device_replacements_old_mac', 'device_replacements')
|
||||||
|
op.drop_index('idx_device_replacements_onu_device_id', 'device_replacements')
|
||||||
|
op.drop_table('device_replacements')
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""审计日志 API"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.services.audit_service import query_logs
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/audit", tags=["审计日志"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
def get_audit_logs(
|
||||||
|
start_time: Optional[datetime] = Query(None),
|
||||||
|
end_time: Optional[datetime] = Query(None),
|
||||||
|
user_id: Optional[str] = Query(None),
|
||||||
|
username: Optional[str] = Query(None),
|
||||||
|
action_type: Optional[str] = Query(None),
|
||||||
|
resource_type: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""查询审计日志(仅管理员)"""
|
||||||
|
total, items = query_logs(
|
||||||
|
db,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
action_type=action_type,
|
||||||
|
resource_type=resource_type,
|
||||||
|
status=status,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_fmt(r) for r in items],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/export/csv")
|
||||||
|
def export_audit_logs(
|
||||||
|
start_time: Optional[datetime] = Query(None),
|
||||||
|
end_time: Optional[datetime] = Query(None),
|
||||||
|
action_type: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""导出审计日志为 CSV"""
|
||||||
|
_, items = query_logs(
|
||||||
|
db,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
action_type=action_type,
|
||||||
|
status=status,
|
||||||
|
page=1,
|
||||||
|
page_size=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(["时间", "用户", "角色", "操作类型", "子类型", "路径", "状态码", "状态", "IP", "描述"])
|
||||||
|
for r in items:
|
||||||
|
t_cst = (r.action_time + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S") if r.action_time else ""
|
||||||
|
writer.writerow([
|
||||||
|
t_cst,
|
||||||
|
r.username, r.user_role, r.action_type, r.action_subtype or "",
|
||||||
|
f"{r.request_method} {r.request_path}", r.status_code, r.status,
|
||||||
|
r.ip_address or "", r.description,
|
||||||
|
])
|
||||||
|
yield buf.getvalue().encode("utf-8-sig")
|
||||||
|
|
||||||
|
filename = f"audit_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/{log_id}")
|
||||||
|
def get_audit_log_detail(
|
||||||
|
log_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""获取单条审计日志详情"""
|
||||||
|
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
||||||
|
if not log:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="日志不存在")
|
||||||
|
return _fmt(log, detail=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_audit_stats(
|
||||||
|
days: int = Query(7, ge=1, le=90),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""审计日志统计(最近N天)"""
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import func
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
rows = (
|
||||||
|
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
||||||
|
.filter(AuditLog.action_time >= since)
|
||||||
|
.group_by(AuditLog.action_type, AuditLog.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
||||||
|
by_type = {}
|
||||||
|
for row in rows:
|
||||||
|
if row.action_type not in by_type:
|
||||||
|
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
||||||
|
by_type[row.action_type][row.status] = row.cnt
|
||||||
|
return {"total": total, "days": days, "by_type": by_type}
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||||
|
base = {
|
||||||
|
"id": r.id,
|
||||||
|
"action_time": (r.action_time.isoformat() + "Z") if r.action_time else None,
|
||||||
|
"user_id": r.user_id,
|
||||||
|
"username": r.username,
|
||||||
|
"user_role": r.user_role,
|
||||||
|
"action_type": r.action_type,
|
||||||
|
"action_subtype": r.action_subtype,
|
||||||
|
"request_method": r.request_method,
|
||||||
|
"request_path": r.request_path,
|
||||||
|
"status": r.status,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"resource_type": r.resource_type,
|
||||||
|
"resource_id": r.resource_id,
|
||||||
|
"resource_name": r.resource_name,
|
||||||
|
"description": r.description,
|
||||||
|
"ip_address": r.ip_address,
|
||||||
|
}
|
||||||
|
if detail:
|
||||||
|
base["request_params"] = r.request_params
|
||||||
|
base["response_data"] = r.response_data
|
||||||
|
base["error_message"] = r.error_message
|
||||||
|
base["user_agent"] = r.user_agent
|
||||||
|
return base
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""认证 API"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.casdoor import casdoor_sdk
|
||||||
|
from app.core.security import create_access_token, verify_token
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.auth import Token, UserInfo
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
|
def decode_jwt_payload(token: str) -> dict:
|
||||||
|
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
|
||||||
|
payload_b64 = token.split(".")[1]
|
||||||
|
rem = len(payload_b64) % 4
|
||||||
|
if rem:
|
||||||
|
payload_b64 += "=" * (4 - rem)
|
||||||
|
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
def login():
|
||||||
|
"""获取 Casdoor 登录 URL"""
|
||||||
|
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/callback", response_model=Token)
|
||||||
|
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Casdoor 登录回调"""
|
||||||
|
try:
|
||||||
|
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
||||||
|
if isinstance(token_response, dict) and "error" in token_response:
|
||||||
|
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
|
||||||
|
|
||||||
|
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
|
||||||
|
|
||||||
|
casdoor_user = decode_jwt_payload(access_token)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
casdoor_id=casdoor_user["sub"],
|
||||||
|
username=casdoor_user.get("preferred_username") or casdoor_user.get("name", ""),
|
||||||
|
display_name=casdoor_user.get("displayName") or casdoor_user.get("name", ""),
|
||||||
|
email=casdoor_user.get("email"),
|
||||||
|
role="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()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
jwt_token = create_access_token({
|
||||||
|
"sub": str(user.id),
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role or "user",
|
||||||
|
})
|
||||||
|
return {"access_token": jwt_token}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
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")
|
||||||
|
def get_my_permissions(
|
||||||
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取当前用户的权限码列表"""
|
||||||
|
from app.middleware.permission_middleware import get_role_permissions
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
|
token = authorization[7:]
|
||||||
|
payload = verify_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="无效的令牌")
|
||||||
|
role = payload.get('role', 'user')
|
||||||
|
perms = get_role_permissions(role, db)
|
||||||
|
return {"role": role, "permissions": perms}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile")
|
||||||
|
def get_profile(
|
||||||
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取当前用户信息"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
|
token = authorization[7:]
|
||||||
|
payload = verify_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="无效的令牌")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == int(payload["sub"])).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""状态检查 API"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
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.core.celery_app import celery_app
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.check_service import CheckService
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckResult(BaseModel):
|
||||||
|
olt_id: int
|
||||||
|
olt_name: str
|
||||||
|
online: Optional[int] = 0
|
||||||
|
offline: Optional[int] = 0
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CheckError(BaseModel):
|
||||||
|
olt_id: int
|
||||||
|
olt_name: str
|
||||||
|
error: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/status")
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
def trigger_check(request: Request, _: dict = Depends(require_permission('device.check'))):
|
||||||
|
"""手动触发状态检查"""
|
||||||
|
try:
|
||||||
|
task = check_all_devices.delay()
|
||||||
|
return {"task_id": task.id, "status": "started"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"触发状态检查失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{task_id}")
|
||||||
|
def get_check_status(
|
||||||
|
task_id: str,
|
||||||
|
_: dict = Depends(require_permission('device.check')),
|
||||||
|
):
|
||||||
|
"""查询状态检查任务进度和结果"""
|
||||||
|
task_result = AsyncResult(task_id, app=celery_app)
|
||||||
|
state = task_result.state
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": state,
|
||||||
|
"progress": None,
|
||||||
|
"result": None
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == 'PROGRESS':
|
||||||
|
result["progress"] = task_result.info
|
||||||
|
|
||||||
|
if state == 'SUCCESS':
|
||||||
|
result["result"] = task_result.result
|
||||||
|
elif state == 'FAILURE':
|
||||||
|
result["error"] = str(task_result.info)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan/{olt_id}")
|
||||||
|
def scan_olt(
|
||||||
|
olt_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.check')),
|
||||||
|
):
|
||||||
|
"""扫描单台 OLT,预览发现的设备(不写入数据库)"""
|
||||||
|
try:
|
||||||
|
service = CheckService(db)
|
||||||
|
result = asyncio.run(service.scan_olt(olt_id))
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discover/{olt_id}")
|
||||||
|
def discover_olt(
|
||||||
|
olt_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.discover')),
|
||||||
|
):
|
||||||
|
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
|
||||||
|
try:
|
||||||
|
service = CheckService(db)
|
||||||
|
result = service.scan_and_discover(olt_id)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -0,0 +1,752 @@
|
|||||||
|
"""设备管理 API"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from sqlalchemy import asc, desc, distinct, or_
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
|
||||||
|
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/devices", tags=["设备管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DeviceListResponse)
|
||||||
|
def get_devices(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
region: str = None,
|
||||||
|
school_name: str = None,
|
||||||
|
keyword: str = None,
|
||||||
|
status: str = None,
|
||||||
|
tag: str = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取设备列表"""
|
||||||
|
# 子查询:每台设备最新一条状态记录
|
||||||
|
from sqlalchemy import func
|
||||||
|
latest_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||||
|
)
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
latest_history = (
|
||||||
|
db.query(DeviceStatusHistory)
|
||||||
|
.join(
|
||||||
|
latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = db.query(ONUDevice)
|
||||||
|
|
||||||
|
# 数据范围过滤:区域管理员只能看自己分配的区域,学校管理员只能看自己分配的学校
|
||||||
|
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 areas:
|
||||||
|
query = query.filter(ONUDevice.region.in_(areas))
|
||||||
|
else:
|
||||||
|
# 未分配区域则看不到任何设备
|
||||||
|
query = query.filter(False)
|
||||||
|
elif role == 'school_admin':
|
||||||
|
assigned = current.get('assigned_school') or ''
|
||||||
|
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||||
|
if schools:
|
||||||
|
query = query.filter(ONUDevice.school_name.in_(schools))
|
||||||
|
else:
|
||||||
|
query = query.filter(False)
|
||||||
|
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
if school_name:
|
||||||
|
query = query.filter(ONUDevice.school_name.contains(school_name))
|
||||||
|
if tag:
|
||||||
|
query = query.filter(ONUDevice.tags.contains(tag))
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
ONUDevice.mac_address.contains(keyword.lower()),
|
||||||
|
ONUDevice.building.contains(keyword),
|
||||||
|
ONUDevice.place_type.contains(keyword),
|
||||||
|
ONUDevice.school_name.contains(keyword),
|
||||||
|
ONUDevice.region.contains(keyword),
|
||||||
|
ONUDevice.room_number.contains(keyword),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status in ("online", "offline", "unknown"):
|
||||||
|
if status in ("online", "offline"):
|
||||||
|
query = query.join(
|
||||||
|
latest_history,
|
||||||
|
ONUDevice.id == latest_history.c.onu_device_id
|
||||||
|
).filter(latest_history.c.status == status)
|
||||||
|
else:
|
||||||
|
# unknown:最新状态为 unknown,或没有任何状态记录
|
||||||
|
query = query.outerjoin(
|
||||||
|
latest_history,
|
||||||
|
ONUDevice.id == latest_history.c.onu_device_id
|
||||||
|
).filter(
|
||||||
|
or_(
|
||||||
|
latest_history.c.onu_device_id == None,
|
||||||
|
latest_history.c.status == "unknown"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 多级排序:区域 > 学校名称 > 楼宇 > 房间号(均为升序)
|
||||||
|
query = query.order_by(
|
||||||
|
asc(ONUDevice.region),
|
||||||
|
asc(ONUDevice.school_name),
|
||||||
|
asc(ONUDevice.building),
|
||||||
|
asc(ONUDevice.room_number)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
items = query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
# 获取每个设备最新的状态(批量,避免 N+1)
|
||||||
|
device_ids = [item.id for item in items]
|
||||||
|
history_map = {}
|
||||||
|
if device_ids:
|
||||||
|
histories = (
|
||||||
|
db.query(DeviceStatusHistory)
|
||||||
|
.join(
|
||||||
|
latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.filter(DeviceStatusHistory.onu_device_id.in_(device_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
history_map = {h.onu_device_id: h for h in histories}
|
||||||
|
|
||||||
|
# 批量加载 OLT 信息
|
||||||
|
olt_ids = {item.olt_id for item in items if item.olt_id}
|
||||||
|
olt_map = {}
|
||||||
|
if olt_ids:
|
||||||
|
olts = db.query(OLTDevice).filter(OLTDevice.id.in_(olt_ids)).all()
|
||||||
|
olt_map = {o.id: o for o in olts}
|
||||||
|
|
||||||
|
result_items = []
|
||||||
|
for item in items:
|
||||||
|
latest_status = history_map.get(item.id)
|
||||||
|
olt = olt_map.get(item.olt_id)
|
||||||
|
|
||||||
|
item_dict = {
|
||||||
|
"id": item.id,
|
||||||
|
"mac_address": item.mac_address,
|
||||||
|
"olt_id": item.olt_id,
|
||||||
|
"region": item.region,
|
||||||
|
"school_name": item.school_name,
|
||||||
|
"building": item.building,
|
||||||
|
"place_type": item.place_type,
|
||||||
|
"room_number": item.room_number,
|
||||||
|
"notes": item.notes,
|
||||||
|
"status": latest_status.status if latest_status else None,
|
||||||
|
"distance_m": latest_status.distance_m if latest_status else None,
|
||||||
|
"slot_number": item.slot_number,
|
||||||
|
"port_number": item.port_number,
|
||||||
|
"port_id": item.port_id,
|
||||||
|
"model": item.model,
|
||||||
|
"olt_location": olt.location if olt else None,
|
||||||
|
"created_at": item.created_at
|
||||||
|
}
|
||||||
|
result_items.append(ONUDeviceResponse(**item_dict))
|
||||||
|
|
||||||
|
return {"total": total, "items": result_items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/regions")
|
||||||
|
def get_regions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取所有区域列表(受角色数据范围限制)"""
|
||||||
|
role = current.get('role', 'user')
|
||||||
|
query = db.query(distinct(ONUDevice.region)).filter(
|
||||||
|
ONUDevice.region.isnot(None),
|
||||||
|
ONUDevice.region != ''
|
||||||
|
)
|
||||||
|
if role == 'area_admin':
|
||||||
|
assigned = current.get('assigned_area') or ''
|
||||||
|
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
||||||
|
if areas:
|
||||||
|
query = query.filter(ONUDevice.region.in_(areas))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
elif role == 'school_admin':
|
||||||
|
assigned = current.get('assigned_school') or ''
|
||||||
|
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||||
|
if schools:
|
||||||
|
query = query.filter(ONUDevice.school_name.in_(schools))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
return [r[0] for r in query.order_by(ONUDevice.region).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schools")
|
||||||
|
def get_schools(
|
||||||
|
region: str = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取所有学校列表(可按区域筛选)"""
|
||||||
|
query = db.query(distinct(ONUDevice.school_name)).filter(
|
||||||
|
ONUDevice.school_name.isnot(None),
|
||||||
|
ONUDevice.school_name != ''
|
||||||
|
)
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
return [r[0] for r in query.order_by(ONUDevice.school_name).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/replacements")
|
||||||
|
def get_all_replacements(
|
||||||
|
region: Optional[str] = Query(None),
|
||||||
|
start_date: Optional[str] = Query(None),
|
||||||
|
end_date: Optional[str] = Query(None),
|
||||||
|
keyword: Optional[str] = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取全量设备更换记录(带位置信息),支持筛选"""
|
||||||
|
from datetime import datetime
|
||||||
|
query = (
|
||||||
|
db.query(DeviceReplacement, ONUDevice)
|
||||||
|
.join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id)
|
||||||
|
)
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if areas:
|
||||||
|
query = query.filter(ONUDevice.region.in_(areas))
|
||||||
|
else:
|
||||||
|
return {"total": 0, "items": []}
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date))
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59'))
|
||||||
|
if keyword:
|
||||||
|
kw = f'%{keyword}%'
|
||||||
|
query = query.filter(or_(
|
||||||
|
ONUDevice.school_name.ilike(kw),
|
||||||
|
ONUDevice.region.ilike(kw),
|
||||||
|
DeviceReplacement.old_mac.ilike(kw),
|
||||||
|
DeviceReplacement.new_mac.ilike(kw),
|
||||||
|
DeviceReplacement.operator_name.ilike(kw),
|
||||||
|
))
|
||||||
|
total = query.count()
|
||||||
|
rows = query.order_by(DeviceReplacement.replaced_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"replaced_at": r.replaced_at,
|
||||||
|
"old_mac": r.old_mac,
|
||||||
|
"new_mac": r.new_mac,
|
||||||
|
"reason": r.reason,
|
||||||
|
"operator_name": r.operator_name,
|
||||||
|
"region": d.region,
|
||||||
|
"school_name": d.school_name,
|
||||||
|
"building": d.building,
|
||||||
|
"room_number": d.room_number,
|
||||||
|
"onu_device_id": r.onu_device_id,
|
||||||
|
}
|
||||||
|
for r, d in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/replacements/export")
|
||||||
|
def export_replacements(
|
||||||
|
region: Optional[str] = Query(None),
|
||||||
|
start_date: Optional[str] = Query(None),
|
||||||
|
end_date: Optional[str] = Query(None),
|
||||||
|
keyword: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""导出更换记录为 CSV"""
|
||||||
|
import csv, io
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(DeviceReplacement, ONUDevice)
|
||||||
|
.join(ONUDevice, DeviceReplacement.onu_device_id == ONUDevice.id)
|
||||||
|
)
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if areas:
|
||||||
|
query = query.filter(ONUDevice.region.in_(areas))
|
||||||
|
else:
|
||||||
|
query = query.filter(False)
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(DeviceReplacement.replaced_at >= datetime.fromisoformat(start_date))
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(DeviceReplacement.replaced_at <= datetime.fromisoformat(end_date + 'T23:59:59'))
|
||||||
|
if keyword:
|
||||||
|
kw = f'%{keyword}%'
|
||||||
|
query = query.filter(or_(
|
||||||
|
ONUDevice.school_name.ilike(kw),
|
||||||
|
ONUDevice.region.ilike(kw),
|
||||||
|
DeviceReplacement.old_mac.ilike(kw),
|
||||||
|
DeviceReplacement.new_mac.ilike(kw),
|
||||||
|
DeviceReplacement.operator_name.ilike(kw),
|
||||||
|
))
|
||||||
|
rows = query.order_by(DeviceReplacement.replaced_at.desc()).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(['更换时间(北京)', '区域', '学校', '楼宇', '房间', '旧MAC', '新MAC', '更换原因', '操作人'])
|
||||||
|
for r, d in rows:
|
||||||
|
bj_time = (r.replaced_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S') if r.replaced_at else ''
|
||||||
|
writer.writerow([
|
||||||
|
bj_time,
|
||||||
|
d.region or '',
|
||||||
|
d.school_name or '',
|
||||||
|
d.building or '',
|
||||||
|
d.room_number or '',
|
||||||
|
r.old_mac,
|
||||||
|
r.new_mac,
|
||||||
|
r.reason or '',
|
||||||
|
r.operator_name or '',
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
filename = f"replacement_records_{datetime.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue().encode('utf-8-sig')]),
|
||||||
|
media_type='text/csv',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
def get_all_tags(db: Session = Depends(get_db)):
|
||||||
|
"""获取所有不重复的设备标签"""
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
rows = db.query(ONUDevice.tags).filter(
|
||||||
|
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
|
||||||
|
).all()
|
||||||
|
tags = set()
|
||||||
|
for (tag_str,) in rows:
|
||||||
|
for t in tag_str.split(','):
|
||||||
|
t = t.strip()
|
||||||
|
if t:
|
||||||
|
tags.add(t)
|
||||||
|
return sorted(tags)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/csv")
|
||||||
|
def export_devices_csv(
|
||||||
|
region: Optional[str] = Query(None),
|
||||||
|
school_name: Optional[str] = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""导出设备列表为 CSV"""
|
||||||
|
from sqlalchemy import func as _func
|
||||||
|
|
||||||
|
query = db.query(ONUDevice)
|
||||||
|
if region:
|
||||||
|
query = query.filter(ONUDevice.region == region)
|
||||||
|
if school_name:
|
||||||
|
query = query.filter(ONUDevice.school_name == school_name)
|
||||||
|
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
|
||||||
|
for d in devices:
|
||||||
|
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
|
||||||
|
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
|
||||||
|
d.loid or "", d.distance_m or "", d.notes or ""])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([output.getvalue()]),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||||
|
def get_device(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取设备详情"""
|
||||||
|
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
|
||||||
|
latest_status = db.query(DeviceStatusHistory).filter(
|
||||||
|
DeviceStatusHistory.onu_device_id == device.id
|
||||||
|
).order_by(desc(DeviceStatusHistory.checked_at)).first()
|
||||||
|
|
||||||
|
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first() if device.olt_id else None
|
||||||
|
|
||||||
|
return ONUDeviceResponse(
|
||||||
|
id=device.id,
|
||||||
|
mac_address=device.mac_address,
|
||||||
|
olt_id=device.olt_id,
|
||||||
|
region=device.region,
|
||||||
|
school_name=device.school_name,
|
||||||
|
building=device.building,
|
||||||
|
place_type=device.place_type,
|
||||||
|
room_number=device.room_number,
|
||||||
|
notes=device.notes,
|
||||||
|
status=latest_status.status if latest_status else None,
|
||||||
|
distance_m=latest_status.distance_m if latest_status else None,
|
||||||
|
slot_number=device.slot_number,
|
||||||
|
port_number=device.port_number,
|
||||||
|
port_id=device.port_id,
|
||||||
|
model=device.model,
|
||||||
|
olt_location=olt.location if olt else None,
|
||||||
|
created_at=device.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{device_id}/refresh")
|
||||||
|
def refresh_device_status(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.check')),
|
||||||
|
):
|
||||||
|
"""通过 SSH 单独更新一台设备的状态和距离"""
|
||||||
|
from app.services.check_service import CheckService
|
||||||
|
try:
|
||||||
|
service = CheckService(db)
|
||||||
|
result = service.check_single_device(device_id)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceUpdate(BaseModel):
|
||||||
|
region: Optional[str] = None
|
||||||
|
school_name: Optional[str] = None
|
||||||
|
building: Optional[str] = None
|
||||||
|
room_number: Optional[str] = None
|
||||||
|
place_type: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
tags: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceReplaceRequest(BaseModel):
|
||||||
|
new_mac: str
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/status/all")
|
||||||
|
def clear_all_status(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.delete')),
|
||||||
|
):
|
||||||
|
"""清空所有设备状态历史记录"""
|
||||||
|
db.query(DeviceStatusHistory).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已清空所有设备状态"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{device_id}")
|
||||||
|
def update_device(
|
||||||
|
device_id: int,
|
||||||
|
body: DeviceUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.edit')),
|
||||||
|
):
|
||||||
|
"""更新设备信息(区域、学校、楼宇、房间号、备注)"""
|
||||||
|
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
device.region = body.region
|
||||||
|
device.school_name = body.school_name
|
||||||
|
device.building = body.building or None
|
||||||
|
device.room_number = body.room_number or None
|
||||||
|
device.place_type = body.place_type or None
|
||||||
|
device.notes = body.notes or None
|
||||||
|
device.tags = body.tags or None
|
||||||
|
|
||||||
|
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
|
||||||
|
from app.models.device import NewDevice
|
||||||
|
dup_new = db.query(NewDevice).join(
|
||||||
|
ONUDevice, NewDevice.onu_device_id == ONUDevice.id
|
||||||
|
).filter(ONUDevice.mac_address == device.mac_address).all()
|
||||||
|
for nd in dup_new:
|
||||||
|
db.delete(nd)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{device_id}/replace")
|
||||||
|
def replace_device(
|
||||||
|
device_id: int,
|
||||||
|
body: DeviceReplaceRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('device.edit')),
|
||||||
|
):
|
||||||
|
"""更换设备 MAC 地址,并记录更换历史"""
|
||||||
|
from datetime import datetime
|
||||||
|
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
|
||||||
|
new_mac_raw = body.new_mac.strip()
|
||||||
|
# 接受 xxxx-xxxx-xxxx、xx:xx:xx:xx:xx:xx、xxxxxxxxxxxx 三种格式,统一标准化为小写 xxxx-xxxx-xxxx
|
||||||
|
import re
|
||||||
|
hex_only = re.sub(r'[:\-]', '', new_mac_raw).lower()
|
||||||
|
if not re.match(r'^[0-9a-f]{12}$', hex_only):
|
||||||
|
raise HTTPException(status_code=400, detail="MAC 地址格式不正确,支持 xxxx-xxxx-xxxx、xx:xx:xx:xx:xx:xx 或 xxxxxxxxxxxx")
|
||||||
|
new_mac = f"{hex_only[0:4]}-{hex_only[4:8]}-{hex_only[8:12]}"
|
||||||
|
|
||||||
|
# 同时匹配大小写和各种分隔符格式,兼容数据库旧数据
|
||||||
|
hex_variants = [
|
||||||
|
new_mac,
|
||||||
|
hex_only,
|
||||||
|
':'.join(hex_only[i:i+2] for i in range(0, 12, 2)),
|
||||||
|
'-'.join(hex_only[i:i+2] for i in range(0, 12, 2)),
|
||||||
|
new_mac.upper(),
|
||||||
|
hex_only.upper(),
|
||||||
|
':'.join(hex_only[i:i+2].upper() for i in range(0, 12, 2)),
|
||||||
|
'-'.join(hex_only[i:i+2].upper() for i in range(0, 12, 2)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 若新 MAC 在 new_devices 待入库列表中,先删除(更换后该记录已无意义)
|
||||||
|
from app.models.device import NewDevice
|
||||||
|
conflict_news = db.query(NewDevice).join(
|
||||||
|
ONUDevice, NewDevice.onu_device_id == ONUDevice.id
|
||||||
|
).filter(
|
||||||
|
ONUDevice.mac_address.in_(hex_variants),
|
||||||
|
ONUDevice.id != device_id
|
||||||
|
).all()
|
||||||
|
conflict_new_onu_ids = {nd.onu_device_id for nd in conflict_news}
|
||||||
|
for nd in conflict_news:
|
||||||
|
db.delete(nd)
|
||||||
|
# 先 flush,让 NewDevice 的 ORM 删除落库,解除对 onu_devices 的外键引用
|
||||||
|
db.flush()
|
||||||
|
# 同时删除对应的空白 ONU 记录,避免设备列表出现重复 MAC
|
||||||
|
if conflict_new_onu_ids:
|
||||||
|
from app.models.device import DeviceStatusHistory
|
||||||
|
db.query(DeviceStatusHistory).filter(
|
||||||
|
DeviceStatusHistory.onu_device_id.in_(conflict_new_onu_ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.query(ONUDevice).filter(ONUDevice.id.in_(conflict_new_onu_ids)).delete(synchronize_session=False)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 检查新 MAC 是否已被其他 ONU 设备使用(排除刚刚从 new_devices 删除的临时 ONU)
|
||||||
|
existing = db.query(ONUDevice).filter(
|
||||||
|
ONUDevice.mac_address.in_(hex_variants),
|
||||||
|
ONUDevice.id != device_id,
|
||||||
|
ONUDevice.id.notin_(conflict_new_onu_ids) if conflict_new_onu_ids else True,
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="该 MAC 地址已被其他设备使用")
|
||||||
|
|
||||||
|
# 同步更新库存序列号设备的 onu_device_id 关联(如有)
|
||||||
|
from app.models.inventory import SerialDevice
|
||||||
|
old_serial = db.query(SerialDevice).filter(SerialDevice.onu_device_id == device_id).first()
|
||||||
|
if old_serial:
|
||||||
|
old_serial.onu_device_id = None
|
||||||
|
old_serial.status = "returned"
|
||||||
|
new_serial = db.query(SerialDevice).filter(SerialDevice.mac_address == new_mac).first()
|
||||||
|
if new_serial:
|
||||||
|
new_serial.onu_device_id = device_id
|
||||||
|
new_serial.status = "in_use"
|
||||||
|
|
||||||
|
record = DeviceReplacement(
|
||||||
|
onu_device_id=device_id,
|
||||||
|
old_mac=device.mac_address,
|
||||||
|
new_mac=new_mac,
|
||||||
|
reason=body.reason or None,
|
||||||
|
operator_id=current.get("sub", ""),
|
||||||
|
operator_name=current.get("username", ""),
|
||||||
|
replaced_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
device.mac_address = new_mac
|
||||||
|
db.commit()
|
||||||
|
return {"message": "更换成功", "old_mac": record.old_mac, "new_mac": new_mac}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/replacements")
|
||||||
|
def get_device_replacements(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取设备更换历史"""
|
||||||
|
records = db.query(DeviceReplacement).filter(
|
||||||
|
DeviceReplacement.onu_device_id == device_id
|
||||||
|
).order_by(DeviceReplacement.replaced_at.desc()).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"old_mac": r.old_mac,
|
||||||
|
"new_mac": r.new_mac,
|
||||||
|
"reason": r.reason,
|
||||||
|
"operator_name": r.operator_name,
|
||||||
|
"replaced_at": r.replaced_at,
|
||||||
|
}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@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 = 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':
|
||||||
|
areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()]
|
||||||
|
if device.region not in areas:
|
||||||
|
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||||
|
elif role == 'school_admin':
|
||||||
|
schools = [s.strip() for s in (current.get('assigned_school') or '').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
|
||||||
|
result = IMCService().reboot_onu(device.mac_address)
|
||||||
|
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)"""
|
||||||
|
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':
|
||||||
|
areas = [a.strip() for a in (current.get('assigned_area') or '').split(',') if a.strip()]
|
||||||
|
if device.region not in areas:
|
||||||
|
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||||
|
elif role == 'school_admin':
|
||||||
|
schools = [s.strip() for s in (current.get('assigned_school') or '').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
|
||||||
|
data = IMCService().get_optical_power(device.mac_address)
|
||||||
|
if data is None:
|
||||||
|
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(
|
||||||
|
power_in=data.get("powerIn"),
|
||||||
|
power_out=data.get("powerOut"),
|
||||||
|
bind_mac=data.get("bindMac"),
|
||||||
|
dev_id=data.get("devId"),
|
||||||
|
epon_dev_name=data.get("eponDevName"),
|
||||||
|
olt_if_name=data.get("oltIfName"),
|
||||||
|
onu_if_desc=data.get("onuIfDesc"),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/onu-events")
|
||||||
|
def get_onu_events(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""查询 ONU 上下线事件记录(SSH 到所属 OLT 执行命令)"""
|
||||||
|
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
if not device.olt_id:
|
||||||
|
raise HTTPException(status_code=400, detail="该设备未关联 OLT,无法查询")
|
||||||
|
if not device.port_id:
|
||||||
|
raise HTTPException(status_code=400, detail="端口信息缺失,请先更新设备状态")
|
||||||
|
|
||||||
|
olt = db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||||
|
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
try:
|
||||||
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
|
events = ssh.get_onu_events(device.port_id)
|
||||||
|
return {
|
||||||
|
"interface": f"Onu{device.port_id}",
|
||||||
|
"olt_location": olt.location,
|
||||||
|
"events": events,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@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,82 @@
|
|||||||
|
"""数据导入 API"""
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Depends, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.services.import_service import ImportService
|
||||||
|
import shutil
|
||||||
|
import io
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/import", tags=["数据导入"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/template")
|
||||||
|
def download_template():
|
||||||
|
"""下载导入数据模板"""
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "ONU设备导入模板"
|
||||||
|
|
||||||
|
# 表头:序号|区域|学校名称|楼宇|场所类型|房间号|MAC地址|备注
|
||||||
|
headers = ["mac_address", "region", "school_name", "building", "place_type", "room_number", "notes"]
|
||||||
|
ws.append(headers)
|
||||||
|
|
||||||
|
# 示例数据
|
||||||
|
example_data = [
|
||||||
|
["AA:BB:CC:DD:EE:01", "区域1", "学校1", "1号楼", "教室", "101", ""],
|
||||||
|
["AA:BB:CC:DD:EE:02", "区域1", "学校1", "1号楼", "办公室", "102", ""],
|
||||||
|
]
|
||||||
|
for row in example_data:
|
||||||
|
ws.append(row)
|
||||||
|
|
||||||
|
# 设置列宽
|
||||||
|
ws.column_dimensions['A'].width = 20 # mac_address
|
||||||
|
ws.column_dimensions['B'].width = 12 # region
|
||||||
|
ws.column_dimensions['C'].width = 18 # school_name
|
||||||
|
ws.column_dimensions['D'].width = 12 # building
|
||||||
|
ws.column_dimensions['E'].width = 12 # place_type
|
||||||
|
ws.column_dimensions['F'].width = 12 # room_number
|
||||||
|
ws.column_dimensions['G'].width = 20 # notes
|
||||||
|
|
||||||
|
# 保存到内存
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=output.getvalue(),
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=onu_import_template.xlsx"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_excel(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
olt_id: int = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.import')),
|
||||||
|
):
|
||||||
|
"""上传并导入 Excel 文件(仅导入 MAC 信息,不关联 OLT)"""
|
||||||
|
file_path = f"/tmp/{file.filename}"
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
service = ImportService(db)
|
||||||
|
records = service.parse_excel(file_path)
|
||||||
|
validation = service.validate_data(records)
|
||||||
|
|
||||||
|
created = updated = 0
|
||||||
|
if validation['valid']:
|
||||||
|
result = service.import_devices(validation['valid'], olt_id)
|
||||||
|
created = result['created']
|
||||||
|
updated = result['updated']
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": created + updated,
|
||||||
|
"created": created,
|
||||||
|
"updated": updated,
|
||||||
|
"failed": validation['invalid']
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""库存管理 API"""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.schemas.inventory import (
|
||||||
|
CategoryCreate, CategoryResponse,
|
||||||
|
MaterialCreate, MaterialUpdate, MaterialListResponse,
|
||||||
|
PurchaseInRequest, AllocateOutRequest, ReturnInRequest,
|
||||||
|
TransactionListResponse, SerialDeviceListResponse,
|
||||||
|
CheckCreate, CheckListResponse, InventorySummary,
|
||||||
|
)
|
||||||
|
import app.services.inventory_service as svc
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/inventory", tags=["库存管理"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── 物料分类 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
def list_categories(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_categories(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories", response_model=CategoryResponse)
|
||||||
|
def create_category(
|
||||||
|
body: CategoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.manage")),
|
||||||
|
):
|
||||||
|
return svc.create_category(db, body.name, body.code, body.description)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 物料 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/materials", response_model=MaterialListResponse)
|
||||||
|
def list_materials(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
category_id: Optional[int] = None,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_materials(db, skip, limit, category_id, keyword)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/materials")
|
||||||
|
def create_material(
|
||||||
|
body: MaterialCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.manage")),
|
||||||
|
):
|
||||||
|
return svc.create_material(db, body.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/materials/{material_id}")
|
||||||
|
def update_material(
|
||||||
|
material_id: int,
|
||||||
|
body: MaterialUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.manage")),
|
||||||
|
):
|
||||||
|
return svc.update_material(db, material_id, body.model_dump(exclude_none=True))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/materials/{material_id}")
|
||||||
|
def delete_material(
|
||||||
|
material_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.manage")),
|
||||||
|
):
|
||||||
|
svc.delete_material(db, material_id)
|
||||||
|
return {"message": "删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 出入库操作 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/transactions/purchase")
|
||||||
|
def purchase_in(
|
||||||
|
body: PurchaseInRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission("inventory.transaction")),
|
||||||
|
):
|
||||||
|
return svc.purchase_in(db, body.model_dump(), int(current.get("sub", 0)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transactions/allocate")
|
||||||
|
def allocate_out(
|
||||||
|
body: AllocateOutRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission("inventory.transaction")),
|
||||||
|
):
|
||||||
|
return svc.allocate_out(db, body.model_dump(), int(current.get("sub", 0)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transactions/return")
|
||||||
|
def return_in(
|
||||||
|
body: ReturnInRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission("inventory.transaction")),
|
||||||
|
):
|
||||||
|
return svc.return_in(db, body.serial_device_id, body.return_type, body.notes, int(current.get("sub", 0)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions", response_model=TransactionListResponse)
|
||||||
|
def list_transactions(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
transaction_type: Optional[str] = None,
|
||||||
|
material_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_transactions(db, skip, limit, transaction_type, material_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/{transaction_id}")
|
||||||
|
def get_transaction(
|
||||||
|
transaction_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_transaction_detail(db, transaction_id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches")
|
||||||
|
def list_batches(
|
||||||
|
material_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_batches_by_material(db, material_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 序列号设备 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/serial-devices", response_model=SerialDeviceListResponse)
|
||||||
|
def list_serial_devices(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
material_id: Optional[int] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_serial_devices(db, skip, limit, material_id, status, keyword)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 库存统计 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=InventorySummary)
|
||||||
|
def get_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_summary(db)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 盘点 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/checks", response_model=CheckListResponse)
|
||||||
|
def list_checks(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
material_id: Optional[int] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission("inventory.view")),
|
||||||
|
):
|
||||||
|
return svc.get_checks(db, skip, limit, material_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checks")
|
||||||
|
def create_check(
|
||||||
|
body: CheckCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission("inventory.check")),
|
||||||
|
):
|
||||||
|
return svc.create_check(db, body.model_dump(), int(current.get("sub", 0)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checks/{check_id}/adjust")
|
||||||
|
def adjust_check(
|
||||||
|
check_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission("inventory.check")),
|
||||||
|
):
|
||||||
|
return svc.adjust_check(db, check_id, int(current.get("sub", 0)))
|
||||||
@@ -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)}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
"""OLT 设备管理 API"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
import pandas as pd
|
||||||
|
import io
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/olt", tags=["OLT设备"])
|
||||||
|
|
||||||
|
|
||||||
|
class OLTCreate(BaseModel):
|
||||||
|
ip_address: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
slot_command: str = "display onu slot"
|
||||||
|
region: str = "城区"
|
||||||
|
location: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class OLTEdit(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str = None
|
||||||
|
slot_command: str = "display onu slot"
|
||||||
|
region: str = "城区"
|
||||||
|
location: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/regions")
|
||||||
|
def get_olt_regions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('olt.view')),
|
||||||
|
):
|
||||||
|
"""获取 OLT 设备的所有区域(受角色数据范围限制)"""
|
||||||
|
query = db.query(distinct(OLTDevice.region)).filter(
|
||||||
|
OLTDevice.region.isnot(None),
|
||||||
|
OLTDevice.region != ''
|
||||||
|
)
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if areas:
|
||||||
|
query = query.filter(OLTDevice.region.in_(areas))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
return sorted([r[0] for r in query.all()])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices")
|
||||||
|
def get_devices(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('olt.view')),
|
||||||
|
):
|
||||||
|
q = db.query(OLTDevice)
|
||||||
|
# 区域管理员只能看自己区域的 OLT
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if areas:
|
||||||
|
q = q.filter(OLTDevice.region.in_(areas))
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices")
|
||||||
|
def create_device(
|
||||||
|
device: OLTCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
# 区域管理员只能创建自己区域的 OLT
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if device.region not in areas:
|
||||||
|
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
|
||||||
|
db_device = OLTDevice(**device.dict())
|
||||||
|
db.add(db_device)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "创建成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/devices/{ip_address}")
|
||||||
|
def update_device(
|
||||||
|
ip_address: str,
|
||||||
|
device: OLTEdit,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
db_device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
|
||||||
|
if not db_device:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
# 区域管理员只能管理自己区域的 OLT
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if db_device.region not in areas:
|
||||||
|
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
|
||||||
|
|
||||||
|
db_device.username = device.username
|
||||||
|
if device.password:
|
||||||
|
db_device.password = device.password
|
||||||
|
db_device.slot_command = device.slot_command
|
||||||
|
db_device.region = device.region
|
||||||
|
db_device.location = device.location
|
||||||
|
db_device.description = device.description
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/devices/{ip_address}")
|
||||||
|
def delete_device(
|
||||||
|
ip_address: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
from app.models.device import ONUDevice
|
||||||
|
|
||||||
|
device = db.query(OLTDevice).filter(OLTDevice.ip_address == ip_address).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
if device.region not in areas:
|
||||||
|
raise HTTPException(status_code=403, detail="只能管理本区域的 OLT")
|
||||||
|
|
||||||
|
onu_count = db.query(ONUDevice).filter(ONUDevice.olt_id == device.id).count()
|
||||||
|
if onu_count > 0:
|
||||||
|
raise HTTPException(status_code=400, detail=f"该 OLT 设备下还有 {onu_count} 个 ONU 设备,无法删除")
|
||||||
|
|
||||||
|
db.delete(device)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_devices(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
df = pd.read_excel(io.BytesIO(content))
|
||||||
|
# 标准化列名
|
||||||
|
df.columns = [str(c).strip() for c in df.columns]
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
|
||||||
|
|
||||||
|
required_cols = ['IP地址', '用户名', '密码']
|
||||||
|
missing = [c for c in required_cols if c not in df.columns]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"缺少必填列: {', '.join(missing)},当前列: {', '.join(df.columns.tolist())}")
|
||||||
|
|
||||||
|
success, failed = 0, []
|
||||||
|
for idx, row in df.iterrows():
|
||||||
|
try:
|
||||||
|
ip = str(row['IP地址']).strip()
|
||||||
|
if not ip or ip == 'nan':
|
||||||
|
continue
|
||||||
|
existing = db.query(OLTDevice).filter(OLTDevice.ip_address == ip).first()
|
||||||
|
if existing:
|
||||||
|
failed.append({"row": idx + 2, "ip": ip, "reason": "IP 已存在"})
|
||||||
|
continue
|
||||||
|
device = OLTDevice(
|
||||||
|
ip_address=ip,
|
||||||
|
username=str(row['用户名']).strip(),
|
||||||
|
password=str(row['密码']).strip(),
|
||||||
|
slot_command=str(row['槽位命令']).strip() if '槽位命令' in df.columns and str(row['槽位命令']) != 'nan' else 'display onu slot',
|
||||||
|
region=str(row['区域']).strip() if '区域' in df.columns and str(row['区域']) != 'nan' else '城区',
|
||||||
|
location=str(row['安装位置']).strip() if '安装位置' in df.columns and str(row['安装位置']) != 'nan' else '',
|
||||||
|
description=str(row['描述']).strip() if '描述' in df.columns and str(row['描述']) != 'nan' else '',
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
success += 1
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"row": idx + 2, "ip": str(row.get('IP地址', '')), "reason": str(e)})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": f"成功导入 {success} 条记录", "success": success, "failed": failed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/template")
|
||||||
|
def download_template():
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
import os
|
||||||
|
# __file__ is at <root>/app/api/v1/olt.py → go up 4 levels to reach <root>
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
path = os.path.join(base, "templates", "OLT设备导入模板.xlsx")
|
||||||
|
return FileResponse(path=path, filename="OLT设备导入模板.xlsx")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/duplicate-macs")
|
||||||
|
def get_duplicate_macs(
|
||||||
|
olt_id: int = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.view')),
|
||||||
|
):
|
||||||
|
"""查询重复 MAC 地址记录"""
|
||||||
|
from app.models.device import DuplicateMac
|
||||||
|
query = db.query(DuplicateMac)
|
||||||
|
if olt_id:
|
||||||
|
query = query.filter(DuplicateMac.olt_id == olt_id)
|
||||||
|
records = query.order_by(DuplicateMac.last_seen_at.desc()).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"olt_id": r.olt_id,
|
||||||
|
"mac_address": r.mac_address,
|
||||||
|
"ports": r.ports,
|
||||||
|
"first_seen_at": r.first_seen_at,
|
||||||
|
"last_seen_at": r.last_seen_at,
|
||||||
|
}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/duplicate-macs/{record_id}")
|
||||||
|
def delete_duplicate_mac(
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
"""删除重复 MAC 记录(已处理后清除)"""
|
||||||
|
from app.models.device import DuplicateMac
|
||||||
|
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
class ClearPortRequest(BaseModel):
|
||||||
|
port_id: str # 如 "1/0/1:3"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/duplicate-macs/{record_id}/clear-port")
|
||||||
|
def clear_onu_port(
|
||||||
|
record_id: int,
|
||||||
|
body: ClearPortRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
"""通过 SSH 清除指定端口的 ONU 配置,并从 ports 列表中移除该端口"""
|
||||||
|
from app.models.device import DuplicateMac
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
|
||||||
|
record = db.query(DuplicateMac).filter(DuplicateMac.id == record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
|
||||||
|
olt = db.query(OLTDevice).filter(OLTDevice.id == record.olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise HTTPException(status_code=404, detail="OLT 设备不存在")
|
||||||
|
|
||||||
|
# 验证 port_id 在记录中
|
||||||
|
port_ids = [p["port_id"] for p in (record.ports or [])]
|
||||||
|
if body.port_id not in port_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
|
ssh.clear_onu_port(body.port_id)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
||||||
|
|
||||||
|
# 从 ports 列表移除已清除的端口
|
||||||
|
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
||||||
|
if remaining:
|
||||||
|
record.ports = remaining
|
||||||
|
else:
|
||||||
|
# 所有端口都清除了,删除整条记录
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": f"端口 Onu{body.port_id} 已清除", "remaining_ports": remaining}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/new-devices")
|
||||||
|
def get_new_devices(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.view')),
|
||||||
|
):
|
||||||
|
"""查询新发现的设备列表(待补全信息)"""
|
||||||
|
from app.models.device import NewDevice, ONUDevice
|
||||||
|
rows = (
|
||||||
|
db.query(NewDevice, ONUDevice)
|
||||||
|
.join(ONUDevice, NewDevice.onu_device_id == ONUDevice.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": nd.id,
|
||||||
|
"onu_device_id": nd.onu_device_id,
|
||||||
|
"olt_id": nd.olt_id,
|
||||||
|
"discovered_at": nd.discovered_at,
|
||||||
|
"mac_address": onu.mac_address,
|
||||||
|
"port_id": f"{onu.slot_number}/{onu.port_number}" if onu.slot_number else None,
|
||||||
|
"loid": onu.loid,
|
||||||
|
"model": onu.model,
|
||||||
|
"region": onu.region,
|
||||||
|
"school_name": onu.school_name,
|
||||||
|
"building": onu.building,
|
||||||
|
"room_number": onu.room_number,
|
||||||
|
}
|
||||||
|
for nd, onu in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NewDeviceUpdate(BaseModel):
|
||||||
|
region: str
|
||||||
|
school_name: str
|
||||||
|
building: str = ""
|
||||||
|
place_type: str = ""
|
||||||
|
room_number: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/new-devices/{record_id}")
|
||||||
|
def update_new_device(
|
||||||
|
record_id: int,
|
||||||
|
body: NewDeviceUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
"""补全新设备信息,完成后从 new_devices 移除"""
|
||||||
|
from app.models.device import NewDevice, ONUDevice
|
||||||
|
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
|
||||||
|
onu = db.query(ONUDevice).filter(ONUDevice.id == record.onu_device_id).first()
|
||||||
|
if not onu:
|
||||||
|
raise HTTPException(status_code=404, detail="ONU 设备不存在")
|
||||||
|
|
||||||
|
onu.region = body.region
|
||||||
|
onu.school_name = body.school_name
|
||||||
|
onu.building = body.building or None
|
||||||
|
onu.place_type = body.place_type or None
|
||||||
|
onu.room_number = body.room_number or None
|
||||||
|
onu.notes = body.notes or None
|
||||||
|
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "信息已补全"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/new-devices/{record_id}")
|
||||||
|
def dismiss_new_device(
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
"""忽略新设备(不补全信息,仅从待处理列表移除)"""
|
||||||
|
from app.models.device import NewDevice
|
||||||
|
record = db.query(NewDevice).filter(NewDevice.id == record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已忽略"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/quick-scan")
|
||||||
|
def quick_scan(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.discover')),
|
||||||
|
):
|
||||||
|
"""多线程对所有 OLT 同时执行扫描,更新已有设备状态"""
|
||||||
|
from app.services.check_service import CheckService
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
olts = db.query(OLTDevice).all()
|
||||||
|
|
||||||
|
def scan_one(olt):
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
thread_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
service = CheckService(thread_db)
|
||||||
|
result = service.scan_and_discover(olt.id)
|
||||||
|
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
|
||||||
|
"online": result.get("online", 0), "offline": result.get("offline", 0),
|
||||||
|
"new_discovered": result.get("new_discovered", 0),
|
||||||
|
"error": None}
|
||||||
|
except Exception as e:
|
||||||
|
return {"olt_id": olt.id, "olt_location": olt.location or olt.ip_address,
|
||||||
|
"online": 0, "offline": 0, "new_discovered": 0, "error": str(e)}
|
||||||
|
finally:
|
||||||
|
thread_db.close()
|
||||||
|
|
||||||
|
olt_results = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||||
|
futures = {executor.submit(scan_one, olt): olt.id for olt in olts}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
r = future.result()
|
||||||
|
olt_results[r["olt_id"]] = r
|
||||||
|
|
||||||
|
results = []
|
||||||
|
errors = []
|
||||||
|
total_online = 0
|
||||||
|
total_offline = 0
|
||||||
|
total_new = 0
|
||||||
|
for olt in olts:
|
||||||
|
r = olt_results.get(olt.id, {})
|
||||||
|
if r.get("error"):
|
||||||
|
errors.append({"olt_location": r["olt_location"], "error": r["error"]})
|
||||||
|
else:
|
||||||
|
total_online += r.get("online", 0)
|
||||||
|
total_offline += r.get("offline", 0)
|
||||||
|
total_new += r.get("new_discovered", 0)
|
||||||
|
results.append({
|
||||||
|
"olt_location": r.get("olt_location", olt.location or olt.ip_address),
|
||||||
|
"online": r.get("online", 0),
|
||||||
|
"offline": r.get("offline", 0),
|
||||||
|
"new_discovered": r.get("new_discovered", 0),
|
||||||
|
"success": not r.get("error"),
|
||||||
|
"error": r.get("error"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_online": total_online,
|
||||||
|
"total_offline": total_offline,
|
||||||
|
"total_new": total_new,
|
||||||
|
"results": results,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/loopback-detection")
|
||||||
|
def loopback_detection(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.loopback')),
|
||||||
|
):
|
||||||
|
"""对所有 OLT 并发执行环路检测,返回有环路的端口及对应设备信息"""
|
||||||
|
from app.models.device import ONUDevice
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
olts = db.query(OLTDevice).all()
|
||||||
|
|
||||||
|
# 预加载所有 ONU 设备,按 (olt_id, port_id) 索引,避免多线程操作 Session
|
||||||
|
all_onus = db.query(ONUDevice).all()
|
||||||
|
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
||||||
|
|
||||||
|
def check_one(olt):
|
||||||
|
try:
|
||||||
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
|
detection = ssh.detect_loopback()
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"olt_id": olt.id,
|
||||||
|
"olt_ip": olt.ip_address,
|
||||||
|
"olt_location": olt.location or olt.ip_address,
|
||||||
|
"error": str(e),
|
||||||
|
"has_loop": False,
|
||||||
|
"loop_interfaces": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
loop_interfaces = []
|
||||||
|
for iface in detection.get("interfaces", []):
|
||||||
|
port_id = iface.removeprefix("Onu")
|
||||||
|
onu = onu_map.get((olt.id, port_id))
|
||||||
|
loop_interfaces.append({
|
||||||
|
"interface": iface,
|
||||||
|
"port_id": port_id,
|
||||||
|
"mac_address": onu.mac_address if onu else None,
|
||||||
|
"region": onu.region if onu else None,
|
||||||
|
"school_name": onu.school_name if onu else None,
|
||||||
|
"building": onu.building if onu else None,
|
||||||
|
"room_number": onu.room_number if onu else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"olt_id": olt.id,
|
||||||
|
"olt_ip": olt.ip_address,
|
||||||
|
"olt_location": olt.location or olt.ip_address,
|
||||||
|
"has_loop": detection["has_loop"],
|
||||||
|
"loop_interfaces": loop_interfaces,
|
||||||
|
"error": None,
|
||||||
|
"raw": detection.get("raw", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
results_map = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||||
|
futures = {executor.submit(check_one, olt): olt.id for olt in olts}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
olt_id = futures[future]
|
||||||
|
results_map[olt_id] = future.result()
|
||||||
|
|
||||||
|
# 按原始顺序返回
|
||||||
|
return [results_map[olt.id] for olt in olts]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncNTPRequest(BaseModel):
|
||||||
|
old_server: str = settings.NTP_OLD_SERVER
|
||||||
|
new_server: str = settings.NTP_NEW_SERVER
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync-ntp")
|
||||||
|
def sync_ntp(
|
||||||
|
body: SyncNTPRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('olt.manage')),
|
||||||
|
):
|
||||||
|
"""对所有 OLT 并发执行 NTP 时间服务器同步"""
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
olts = db.query(OLTDevice).all()
|
||||||
|
if current.get('role') == 'area_admin' and current.get('assigned_area'):
|
||||||
|
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||||
|
olts = [o for o in olts if o.region in areas] if areas else []
|
||||||
|
|
||||||
|
def sync_one(olt):
|
||||||
|
try:
|
||||||
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
|
ssh.sync_ntp(body.old_server, body.new_server)
|
||||||
|
return {
|
||||||
|
"olt_ip": olt.ip_address,
|
||||||
|
"olt_location": olt.location or olt.ip_address,
|
||||||
|
"success": True,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"olt_ip": olt.ip_address,
|
||||||
|
"olt_location": olt.location or olt.ip_address,
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
results_map = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||||
|
futures = {executor.submit(sync_one, olt): olt.id for olt in olts}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
r = future.result()
|
||||||
|
results_map[r["olt_ip"]] = r
|
||||||
|
|
||||||
|
results = [results_map[olt.ip_address] for olt in olts]
|
||||||
|
success_count = sum(1 for r in results if r["success"])
|
||||||
|
return {
|
||||||
|
"total": len(results),
|
||||||
|
"success": success_count,
|
||||||
|
"failed": len(results) - success_count,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TogglePortRequest(BaseModel):
|
||||||
|
action: str # "shutdown" 或 "undo shutdown"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{olt_id}/ports")
|
||||||
|
def get_olt_ports(
|
||||||
|
olt_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('olt.port_manage')),
|
||||||
|
):
|
||||||
|
"""获取指定 OLT 的所有 Olt 端口状态"""
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||||
|
try:
|
||||||
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
|
ports = ssh.get_olt_ports()
|
||||||
|
return {"ports": ports}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{olt_id}/ports/toggle")
|
||||||
|
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db), _: dict = Depends(require_permission('olt.port_manage'))):
|
||||||
|
"""开启或关闭指定 OLT 端口"""
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
if body.action not in ("shutdown", "undo shutdown"):
|
||||||
|
raise HTTPException(status_code=400, detail="action 必须为 shutdown 或 undo shutdown")
|
||||||
|
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||||
|
try:
|
||||||
|
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||||
|
ssh.toggle_olt_port(port_name, body.action)
|
||||||
|
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""业务下发 API"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.services.provision_service import ProvisionService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/provision", tags=["业务下发"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionRequest(BaseModel):
|
||||||
|
device_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class BatchProvisionRequest(BaseModel):
|
||||||
|
device_ids: List[int]
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
device_id: Optional[int] = None
|
||||||
|
mac_address: Optional[str] = None
|
||||||
|
olt_ip: Optional[str] = None
|
||||||
|
port: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/service", response_model=ProvisionResponse)
|
||||||
|
def provision_single_device(
|
||||||
|
request: ProvisionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.edit')),
|
||||||
|
):
|
||||||
|
"""下发业务到单个设备"""
|
||||||
|
service = ProvisionService(db)
|
||||||
|
result = service.provision_device(request.device_id)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
return ProvisionResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"业务下发成功,MAC: {result['mac_address']}, 端口: {result['port']}",
|
||||||
|
device_id=result["device_id"],
|
||||||
|
mac_address=result["mac_address"],
|
||||||
|
olt_ip=result.get("olt_ip"),
|
||||||
|
port=result.get("port")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "业务下发失败"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=dict)
|
||||||
|
def provision_batch_devices(
|
||||||
|
request: BatchProvisionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.edit')),
|
||||||
|
):
|
||||||
|
"""批量下发业务"""
|
||||||
|
service = ProvisionService(db)
|
||||||
|
result = service.batch_provision(request.device_ids)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""角色权限配置 API"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission, invalidate_role_cache
|
||||||
|
from app.models.permission import Permission
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["角色权限"])
|
||||||
|
|
||||||
|
VALID_ROLES = ['admin', 'area_admin', 'school_admin', 'user']
|
||||||
|
|
||||||
|
|
||||||
|
class RolePermissionsUpdate(BaseModel):
|
||||||
|
permissions: list[str] # 权限码列表
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/permissions")
|
||||||
|
def get_all_permissions(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('user.view')),
|
||||||
|
):
|
||||||
|
"""获取所有权限码列表(按模块分组)"""
|
||||||
|
perms = db.query(Permission).order_by(Permission.module, Permission.code).all()
|
||||||
|
result = {}
|
||||||
|
for p in perms:
|
||||||
|
module = p.module or 'other'
|
||||||
|
if module not in result:
|
||||||
|
result[module] = []
|
||||||
|
result[module].append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"code": p.code,
|
||||||
|
"description": p.description,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles")
|
||||||
|
def get_roles(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('user.view')),
|
||||||
|
):
|
||||||
|
"""获取所有角色及其当前权限"""
|
||||||
|
rows = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT rp.role, p.code
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
ORDER BY rp.role, p.code
|
||||||
|
""")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
role_map: dict[str, list[str]] = {r: [] for r in VALID_ROLES}
|
||||||
|
for role, code in rows:
|
||||||
|
if role in role_map:
|
||||||
|
role_map[role].append(code)
|
||||||
|
|
||||||
|
# admin 特殊处理
|
||||||
|
role_map['admin'] = ['*']
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"role": role, "permissions": perms}
|
||||||
|
for role, perms in role_map.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/roles/{role}/permissions")
|
||||||
|
def update_role_permissions(
|
||||||
|
role: str,
|
||||||
|
body: RolePermissionsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('user.manage')),
|
||||||
|
):
|
||||||
|
"""更新角色权限(替换全量)"""
|
||||||
|
if role not in VALID_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"无效角色,可选:{', '.join(VALID_ROLES)}")
|
||||||
|
if role == 'admin':
|
||||||
|
raise HTTPException(status_code=400, detail="admin 角色权限不可修改")
|
||||||
|
|
||||||
|
# 验证权限码是否存在
|
||||||
|
if body.permissions:
|
||||||
|
existing = {p.code for p in db.query(Permission).filter(
|
||||||
|
Permission.code.in_(body.permissions)
|
||||||
|
).all()}
|
||||||
|
invalid = set(body.permissions) - existing
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(status_code=400, detail=f"无效权限码:{', '.join(invalid)}")
|
||||||
|
|
||||||
|
# 删除旧权限,插入新权限
|
||||||
|
db.execute(text("DELETE FROM role_permissions WHERE role = :role"), {"role": role})
|
||||||
|
if body.permissions:
|
||||||
|
# 查出权限 id 再插入,避免 ANY 语法兼容问题
|
||||||
|
perm_ids = db.execute(
|
||||||
|
text("SELECT id FROM permissions WHERE code IN :codes"),
|
||||||
|
{"codes": tuple(body.permissions)}
|
||||||
|
).fetchall()
|
||||||
|
for (pid,) in perm_ids:
|
||||||
|
db.execute(
|
||||||
|
text("INSERT INTO role_permissions (role, permission_id) VALUES (:role, :pid)"),
|
||||||
|
{"role": role, "pid": pid}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 清除 Redis 缓存
|
||||||
|
invalidate_role_cache(role)
|
||||||
|
|
||||||
|
return {"message": "权限更新成功"}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""系统设置 API(仅管理员)"""
|
||||||
|
import time
|
||||||
|
import redis as redis_lib
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["系统设置"])
|
||||||
|
|
||||||
|
MIN_CHECK_INTERVAL = 300 # 5 分钟
|
||||||
|
MAX_CHECK_INTERVAL = 86400 # 24 小时
|
||||||
|
|
||||||
|
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis():
|
||||||
|
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def get_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""获取所有系统设置,附带下次扫描时间"""
|
||||||
|
rows = db.query(SystemSetting).all()
|
||||||
|
result = {row.key: {"value": row.value, "description": row.description} for row in rows}
|
||||||
|
|
||||||
|
# 计算下次扫描时间
|
||||||
|
try:
|
||||||
|
r = _get_redis()
|
||||||
|
interval_str = r.get(_INTERVAL_REDIS_KEY)
|
||||||
|
last_run_str = r.get("check_all_devices:last_run")
|
||||||
|
is_running = bool(r.get("check_all_devices:running"))
|
||||||
|
interval = int(interval_str) if interval_str else 1800
|
||||||
|
next_run_ts = (float(last_run_str) + interval) if last_run_str else None
|
||||||
|
result["next_check_at"] = {
|
||||||
|
"value": str(int(next_run_ts)) if next_run_ts else None,
|
||||||
|
"running": is_running,
|
||||||
|
"description": "下次扫描时间戳(Unix)"
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
result["next_check_at"] = {"value": None, "running": False, "description": "下次扫描时间戳(Unix)"}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/check_interval")
|
||||||
|
def update_check_interval(
|
||||||
|
seconds: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""更新定时检查间隔(秒),范围 300~86400"""
|
||||||
|
if seconds < MIN_CHECK_INTERVAL:
|
||||||
|
raise HTTPException(status_code=400, detail=f"间隔不能小于 {MIN_CHECK_INTERVAL} 秒(5分钟)")
|
||||||
|
if seconds > MAX_CHECK_INTERVAL:
|
||||||
|
raise HTTPException(status_code=400, detail=f"间隔不能大于 {MAX_CHECK_INTERVAL} 秒(24小时)")
|
||||||
|
|
||||||
|
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
|
||||||
|
if setting:
|
||||||
|
setting.value = str(seconds)
|
||||||
|
else:
|
||||||
|
db.add(SystemSetting(key='check_interval_seconds', value=str(seconds), description='定时检查间隔(秒)'))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 同步到 Redis,让 Celery 任务立即生效
|
||||||
|
_get_redis().set(_INTERVAL_REDIS_KEY, str(seconds))
|
||||||
|
|
||||||
|
# 重置上次运行时间,让下次触发时立即按新间隔计算
|
||||||
|
_get_redis().delete("check_all_devices:last_run")
|
||||||
|
|
||||||
|
return {"key": "check_interval_seconds", "value": seconds}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/about")
|
||||||
|
def get_about(db: Session = Depends(get_db)):
|
||||||
|
"""获取关于页面内容(公开接口,无需登录)"""
|
||||||
|
setting = db.query(SystemSetting).filter_by(key='about_content').first()
|
||||||
|
return {"content": setting.value if setting else ""}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/about")
|
||||||
|
def update_about(
|
||||||
|
body: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('*')),
|
||||||
|
):
|
||||||
|
"""更新关于页面内容(仅管理员)"""
|
||||||
|
content = body.get("content", "")
|
||||||
|
setting = db.query(SystemSetting).filter_by(key='about_content').first()
|
||||||
|
if setting:
|
||||||
|
setting.value = content
|
||||||
|
else:
|
||||||
|
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
||||||
|
db.commit()
|
||||||
|
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": "企业微信配置已保存"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""统计 API"""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.models.device import ONUDevice, DeviceStatusHistory, DeviceDailySnapshot
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/stats", tags=["统计"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def get_dashboard(
|
||||||
|
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.region,
|
||||||
|
ONUDevice.school_name,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.region, ONUDevice.school_name)
|
||||||
|
.order_by(ONUDevice.region, ONUDevice.school_name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
overall = {"total": 0, "online": 0, "offline": 0}
|
||||||
|
urban_schools, suburban_schools = [], []
|
||||||
|
rural_towns = {}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
region = row.region or ""
|
||||||
|
total = int(row.total or 0)
|
||||||
|
online = int(row.online or 0)
|
||||||
|
offline = int(row.offline or 0)
|
||||||
|
overall["total"] += total
|
||||||
|
overall["online"] += online
|
||||||
|
overall["offline"] += offline
|
||||||
|
|
||||||
|
school_stat = {"name": row.school_name or "未知", "total": total, "online": online, "offline": offline}
|
||||||
|
|
||||||
|
if region == "城区":
|
||||||
|
urban_schools.append(school_stat)
|
||||||
|
elif region == "城郊":
|
||||||
|
suburban_schools.append(school_stat)
|
||||||
|
else:
|
||||||
|
if region not in rural_towns:
|
||||||
|
rural_towns[region] = {"region": region, "total": 0, "online": 0, "offline": 0, "schools": []}
|
||||||
|
rural_towns[region]["total"] += total
|
||||||
|
rural_towns[region]["online"] += online
|
||||||
|
rural_towns[region]["offline"] += offline
|
||||||
|
rural_towns[region]["schools"].append(school_stat)
|
||||||
|
|
||||||
|
def sort_by_rate(items):
|
||||||
|
return sorted(items, key=lambda x: x["online"] / x["total"] if x["total"] > 0 else 0)
|
||||||
|
|
||||||
|
def agg(items):
|
||||||
|
return {"total": sum(s["total"] for s in items), "online": sum(s["online"] for s in items), "offline": sum(s["offline"] for s in items)}
|
||||||
|
|
||||||
|
rural_list = sort_by_rate(list(rural_towns.values()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overall": overall,
|
||||||
|
"urban": {**agg(urban_schools), "schools": sort_by_rate(urban_schools)},
|
||||||
|
"suburban": {**agg(suburban_schools), "schools": sort_by_rate(suburban_schools)},
|
||||||
|
"rural": {**agg(list(rural_towns.values())), "towns": rural_list},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def get_summary(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取统计摘要"""
|
||||||
|
total = db.query(ONUDevice).count()
|
||||||
|
latest_status = db.query(
|
||||||
|
DeviceStatusHistory.status,
|
||||||
|
func.count(DeviceStatusHistory.id)
|
||||||
|
).group_by(DeviceStatusHistory.status).all()
|
||||||
|
status_dict = dict(latest_status)
|
||||||
|
return {"total": total, "online": status_dict.get('online', 0), "offline": status_dict.get('offline', 0)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-region")
|
||||||
|
def get_by_region(
|
||||||
|
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.region,
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||||
|
.group_by(ONUDevice.region)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"region": row.region or "未知",
|
||||||
|
"total": int(row.total or 0),
|
||||||
|
"online": int(row.online or 0),
|
||||||
|
"offline": int(row.offline or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trend")
|
||||||
|
def get_trend(
|
||||||
|
days: int = 7,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('device.view')),
|
||||||
|
):
|
||||||
|
"""获取状态趋势数据(优先查快照表,不足时实时聚合)"""
|
||||||
|
today = date.today()
|
||||||
|
date_range = [(today - timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days - 1, -1, -1)]
|
||||||
|
|
||||||
|
# 查快照表(不含今天,今天用实时数据)
|
||||||
|
snapshots = (
|
||||||
|
db.query(DeviceDailySnapshot)
|
||||||
|
.filter(DeviceDailySnapshot.snapshot_date.in_(date_range[:-1]))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
||||||
|
|
||||||
|
# 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况
|
||||||
|
today_str = today.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
latest_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||||
|
)
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
today_row = (
|
||||||
|
db.query(
|
||||||
|
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for d in date_range:
|
||||||
|
if d == today_str:
|
||||||
|
result.append({
|
||||||
|
"date": d,
|
||||||
|
"online": int(today_row.online or 0),
|
||||||
|
"offline": int(today_row.offline or 0),
|
||||||
|
})
|
||||||
|
elif d in snapshot_map:
|
||||||
|
s = snapshot_map[d]
|
||||||
|
result.append({"date": d, "online": s.online, "offline": s.offline})
|
||||||
|
else:
|
||||||
|
# 快照缺失时实时聚合该天数据
|
||||||
|
day = datetime.strptime(d, '%Y-%m-%d').date()
|
||||||
|
day_start = datetime.combine(day, datetime.min.time())
|
||||||
|
day_end = datetime.combine(day, datetime.max.time())
|
||||||
|
subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||||
|
)
|
||||||
|
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
row = (
|
||||||
|
db.query(
|
||||||
|
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
result.append({"date": d, "online": int(row.online or 0), "offline": int(row.offline or 0)})
|
||||||
|
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""用户管理 API"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import asc
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.middleware.permission_middleware import require_permission
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserListResponse, UserListItem, UserRoleUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["用户管理"])
|
||||||
|
|
||||||
|
VALID_ROLES = {'admin', 'area_admin', 'school_admin', 'user'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserListResponse)
|
||||||
|
def get_users(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
role: str = None,
|
||||||
|
keyword: str = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_permission('user.view')),
|
||||||
|
):
|
||||||
|
"""获取用户列表"""
|
||||||
|
query = db.query(User)
|
||||||
|
if role:
|
||||||
|
query = query.filter(User.role == role)
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
User.username.contains(keyword) | User.display_name.contains(keyword) | User.email.contains(keyword)
|
||||||
|
)
|
||||||
|
query = query.order_by(asc(User.created_at))
|
||||||
|
total = query.count()
|
||||||
|
items = query.offset(skip).limit(limit).all()
|
||||||
|
return {"total": total, "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/role")
|
||||||
|
def update_user_role(
|
||||||
|
user_id: int,
|
||||||
|
body: UserRoleUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('user.manage')),
|
||||||
|
):
|
||||||
|
"""修改用户角色及分配区域/学校"""
|
||||||
|
if body.role not in VALID_ROLES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"无效角色,可选:{', '.join(VALID_ROLES)}")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
# 不允许修改自己的角色
|
||||||
|
if str(user.id) == current.get('sub'):
|
||||||
|
raise HTTPException(status_code=400, detail="不能修改自己的角色")
|
||||||
|
|
||||||
|
user.role = body.role
|
||||||
|
user.assigned_area = body.assigned_area
|
||||||
|
user.assigned_school = body.assigned_school
|
||||||
|
db.commit()
|
||||||
|
return {"message": "更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/toggle")
|
||||||
|
def toggle_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current: dict = Depends(require_permission('user.manage')),
|
||||||
|
):
|
||||||
|
"""启用/禁用用户"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
|
|
||||||
|
if str(user.id) == current.get('sub'):
|
||||||
|
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||||
|
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
db.commit()
|
||||||
|
return {"message": "已禁用" if not user.is_active else "已启用", "is_active": user.is_active}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Casdoor 认证配置"""
|
||||||
|
from casdoor import CasdoorSDK
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
casdoor_sdk = CasdoorSDK(
|
||||||
|
endpoint=settings.CASDOOR_ENDPOINT,
|
||||||
|
client_id=settings.CASDOOR_CLIENT_ID,
|
||||||
|
client_secret=settings.CASDOOR_CLIENT_SECRET,
|
||||||
|
certificate=settings.casdoor_cert_content,
|
||||||
|
org_name=settings.CASDOOR_ORG_NAME,
|
||||||
|
application_name=settings.CASDOOR_APP_NAME,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Celery 配置"""
|
||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
celery_app = Celery(
|
||||||
|
"h3c_onu_ms",
|
||||||
|
broker=settings.REDIS_URL,
|
||||||
|
backend=settings.REDIS_URL
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer='json',
|
||||||
|
result_serializer='json',
|
||||||
|
accept_content=['json'],
|
||||||
|
timezone='Asia/Shanghai',
|
||||||
|
enable_utc=True,
|
||||||
|
# 使用专属队列,避免与同 Redis 上的其他 Celery 项目抢任务
|
||||||
|
task_default_queue='h3c_onu_ms',
|
||||||
|
beat_schedule={
|
||||||
|
# 每5分钟触发一次(最小间隔),任务内部根据配置的间隔自行节流
|
||||||
|
'check-devices-scheduler': {
|
||||||
|
'task': 'app.tasks.check_tasks.check_all_devices',
|
||||||
|
'schedule': 300,
|
||||||
|
'options': {'queue': 'h3c_onu_ms'},
|
||||||
|
},
|
||||||
|
'aggregate-daily-snapshot': {
|
||||||
|
'task': 'app.tasks.check_tasks.aggregate_daily_snapshot',
|
||||||
|
'schedule': crontab(hour=1, minute=0), # 每天凌晨 1:00
|
||||||
|
'options': {'queue': 'h3c_onu_ms'},
|
||||||
|
},
|
||||||
|
'cleanup-audit-logs': {
|
||||||
|
'task': 'app.tasks.audit_tasks.cleanup_audit_logs_task',
|
||||||
|
'schedule': crontab(hour=2, minute=0), # 每天凌晨 2:00
|
||||||
|
'options': {'queue': 'h3c_onu_ms'},
|
||||||
|
},
|
||||||
|
'cleanup-status-history': {
|
||||||
|
'task': 'app.tasks.check_tasks.cleanup_status_history',
|
||||||
|
'schedule': crontab(hour=3, minute=0), # 每天凌晨 3:00
|
||||||
|
'options': {'queue': 'h3c_onu_ms'},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""应用配置"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
APP_NAME: str = "H3C-ONU-MS"
|
||||||
|
DEBUG: bool = False
|
||||||
|
SECRET_KEY: str
|
||||||
|
|
||||||
|
DATABASE_URL: str
|
||||||
|
REDIS_URL: str
|
||||||
|
|
||||||
|
CASDOOR_ENDPOINT: str
|
||||||
|
CASDOOR_CLIENT_ID: str
|
||||||
|
CASDOOR_CLIENT_SECRET: str
|
||||||
|
CASDOOR_ORG_NAME: str
|
||||||
|
CASDOOR_APP_NAME: str
|
||||||
|
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||||
|
CASDOOR_REDIRECT_URL: str = ""
|
||||||
|
|
||||||
|
SSH_TIMEOUT: int = 30
|
||||||
|
CHECK_INTERVAL: int = 1800
|
||||||
|
MANUAL_COOLDOWN: int = 300
|
||||||
|
|
||||||
|
# CORS & 前端
|
||||||
|
CORS_ORIGINS: str = "" # 逗号分隔
|
||||||
|
FRONTEND_URL: str = "https://onu.dhdx.fun"
|
||||||
|
|
||||||
|
# NTP 同步配置
|
||||||
|
NTP_OLD_SERVER: str = "172.16.0.254"
|
||||||
|
NTP_NEW_SERVER: str = "172.16.1.252"
|
||||||
|
|
||||||
|
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||||
|
IMC_API_URL: str = ""
|
||||||
|
|
||||||
|
# 企业微信应用消息 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_PASSWORD: str = ""
|
||||||
|
IMC_API_VERIFY_SSL: bool = False
|
||||||
|
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||||
|
IMC_READ_TIMEOUT: float = 20.0
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = str(PROJECT_ROOT / ".env")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def casdoor_cert_content(self) -> str:
|
||||||
|
"""读取证书文件内容或直接返回证书字符串"""
|
||||||
|
cert = self.CASDOOR_CERTIFICATE
|
||||||
|
if not cert:
|
||||||
|
return ""
|
||||||
|
cert_path = Path(cert)
|
||||||
|
if cert_path.is_absolute():
|
||||||
|
path = cert_path
|
||||||
|
else:
|
||||||
|
# 相对路径基于项目根目录解析
|
||||||
|
path = PROJECT_ROOT / cert
|
||||||
|
if path.is_file():
|
||||||
|
return path.read_text()
|
||||||
|
return cert # 直接是 PEM 内容
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""数据库配置"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=20,
|
||||||
|
max_overflow=40,
|
||||||
|
pool_recycle=3600,
|
||||||
|
pool_timeout=30,
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""JWT 安全配置"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict):
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""FastAPI 主应用"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pythonjsonlogger import jsonlogger
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
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, wechat, ws, monitor
|
||||||
|
from app.middleware.audit_middleware import AuditMiddleware
|
||||||
|
|
||||||
|
# 结构化 JSON 日志
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
|
||||||
|
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(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(AuditMiddleware)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(devices.router)
|
||||||
|
app.include_router(check.router)
|
||||||
|
app.include_router(import_data.router)
|
||||||
|
app.include_router(stats.router)
|
||||||
|
app.include_router(olt.router)
|
||||||
|
app.include_router(provision.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(roles.router)
|
||||||
|
app.include_router(inventory.router)
|
||||||
|
app.include_router(settings_api.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")
|
||||||
|
def health_check():
|
||||||
|
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()
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""审计日志中间件:拦截所有 API 请求,异步写入审计日志"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
from app.core.security import verify_token
|
||||||
|
|
||||||
|
# 不记录审计日志的路径前缀
|
||||||
|
_SKIP_PATHS = {
|
||||||
|
"/health",
|
||||||
|
"/docs",
|
||||||
|
"/redoc",
|
||||||
|
"/openapi.json",
|
||||||
|
"/api/auth/login", # 仅获取登录 URL,无用户身份
|
||||||
|
"/api/auth/permissions", # 高频只读
|
||||||
|
"/api/stats/",
|
||||||
|
"/api/olt/regions",
|
||||||
|
"/api/olt/new-devices",
|
||||||
|
"/api/olt/duplicate-macs",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 只记录写操作 + 登录回调 + 特定查询(GET 默认跳过,以下 GET 例外)
|
||||||
|
_ALWAYS_LOG_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||||
|
_LOG_GET_PATHS = {
|
||||||
|
"/api/auth/profile",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _should_log(method: str, path: str) -> bool:
|
||||||
|
for skip in _SKIP_PATHS:
|
||||||
|
if path.startswith(skip):
|
||||||
|
return False
|
||||||
|
if method in _ALWAYS_LOG_METHODS:
|
||||||
|
return True
|
||||||
|
if method == "GET":
|
||||||
|
return path in _LOG_GET_PATHS
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token_payload(request: Request) -> dict:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
payload = verify_token(auth[7:])
|
||||||
|
if payload:
|
||||||
|
return payload
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
if not _should_log(method, path):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# 读取请求体(只读一次,需要重新构造)
|
||||||
|
request_params = None
|
||||||
|
try:
|
||||||
|
body_bytes = await request.body()
|
||||||
|
if body_bytes:
|
||||||
|
try:
|
||||||
|
request_params = json.loads(body_bytes)
|
||||||
|
# 脱敏:移除密码字段
|
||||||
|
if isinstance(request_params, dict):
|
||||||
|
for k in ("password", "passwd", "secret"):
|
||||||
|
if k in request_params:
|
||||||
|
request_params[k] = "***"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = int((time.time() - start_time) * 1000)
|
||||||
|
|
||||||
|
# 异步写日志(不等待)
|
||||||
|
try:
|
||||||
|
payload = _extract_token_payload(request)
|
||||||
|
user_id = payload.get("sub", "anonymous")
|
||||||
|
username = payload.get("username", "anonymous")
|
||||||
|
user_role = payload.get("role", "")
|
||||||
|
ip_address = ""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
ip_address = forwarded.split(",")[0].strip()
|
||||||
|
elif request.client:
|
||||||
|
ip_address = request.client.host
|
||||||
|
|
||||||
|
from app.tasks.audit_tasks import create_audit_log_task
|
||||||
|
create_audit_log_task.delay(
|
||||||
|
user_id=str(user_id),
|
||||||
|
username=username,
|
||||||
|
user_role=user_role,
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=request.headers.get("user-agent", "")[:500],
|
||||||
|
status_code=response.status_code,
|
||||||
|
request_params=request_params,
|
||||||
|
response_data=None, # 不捕获响应体(性能考虑)
|
||||||
|
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # 中间件异常绝不影响主响应
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from fastapi import HTTPException, Depends, Header
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import verify_token
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_redis_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis() -> redis.Redis:
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_role_permissions(role: str, db: Session) -> list:
|
||||||
|
"""从数据库加载角色权限,结果缓存到 Redis(TTL 5分钟)"""
|
||||||
|
if role == 'admin':
|
||||||
|
return ['*']
|
||||||
|
|
||||||
|
r = get_redis()
|
||||||
|
cache_key = f"permissions:role:{role}"
|
||||||
|
cached = r.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT p.code FROM permissions p
|
||||||
|
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||||
|
WHERE rp.role = :role
|
||||||
|
"""),
|
||||||
|
{"role": role}
|
||||||
|
).fetchall()
|
||||||
|
perms = [row[0] for row in rows]
|
||||||
|
|
||||||
|
r.setex(cache_key, 300, json.dumps(perms))
|
||||||
|
return perms
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_role_cache(role: str) -> None:
|
||||||
|
"""修改角色权限后清除缓存"""
|
||||||
|
get_redis().delete(f"permissions:role:{role}")
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(permission: str):
|
||||||
|
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||||
|
def dependency(
|
||||||
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
if not authorization:
|
||||||
|
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
|
token = authorization[7:]
|
||||||
|
payload = verify_token(token)
|
||||||
|
if not payload:
|
||||||
|
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
|
||||||
|
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||||
|
|
||||||
|
role = payload.get('role', 'user')
|
||||||
|
perms = get_role_permissions(role, db)
|
||||||
|
|
||||||
|
if '*' not in perms and permission not in perms:
|
||||||
|
raise HTTPException(status_code=403, detail="权限不足")
|
||||||
|
|
||||||
|
# 附加用户的区域/学校分配信息,供数据范围过滤使用
|
||||||
|
user_id = payload.get('sub')
|
||||||
|
if user_id and role in ('area_admin', 'school_admin'):
|
||||||
|
from app.models.user import User
|
||||||
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||||
|
if user:
|
||||||
|
payload['assigned_area'] = user.assigned_area
|
||||||
|
payload['assigned_school'] = user.assigned_school
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
authorization: str = Header(None, alias="Authorization"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""仅验证登录状态,不检查具体权限"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="未授权")
|
||||||
|
token = authorization[7:]
|
||||||
|
payload = verify_token(token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""审计日志数据库模型"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# 用户信息
|
||||||
|
user_id = Column(String(100), nullable=False)
|
||||||
|
username = Column(String(100), nullable=False)
|
||||||
|
user_role = Column(String(50))
|
||||||
|
|
||||||
|
# 操作信息
|
||||||
|
action_time = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||||
|
action_type = Column(String(50), nullable=False) # auth/device/olt/user/system/inventory
|
||||||
|
action_subtype = Column(String(50)) # create/update/delete/login/...
|
||||||
|
|
||||||
|
# 请求信息
|
||||||
|
ip_address = Column(String(45))
|
||||||
|
user_agent = Column(Text)
|
||||||
|
request_method = Column(String(10))
|
||||||
|
request_path = Column(String(500))
|
||||||
|
|
||||||
|
# 操作结果
|
||||||
|
status = Column(String(20), nullable=False) # success/failed/error
|
||||||
|
status_code = Column(Integer)
|
||||||
|
|
||||||
|
# 资源信息
|
||||||
|
resource_type = Column(String(50))
|
||||||
|
resource_id = Column(String(100))
|
||||||
|
resource_name = Column(String(200))
|
||||||
|
|
||||||
|
# 日志内容
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
request_params = Column(JSONB)
|
||||||
|
response_data = Column(JSONB)
|
||||||
|
error_message = Column(Text)
|
||||||
|
|
||||||
|
created_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_audit_logs_action_time", "action_time"),
|
||||||
|
Index("idx_audit_logs_user_id", "user_id"),
|
||||||
|
Index("idx_audit_logs_action_type", "action_type"),
|
||||||
|
Index("idx_audit_logs_resource_type", "resource_type"),
|
||||||
|
Index("idx_audit_logs_status", "status"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""设备数据模型"""
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTAMP, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class OLTDevice(Base):
|
||||||
|
__tablename__ = "olt_devices"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
ip_address = Column(String(45), nullable=False)
|
||||||
|
username = Column(String(100), nullable=False)
|
||||||
|
password = Column(Text, nullable=False)
|
||||||
|
slot_command = Column(String(50), nullable=False)
|
||||||
|
region = Column(String(100), index=True)
|
||||||
|
location = Column(String(200))
|
||||||
|
description = Column(Text)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ONUDevice(Base):
|
||||||
|
__tablename__ = "onu_devices"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
mac_address = Column(String(17), nullable=False, index=True)
|
||||||
|
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"))
|
||||||
|
slot_number = Column(Integer)
|
||||||
|
port_number = Column(Integer)
|
||||||
|
port_id = Column(String(20)) # 完整端口标识,如 "1/0/2:4"
|
||||||
|
# OLT 返回的额外信息
|
||||||
|
loid = Column(String(50)) # LOID
|
||||||
|
model = Column(String(100)) # 设备型号
|
||||||
|
distance_m = Column(Integer) # 距离(米)
|
||||||
|
region = Column(String(100), index=True)
|
||||||
|
school_name = Column(String(200), index=True)
|
||||||
|
building = Column(String(100))
|
||||||
|
place_type = Column(String(50)) # 场所类型
|
||||||
|
room_number = Column(String(50))
|
||||||
|
notes = Column(Text) # 备注
|
||||||
|
tags = Column(Text) # 标签(逗号分隔),如 "重点设备,考试用"
|
||||||
|
latitude = Column(Float, nullable=True) # 纬度
|
||||||
|
longitude = Column(Float, nullable=True) # 经度
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
# 关联状态历史
|
||||||
|
status_history = relationship("DeviceStatusHistory", back_populates="device", lazy="selectin")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatusHistory(Base):
|
||||||
|
__tablename__ = "device_status_history"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"))
|
||||||
|
status = Column(String(20), nullable=False) # online, offline
|
||||||
|
distance_m = Column(Integer) # 距离(米)
|
||||||
|
checked_at = Column(TIMESTAMP, nullable=False)
|
||||||
|
response_data = Column(Text)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
device = relationship("ONUDevice", back_populates="status_history")
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateMac(Base):
|
||||||
|
"""重复 MAC 地址记录(同一 MAC 出现在多个端口)"""
|
||||||
|
__tablename__ = "duplicate_macs"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||||
|
mac_address = Column(String(17), nullable=False, index=True)
|
||||||
|
# 所有出现的端口列表,JSON 格式: [{"port_id": "1/0/1:1", "status": "online"}, ...]
|
||||||
|
ports = Column(JSON, nullable=False)
|
||||||
|
first_seen_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
last_seen_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceDailySnapshot(Base):
|
||||||
|
"""设备每日状态快照(用于趋势图,避免全量扫描历史表)"""
|
||||||
|
__tablename__ = "device_daily_snapshots"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
snapshot_date = Column(String(10), nullable=False, index=True) # YYYY-MM-DD
|
||||||
|
total = Column(Integer, nullable=False, default=0)
|
||||||
|
online = Column(Integer, nullable=False, default=0)
|
||||||
|
offline = Column(Integer, nullable=False, default=0)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class NewDevice(Base):
|
||||||
|
"""新发现设备(OLT 扫描到但尚未补全信息的设备)"""
|
||||||
|
__tablename__ = "new_devices"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, unique=True)
|
||||||
|
olt_id = Column(BigInteger, ForeignKey("olt_devices.id"), nullable=False)
|
||||||
|
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""设备更换记录"""
|
||||||
|
__tablename__ = "device_replacements"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
|
||||||
|
old_mac = Column(String(17), nullable=False)
|
||||||
|
new_mac = Column(String(17), nullable=False)
|
||||||
|
reason = Column(Text)
|
||||||
|
operator_id = Column(String(100)) # 操作人 user_id
|
||||||
|
operator_name = Column(String(100))
|
||||||
|
replaced_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""库存管理数据模型"""
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, Boolean, Numeric, Date
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialCategory(Base):
|
||||||
|
__tablename__ = "material_categories"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
code = Column(String(50), unique=True, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
materials = relationship("Material", back_populates="category")
|
||||||
|
|
||||||
|
|
||||||
|
class Material(Base):
|
||||||
|
__tablename__ = "materials"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
category_id = Column(BigInteger, ForeignKey("material_categories.id"))
|
||||||
|
name = Column(String(200), nullable=False)
|
||||||
|
model = Column(String(100))
|
||||||
|
specification = Column(Text)
|
||||||
|
brand = Column(String(100))
|
||||||
|
unit = Column(String(20), default="个")
|
||||||
|
safe_quantity = Column(Integer, default=0)
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
category = relationship("MaterialCategory", back_populates="materials")
|
||||||
|
batches = relationship("InventoryBatch", back_populates="material")
|
||||||
|
serial_devices = relationship("SerialDevice", back_populates="material")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryBatch(Base):
|
||||||
|
__tablename__ = "inventory_batches"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||||
|
batch_no = Column(String(50), nullable=False)
|
||||||
|
quantity = Column(Integer, nullable=False)
|
||||||
|
available_quantity = Column(Integer, nullable=False, default=0)
|
||||||
|
supplier = Column(String(200))
|
||||||
|
purchase_date = Column(Date)
|
||||||
|
purchase_price = Column(Numeric(10, 2))
|
||||||
|
expiry_date = Column(Date)
|
||||||
|
location = Column(String(100))
|
||||||
|
status = Column(String(20), default="in_stock") # in_stock, reserved, out_of_stock
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
material = relationship("Material", back_populates="batches")
|
||||||
|
serial_devices = relationship("SerialDevice", back_populates="batch")
|
||||||
|
|
||||||
|
|
||||||
|
class SerialDevice(Base):
|
||||||
|
__tablename__ = "serial_devices"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||||
|
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"))
|
||||||
|
serial_no = Column(String(100), unique=True, nullable=False)
|
||||||
|
mac_address = Column(String(17), index=True)
|
||||||
|
asset_no = Column(String(50))
|
||||||
|
status = Column(String(20), default="in_stock", index=True)
|
||||||
|
# in_stock, allocated, installed, in_use, returned, repairing, scrapped
|
||||||
|
current_location = Column(String(200))
|
||||||
|
installed_info = Column(JSONB)
|
||||||
|
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=True)
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
material = relationship("Material", back_populates="serial_devices")
|
||||||
|
batch = relationship("InventoryBatch", back_populates="serial_devices")
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryTransaction(Base):
|
||||||
|
__tablename__ = "inventory_transactions"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
transaction_no = Column(String(50), unique=True, nullable=False)
|
||||||
|
transaction_type = Column(String(20), nullable=False)
|
||||||
|
# purchase_in, allocate_out, return_in, scrap_out, adjust
|
||||||
|
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||||
|
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
|
||||||
|
serial_device_id = Column(BigInteger, ForeignKey("serial_devices.id"), nullable=True)
|
||||||
|
quantity = Column(Integer, nullable=False)
|
||||||
|
from_status = Column(String(20))
|
||||||
|
to_status = Column(String(20))
|
||||||
|
operator_id = Column(BigInteger, ForeignKey("users.id"))
|
||||||
|
project_name = Column(String(200))
|
||||||
|
installation_info = Column(JSONB)
|
||||||
|
notes = Column(Text)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryCheck(Base):
|
||||||
|
__tablename__ = "inventory_checks"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
check_no = Column(String(50), unique=True, nullable=False)
|
||||||
|
check_date = Column(Date, nullable=False)
|
||||||
|
checker_id = Column(BigInteger, ForeignKey("users.id"))
|
||||||
|
material_id = Column(BigInteger, ForeignKey("materials.id"))
|
||||||
|
batch_id = Column(BigInteger, ForeignKey("inventory_batches.id"), nullable=True)
|
||||||
|
book_quantity = Column(Integer)
|
||||||
|
actual_quantity = Column(Integer)
|
||||||
|
difference = Column(Integer)
|
||||||
|
reason = Column(Text)
|
||||||
|
adjusted = Column(Boolean, default=False)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""权限数据模型"""
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Text, ForeignKey, Table
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
role_permissions = Table(
|
||||||
|
'role_permissions',
|
||||||
|
Base.metadata,
|
||||||
|
Column('role', String(50), primary_key=True),
|
||||||
|
Column('permission_id', BigInteger, ForeignKey('permissions.id'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(Base):
|
||||||
|
__tablename__ = "permissions"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
code = Column(String(50), unique=True, nullable=False)
|
||||||
|
module = Column(String(50))
|
||||||
|
description = Column(Text)
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""系统设置模型"""
|
||||||
|
from sqlalchemy import Column, String, Text, TIMESTAMP
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSetting(Base):
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
|
key = Column(String(100), primary_key=True)
|
||||||
|
value = Column(Text, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""用户数据模型"""
|
||||||
|
from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, index=True)
|
||||||
|
casdoor_id = Column(String(100), unique=True, nullable=False)
|
||||||
|
username = Column(String(100), nullable=False)
|
||||||
|
display_name = Column(String(100)) # 中文姓名
|
||||||
|
email = Column(String(255))
|
||||||
|
role = Column(String(50), default="user")
|
||||||
|
assigned_area = Column(String(100))
|
||||||
|
assigned_school = Column(String(200))
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
last_login = Column(TIMESTAMP)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""认证相关 Schema"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: Optional[str]
|
||||||
|
role: str
|
||||||
|
assigned_area: Optional[str]
|
||||||
|
assigned_school: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""设备相关 Schema"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ONUDeviceBase(BaseModel):
|
||||||
|
mac_address: str
|
||||||
|
olt_id: Optional[int] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
school_name: Optional[str] = None
|
||||||
|
building: Optional[str] = None
|
||||||
|
place_type: Optional[str] = None # 场所类型
|
||||||
|
room_number: Optional[str] = None
|
||||||
|
notes: Optional[str] = None # 备注
|
||||||
|
|
||||||
|
|
||||||
|
class ONUDeviceResponse(ONUDeviceBase):
|
||||||
|
id: int
|
||||||
|
status: Optional[str] = None
|
||||||
|
distance_m: Optional[int] = None
|
||||||
|
slot_number: Optional[int] = None
|
||||||
|
port_number: Optional[int] = None
|
||||||
|
port_id: Optional[str] = None # 完整端口标识,如 "1/0/2:4"
|
||||||
|
model: Optional[str] = None
|
||||||
|
olt_location: Optional[str] = None # OLT 安装位置
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[ONUDeviceResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class RebootResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class OpticalPowerResponse(BaseModel):
|
||||||
|
power_in: Optional[str] = None
|
||||||
|
power_out: Optional[str] = None
|
||||||
|
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
|
||||||
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""库存管理 Pydantic 模式"""
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
# ── 物料分类 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CategoryCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 物料 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class MaterialCreate(BaseModel):
|
||||||
|
category_id: int
|
||||||
|
name: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
specification: Optional[str] = None
|
||||||
|
brand: Optional[str] = None
|
||||||
|
unit: str = "个"
|
||||||
|
safe_quantity: int = 0
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
specification: Optional[str] = None
|
||||||
|
brand: Optional[str] = None
|
||||||
|
unit: Optional[str] = None
|
||||||
|
safe_quantity: Optional[int] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
category_name: Optional[str] = None
|
||||||
|
name: str
|
||||||
|
model: Optional[str] = None
|
||||||
|
specification: Optional[str] = None
|
||||||
|
brand: Optional[str] = None
|
||||||
|
unit: str
|
||||||
|
safe_quantity: int
|
||||||
|
notes: Optional[str] = None
|
||||||
|
total_quantity: int = 0
|
||||||
|
available_quantity: int = 0
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: List[MaterialResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 库存批次 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BatchCreate(BaseModel):
|
||||||
|
material_id: int
|
||||||
|
batch_no: str
|
||||||
|
quantity: int
|
||||||
|
supplier: Optional[str] = None
|
||||||
|
purchase_date: Optional[date] = None
|
||||||
|
purchase_price: Optional[Decimal] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
material_id: int
|
||||||
|
material_name: Optional[str] = None
|
||||||
|
batch_no: str
|
||||||
|
quantity: int
|
||||||
|
available_quantity: int
|
||||||
|
supplier: Optional[str] = None
|
||||||
|
purchase_date: Optional[date] = None
|
||||||
|
purchase_price: Optional[Decimal] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 序列号设备 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SerialDeviceCreate(BaseModel):
|
||||||
|
material_id: int
|
||||||
|
batch_id: Optional[int] = None
|
||||||
|
serial_no: str
|
||||||
|
mac_address: Optional[str] = None
|
||||||
|
asset_no: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SerialDeviceResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
material_id: int
|
||||||
|
material_name: Optional[str] = None
|
||||||
|
batch_id: Optional[int] = None
|
||||||
|
serial_no: str
|
||||||
|
mac_address: Optional[str] = None
|
||||||
|
asset_no: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
current_location: Optional[str] = None
|
||||||
|
installed_info: Optional[Any] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SerialDeviceListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: List[SerialDeviceResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 出入库操作 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PurchaseInRequest(BaseModel):
|
||||||
|
material_id: int
|
||||||
|
batch_no: str
|
||||||
|
quantity: int
|
||||||
|
supplier: Optional[str] = None
|
||||||
|
purchase_date: Optional[date] = None
|
||||||
|
purchase_price: Optional[Decimal] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
serial_nos: Optional[List[str]] = None # 高价值设备的序列号列表
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AllocateOutRequest(BaseModel):
|
||||||
|
serial_device_id: Optional[int] = None # 序列号设备
|
||||||
|
batch_id: Optional[int] = None # 批次设备
|
||||||
|
quantity: int = 1
|
||||||
|
project_name: Optional[str] = None
|
||||||
|
installation_info: Optional[Any] = None
|
||||||
|
# 方案三:领用时补录设备标识(适用于批次出库场景)
|
||||||
|
mac_address: Optional[str] = None # MAC 地址
|
||||||
|
serial_no: Optional[str] = None # 序列号
|
||||||
|
asset_no: Optional[str] = None # 资产编号
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnInRequest(BaseModel):
|
||||||
|
serial_device_id: int
|
||||||
|
return_type: str # simple, repair, scrap
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
transaction_no: str
|
||||||
|
transaction_type: str
|
||||||
|
material_id: int
|
||||||
|
material_name: Optional[str] = None
|
||||||
|
quantity: int
|
||||||
|
from_status: Optional[str] = None
|
||||||
|
to_status: Optional[str] = None
|
||||||
|
operator_id: Optional[int] = None
|
||||||
|
project_name: Optional[str] = None
|
||||||
|
installation_info: Optional[Any] = None # 包含补录的设备信息
|
||||||
|
notes: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: List[TransactionResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 盘点 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CheckCreate(BaseModel):
|
||||||
|
check_date: date
|
||||||
|
material_id: int
|
||||||
|
batch_id: Optional[int] = None
|
||||||
|
actual_quantity: int
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CheckResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
check_no: str
|
||||||
|
check_date: date
|
||||||
|
material_id: int
|
||||||
|
material_name: Optional[str] = None
|
||||||
|
batch_id: Optional[int] = None
|
||||||
|
book_quantity: Optional[int] = None
|
||||||
|
actual_quantity: Optional[int] = None
|
||||||
|
difference: Optional[int] = None
|
||||||
|
reason: Optional[str] = None
|
||||||
|
adjusted: bool
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CheckListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: List[CheckResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 统计 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class InventorySummary(BaseModel):
|
||||||
|
total_materials: int
|
||||||
|
total_quantity: int
|
||||||
|
available_quantity: int
|
||||||
|
allocated_quantity: int
|
||||||
|
low_stock_count: int
|
||||||
|
category_stats: List[dict]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""用户相关 Schema"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserListItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
role: str
|
||||||
|
assigned_area: Optional[str] = None
|
||||||
|
assigned_school: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[UserListItem]
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleUpdate(BaseModel):
|
||||||
|
role: str # admin / area_admin / school_admin / user
|
||||||
|
assigned_area: Optional[str] = None
|
||||||
|
assigned_school: Optional[str] = None
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""审计日志服务"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_LOG_DIR = os.environ.get("AUDIT_LOG_DIR", "/app/logs/audit")
|
||||||
|
AUDIT_LOG_RETENTION_DAYS = int(os.environ.get("AUDIT_LOG_RETENTION_DAYS", "90"))
|
||||||
|
|
||||||
|
# 路由分类规则:(method, path_pattern, exact, action_type, action_subtype, resource_type, description)
|
||||||
|
# exact=True 精确匹配路径;exact=False 前缀匹配。
|
||||||
|
# 精确匹配规则排在前,前缀匹配按从长到短排列,避免短前缀误匹配。
|
||||||
|
_ROUTE_MAP = [
|
||||||
|
# ── 认证 ──────────────────────────────────────────────────────
|
||||||
|
("POST", "/api/auth/callback", True, "auth", "login", "user", "用户登录"),
|
||||||
|
("GET", "/api/auth/profile", True, "auth", "profile", "user", "查看个人信息"),
|
||||||
|
# ── OLT:精确路径(不含 ID 段)────────────────────────────────
|
||||||
|
("GET", "/api/olt/devices", True, "olt", "list", "olt", "查询 OLT 设备列表"),
|
||||||
|
("POST", "/api/olt/devices", True, "olt", "create", "olt", "新建 OLT 设备"),
|
||||||
|
("POST", "/api/olt/import", True, "olt", "import", "olt", "批量导入 OLT 设备"),
|
||||||
|
("POST", "/api/olt/quick-scan", True, "olt", "quick_scan", "olt", "触发快速扫描"),
|
||||||
|
("POST", "/api/olt/loopback-detection", True, "olt", "loopback", "olt", "触发环路检测"),
|
||||||
|
# OLT 端口操作(路径含 ID,前缀匹配;/ports/toggle 比 /devices/ 更具体,先列)
|
||||||
|
("POST", "/api/olt/devices/", False, "olt", "port_toggle", "olt", "切换 OLT 端口状态"),
|
||||||
|
("GET", "/api/olt/devices/", False, "olt", "port_list", "olt", "查询 OLT 端口列表"),
|
||||||
|
("PUT", "/api/olt/devices/", False, "olt", "update", "olt", "更新 OLT 设备信息"),
|
||||||
|
("DELETE", "/api/olt/devices/", False, "olt", "delete", "olt", "删除 OLT 设备"),
|
||||||
|
# 重复 MAC
|
||||||
|
("POST", "/api/olt/duplicate-macs/", False, "olt", "port_clear", "olt", "清除重复 MAC 端口占用"),
|
||||||
|
("DELETE", "/api/olt/duplicate-macs/", False, "olt", "mac_delete", "olt", "删除重复 MAC 记录"),
|
||||||
|
# 新发现设备
|
||||||
|
("PUT", "/api/olt/new-devices/", False, "olt", "device_fill", "olt", "补全新发现设备信息"),
|
||||||
|
("DELETE", "/api/olt/new-devices/", False, "olt", "device_ignore", "olt", "忽略新发现设备"),
|
||||||
|
# ── ONU 设备 ──────────────────────────────────────────────────
|
||||||
|
("POST", "/api/check/status", True, "system", "scan_trigger", "device", "手动触发全量状态扫描"),
|
||||||
|
("POST", "/api/import/upload", True, "device", "import", "device", "批量导入 ONU 设备"),
|
||||||
|
("DELETE", "/api/devices/status/all", True, "device", "status_clear", "device", "清除所有设备状态"),
|
||||||
|
("POST", "/api/devices/", False, "device", "refresh", "device", "刷新单台设备在线状态"),
|
||||||
|
("PUT", "/api/devices/", False, "device", "update", "device", "更新 ONU 设备信息"),
|
||||||
|
("DELETE", "/api/devices/", False, "device", "delete", "device", "删除 ONU 设备"),
|
||||||
|
# 设备更换(路径含 /replace,需在 refresh 前匹配,通过 _classify 特殊处理)
|
||||||
|
("POST", "/replace", False, "device", "replace", "device", "更换设备 MAC 地址"),
|
||||||
|
# ── 用户管理 ──────────────────────────────────────────────────
|
||||||
|
("POST", "/api/users", True, "user", "create", "user", "创建用户"),
|
||||||
|
("PUT", "/api/users/", False, "user", "update", "user", "更新用户信息"),
|
||||||
|
("DELETE", "/api/users/", False, "user", "delete", "user", "删除用户"),
|
||||||
|
# ── 角色权限 ──────────────────────────────────────────────────
|
||||||
|
("PUT", "/api/roles/", False, "user", "role_update", "role", "更新角色权限配置"),
|
||||||
|
# ── 系统设置 ──────────────────────────────────────────────────
|
||||||
|
("PUT", "/api/settings/check_interval", True, "system", "config_update", "system", "更新定时扫描间隔"),
|
||||||
|
("PUT", "/api/settings/", False, "system", "config_update", "system", "更新系统配置"),
|
||||||
|
# ── 库存管理(精确路径优先)───────────────────────────────────
|
||||||
|
("POST", "/api/inventory/transactions/purchase", True, "inventory", "purchase", "inventory", "物料采购入库"),
|
||||||
|
("POST", "/api/inventory/transactions/allocate", True, "inventory", "allocate", "inventory", "物料分配出库"),
|
||||||
|
("POST", "/api/inventory/transactions/return", True, "inventory", "return", "inventory", "物料退库"),
|
||||||
|
("POST", "/api/inventory/categories", True, "inventory", "cat_create", "inventory", "新建库存分类"),
|
||||||
|
("POST", "/api/inventory/materials", True, "inventory", "mat_create", "inventory", "新建物料"),
|
||||||
|
("PUT", "/api/inventory/materials/", False, "inventory", "mat_update", "inventory", "更新物料信息"),
|
||||||
|
("DELETE", "/api/inventory/materials/", False, "inventory", "mat_delete", "inventory", "删除物料"),
|
||||||
|
("POST", "/api/inventory/checks", True, "inventory", "check_create", "inventory", "发起库存盘点"),
|
||||||
|
("POST", "/api/inventory/checks/", False, "inventory", "check_adjust", "inventory", "盘点差异调整"),
|
||||||
|
("PUT", "/api/inventory/checks/", False, "inventory", "check_update", "inventory", "更新盘点记录"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(method: str, path: str):
|
||||||
|
"""根据请求方法和路径推断操作分类,精确匹配优先于前缀匹配"""
|
||||||
|
# 第一轮:精确匹配
|
||||||
|
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
|
||||||
|
if exact and method == m and path == p:
|
||||||
|
return atype, subtype, rtype, desc
|
||||||
|
# 第二轮:后缀匹配(path_pattern 以 "/" 开头但不含 "/api",视为后缀)
|
||||||
|
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
|
||||||
|
if not exact and not p.startswith('/api') and method == m and path.endswith(p):
|
||||||
|
return atype, subtype, rtype, desc
|
||||||
|
# 第三轮:前缀匹配(规则列表已按从具体到宽泛排列)
|
||||||
|
for m, p, exact, atype, subtype, rtype, desc in _ROUTE_MAP:
|
||||||
|
if not exact and p.startswith('/api') and method == m and path.startswith(p):
|
||||||
|
return atype, subtype, rtype, desc
|
||||||
|
return "system", "request", "unknown", f"{method} {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ip(request) -> str:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
if request.client:
|
||||||
|
return request.client.host
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_audit_log(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
username: str,
|
||||||
|
user_role: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
ip_address: str = "",
|
||||||
|
user_agent: str = "",
|
||||||
|
status_code: int,
|
||||||
|
request_params: Optional[dict] = None,
|
||||||
|
response_data: Optional[dict] = None,
|
||||||
|
error_message: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
resource_id: Optional[str] = None,
|
||||||
|
resource_name: Optional[str] = None,
|
||||||
|
):
|
||||||
|
action_type, action_subtype, resource_type, default_desc = _classify(method, path)
|
||||||
|
status = "success" if status_code < 400 else ("failed" if status_code < 500 else "error")
|
||||||
|
|
||||||
|
log = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
user_role=user_role,
|
||||||
|
action_type=action_type,
|
||||||
|
action_subtype=action_subtype,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent[:500] if user_agent else "",
|
||||||
|
request_method=method,
|
||||||
|
request_path=path,
|
||||||
|
status=status,
|
||||||
|
status_code=status_code,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
resource_name=resource_name,
|
||||||
|
description=description or default_desc,
|
||||||
|
request_params=request_params,
|
||||||
|
response_data=response_data if status_code < 400 else None,
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
return log.id
|
||||||
|
|
||||||
|
|
||||||
|
def query_logs(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
start_time: Optional[datetime] = None,
|
||||||
|
end_time: Optional[datetime] = None,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
action_type: Optional[str] = None,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
):
|
||||||
|
q = db.query(AuditLog)
|
||||||
|
filters = []
|
||||||
|
if start_time:
|
||||||
|
filters.append(AuditLog.action_time >= start_time)
|
||||||
|
if end_time:
|
||||||
|
filters.append(AuditLog.action_time <= end_time)
|
||||||
|
if user_id:
|
||||||
|
filters.append(AuditLog.user_id == user_id)
|
||||||
|
if username:
|
||||||
|
filters.append(AuditLog.username.ilike(f"%{username}%"))
|
||||||
|
if action_type:
|
||||||
|
filters.append(AuditLog.action_type == action_type)
|
||||||
|
if resource_type:
|
||||||
|
filters.append(AuditLog.resource_type == resource_type)
|
||||||
|
if status:
|
||||||
|
filters.append(AuditLog.status == status)
|
||||||
|
if filters:
|
||||||
|
q = q.filter(and_(*filters))
|
||||||
|
|
||||||
|
total = q.count()
|
||||||
|
items = q.order_by(AuditLog.action_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return total, items
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_logs(db: Session, retention_days: int = AUDIT_LOG_RETENTION_DAYS):
|
||||||
|
"""清理超过保留期的日志"""
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=retention_days)
|
||||||
|
deleted = db.query(AuditLog).filter(AuditLog.created_at < cutoff).delete()
|
||||||
|
db.commit()
|
||||||
|
return deleted
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""设备状态检查服务"""
|
||||||
|
import time
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
|
||||||
|
from datetime import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 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]:
|
||||||
|
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
||||||
|
if not distance_str:
|
||||||
|
return None
|
||||||
|
m = re.search(r'\d+', distance_str)
|
||||||
|
return int(m.group()) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
class CheckService:
|
||||||
|
"""设备状态检查服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def update_status_only(self, olt_id: int) -> Dict:
|
||||||
|
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
|
||||||
|
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
|
||||||
|
"""
|
||||||
|
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
|
try:
|
||||||
|
output = ssh.execute_command(olt.slot_command)
|
||||||
|
onu_info_dict, _ = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
|
checked_at = datetime.utcnow()
|
||||||
|
online_count = 0
|
||||||
|
offline_count = 0
|
||||||
|
|
||||||
|
# 全局 MAC → (id, model) 索引
|
||||||
|
existing = {
|
||||||
|
row.mac_address.lower(): (row.id, row.model)
|
||||||
|
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id, ONUDevice.model).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for mac, onu_info in onu_info_dict.items():
|
||||||
|
entry = existing.get(mac)
|
||||||
|
if entry is None:
|
||||||
|
continue # 不在库中,跳过
|
||||||
|
|
||||||
|
onu_id, onu_model = entry
|
||||||
|
status = onu_info.status
|
||||||
|
distance_m = parse_distance(onu_info.distance_str)
|
||||||
|
if status == 'online':
|
||||||
|
online_count += 1
|
||||||
|
else:
|
||||||
|
offline_count += 1
|
||||||
|
|
||||||
|
# 有值时同步端口和 OLT 归属(不用 None 覆盖现有值)
|
||||||
|
update_fields = {"olt_id": olt_id}
|
||||||
|
if onu_info.slot_number is not None:
|
||||||
|
update_fields["slot_number"] = onu_info.slot_number
|
||||||
|
if onu_info.port_number is not None:
|
||||||
|
update_fields["port_number"] = onu_info.port_number
|
||||||
|
if onu_info.port_id:
|
||||||
|
update_fields["port_id"] = onu_info.port_id
|
||||||
|
if onu_info.model and not onu_model:
|
||||||
|
update_fields["model"] = onu_info.model
|
||||||
|
self.db.query(ONUDevice).filter(ONUDevice.id == onu_id).update(update_fields)
|
||||||
|
|
||||||
|
self.db.add(DeviceStatusHistory(
|
||||||
|
onu_device_id=onu_id,
|
||||||
|
status=status,
|
||||||
|
distance_m=distance_m,
|
||||||
|
checked_at=checked_at,
|
||||||
|
response_data=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
return {
|
||||||
|
"online": online_count,
|
||||||
|
"offline": offline_count,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
# 连接异常时清除缓存,下次自动重连
|
||||||
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def check_single_device(self, device_id: int) -> Dict:
|
||||||
|
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||||
|
命令格式: display onu slot {slot} | include {mac}
|
||||||
|
"""
|
||||||
|
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
raise Exception("设备不存在")
|
||||||
|
if not device.olt_id:
|
||||||
|
raise Exception("该设备未关联 OLT,无法查询")
|
||||||
|
|
||||||
|
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise Exception("关联的 OLT 不存在")
|
||||||
|
|
||||||
|
mac = device.mac_address.lower()
|
||||||
|
cmd = f"{olt.slot_command} | include {mac}"
|
||||||
|
|
||||||
|
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||||
|
try:
|
||||||
|
ssh.connect()
|
||||||
|
output = ssh.execute_command(cmd)
|
||||||
|
|
||||||
|
# 跳过命令回显行(含 MAC 但无端口标识),只接受有 port_id 的行
|
||||||
|
info = None
|
||||||
|
for line in output.splitlines():
|
||||||
|
if mac in line.lower():
|
||||||
|
parsed = ssh._parse_device_line(line, device.slot_number)
|
||||||
|
if parsed and parsed.port_id:
|
||||||
|
info = parsed
|
||||||
|
break
|
||||||
|
|
||||||
|
if info is None:
|
||||||
|
status = 'offline'
|
||||||
|
distance_m = None
|
||||||
|
else:
|
||||||
|
status = info.status
|
||||||
|
distance_m = parse_distance(info.distance_str)
|
||||||
|
if info.model and not device.model:
|
||||||
|
device.model = info.model
|
||||||
|
# 同步端口信息到数据库
|
||||||
|
device.slot_number = info.slot_number
|
||||||
|
device.port_number = info.port_number
|
||||||
|
device.port_id = info.port_id
|
||||||
|
|
||||||
|
self.db.add(DeviceStatusHistory(
|
||||||
|
onu_device_id=device.id,
|
||||||
|
status=status,
|
||||||
|
distance_m=distance_m,
|
||||||
|
checked_at=datetime.utcnow(),
|
||||||
|
response_data=None,
|
||||||
|
))
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
olt_location = olt.location if olt else None
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"model": device.model,
|
||||||
|
"port_id": device.port_id,
|
||||||
|
"slot_number": device.slot_number,
|
||||||
|
"port_number": device.port_number,
|
||||||
|
"olt_location": olt_location,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
ssh.close()
|
||||||
|
|
||||||
|
def scan_and_discover(self, olt_id: int) -> Dict:
|
||||||
|
"""扫描 OLT,更新已有设备状态,并将新发现的在线设备入库。
|
||||||
|
不标记 unknown,不修改已有设备的 olt_id/端口以外的字段。
|
||||||
|
"""
|
||||||
|
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
|
try:
|
||||||
|
output = ssh.execute_command(olt.slot_command)
|
||||||
|
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
|
checked_at = datetime.utcnow()
|
||||||
|
online_count = 0
|
||||||
|
offline_count = 0
|
||||||
|
new_count = 0
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
row.mac_address.lower(): (row.id, row.model)
|
||||||
|
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id, ONUDevice.model).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
for mac, onu_info in onu_info_dict.items():
|
||||||
|
entry = existing.get(mac)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
# 新设备:只入库在线的
|
||||||
|
if onu_info.status != 'online':
|
||||||
|
continue
|
||||||
|
onu = ONUDevice(
|
||||||
|
mac_address=mac,
|
||||||
|
olt_id=olt_id,
|
||||||
|
slot_number=onu_info.slot_number,
|
||||||
|
port_number=onu_info.port_number,
|
||||||
|
port_id=onu_info.port_id,
|
||||||
|
distance_m=parse_distance(onu_info.distance_str),
|
||||||
|
loid=onu_info.loid,
|
||||||
|
model=onu_info.model,
|
||||||
|
)
|
||||||
|
self.db.add(onu)
|
||||||
|
self.db.flush()
|
||||||
|
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
|
||||||
|
new_count += 1
|
||||||
|
onu_id = onu.id
|
||||||
|
else:
|
||||||
|
onu_id, onu_model = entry
|
||||||
|
# 同步端口和 OLT 归属(扫描结果以当前 OLT 为准)
|
||||||
|
self.db.query(ONUDevice).filter(ONUDevice.id == onu_id).update({
|
||||||
|
"olt_id": olt_id,
|
||||||
|
"slot_number": onu_info.slot_number,
|
||||||
|
"port_number": onu_info.port_number,
|
||||||
|
"port_id": onu_info.port_id,
|
||||||
|
})
|
||||||
|
if onu_info.model and not onu_model:
|
||||||
|
self.db.query(ONUDevice).filter(ONUDevice.id == onu_id).update({"model": onu_info.model})
|
||||||
|
|
||||||
|
status = onu_info.status
|
||||||
|
distance_m = parse_distance(onu_info.distance_str)
|
||||||
|
if status == 'online':
|
||||||
|
online_count += 1
|
||||||
|
else:
|
||||||
|
offline_count += 1
|
||||||
|
|
||||||
|
self.db.add(DeviceStatusHistory(
|
||||||
|
onu_device_id=onu_id,
|
||||||
|
status=status,
|
||||||
|
distance_m=distance_m,
|
||||||
|
checked_at=checked_at,
|
||||||
|
response_data=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
self._save_duplicate_macs(olt_id, duplicate_dict)
|
||||||
|
self.db.commit()
|
||||||
|
return {
|
||||||
|
"online": online_count,
|
||||||
|
"offline": offline_count,
|
||||||
|
"new_discovered": new_count,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def scan_olt(self, olt_id: int) -> Dict:
|
||||||
|
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||||
|
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||||
|
|
||||||
|
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||||
|
try:
|
||||||
|
output = ssh.execute_command(olt.slot_command)
|
||||||
|
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||||
|
|
||||||
|
# 对比全局 MAC
|
||||||
|
existing_macs = {
|
||||||
|
onu.mac_address.lower()
|
||||||
|
for onu in self.db.query(ONUDevice.mac_address).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = []
|
||||||
|
for mac, info in onu_info_dict.items():
|
||||||
|
devices.append({
|
||||||
|
"mac_address": mac,
|
||||||
|
"status": info.status,
|
||||||
|
"distance_m": info.distance_str,
|
||||||
|
"slot_number": info.slot_number,
|
||||||
|
"port_number": info.port_number,
|
||||||
|
"port_id": info.port_id,
|
||||||
|
"loid": info.loid,
|
||||||
|
"model": info.model,
|
||||||
|
"is_new": mac not in existing_macs,
|
||||||
|
})
|
||||||
|
|
||||||
|
duplicates = []
|
||||||
|
for mac, records in duplicate_dict.items():
|
||||||
|
duplicates.append({
|
||||||
|
"mac_address": mac,
|
||||||
|
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"olt_id": olt_id,
|
||||||
|
"olt_ip": olt.ip_address,
|
||||||
|
"total": len(devices),
|
||||||
|
"new": sum(1 for d in devices if d["is_new"]),
|
||||||
|
"devices": devices,
|
||||||
|
"duplicates": duplicates,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
_conn_pool.pop(olt_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||||
|
for mac, records in duplicate_dict.items():
|
||||||
|
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
|
||||||
|
existing = self.db.query(DuplicateMac).filter(
|
||||||
|
DuplicateMac.olt_id == olt_id,
|
||||||
|
DuplicateMac.mac_address == mac
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
existing.ports = ports
|
||||||
|
existing.last_seen_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
iMC REST API 服务
|
||||||
|
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
||||||
|
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
||||||
|
- 功能:ONU 远程重启、光功率查询
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
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': '重启失败',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 并发控制:防止同一设备被重复重启
|
||||||
|
_reboot_locks: dict = {}
|
||||||
|
_reboot_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_mac(mac: str) -> str:
|
||||||
|
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840(大写,4位分组)"""
|
||||||
|
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
||||||
|
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
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.connect_timeout = settings.IMC_CONNECT_TIMEOUT
|
||||||
|
self.read_timeout = settings.IMC_READ_TIMEOUT
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.realm = "iMC RESTful Web Services"
|
||||||
|
self.nonce = None
|
||||||
|
self.nc = 1
|
||||||
|
|
||||||
|
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
||||||
|
"""构建 HTTP Digest 认证头,首次调用自动握手获取 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_parts = {}
|
||||||
|
for part in resp.headers['WWW-Authenticate'].split(','):
|
||||||
|
if '=' in part:
|
||||||
|
k, v = part.split('=', 1)
|
||||||
|
auth_parts[k.strip()] = v.strip(' "')
|
||||||
|
self.nonce = auth_parts.get('nonce', '')
|
||||||
|
self.realm = auth_parts.get('realm', self.realm)
|
||||||
|
else:
|
||||||
|
logger.error(f"获取 nonce 失败,状态码: {resp.status_code}")
|
||||||
|
return None
|
||||||
|
except requests.Timeout:
|
||||||
|
raise TimeoutError("iMC 认证超时")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取 nonce 异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
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}", realm="{self.realm}", '
|
||||||
|
f'nonce="{self.nonce}", uri="{uri}", response="{response_hash}", '
|
||||||
|
f'qop=auth, nc={self.nc:08d}, 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 设备。
|
||||||
|
使用 per-MAC 锁防止同一设备并发重启。
|
||||||
|
"""
|
||||||
|
imc_mac = _normalize_mac(mac)
|
||||||
|
|
||||||
|
# 并发锁
|
||||||
|
with _reboot_lock:
|
||||||
|
if imc_mac not in _reboot_locks:
|
||||||
|
_reboot_locks[imc_mac] = threading.Lock()
|
||||||
|
lock = _reboot_locks[imc_mac]
|
||||||
|
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
return {"success": False, "message": "该设备正在重启中,请稍后再试"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for retry in range(2):
|
||||||
|
try:
|
||||||
|
uri = f"/imcrs/epon/onu/reboot?mac={imc_mac}"
|
||||||
|
auth = self._get_digest_auth_header("POST", uri)
|
||||||
|
if not auth:
|
||||||
|
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
||||||
|
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.base_url}{uri}",
|
||||||
|
headers={
|
||||||
|
"Accept": "application/xml",
|
||||||
|
"Content-Type": "application/xml",
|
||||||
|
"Content-Length": "0",
|
||||||
|
"Authorization": auth,
|
||||||
|
},
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
timeout=(self.connect_timeout, self.read_timeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
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:
|
||||||
|
self._clear_auth()
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {"success": False, "message": f"重启请求失败(HTTP {resp.status_code})"}
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"重启异常 (retry={retry}): {e}")
|
||||||
|
if retry == 0:
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
return {"success": False, "message": f"重启异常: {e}"}
|
||||||
|
|
||||||
|
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
# ── 光功率查询 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_optical_power(self, mac: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
获取 ONU 设备光功率信息。
|
||||||
|
返回 dict 或 None(失败时)。
|
||||||
|
"""
|
||||||
|
imc_mac = _normalize_mac(mac)
|
||||||
|
|
||||||
|
for retry in range(2):
|
||||||
|
try:
|
||||||
|
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={imc_mac}"
|
||||||
|
auth = self._get_digest_auth_header("GET", uri)
|
||||||
|
if not auth:
|
||||||
|
logger.error("生成认证头失败,无法获取光功率")
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{self.base_url}{uri}",
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": auth,
|
||||||
|
},
|
||||||
|
verify=self.verify_ssl,
|
||||||
|
timeout=(self.connect_timeout, self.read_timeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
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}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
elif resp.status_code == 401:
|
||||||
|
self._clear_auth()
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.error(f"光功率 API 失败,状态码: {resp.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
raise TimeoutError("iMC 光功率接口请求超时")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取光功率异常 (retry={retry}): {e}")
|
||||||
|
if retry == 0:
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Excel 导入服务"""
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List, Dict
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models.device import ONUDevice
|
||||||
|
import math
|
||||||
|
|
||||||
|
# 定义字段映射:Excel列名 -> 数据库字段名
|
||||||
|
FIELD_MAPPING = {
|
||||||
|
'mac_address': 'mac_address',
|
||||||
|
'region': 'region',
|
||||||
|
'school_name': 'school_name',
|
||||||
|
'building': 'building',
|
||||||
|
'place_type': 'place_type',
|
||||||
|
'room_number': 'room_number',
|
||||||
|
'notes': 'notes',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clean_value(value) -> str:
|
||||||
|
"""清理单元格值,处理NaN和None"""
|
||||||
|
if value is None:
|
||||||
|
return ''
|
||||||
|
if isinstance(value, float) and math.isnan(value):
|
||||||
|
return ''
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ImportService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def parse_excel(self, file_path: str) -> List[Dict]:
|
||||||
|
"""解析 Excel 文件"""
|
||||||
|
df = pd.read_excel(file_path)
|
||||||
|
# 标准化列名(去除空格,转小写)
|
||||||
|
df.columns = [col.strip().lower() for col in df.columns]
|
||||||
|
# 转换为记录列表
|
||||||
|
records = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
record = {}
|
||||||
|
for col_name, db_field in FIELD_MAPPING.items():
|
||||||
|
if col_name in row:
|
||||||
|
record[db_field] = clean_value(row[col_name])
|
||||||
|
records.append(record)
|
||||||
|
return records
|
||||||
|
|
||||||
|
def validate_data(self, records: List[Dict]) -> Dict:
|
||||||
|
"""验证数据"""
|
||||||
|
valid = []
|
||||||
|
invalid = []
|
||||||
|
|
||||||
|
for idx, record in enumerate(records):
|
||||||
|
mac = record.get('mac_address', '').strip()
|
||||||
|
if not mac:
|
||||||
|
invalid.append({'row': idx + 2, 'mac': mac, 'reason': 'MAC地址缺失'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 标准化MAC地址:统一使用横杠分隔小写格式
|
||||||
|
# 支持格式:AA:BB:CC:DD:EE:FF, AA-BB-CC-DD-EE-FF, AABBCCDDEEFF, aa:bb:cc:dd:ee:ff
|
||||||
|
mac_clean = mac.upper().replace(':', '-')
|
||||||
|
|
||||||
|
# 验证基本格式:12个十六进制字符(可能有分隔符)
|
||||||
|
hex_chars = mac_clean.replace('-', '')
|
||||||
|
if len(hex_chars) != 12 or not all(c in '0123456789ABCDEF' for c in hex_chars):
|
||||||
|
invalid.append({'row': idx + 2, 'mac': mac, 'reason': f'MAC地址格式错误 "{mac}"'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 转换为标准格式 34dc-99c8-56e0(小写4位分组)
|
||||||
|
mac_formatted = '-'.join([hex_chars[i:i+4].lower() for i in range(0, 12, 4)])
|
||||||
|
record['mac_address'] = mac_formatted
|
||||||
|
valid.append(record)
|
||||||
|
|
||||||
|
return {'valid': valid, 'invalid': invalid}
|
||||||
|
|
||||||
|
def import_devices(self, records: List[Dict], olt_id: int = None) -> Dict:
|
||||||
|
"""批量导入设备,存在则更新,不存在则新增"""
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
mac = record.get('mac_address', '')
|
||||||
|
if not mac:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 查询是否已存在该 MAC 地址
|
||||||
|
existing = self.db.query(ONUDevice).filter(ONUDevice.mac_address == mac).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# 更新现有记录(MAC 地址不变)
|
||||||
|
existing.region = record.get('region', '')
|
||||||
|
existing.school_name = record.get('school_name', '')
|
||||||
|
existing.building = record.get('building') or None
|
||||||
|
existing.place_type = record.get('place_type') or None
|
||||||
|
existing.room_number = record.get('room_number') or None
|
||||||
|
existing.notes = record.get('notes') or None
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
# 新增记录
|
||||||
|
device = ONUDevice(
|
||||||
|
mac_address=mac,
|
||||||
|
olt_id=olt_id,
|
||||||
|
region=record.get('region', ''),
|
||||||
|
school_name=record.get('school_name', ''),
|
||||||
|
building=record.get('building') or None,
|
||||||
|
place_type=record.get('place_type') or None,
|
||||||
|
room_number=record.get('room_number') or None,
|
||||||
|
notes=record.get('notes') or None
|
||||||
|
)
|
||||||
|
self.db.add(device)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
return {'success': created_count + updated_count, 'created': created_count, 'updated': updated_count}
|
||||||
|
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
"""库存管理业务逻辑"""
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, text
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.models.inventory import (
|
||||||
|
MaterialCategory, Material, InventoryBatch,
|
||||||
|
SerialDevice, InventoryTransaction, InventoryCheck
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_no(prefix: str) -> str:
|
||||||
|
return f"{prefix}{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 物料分类 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_categories(db: Session) -> List[MaterialCategory]:
|
||||||
|
return db.query(MaterialCategory).order_by(MaterialCategory.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
def create_category(db: Session, name: str, code: str, description: Optional[str]) -> MaterialCategory:
|
||||||
|
if db.query(MaterialCategory).filter(MaterialCategory.code == code).first():
|
||||||
|
raise HTTPException(status_code=400, detail="分类代码已存在")
|
||||||
|
cat = MaterialCategory(name=name, code=code, description=description)
|
||||||
|
db.add(cat)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cat)
|
||||||
|
return cat
|
||||||
|
|
||||||
|
|
||||||
|
# ── 物料 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_materials(db: Session, skip: int, limit: int, category_id: Optional[int], keyword: Optional[str]):
|
||||||
|
q = db.query(Material)
|
||||||
|
if category_id:
|
||||||
|
q = q.filter(Material.category_id == category_id)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(Material.name.contains(keyword) | Material.model.contains(keyword))
|
||||||
|
total = q.count()
|
||||||
|
items = q.order_by(Material.id).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for m in items:
|
||||||
|
total_qty = db.query(func.sum(InventoryBatch.quantity)).filter(
|
||||||
|
InventoryBatch.material_id == m.id
|
||||||
|
).scalar() or 0
|
||||||
|
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
|
||||||
|
InventoryBatch.material_id == m.id
|
||||||
|
).scalar() or 0
|
||||||
|
item = {
|
||||||
|
"id": m.id,
|
||||||
|
"category_id": m.category_id,
|
||||||
|
"category_name": m.category.name if m.category else None,
|
||||||
|
"name": m.name,
|
||||||
|
"model": m.model,
|
||||||
|
"specification": m.specification,
|
||||||
|
"brand": m.brand,
|
||||||
|
"unit": m.unit,
|
||||||
|
"safe_quantity": m.safe_quantity,
|
||||||
|
"notes": m.notes,
|
||||||
|
"total_quantity": total_qty,
|
||||||
|
"available_quantity": avail_qty,
|
||||||
|
"created_at": m.created_at,
|
||||||
|
}
|
||||||
|
result.append(item)
|
||||||
|
return {"total": total, "items": result}
|
||||||
|
|
||||||
|
|
||||||
|
def create_material(db: Session, data: dict) -> Material:
|
||||||
|
m = Material(**data)
|
||||||
|
db.add(m)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def update_material(db: Session, material_id: int, data: dict) -> Material:
|
||||||
|
m = db.query(Material).filter(Material.id == material_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(status_code=404, detail="物料不存在")
|
||||||
|
for k, v in data.items():
|
||||||
|
if v is not None:
|
||||||
|
setattr(m, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def delete_material(db: Session, material_id: int):
|
||||||
|
m = db.query(Material).filter(Material.id == material_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(status_code=404, detail="物料不存在")
|
||||||
|
has_stock = db.query(InventoryBatch).filter(
|
||||||
|
InventoryBatch.material_id == material_id,
|
||||||
|
InventoryBatch.available_quantity > 0,
|
||||||
|
).first()
|
||||||
|
if has_stock:
|
||||||
|
raise HTTPException(status_code=400, detail="该物料仍有库存,请先出库后再删除")
|
||||||
|
db.delete(m)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 采购入库 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def purchase_in(db: Session, data: dict, operator_id: int) -> dict:
|
||||||
|
material_id = data["material_id"]
|
||||||
|
m = db.query(Material).filter(Material.id == material_id).first()
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(status_code=404, detail="物料不存在")
|
||||||
|
|
||||||
|
serial_nos = data.pop("serial_nos", None) or []
|
||||||
|
quantity = data["quantity"]
|
||||||
|
|
||||||
|
batch = InventoryBatch(
|
||||||
|
material_id=material_id,
|
||||||
|
batch_no=data["batch_no"],
|
||||||
|
quantity=quantity,
|
||||||
|
available_quantity=quantity,
|
||||||
|
supplier=data.get("supplier"),
|
||||||
|
purchase_date=data.get("purchase_date"),
|
||||||
|
purchase_price=data.get("purchase_price"),
|
||||||
|
location=data.get("location"),
|
||||||
|
status="in_stock",
|
||||||
|
)
|
||||||
|
db.add(batch)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 高价值设备:逐个创建序列号记录
|
||||||
|
for sn in serial_nos:
|
||||||
|
sd = SerialDevice(
|
||||||
|
material_id=material_id,
|
||||||
|
batch_id=batch.id,
|
||||||
|
serial_no=sn,
|
||||||
|
status="in_stock",
|
||||||
|
)
|
||||||
|
db.add(sd)
|
||||||
|
|
||||||
|
txn = InventoryTransaction(
|
||||||
|
transaction_no=_gen_no("PI"),
|
||||||
|
transaction_type="purchase_in",
|
||||||
|
material_id=material_id,
|
||||||
|
batch_id=batch.id,
|
||||||
|
quantity=quantity,
|
||||||
|
from_status=None,
|
||||||
|
to_status="in_stock",
|
||||||
|
operator_id=operator_id,
|
||||||
|
notes=data.get("notes"),
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "入库成功", "batch_id": batch.id, "transaction_no": txn.transaction_no}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 领用出库 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def allocate_out(db: Session, data: dict, operator_id: int) -> dict:
|
||||||
|
serial_device_id = data.get("serial_device_id")
|
||||||
|
batch_id = data.get("batch_id")
|
||||||
|
quantity = data.get("quantity", 1)
|
||||||
|
|
||||||
|
if serial_device_id:
|
||||||
|
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
|
||||||
|
if not sd:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
if sd.status != "in_stock":
|
||||||
|
raise HTTPException(status_code=400, detail=f"设备当前状态为 {sd.status},无法领用")
|
||||||
|
|
||||||
|
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
|
||||||
|
if batch:
|
||||||
|
batch.available_quantity = max(0, batch.available_quantity - 1)
|
||||||
|
|
||||||
|
sd.status = "allocated"
|
||||||
|
sd.installed_info = data.get("installation_info")
|
||||||
|
|
||||||
|
txn = InventoryTransaction(
|
||||||
|
transaction_no=_gen_no("AO"),
|
||||||
|
transaction_type="allocate_out",
|
||||||
|
material_id=sd.material_id,
|
||||||
|
batch_id=sd.batch_id,
|
||||||
|
serial_device_id=sd.id,
|
||||||
|
quantity=1,
|
||||||
|
from_status="in_stock",
|
||||||
|
to_status="allocated",
|
||||||
|
operator_id=operator_id,
|
||||||
|
project_name=data.get("project_name"),
|
||||||
|
installation_info=data.get("installation_info"),
|
||||||
|
notes=data.get("notes"),
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "领用成功", "transaction_no": txn.transaction_no}
|
||||||
|
|
||||||
|
elif batch_id:
|
||||||
|
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
|
||||||
|
if not batch:
|
||||||
|
raise HTTPException(status_code=404, detail="批次不存在")
|
||||||
|
if batch.available_quantity < quantity:
|
||||||
|
raise HTTPException(status_code=400, detail="库存不足")
|
||||||
|
|
||||||
|
batch.available_quantity -= quantity
|
||||||
|
|
||||||
|
# 方案三:领用时补录设备标识,存入 installation_info
|
||||||
|
device_info = data.get("installation_info") or {}
|
||||||
|
if isinstance(device_info, str):
|
||||||
|
device_info = {}
|
||||||
|
mac = data.get("mac_address", "").strip() if data.get("mac_address") else ""
|
||||||
|
sn = data.get("serial_no", "").strip() if data.get("serial_no") else ""
|
||||||
|
asset = data.get("asset_no", "").strip() if data.get("asset_no") else ""
|
||||||
|
if mac or sn or asset:
|
||||||
|
device_info = {k: v for k, v in {"mac_address": mac, "serial_no": sn, "asset_no": asset}.items() if v}
|
||||||
|
|
||||||
|
txn = InventoryTransaction(
|
||||||
|
transaction_no=_gen_no("AO"),
|
||||||
|
transaction_type="allocate_out",
|
||||||
|
material_id=batch.material_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
quantity=quantity,
|
||||||
|
from_status="in_stock",
|
||||||
|
to_status="allocated",
|
||||||
|
operator_id=operator_id,
|
||||||
|
project_name=data.get("project_name"),
|
||||||
|
installation_info=device_info if device_info else None,
|
||||||
|
notes=data.get("notes"),
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "领用成功", "transaction_no": txn.transaction_no}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=400, detail="需要指定序列号设备或批次")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 退库 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def return_in(db: Session, serial_device_id: int, return_type: str, notes: Optional[str], operator_id: int) -> dict:
|
||||||
|
sd = db.query(SerialDevice).filter(SerialDevice.id == serial_device_id).first()
|
||||||
|
if not sd:
|
||||||
|
raise HTTPException(status_code=404, detail="设备不存在")
|
||||||
|
|
||||||
|
from_status = sd.status
|
||||||
|
if return_type == "scrap":
|
||||||
|
to_status = "scrapped"
|
||||||
|
elif return_type == "repair":
|
||||||
|
to_status = "repairing"
|
||||||
|
else:
|
||||||
|
to_status = "in_stock"
|
||||||
|
# 归还时恢复批次可用数量
|
||||||
|
if sd.batch_id:
|
||||||
|
batch = db.query(InventoryBatch).filter(InventoryBatch.id == sd.batch_id).first()
|
||||||
|
if batch:
|
||||||
|
batch.available_quantity += 1
|
||||||
|
|
||||||
|
sd.status = to_status
|
||||||
|
|
||||||
|
txn = InventoryTransaction(
|
||||||
|
transaction_no=_gen_no("RI"),
|
||||||
|
transaction_type="return_in" if return_type != "scrap" else "scrap_out",
|
||||||
|
material_id=sd.material_id,
|
||||||
|
batch_id=sd.batch_id,
|
||||||
|
serial_device_id=sd.id,
|
||||||
|
quantity=1,
|
||||||
|
from_status=from_status,
|
||||||
|
to_status=to_status,
|
||||||
|
operator_id=operator_id,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "退库成功", "transaction_no": txn.transaction_no}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 批次查询 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_batches_by_material(db: Session, material_id: int) -> list:
|
||||||
|
batches = db.query(InventoryBatch).filter(
|
||||||
|
InventoryBatch.material_id == material_id,
|
||||||
|
InventoryBatch.available_quantity > 0,
|
||||||
|
).order_by(InventoryBatch.id.desc()).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": b.id,
|
||||||
|
"batch_no": b.batch_no,
|
||||||
|
"quantity": b.quantity,
|
||||||
|
"available_quantity": b.available_quantity,
|
||||||
|
"supplier": b.supplier,
|
||||||
|
"location": b.location,
|
||||||
|
}
|
||||||
|
for b in batches
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 序列号设备查询 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_serial_devices(db: Session, skip: int, limit: int, material_id: Optional[int], status: Optional[str], keyword: Optional[str]):
|
||||||
|
q = db.query(SerialDevice)
|
||||||
|
if material_id:
|
||||||
|
q = q.filter(SerialDevice.material_id == material_id)
|
||||||
|
if status:
|
||||||
|
q = q.filter(SerialDevice.status == status)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(
|
||||||
|
SerialDevice.serial_no.contains(keyword) |
|
||||||
|
SerialDevice.mac_address.contains(keyword) |
|
||||||
|
SerialDevice.asset_no.contains(keyword)
|
||||||
|
)
|
||||||
|
total = q.count()
|
||||||
|
items = q.order_by(SerialDevice.id.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for sd in items:
|
||||||
|
result.append({
|
||||||
|
"id": sd.id,
|
||||||
|
"material_id": sd.material_id,
|
||||||
|
"material_name": sd.material.name if sd.material else None,
|
||||||
|
"batch_id": sd.batch_id,
|
||||||
|
"serial_no": sd.serial_no,
|
||||||
|
"mac_address": sd.mac_address,
|
||||||
|
"asset_no": sd.asset_no,
|
||||||
|
"status": sd.status,
|
||||||
|
"current_location": sd.current_location,
|
||||||
|
"installed_info": sd.installed_info,
|
||||||
|
"notes": sd.notes,
|
||||||
|
"created_at": sd.created_at,
|
||||||
|
"updated_at": sd.updated_at,
|
||||||
|
})
|
||||||
|
return {"total": total, "items": result}
|
||||||
|
|
||||||
|
|
||||||
|
def get_transaction_detail(db: Session, transaction_id: int) -> dict:
|
||||||
|
t = db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(status_code=404, detail="记录不存在")
|
||||||
|
|
||||||
|
m = db.query(Material).filter(Material.id == t.material_id).first()
|
||||||
|
batch = db.query(InventoryBatch).filter(InventoryBatch.id == t.batch_id).first() if t.batch_id else None
|
||||||
|
sd = db.query(SerialDevice).filter(SerialDevice.id == t.serial_device_id).first() if t.serial_device_id else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"transaction_no": t.transaction_no,
|
||||||
|
"transaction_type": t.transaction_type,
|
||||||
|
"material_id": t.material_id,
|
||||||
|
"material_name": m.name if m else None,
|
||||||
|
"material_model": m.model if m else None,
|
||||||
|
"material_brand": m.brand if m else None,
|
||||||
|
"quantity": t.quantity,
|
||||||
|
"from_status": t.from_status,
|
||||||
|
"to_status": t.to_status,
|
||||||
|
"operator_id": t.operator_id,
|
||||||
|
"project_name": t.project_name,
|
||||||
|
"installation_info": t.installation_info,
|
||||||
|
"notes": t.notes,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"batch": {
|
||||||
|
"id": batch.id,
|
||||||
|
"batch_no": batch.batch_no,
|
||||||
|
"supplier": batch.supplier,
|
||||||
|
"purchase_date": str(batch.purchase_date) if batch.purchase_date else None,
|
||||||
|
"purchase_price": str(batch.purchase_price) if batch.purchase_price else None,
|
||||||
|
"location": batch.location,
|
||||||
|
} if batch else None,
|
||||||
|
"serial_device": {
|
||||||
|
"id": sd.id,
|
||||||
|
"serial_no": sd.serial_no,
|
||||||
|
"mac_address": sd.mac_address,
|
||||||
|
"asset_no": sd.asset_no,
|
||||||
|
} if sd else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_transactions(db: Session, skip: int, limit: int, transaction_type: Optional[str], material_id: Optional[int]):
|
||||||
|
q = db.query(InventoryTransaction)
|
||||||
|
if transaction_type:
|
||||||
|
q = q.filter(InventoryTransaction.transaction_type == transaction_type)
|
||||||
|
if material_id:
|
||||||
|
q = q.filter(InventoryTransaction.material_id == material_id)
|
||||||
|
total = q.count()
|
||||||
|
items = q.order_by(InventoryTransaction.id.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for t in items:
|
||||||
|
m = db.query(Material).filter(Material.id == t.material_id).first()
|
||||||
|
result.append({
|
||||||
|
"id": t.id,
|
||||||
|
"transaction_no": t.transaction_no,
|
||||||
|
"transaction_type": t.transaction_type,
|
||||||
|
"material_id": t.material_id,
|
||||||
|
"material_name": m.name if m else None,
|
||||||
|
"quantity": t.quantity,
|
||||||
|
"from_status": t.from_status,
|
||||||
|
"to_status": t.to_status,
|
||||||
|
"operator_id": t.operator_id,
|
||||||
|
"project_name": t.project_name,
|
||||||
|
"installation_info": t.installation_info,
|
||||||
|
"notes": t.notes,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
})
|
||||||
|
return {"total": total, "items": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 库存总览 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_summary(db: Session) -> dict:
|
||||||
|
total_materials = db.query(Material).count()
|
||||||
|
total_qty = db.query(func.sum(InventoryBatch.quantity)).scalar() or 0
|
||||||
|
avail_qty = db.query(func.sum(InventoryBatch.available_quantity)).scalar() or 0
|
||||||
|
allocated_qty = total_qty - avail_qty
|
||||||
|
|
||||||
|
# 低库存物料数
|
||||||
|
low_stock = db.execute(text("""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT m.id, m.safe_quantity, COALESCE(SUM(b.available_quantity), 0) AS avail
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN inventory_batches b ON b.material_id = m.id
|
||||||
|
GROUP BY m.id, m.safe_quantity
|
||||||
|
HAVING COALESCE(SUM(b.available_quantity), 0) <= m.safe_quantity AND m.safe_quantity > 0
|
||||||
|
) t
|
||||||
|
""")).scalar() or 0
|
||||||
|
|
||||||
|
# 按分类统计
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT c.name, COUNT(m.id) AS material_count,
|
||||||
|
COALESCE(SUM(b.available_quantity), 0) AS available
|
||||||
|
FROM material_categories c
|
||||||
|
LEFT JOIN materials m ON m.category_id = c.id
|
||||||
|
LEFT JOIN inventory_batches b ON b.material_id = m.id
|
||||||
|
GROUP BY c.id, c.name
|
||||||
|
ORDER BY c.id
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
category_stats = [{"name": r[0], "material_count": r[1], "available": r[2]} for r in rows]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_materials": total_materials,
|
||||||
|
"total_quantity": total_qty,
|
||||||
|
"available_quantity": avail_qty,
|
||||||
|
"allocated_quantity": allocated_qty,
|
||||||
|
"low_stock_count": low_stock,
|
||||||
|
"category_stats": category_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 盘点 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_check(db: Session, data: dict, checker_id: int) -> InventoryCheck:
|
||||||
|
material_id = data["material_id"]
|
||||||
|
batch_id = data.get("batch_id")
|
||||||
|
|
||||||
|
if batch_id:
|
||||||
|
batch = db.query(InventoryBatch).filter(InventoryBatch.id == batch_id).first()
|
||||||
|
book_qty = batch.available_quantity if batch else 0
|
||||||
|
else:
|
||||||
|
book_qty = db.query(func.sum(InventoryBatch.available_quantity)).filter(
|
||||||
|
InventoryBatch.material_id == material_id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
actual_qty = data["actual_quantity"]
|
||||||
|
diff = actual_qty - book_qty
|
||||||
|
|
||||||
|
check = InventoryCheck(
|
||||||
|
check_no=_gen_no("CK"),
|
||||||
|
check_date=data["check_date"],
|
||||||
|
checker_id=checker_id,
|
||||||
|
material_id=material_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
book_quantity=book_qty,
|
||||||
|
actual_quantity=actual_qty,
|
||||||
|
difference=diff,
|
||||||
|
reason=data.get("reason"),
|
||||||
|
adjusted=False,
|
||||||
|
)
|
||||||
|
db.add(check)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(check)
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
|
def adjust_check(db: Session, check_id: int, operator_id: int) -> dict:
|
||||||
|
check = db.query(InventoryCheck).filter(InventoryCheck.id == check_id).first()
|
||||||
|
if not check:
|
||||||
|
raise HTTPException(status_code=404, detail="盘点记录不存在")
|
||||||
|
if check.adjusted:
|
||||||
|
raise HTTPException(status_code=400, detail="已调整过")
|
||||||
|
|
||||||
|
if check.batch_id:
|
||||||
|
batch = db.query(InventoryBatch).filter(InventoryBatch.id == check.batch_id).first()
|
||||||
|
if batch:
|
||||||
|
batch.available_quantity = check.actual_quantity
|
||||||
|
else:
|
||||||
|
# 调整第一个批次(简化处理)
|
||||||
|
batch = db.query(InventoryBatch).filter(
|
||||||
|
InventoryBatch.material_id == check.material_id
|
||||||
|
).first()
|
||||||
|
if batch:
|
||||||
|
batch.available_quantity = check.actual_quantity
|
||||||
|
|
||||||
|
txn = InventoryTransaction(
|
||||||
|
transaction_no=_gen_no("ADJ"),
|
||||||
|
transaction_type="adjust",
|
||||||
|
material_id=check.material_id,
|
||||||
|
batch_id=check.batch_id,
|
||||||
|
quantity=abs(check.difference or 0),
|
||||||
|
from_status="in_stock",
|
||||||
|
to_status="in_stock",
|
||||||
|
operator_id=operator_id,
|
||||||
|
notes=f"盘点调整:{check.check_no},差异 {check.difference}",
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
check.adjusted = True
|
||||||
|
db.commit()
|
||||||
|
return {"message": "调整成功"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_checks(db: Session, skip: int, limit: int, material_id: Optional[int]):
|
||||||
|
q = db.query(InventoryCheck)
|
||||||
|
if material_id:
|
||||||
|
q = q.filter(InventoryCheck.material_id == material_id)
|
||||||
|
total = q.count()
|
||||||
|
items = q.order_by(InventoryCheck.id.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for c in items:
|
||||||
|
m = db.query(Material).filter(Material.id == c.material_id).first()
|
||||||
|
result.append({
|
||||||
|
"id": c.id,
|
||||||
|
"check_no": c.check_no,
|
||||||
|
"check_date": c.check_date,
|
||||||
|
"material_id": c.material_id,
|
||||||
|
"material_name": m.name if m else None,
|
||||||
|
"batch_id": c.batch_id,
|
||||||
|
"book_quantity": c.book_quantity,
|
||||||
|
"actual_quantity": c.actual_quantity,
|
||||||
|
"difference": c.difference,
|
||||||
|
"reason": c.reason,
|
||||||
|
"adjusted": c.adjusted,
|
||||||
|
"created_at": c.created_at,
|
||||||
|
})
|
||||||
|
return {"total": total, "items": result}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""业务下发服务"""
|
||||||
|
from typing import Dict
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.services.ssh_service import SSHService
|
||||||
|
from app.models.device import ONUDevice, OLTDevice
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionService:
|
||||||
|
"""业务下发服务"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def provision_device(self, device_id: int) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
业务下发流程(参照下发业务流程.md):
|
||||||
|
1. system-view
|
||||||
|
2. interface Onu{port_id}
|
||||||
|
3. uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||||
|
4. port link-type trunk
|
||||||
|
5. undo port trunk permit vlan 1
|
||||||
|
6. port trunk permit vlan 2000 3000 to 3010 4094
|
||||||
|
7. save force
|
||||||
|
"""
|
||||||
|
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
return {"success": False, "error": f"设备不存在: {device_id}"}
|
||||||
|
|
||||||
|
if not device.olt_id:
|
||||||
|
return {"success": False, "error": "设备未关联 OLT,请先进行扫描"}
|
||||||
|
|
||||||
|
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
|
||||||
|
if not olt:
|
||||||
|
return {"success": False, "error": "关联的 OLT 设备不存在"}
|
||||||
|
|
||||||
|
if not device.port_id:
|
||||||
|
return {"success": False, "error": "设备端口信息不完整,请先对 OLT 执行扫描"}
|
||||||
|
|
||||||
|
port_name = f"Onu{device.port_id}"
|
||||||
|
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssh.connect()
|
||||||
|
|
||||||
|
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
||||||
|
ssh.shell.send(cmd + "\n")
|
||||||
|
buf = ""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if ssh.shell.recv_ready():
|
||||||
|
buf += ssh.shell.recv(4096).decode("utf-8", errors="ignore")
|
||||||
|
if expect in buf:
|
||||||
|
return buf
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# 进入系统视图
|
||||||
|
out = send_and_wait("system-view", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
|
# 进入端口
|
||||||
|
out = send_and_wait(f"interface {port_name}", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception(f"进入端口 {port_name} 失败")
|
||||||
|
|
||||||
|
# 配置 VLAN
|
||||||
|
send_and_wait("uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010", "]")
|
||||||
|
send_and_wait("port link-type trunk", "]")
|
||||||
|
send_and_wait("undo port trunk permit vlan 1", "]")
|
||||||
|
send_and_wait("port trunk permit vlan 2000 3000 to 3010 4094", "]")
|
||||||
|
|
||||||
|
# 保存配置(等待 "successfully" 出现)
|
||||||
|
save_out = send_and_wait("save force", "successfully", timeout=30)
|
||||||
|
if "successfully" not in save_out:
|
||||||
|
raise Exception("save force 未确认成功,请检查 OLT 日志")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"device_id": device_id,
|
||||||
|
"mac_address": device.mac_address,
|
||||||
|
"olt_ip": olt.ip_address,
|
||||||
|
"port": port_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"device_id": device_id,
|
||||||
|
"mac_address": device.mac_address,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
ssh.close()
|
||||||
|
|
||||||
|
def batch_provision(self, device_ids: list) -> Dict[str, any]:
|
||||||
|
results = []
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
for device_id in device_ids:
|
||||||
|
result = self.provision_device(device_id)
|
||||||
|
results.append(result)
|
||||||
|
if result.get("success"):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
return {
|
||||||
|
"total": len(device_ids),
|
||||||
|
"success": success_count,
|
||||||
|
"failed": fail_count,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
"""SSH 连接服务"""
|
||||||
|
import paramiko
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ONUInfo:
|
||||||
|
"""ONU 设备完整信息"""
|
||||||
|
mac_address: str
|
||||||
|
status: str # online/offline
|
||||||
|
distance_m: Optional[int] = None # 距离(米)
|
||||||
|
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
|
||||||
|
slot_number: Optional[int] = None # 插槽号
|
||||||
|
port_number: Optional[int] = None # 端口号
|
||||||
|
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
|
||||||
|
loid: Optional[str] = None # LOID
|
||||||
|
model: Optional[str] = None # 设备型号
|
||||||
|
|
||||||
|
|
||||||
|
class SSHService:
|
||||||
|
"""SSH 连接和命令执行服务"""
|
||||||
|
|
||||||
|
def __init__(self, host: str, username: str, password: str, port: int = 22):
|
||||||
|
self.host = host
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.port = port
|
||||||
|
self.client: Optional[paramiko.SSHClient] = None
|
||||||
|
self.shell = None
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||||
|
try:
|
||||||
|
self.client = paramiko.SSHClient()
|
||||||
|
self.client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||||
|
self.client.connect(
|
||||||
|
hostname=self.host,
|
||||||
|
port=self.port,
|
||||||
|
username=self.username,
|
||||||
|
password=self.password,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
self.shell = self.client.invoke_shell(width=200, height=50)
|
||||||
|
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
|
||||||
|
deadline = time.time() + 10
|
||||||
|
buf = ""
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self.shell.recv_ready():
|
||||||
|
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||||
|
if re.search(r'<[^>]+>', buf):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"SSH 连接失败: {str(e)}")
|
||||||
|
|
||||||
|
def execute_command(self, command: str) -> str:
|
||||||
|
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
|
||||||
|
if not self.shell:
|
||||||
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
|
self.shell.send(command + "\n")
|
||||||
|
output = ""
|
||||||
|
# 等待命令回显出现,再开始收集输出
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
deadline = time.time() + 60
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self.shell.recv_ready():
|
||||||
|
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||||
|
output += chunk
|
||||||
|
if "---- More ----" in chunk:
|
||||||
|
self.shell.send(" ")
|
||||||
|
time.sleep(0.3)
|
||||||
|
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
|
||||||
|
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""清除指定端口的 ONU 配置(恢复默认)
|
||||||
|
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||||
|
"""
|
||||||
|
if not self.shell:
|
||||||
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
|
# 进入系统视图
|
||||||
|
out = self._send_and_wait("system-view", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
|
# 进入端口
|
||||||
|
out = self._send_and_wait(f"interface Onu{port_id}", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||||
|
|
||||||
|
# 执行 default,等待确认提示
|
||||||
|
self.shell.send("default\n")
|
||||||
|
buf = ""
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self.shell.recv_ready():
|
||||||
|
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||||
|
if "[Y/N]" in buf or "[y/n]" in buf:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if "[Y/N]" not in buf and "[y/n]" not in buf:
|
||||||
|
raise Exception("未收到确认提示")
|
||||||
|
|
||||||
|
# 确认
|
||||||
|
self.shell.send("Y\n")
|
||||||
|
time.sleep(1)
|
||||||
|
# 排空缓冲区
|
||||||
|
if self.shell.recv_ready():
|
||||||
|
self.shell.recv(4096)
|
||||||
|
|
||||||
|
# 退出到用户视图
|
||||||
|
self._send_and_wait("quit", "]", timeout=5)
|
||||||
|
self._send_and_wait("quit", ">", timeout=5)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def detect_loopback(self) -> dict:
|
||||||
|
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
|
||||||
|
output = self.execute_command("display loopback-detection")
|
||||||
|
has_loop = "Loop is detected on following interfaces" in output
|
||||||
|
interfaces = []
|
||||||
|
if has_loop:
|
||||||
|
for line in output.splitlines():
|
||||||
|
m = re.match(r'\s+(Onu\S+)', line)
|
||||||
|
if m:
|
||||||
|
interfaces.append(m.group(1))
|
||||||
|
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||||
|
|
||||||
|
def parse_onu_status(self, output: str) -> Dict[str, str]:
|
||||||
|
"""解析 ONU 状态输出"""
|
||||||
|
devices = {}
|
||||||
|
lines = output.split('\n')
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# 匹配包含 MAC 地址的行
|
||||||
|
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||||
|
if mac_match:
|
||||||
|
mac = mac_match.group(1).lower()
|
||||||
|
if re.search(r'\b(up|online)\b', line.lower()):
|
||||||
|
devices[mac] = 'online'
|
||||||
|
elif re.search(r'\b(offline|down)\b', line.lower()):
|
||||||
|
devices[mac] = 'offline'
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def _clean_output(self, output: str) -> str:
|
||||||
|
"""清理终端控制字符和 More 分页标记,避免污染解析"""
|
||||||
|
# 移除 ANSI 转义序列
|
||||||
|
output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
|
||||||
|
# 移除 ---- More ---- 标记(仅标记本身,保留同行后续设备数据)
|
||||||
|
output = re.sub(r'---- More ----', '', output)
|
||||||
|
# 将独立的 \r(不跟 \n)替换为空,避免覆盖行内容
|
||||||
|
output = re.sub(r'\r(?!\n)', '', output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
def parse_onu_info(self, output: str) -> Tuple[Dict[str, ONUInfo], Dict[str, List[ONUInfo]]]:
|
||||||
|
"""增强解析:提取完整 ONU 信息
|
||||||
|
返回: (unique_devices, duplicate_devices)
|
||||||
|
- unique_devices: MAC -> ONUInfo(每个 MAC 只保留最新端口)
|
||||||
|
- duplicate_devices: MAC -> [ONUInfo, ...] (出现在多个端口的 MAC)
|
||||||
|
"""
|
||||||
|
all_records: Dict[str, List[ONUInfo]] = {}
|
||||||
|
output = self._clean_output(output)
|
||||||
|
lines = output.split('\n')
|
||||||
|
|
||||||
|
current_slot = None
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# 检测新的插槽区域: Olt1/0/1
|
||||||
|
slot_match = re.search(r'Olt(\d+)/(\d+)/(\d+)', line)
|
||||||
|
if slot_match:
|
||||||
|
current_slot = int(slot_match.group(3))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 跳过表头行和空行
|
||||||
|
if 'MAC' in line and 'LOID' in line:
|
||||||
|
continue
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 解析设备行
|
||||||
|
device = self._parse_device_line(line, current_slot)
|
||||||
|
if device:
|
||||||
|
if device.mac_address not in all_records:
|
||||||
|
all_records[device.mac_address] = []
|
||||||
|
all_records[device.mac_address].append(device)
|
||||||
|
|
||||||
|
unique_devices: Dict[str, ONUInfo] = {}
|
||||||
|
duplicate_devices: Dict[str, List[ONUInfo]] = {}
|
||||||
|
|
||||||
|
for mac, records in all_records.items():
|
||||||
|
if len(records) == 1:
|
||||||
|
unique_devices[mac] = records[0]
|
||||||
|
else:
|
||||||
|
duplicate_devices[mac] = records
|
||||||
|
# unique 中保留在线的,若都离线则保留最后一条
|
||||||
|
online = [r for r in records if r.status == 'online']
|
||||||
|
unique_devices[mac] = online[0] if online else records[-1]
|
||||||
|
|
||||||
|
return unique_devices, duplicate_devices
|
||||||
|
|
||||||
|
def _parse_device_line(self, line: str, slot: Optional[int]) -> Optional[ONUInfo]:
|
||||||
|
"""解析单行设备信息"""
|
||||||
|
# 匹配 MAC 地址
|
||||||
|
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
|
||||||
|
if not mac_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mac = mac_match.group(1).lower()
|
||||||
|
|
||||||
|
# 提取状态:H3C OLT 不同固件版本可能输出 Up/UP/up/Online/online
|
||||||
|
status = 'offline'
|
||||||
|
line_lower = line.lower()
|
||||||
|
# 检查行末状态字段(避免误匹配 "Onu" 中的字母)
|
||||||
|
if re.search(r'\b(up|online)\b', line_lower):
|
||||||
|
status = 'online'
|
||||||
|
|
||||||
|
# 提取端口信息: Onu1/0/2:1 -> slot=2, port=1, port_id="1/0/2:1"
|
||||||
|
slot_num, port_num, port_id = None, None, None
|
||||||
|
port_match = re.search(r'Onu(\d+)/(\d+)/(\d+):(\d+)', line)
|
||||||
|
if port_match:
|
||||||
|
slot_num = int(port_match.group(3)) # 第三段数字为槽位
|
||||||
|
port_num = int(port_match.group(4)) # 冒号后为端口号
|
||||||
|
port_id = f"{port_match.group(1)}/{port_match.group(2)}/{port_match.group(3)}:{port_match.group(4)}"
|
||||||
|
|
||||||
|
# 提取距离 - Port 列前的字段,如 "<1000" 或 "N/A"
|
||||||
|
distance_str = None
|
||||||
|
dist_match = re.search(r'(\S+)\s+Onu\d+/\d+/\d+:\d+', line)
|
||||||
|
if dist_match:
|
||||||
|
val = dist_match.group(1)
|
||||||
|
if val != 'N/A':
|
||||||
|
distance_str = val
|
||||||
|
|
||||||
|
# 提取 LOID - MAC 后第一个非空字段
|
||||||
|
loid = None
|
||||||
|
loid_match = re.search(r'[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\s+(\S+)', line, re.IGNORECASE)
|
||||||
|
if loid_match:
|
||||||
|
loid_val = loid_match.group(1)
|
||||||
|
if loid_val != 'N/A' and loid_val.isdigit():
|
||||||
|
loid = loid_val
|
||||||
|
|
||||||
|
# 提取设备型号 - Port 列之后的第一个字段,如 "WA6520H-EGPON/A"
|
||||||
|
model = None
|
||||||
|
model_match = re.search(r'Onu\d+/\d+/\d+:\d+\s+(\S+)', line)
|
||||||
|
if model_match:
|
||||||
|
potential_model = model_match.group(1)
|
||||||
|
if potential_model != 'N/A':
|
||||||
|
model = potential_model
|
||||||
|
|
||||||
|
return ONUInfo(
|
||||||
|
mac_address=mac,
|
||||||
|
status=status,
|
||||||
|
distance_m=None,
|
||||||
|
distance_str=distance_str,
|
||||||
|
slot_number=slot_num or slot,
|
||||||
|
port_number=port_num,
|
||||||
|
port_id=port_id,
|
||||||
|
loid=loid,
|
||||||
|
model=model
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_olt_ports(self) -> list:
|
||||||
|
"""获取所有 Olt 端口状态,返回 [{'name': 'Olt1/0/1', 'status': 'up'}, ...]"""
|
||||||
|
output = self.execute_command("display interface brief")
|
||||||
|
ports = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line.startswith("Olt"):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
name = parts[0]
|
||||||
|
link = parts[1].upper()
|
||||||
|
# ADM 表示 administratively down(手动关闭)
|
||||||
|
if link == "ADM":
|
||||||
|
status = "adm-down"
|
||||||
|
elif link == "UP":
|
||||||
|
status = "up"
|
||||||
|
else:
|
||||||
|
status = "down"
|
||||||
|
ports.append({"name": name, "status": status})
|
||||||
|
return ports
|
||||||
|
|
||||||
|
def toggle_olt_port(self, port_name: str, action: str) -> bool:
|
||||||
|
"""开启或关闭 OLT 端口
|
||||||
|
action: 'shutdown' 或 'undo shutdown'
|
||||||
|
流程: system-view -> interface {port_name} -> shutdown/undo shutdown -> quit -> quit
|
||||||
|
"""
|
||||||
|
if not self.shell:
|
||||||
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
|
out = self._send_and_wait("system-view", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
|
out = self._send_and_wait(f"interface {port_name}", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception(f"进入端口 {port_name} 失败")
|
||||||
|
|
||||||
|
out = self._send_and_wait(action, "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception(f"执行 {action} 失败")
|
||||||
|
|
||||||
|
self._send_and_wait("quit", "]", timeout=5)
|
||||||
|
self._send_and_wait("quit", ">", timeout=5)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||||
|
"""同步 NTP 时间服务器配置
|
||||||
|
流程: system-view -> undo ntp old -> ntp new -> clock timezone -> quit -> save force
|
||||||
|
old_server 若不存在会报错,直接忽略继续执行。
|
||||||
|
"""
|
||||||
|
if not self.shell:
|
||||||
|
raise Exception("SSH 未连接")
|
||||||
|
|
||||||
|
out = self._send_and_wait("system-view", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception("进入 system-view 失败")
|
||||||
|
|
||||||
|
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||||
|
self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||||
|
|
||||||
|
# 添加新 NTP 服务器
|
||||||
|
out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||||
|
|
||||||
|
# 设置时区为北京时间
|
||||||
|
out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||||
|
if "]" not in out:
|
||||||
|
raise Exception("配置时区失败")
|
||||||
|
|
||||||
|
# 退出系统视图
|
||||||
|
self._send_and_wait("quit", ">")
|
||||||
|
|
||||||
|
# 强制保存配置
|
||||||
|
self._send_and_wait("save force", ">", timeout=30)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_onu_events(self, port_id: str) -> list:
|
||||||
|
"""
|
||||||
|
查询 ONU 上下线事件记录
|
||||||
|
命令: display epon onu-event interface Onu{port_id}
|
||||||
|
返回: [{'date', 'time', 'event', 'status', 'datetime_str'}, ...]
|
||||||
|
时间按倒序(最新在前)返回
|
||||||
|
"""
|
||||||
|
output = self.execute_command(
|
||||||
|
f"display epon onu-event interface Onu{port_id}"
|
||||||
|
)
|
||||||
|
output = self._clean_output(output)
|
||||||
|
events = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
m = re.match(r'(\d{4}/\d{2}/\d{2})\s+(\d{2}:\d{2}:\d{2})\s+(.+)', line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
date_str, time_str, rest = m.group(1), m.group(2), m.group(3).strip()
|
||||||
|
# 最后一个单词是 ONU Status(Up/Offline),前面整体是 Event 名称
|
||||||
|
parts = rest.rsplit(None, 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
event, status = parts[0].strip(), parts[1].strip()
|
||||||
|
else:
|
||||||
|
event, status = rest, ''
|
||||||
|
events.append({
|
||||||
|
'date': date_str,
|
||||||
|
'time': time_str,
|
||||||
|
'event': event,
|
||||||
|
'status': status,
|
||||||
|
'datetime_str': f"{date_str} {time_str}",
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""上下文管理器入口,自动连接"""
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""上下文管理器出口,自动关闭连接"""
|
||||||
|
self.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""关闭 SSH 连接"""
|
||||||
|
if self.client:
|
||||||
|
self.client.close()
|
||||||
@@ -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,4 @@
|
|||||||
|
"""Celery 任务模块"""
|
||||||
|
from app.tasks.check_tasks import check_all_devices # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ['check_all_devices']
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""告警相关 Celery 任务"""
|
||||||
|
import traceback
|
||||||
|
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": str(e), "traceback": traceback.format_exc()}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""审计日志 Celery 任务"""
|
||||||
|
import traceback
|
||||||
|
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 create_audit_log_task(
|
||||||
|
user_id: str,
|
||||||
|
username: str,
|
||||||
|
user_role: str,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
ip_address: str,
|
||||||
|
user_agent: str,
|
||||||
|
status_code: int,
|
||||||
|
request_params: dict = None,
|
||||||
|
response_data: dict = None,
|
||||||
|
error_message: str = None,
|
||||||
|
description: str = None,
|
||||||
|
resource_id: str = None,
|
||||||
|
resource_name: str = None,
|
||||||
|
):
|
||||||
|
"""异步写入审计日志,不阻塞主请求流程"""
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
user_role=user_role,
|
||||||
|
method=method,
|
||||||
|
path=path,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
status_code=status_code,
|
||||||
|
request_params=request_params,
|
||||||
|
response_data=response_data,
|
||||||
|
error_message=error_message,
|
||||||
|
description=description,
|
||||||
|
resource_id=resource_id,
|
||||||
|
resource_name=resource_name,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # 审计日志失败不影响主业务
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(queue='h3c_onu_ms')
|
||||||
|
def cleanup_audit_logs_task():
|
||||||
|
"""清理90天前的审计日志(每天执行)"""
|
||||||
|
from app.services.audit_service import cleanup_old_logs
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
deleted = cleanup_old_logs(db)
|
||||||
|
return {'success': True, 'deleted': deleted}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""状态检查任务"""
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import redis as redis_lib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import func, case
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.check_service import CheckService
|
||||||
|
|
||||||
|
_LAST_RUN_KEY = "check_all_devices:last_run"
|
||||||
|
_RUNNING_KEY = "check_all_devices:running"
|
||||||
|
_INTERVAL_REDIS_KEY = "system:check_interval_seconds"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis():
|
||||||
|
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_check_interval(r) -> int:
|
||||||
|
"""从 Redis 读取配置间隔,回退到 DB,再回退到默认值"""
|
||||||
|
cached = r.get(_INTERVAL_REDIS_KEY)
|
||||||
|
if cached:
|
||||||
|
return int(cached)
|
||||||
|
# 从 DB 读取并缓存
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
setting = db.query(SystemSetting).filter_by(key='check_interval_seconds').first()
|
||||||
|
interval = int(setting.value) if setting else settings.CHECK_INTERVAL
|
||||||
|
r.set(_INTERVAL_REDIS_KEY, str(interval))
|
||||||
|
return interval
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True)
|
||||||
|
def check_all_devices(self):
|
||||||
|
"""检查所有设备状态(支持可配置间隔,最小5分钟)"""
|
||||||
|
r = _get_redis()
|
||||||
|
interval = _get_check_interval(r)
|
||||||
|
|
||||||
|
# Redis 节流:检查距上次运行是否已超过配置间隔
|
||||||
|
last_run = r.get(_LAST_RUN_KEY)
|
||||||
|
now = time.time()
|
||||||
|
if last_run and (now - float(last_run)) < interval:
|
||||||
|
remaining = int(interval - (now - float(last_run)))
|
||||||
|
return {'skipped': True, 'reason': f'间隔未到,还需等待 {remaining} 秒', 'interval': interval}
|
||||||
|
|
||||||
|
# 标记正在运行(TTL 10分钟防止异常时永久卡住)
|
||||||
|
r.set(_RUNNING_KEY, '1', ex=600)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
self.update_state(state='PROGRESS', meta={'current': 0, 'total': 0, 'status': '获取OLT列表...'})
|
||||||
|
try:
|
||||||
|
service = CheckService(db)
|
||||||
|
from app.models.device import OLTDevice
|
||||||
|
olts = db.query(OLTDevice).all()
|
||||||
|
|
||||||
|
total = len(olts)
|
||||||
|
self.update_state(state='PROGRESS', meta={'current': 0, 'total': total, 'status': f'准备检查 {total} 个OLT...'})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
errors = []
|
||||||
|
total_online = 0
|
||||||
|
total_offline = 0
|
||||||
|
|
||||||
|
for idx, olt in enumerate(olts):
|
||||||
|
self.update_state(state='PROGRESS', meta={
|
||||||
|
'current': idx, 'total': total,
|
||||||
|
'status': f'检查 OLT: {olt.location or olt.ip_address}...'
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
result = service.update_status_only(olt.id)
|
||||||
|
total_online += result.get('online', 0)
|
||||||
|
total_offline += result.get('offline', 0)
|
||||||
|
results.append({
|
||||||
|
'olt_id': olt.id,
|
||||||
|
'olt_name': olt.location or olt.ip_address,
|
||||||
|
'online': result.get('online', 0),
|
||||||
|
'offline': result.get('offline', 0),
|
||||||
|
'success': True
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({
|
||||||
|
'olt_id': olt.id,
|
||||||
|
'olt_name': olt.location or olt.ip_address,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
results.append({
|
||||||
|
'olt_id': olt.id,
|
||||||
|
'olt_name': olt.location or olt.ip_address,
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||||
|
|
||||||
|
# 通知 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 {
|
||||||
|
'success': True,
|
||||||
|
'total_olts': total,
|
||||||
|
'total_online': total_online,
|
||||||
|
'total_offline': total_offline,
|
||||||
|
'results': results,
|
||||||
|
'errors': errors
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'traceback': traceback.format_exc()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
# 任务完成后记录时间、清除运行标记
|
||||||
|
r.set(_LAST_RUN_KEY, str(time.time()))
|
||||||
|
r.delete(_RUNNING_KEY)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def aggregate_daily_snapshot():
|
||||||
|
"""聚合昨日设备状态快照(每天凌晨执行)"""
|
||||||
|
from app.models.device import DeviceStatusHistory, DeviceDailySnapshot, ONUDevice
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yesterday = (datetime.utcnow() - timedelta(days=1)).date()
|
||||||
|
date_str = yesterday.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 如果已存在则跳过(幂等)
|
||||||
|
exists = db.query(DeviceDailySnapshot).filter_by(snapshot_date=date_str).first()
|
||||||
|
if exists:
|
||||||
|
return {'skipped': True, 'date': date_str}
|
||||||
|
|
||||||
|
# 昨天每台设备的最后一次检查状态
|
||||||
|
day_start = datetime.combine(yesterday, datetime.min.time())
|
||||||
|
day_end = datetime.combine(yesterday, datetime.max.time())
|
||||||
|
|
||||||
|
daily_latest_subq = (
|
||||||
|
db.query(
|
||||||
|
DeviceStatusHistory.onu_device_id,
|
||||||
|
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||||
|
)
|
||||||
|
.filter(DeviceStatusHistory.checked_at.between(day_start, day_end))
|
||||||
|
.group_by(DeviceStatusHistory.onu_device_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
row = (
|
||||||
|
db.query(
|
||||||
|
func.count().label("total"),
|
||||||
|
func.sum(case((DeviceStatusHistory.status == 'online', 1), else_=0)).label("online"),
|
||||||
|
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
daily_latest_subq,
|
||||||
|
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
|
||||||
|
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
|
||||||
|
)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = DeviceDailySnapshot(
|
||||||
|
snapshot_date=date_str,
|
||||||
|
total=int(row.total or 0),
|
||||||
|
online=int(row.online or 0),
|
||||||
|
offline=int(row.offline or 0),
|
||||||
|
)
|
||||||
|
db.add(snapshot)
|
||||||
|
db.commit()
|
||||||
|
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def cleanup_status_history():
|
||||||
|
"""清理 30 天前的设备状态历史记录(每天凌晨执行)"""
|
||||||
|
from app.models.device import DeviceStatusHistory
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=30)
|
||||||
|
deleted = db.query(DeviceStatusHistory).filter(
|
||||||
|
DeviceStatusHistory.checked_at < cutoff
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": deleted}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Celery Worker 入口模块"""
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
from app.tasks import alert_tasks # noqa: F401 - 导入以注册任务
|
||||||
|
|
||||||
|
__all__ = ['celery_app']
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
alembic==1.13.1
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.5.3
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.6
|
||||||
|
celery==5.3.6
|
||||||
|
redis==5.0.1
|
||||||
|
paramiko==3.4.0
|
||||||
|
pandas==2.1.4
|
||||||
|
openpyxl==3.1.2
|
||||||
|
cryptography==42.0.0
|
||||||
|
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
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
PyJWT>=2.8.0
|
||||||
|
requests>=2.31.0
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""数据库初始化脚本"""
|
||||||
|
from app.core.database import engine, Base
|
||||||
|
from app.models import device, user, permission
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""创建所有表"""
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
print("数据库表创建完成")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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,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,209 @@
|
|||||||
|
# H3C ONU设备管理系统 - 环境变量配置示例
|
||||||
|
# 复制此文件为 .env 并修改相应配置
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 应用基础配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 应用环境 (development, production)
|
||||||
|
APP_ENV=production
|
||||||
|
|
||||||
|
# 调试模式 (true/false)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
|
||||||
|
# 时区设置
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 数据库配置 (PostgreSQL)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# PostgreSQL连接字符串
|
||||||
|
# 格式: postgresql://username:password@host:port/database
|
||||||
|
DATABASE_URL=postgresql://h3c_user:your_password@postgres-host:5432/h3c_onu_ms
|
||||||
|
|
||||||
|
# 数据库连接池配置
|
||||||
|
DATABASE_POOL_SIZE=20
|
||||||
|
DATABASE_MAX_OVERFLOW=40
|
||||||
|
DATABASE_POOL_RECYCLE=3600
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Redis配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Redis连接URL (如果使用外部Redis,修改为外部地址)
|
||||||
|
REDIS_URL=redis://:redispass@redis:6379/0
|
||||||
|
|
||||||
|
# Redis密码 (容器内Redis使用)
|
||||||
|
REDIS_PASSWORD=redispass
|
||||||
|
|
||||||
|
# Redis连接池大小
|
||||||
|
REDIS_POOL_SIZE=10
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Casdoor认证配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Casdoor服务地址
|
||||||
|
CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||||
|
|
||||||
|
# 应用Client ID
|
||||||
|
CASDOOR_CLIENT_ID=your_casdoor_client_id
|
||||||
|
|
||||||
|
# 应用Client Secret
|
||||||
|
CASDOOR_CLIENT_SECRET=your_casdoor_client_secret
|
||||||
|
|
||||||
|
# 应用Certificate
|
||||||
|
CASDOOR_CERTIFICATE=your_casdoor_certificate
|
||||||
|
|
||||||
|
# 组织名称
|
||||||
|
CASDOOR_ORG_NAME=your_organization
|
||||||
|
|
||||||
|
# 应用名称
|
||||||
|
CASDOOR_APP_NAME=h3c-onu-ms
|
||||||
|
|
||||||
|
# Casdoor 回调地址(部署后改为实际域名)
|
||||||
|
CASDOOR_REDIRECT_URL=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SSH连接配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# SSH连接超时时间(秒)
|
||||||
|
SSH_TIMEOUT=30
|
||||||
|
|
||||||
|
# 最大并发连接数
|
||||||
|
SSH_MAX_CONNECTIONS=10
|
||||||
|
|
||||||
|
# SSH重试次数
|
||||||
|
SSH_RETRY_COUNT=3
|
||||||
|
|
||||||
|
# SSH连接保持时间(秒)
|
||||||
|
SSH_KEEPALIVE=60
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 任务调度配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 自动检查间隔(秒) - 30分钟
|
||||||
|
CHECK_INTERVAL=1800
|
||||||
|
|
||||||
|
# 手动刷新冷却时间(秒) - 5分钟
|
||||||
|
MANUAL_COOLDOWN=300
|
||||||
|
|
||||||
|
# 历史记录保留天数
|
||||||
|
HISTORY_RETENTION=90
|
||||||
|
|
||||||
|
# 批量检查设备数量
|
||||||
|
BATCH_CHECK_SIZE=100
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 前端 & CORS 配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 前端访问地址
|
||||||
|
FRONTEND_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# CORS允许的域名 (逗号分隔)
|
||||||
|
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# NTP 同步配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# NTP 旧服务器 IP
|
||||||
|
NTP_OLD_SERVER=172.16.0.254
|
||||||
|
# NTP 新服务器 IP
|
||||||
|
NTP_NEW_SERVER=172.16.1.252
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# iMC 网管 API 配置(ONU 远程重启/光功率查询)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
IMC_API_URL=
|
||||||
|
IMC_API_USERNAME=
|
||||||
|
IMC_API_PASSWORD=
|
||||||
|
# 本地认证不需要 SSL 验证
|
||||||
|
IMC_API_VERIFY_SSL=false
|
||||||
|
IMC_CONNECT_TIMEOUT=5
|
||||||
|
IMC_READ_TIMEOUT=20
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 日志配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# 日志文件路径
|
||||||
|
LOG_FILE=/app/logs/app.log
|
||||||
|
|
||||||
|
# 日志轮转大小
|
||||||
|
LOG_ROTATION=10MB
|
||||||
|
|
||||||
|
# 日志保留天数
|
||||||
|
LOG_RETENTION=30
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 邮件通知配置 (可选)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# SMTP服务器
|
||||||
|
# SMTP_HOST=smtp.example.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your_email@example.com
|
||||||
|
# SMTP_PASSWORD=your_email_password
|
||||||
|
# SMTP_FROM=noreply@example.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 监控配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 健康检查端口
|
||||||
|
HEALTH_CHECK_PORT=8000
|
||||||
|
|
||||||
|
# 监控指标端口
|
||||||
|
METRICS_PORT=8000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 安全配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 速率限制配置
|
||||||
|
RATE_LIMIT_PER_MINUTE=60
|
||||||
|
RATE_LIMIT_PER_HOUR=1000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 备份配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 数据库备份目录
|
||||||
|
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
|
||||||
|
|
||||||
|
# 自动备份时间 (cron格式)
|
||||||
|
BACKUP_SCHEDULE="0 2 * * *"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 开发环境覆盖配置
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# 如果是开发环境,取消注释以下配置
|
||||||
|
# DEBUG=true
|
||||||
|
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
|
||||||
|
# REDIS_URL=redis://localhost:6379/0
|
||||||
|
# VITE_API_BASE_URL=http://localhost:8000
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
services:
|
||||||
|
# 后端API服务
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: h3c-onu-ms-backend
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
|
||||||
|
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||||
|
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||||
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- DEBUG=${DEBUG:-false}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
|
||||||
|
- NTP_OLD_SERVER=${NTP_OLD_SERVER:-172.16.0.254}
|
||||||
|
- NTP_NEW_SERVER=${NTP_NEW_SERVER:-172.16.1.252}
|
||||||
|
- IMC_API_URL=${IMC_API_URL:-}
|
||||||
|
- IMC_API_USERNAME=${IMC_API_USERNAME:-}
|
||||||
|
- IMC_API_PASSWORD=${IMC_API_PASSWORD:-}
|
||||||
|
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||||
|
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||||
|
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||||
|
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||||
|
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||||
|
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||||
|
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-}
|
||||||
|
volumes:
|
||||||
|
- ../backend/logs:/app/logs
|
||||||
|
- ../backend/static:/app/static
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Celery Worker - 处理异步任务
|
||||||
|
celery-worker:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: h3c-onu-ms-celery-worker
|
||||||
|
command: celery -A app.core.celery_app worker --loglevel=info --concurrency=4
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
|
||||||
|
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||||
|
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||||
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- 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:
|
||||||
|
- ../backend/logs:/app/logs
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Celery Beat - 定时任务调度
|
||||||
|
celery-beat:
|
||||||
|
build:
|
||||||
|
context: ../backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: h3c-onu-ms-celery-beat
|
||||||
|
command: celery -A app.core.celery_app beat --loglevel=info
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
|
||||||
|
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
|
||||||
|
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
|
||||||
|
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||||
|
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||||
|
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- 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:
|
||||||
|
- ../backend/logs:/app/logs
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# 前端应用(生产模式:nginx 静态文件服务)
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: h3c-onu-ms-frontend
|
||||||
|
ports:
|
||||||
|
- "18062:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: h3c-onu-ms-network
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# H3C ONU设备管理系统 - Nginx站点配置
|
||||||
|
|
||||||
|
# 前端静态文件服务
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# 根目录指向前端构建文件
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip压缩
|
||||||
|
gzip_static on;
|
||||||
|
|
||||||
|
# 静态文件缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 前端路由支持 (Vue Router history模式)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API代理到后端
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# 缓冲区设置
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
proxy_busy_buffers_size 8k;
|
||||||
|
|
||||||
|
# 禁用代理缓存
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket支持
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|
||||||
|
# WebSocket超时设置
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态文件代理 (后端静态文件)
|
||||||
|
location /static/ {
|
||||||
|
alias /usr/share/nginx/static/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://backend:8000/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 监控指标端点
|
||||||
|
location /metrics {
|
||||||
|
proxy_pass http://backend:8000/metrics;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API文档
|
||||||
|
location /docs {
|
||||||
|
proxy_pass http://backend:8000/docs;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /redoc {
|
||||||
|
proxy_pass http://backend:8000/redoc;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 错误页面
|
||||||
|
error_page 404 /index.html;
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS配置 (需要SSL证书)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
#
|
||||||
|
# # SSL证书配置
|
||||||
|
# ssl_certificate /etc/nginx/ssl/your-domain.com.crt;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/your-domain.com.key;
|
||||||
|
#
|
||||||
|
# # SSL优化配置
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
# ssl_prefer_server_ciphers off;
|
||||||
|
# ssl_session_cache shared:SSL:10m;
|
||||||
|
# ssl_session_timeout 10m;
|
||||||
|
#
|
||||||
|
# # HSTS头
|
||||||
|
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
#
|
||||||
|
# # 其他配置与HTTP相同
|
||||||
|
# root /usr/share/nginx/html;
|
||||||
|
# index index.html;
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# try_files $uri $uri/ /index.html;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /api/ {
|
||||||
|
# proxy_pass http://backend:8000;
|
||||||
|
# # ... 其他代理配置
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# # 强制HTTP重定向到HTTPS
|
||||||
|
# # if ($scheme != "https") {
|
||||||
|
# # return 301 https://$host$request_uri;
|
||||||
|
# # }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 重定向HTTP到HTTPS (如果启用HTTPS)
|
||||||
|
# server {
|
||||||
|
# listen 80;
|
||||||
|
# server_name your-domain.com;
|
||||||
|
# return 301 https://$server_name$request_uri;
|
||||||
|
# }
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# 日志格式
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
# 基础配置
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# Gzip压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/atom+xml
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# 包含站点配置
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+381
@@ -0,0 +1,381 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# H3C ONU设备管理系统 - 部署脚本
|
||||||
|
# 使用方法: ./deploy.sh [环境] [操作]
|
||||||
|
# 环境: dev, prod (默认: prod)
|
||||||
|
# 操作: up, down, restart, logs, build (默认: up)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色输出
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 默认值
|
||||||
|
ENV=${1:-prod}
|
||||||
|
ACTION=${2:-up}
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
|
||||||
|
# 根据环境选择配置文件
|
||||||
|
if [ "$ENV" = "dev" ]; then
|
||||||
|
COMPOSE_FILE="docker-compose.dev.yml"
|
||||||
|
echo -e "${BLUE}使用开发环境配置${NC}"
|
||||||
|
elif [ "$ENV" = "prod" ]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
echo -e "${BLUE}使用生产环境配置${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}错误: 未知环境 '$ENV'${NC}"
|
||||||
|
echo "可用环境: dev, prod"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查Docker Compose文件是否存在
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
echo -e "${RED}错误: Docker Compose文件 '$COMPOSE_FILE' 不存在${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查环境变量文件
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo -e "${YELLOW}警告: .env 文件不存在,从 .env.example 复制${NC}"
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo -e "${YELLOW}请编辑 .env 文件并设置正确的配置${NC}"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo -e "${RED}错误: .env.example 文件也不存在${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 加载环境变量
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
# 函数:显示帮助信息
|
||||||
|
show_help() {
|
||||||
|
echo "H3C ONU设备管理系统 - 部署脚本"
|
||||||
|
echo ""
|
||||||
|
echo "使用方法: $0 [环境] [操作]"
|
||||||
|
echo ""
|
||||||
|
echo "环境:"
|
||||||
|
echo " dev 开发环境"
|
||||||
|
echo " prod 生产环境 (默认)"
|
||||||
|
echo ""
|
||||||
|
echo "操作:"
|
||||||
|
echo " up 启动服务 (默认)"
|
||||||
|
echo " down 停止服务"
|
||||||
|
echo " restart 重启服务"
|
||||||
|
echo " logs 查看日志"
|
||||||
|
echo " build 构建镜像"
|
||||||
|
echo " ps 查看服务状态"
|
||||||
|
echo " exec 进入容器"
|
||||||
|
echo " backup 备份数据"
|
||||||
|
echo " restore 恢复数据"
|
||||||
|
echo ""
|
||||||
|
echo "示例:"
|
||||||
|
echo " $0 prod up # 启动生产环境"
|
||||||
|
echo " $0 dev logs # 查看开发环境日志"
|
||||||
|
echo " $0 prod restart # 重启生产环境"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:检查Docker是否运行
|
||||||
|
check_docker() {
|
||||||
|
if ! docker info > /dev/null 2>&1; then
|
||||||
|
echo -e "${RED}错误: Docker未运行或当前用户无权限${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:检查Docker Compose是否安装
|
||||||
|
check_docker_compose() {
|
||||||
|
if ! command -v docker-compose &> /dev/null; then
|
||||||
|
echo -e "${RED}错误: Docker Compose未安装${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:启动服务
|
||||||
|
start_services() {
|
||||||
|
echo -e "${GREEN}启动服务...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
# 等待服务启动
|
||||||
|
echo -e "${BLUE}等待服务启动...${NC}"
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# 检查服务状态
|
||||||
|
check_services_status
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:停止服务
|
||||||
|
stop_services() {
|
||||||
|
echo -e "${YELLOW}停止服务...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" down
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:重启服务
|
||||||
|
restart_services() {
|
||||||
|
echo -e "${YELLOW}重启服务...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" restart
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:查看日志
|
||||||
|
show_logs() {
|
||||||
|
echo -e "${BLUE}查看日志 (Ctrl+C退出)...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" logs -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:构建镜像
|
||||||
|
build_images() {
|
||||||
|
echo -e "${GREEN}构建Docker镜像...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" build --no-cache
|
||||||
|
|
||||||
|
# 清理未使用的镜像
|
||||||
|
echo -e "${BLUE}清理未使用的镜像...${NC}"
|
||||||
|
docker image prune -f
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:查看服务状态
|
||||||
|
show_status() {
|
||||||
|
echo -e "${BLUE}服务状态:${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
|
echo -e "\n${BLUE}容器资源使用:${NC}"
|
||||||
|
docker stats --no-stream $(docker-compose -f "$COMPOSE_FILE" ps -q)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:进入容器
|
||||||
|
exec_container() {
|
||||||
|
echo -e "${BLUE}选择要进入的容器:${NC}"
|
||||||
|
echo "1) backend (后端API)"
|
||||||
|
echo "2) frontend (前端)"
|
||||||
|
echo "3) celery-worker (Celery Worker)"
|
||||||
|
echo "4) celery-beat (Celery Beat)"
|
||||||
|
echo "5) nginx (Nginx)"
|
||||||
|
echo "6) redis (Redis)"
|
||||||
|
|
||||||
|
read -p "请输入数字 (1-6): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1) CONTAINER="backend" ;;
|
||||||
|
2) CONTAINER="frontend" ;;
|
||||||
|
3) CONTAINER="celery-worker" ;;
|
||||||
|
4) CONTAINER="celery-beat" ;;
|
||||||
|
5) CONTAINER="nginx" ;;
|
||||||
|
6) CONTAINER="redis" ;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}无效的选择${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo -e "${GREEN}进入 $CONTAINER 容器...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" exec "$CONTAINER" sh
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:备份数据
|
||||||
|
backup_data() {
|
||||||
|
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo -e "${GREEN}开始备份数据...${NC}"
|
||||||
|
|
||||||
|
# 备份数据库
|
||||||
|
echo -e "${BLUE}备份数据库...${NC}"
|
||||||
|
DB_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q backend)
|
||||||
|
if [ -n "$DB_CONTAINER" ]; then
|
||||||
|
docker exec "$DB_CONTAINER" pg_dump "$DATABASE_URL" > "$BACKUP_DIR/database.sql"
|
||||||
|
echo "数据库备份完成: $BACKUP_DIR/database.sql"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}警告: 数据库容器未运行${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 备份Redis数据
|
||||||
|
echo -e "${BLUE}备份Redis数据...${NC}"
|
||||||
|
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
|
||||||
|
if [ -n "$REDIS_CONTAINER" ]; then
|
||||||
|
docker exec "$REDIS_CONTAINER" redis-cli --rdb /data/dump.rdb
|
||||||
|
docker cp "$REDIS_CONTAINER:/data/dump.rdb" "$BACKUP_DIR/redis.rdb"
|
||||||
|
echo "Redis备份完成: $BACKUP_DIR/redis.rdb"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}警告: Redis容器未运行${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 备份配置文件
|
||||||
|
echo -e "${BLUE}备份配置文件...${NC}"
|
||||||
|
cp .env "$BACKUP_DIR/"
|
||||||
|
cp "$COMPOSE_FILE" "$BACKUP_DIR/"
|
||||||
|
|
||||||
|
# 创建备份信息文件
|
||||||
|
echo "备份时间: $(date)" > "$BACKUP_DIR/backup.info"
|
||||||
|
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
||||||
|
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
||||||
|
|
||||||
|
# 验证数据库备份
|
||||||
|
echo -e "${BLUE}验证数据库备份...${NC}"
|
||||||
|
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}"
|
||||||
|
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# 清理旧备份(保留最近 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}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:恢复数据
|
||||||
|
restore_data() {
|
||||||
|
echo -e "${YELLOW}警告: 恢复数据将覆盖现有数据${NC}"
|
||||||
|
read -p "请输入要恢复的备份文件路径: " BACKUP_FILE
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_FILE" ]; then
|
||||||
|
echo -e "${RED}错误: 备份文件不存在${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 解压备份文件
|
||||||
|
BACKUP_DIR="${BACKUP_FILE%.tar.gz}"
|
||||||
|
echo -e "${BLUE}解压备份文件...${NC}"
|
||||||
|
tar -xzf "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
echo -e "${BLUE}停止服务...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" down
|
||||||
|
|
||||||
|
# 恢复数据库
|
||||||
|
if [ -f "$BACKUP_DIR/database.sql" ]; then
|
||||||
|
echo -e "${BLUE}恢复数据库...${NC}"
|
||||||
|
# 这里需要根据实际情况调整数据库恢复命令
|
||||||
|
# 例如: psql -d database -f backup.sql
|
||||||
|
echo "数据库恢复命令需要根据实际情况配置"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 恢复Redis
|
||||||
|
if [ -f "$BACKUP_DIR/redis.rdb" ]; then
|
||||||
|
echo -e "${BLUE}恢复Redis数据...${NC}"
|
||||||
|
# 这里需要根据实际情况调整Redis恢复命令
|
||||||
|
echo "Redis恢复命令需要根据实际情况配置"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 恢复配置文件
|
||||||
|
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||||
|
echo -e "${BLUE}恢复配置文件...${NC}"
|
||||||
|
cp "$BACKUP_DIR/.env" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
rm -rf "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
echo -e "${BLUE}启动服务...${NC}"
|
||||||
|
docker-compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
echo -e "${GREEN}数据恢复完成${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 函数:检查服务状态
|
||||||
|
check_services_status() {
|
||||||
|
echo -e "${BLUE}检查服务健康状态...${NC}"
|
||||||
|
|
||||||
|
# 检查后端服务
|
||||||
|
if curl -f http://localhost:8000/health > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}✓ 后端服务运行正常${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ 后端服务异常${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查前端服务
|
||||||
|
if curl -f http://localhost:8080 > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}✓ 前端服务运行正常${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ 前端服务异常${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查Redis
|
||||||
|
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
|
||||||
|
if [ -n "$REDIS_CONTAINER" ] && docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}✓ Redis服务运行正常${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Redis服务异常${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}部署完成!${NC}"
|
||||||
|
echo -e "前端访问: ${BLUE}http://localhost:8080${NC}"
|
||||||
|
echo -e "API文档: ${BLUE}http://localhost:8000/docs${NC}"
|
||||||
|
echo -e "健康检查: ${BLUE}http://localhost:8000/health${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主程序
|
||||||
|
main() {
|
||||||
|
# 显示标题
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN} H3C ONU设备管理系统 - 部署工具${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查依赖
|
||||||
|
check_docker
|
||||||
|
check_docker_compose
|
||||||
|
|
||||||
|
# 根据操作执行相应命令
|
||||||
|
case "$ACTION" in
|
||||||
|
up)
|
||||||
|
start_services
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
stop_services
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
restart_services
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
show_logs
|
||||||
|
;;
|
||||||
|
build)
|
||||||
|
build_images
|
||||||
|
;;
|
||||||
|
ps)
|
||||||
|
show_status
|
||||||
|
;;
|
||||||
|
exec)
|
||||||
|
exec_container
|
||||||
|
;;
|
||||||
|
backup)
|
||||||
|
backup_data
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
restore_data
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}错误: 未知操作 '$ACTION'${NC}"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 执行主程序
|
||||||
|
main "$@"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user