Compare commits

1 Commits

Author SHA1 Message Date
v6ole bba319f8af Initial commit 2026-05-12 10:25:04 +08:00
153 changed files with 398 additions and 25048 deletions
-177
View File
@@ -1,177 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
-42
View File
@@ -1,42 +0,0 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
-73
View File
@@ -1,73 +0,0 @@
# 代码规范
## Python 后端规范
- 遵循 PEP 8 规范
- 使用 Black 进行代码格式化
- 使用 isort 进行导入排序
- 使用类型注解(Type Hints
- 异步函数使用 async/await
- 错误处理使用自定义异常类
**示例**
```python
from typing import List, Optional
from fastapi import HTTPException
async def get_devices(
skip: int = 0,
limit: int = 100
) -> List[Device]:
"""获取设备列表"""
try:
devices = await device_service.get_all(skip, limit)
return devices
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
## Vue 前端规范
- 使用 Composition APIsetup script
- 组件使用 PascalCase 命名
- 使用 TypeScript 类型检查
- 遵循 Vue 官方风格指南
- 使用 ESLint + Prettier 格式化
**示例**
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Device } from '@/types'
const devices = ref<Device[]>([])
const fetchDevices = async () => {
// 实现逻辑
}
onMounted(() => {
fetchDevices()
})
</script>
```
## Git 提交规范
使用 Conventional Commits 格式:
```
<type>(<scope>): <subject>
类型:
- feat: 新功能
- fix: 修复bug
- docs: 文档更新
- style: 代码格式
- refactor: 重构
- test: 测试
- chore: 构建/工具
示例:
feat(device): 添加设备导入功能
fix(auth): 修复登录token过期问题
```
-40
View File
@@ -1,40 +0,0 @@
# 架构规则
## 后端架构
### 分层架构
- **API 层**:仅处理请求/响应,调用 Service
- **Service 层**:业务逻辑,不直接操作数据库
- **Model 层**SQLAlchemy 模型定义
### 目录结构
```
app/
├── api/v1/ # API 路由
├── services/ # 业务逻辑
├── models/ # 数据模型
├── schemas/ # Pydantic 模式
├── core/ # 核心配置
├── tasks/ # Celery 任务
└── utils/ # 工具函数
```
### 异步任务
- 耗时操作使用 Celery
- SSH 状态检查使用异步任务
- 批量导入使用后台任务
## 前端架构
### 组件分类
- `components/common/`:通用组件(Layout、Table、Form
- `components/business/`:业务组件(Device、User
- `views/`:页面组件
### 状态管理
- 使用 Pinia stores
- 按模块划分:auth、device、user、system
### API 调用
- 统一在 `api/` 目录封装
- 使用 Axios 拦截器处理认证和错误
-44
View File
@@ -1,44 +0,0 @@
# 安全规则
## 数据安全
- SSH 密码必须加密存储(使用 Fernet 加密)
- 敏感信息通过环境变量配置
- 数据库连接使用 SSL
- 定期备份数据(90天历史记录)
**密码加密示例**
```python
from cryptography.fernet import Fernet
def encrypt_password(password: str, key: bytes) -> str:
f = Fernet(key)
return f.encrypt(password.encode()).decode()
def decrypt_password(encrypted: str, key: bytes) -> str:
f = Fernet(key)
return f.decrypt(encrypted.encode()).decode()
```
## 应用安全
- 所有 API 端点需要认证(除登录接口)
- 实现 CSRF 保护
- 输入验证使用 Pydantic
- SQL 注入防护(使用 ORM
- XSS 防护(前端转义)
- 实现速率限制
## 访问控制
- 基于 RBAC 的权限控制
- 数据级权限过滤(按区域/学校)
- 操作审计日志记录
- 设备信息变更需要审核
**权限级别**
- 超级管理员:所有权限
- 管理员:管理所有设备和用户
- 区域管理员:管理指定区域的设备
- 学校管理员:管理指定学校的设备
- 普通用户:只读权限
-41
View File
@@ -1,41 +0,0 @@
# 性能规则
## 数据库优化
- 为常用查询字段添加索引(mac_address, region, school_name
- 使用连接池(pool_size=20, max_overflow=40
- 避免 N+1 查询问题
- 定期清理历史数据(保留90天)
- 使用 select_related/joinedload 预加载关联数据
**索引示例**
```python
class ONUDevice(Base):
__tablename__ = "onu_devices"
mac_address = Column(String(17), index=True)
region = Column(String(100), index=True)
school_name = Column(String(200), index=True)
```
## 缓存策略
- Redis 缓存热点数据(设备状态)
- 缓存过期时间:30分钟
- 手动刷新有5分钟冷却限制
- 使用缓存键命名规范:`device:status:{device_id}`
## 异步处理
- SSH 状态检查使用 Celery 异步任务
- 批量导入使用后台任务
- 定时任务使用 Celery Beat(每30分钟)
- 分批处理大量设备(每批100个)
## 前端优化
- 组件懒加载
- 图片懒加载
- 虚拟滚动(大列表)
- 防抖和节流
- 资源压缩和 CDN
-48
View File
@@ -1,48 +0,0 @@
# 开发规则
## 环境配置
- 开发环境使用 `.env.development`
- 生产环境使用 `.env.production`
- 不提交 `.env` 文件到 Git
- 提供 `.env.example` 模板
**必需环境变量**
```bash
DATABASE_URL=postgresql://user:pass@host:5432/dbname
REDIS_URL=redis://host:6379/0
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
CASDOOR_CLIENT_SECRET=your_client_secret
SECRET_KEY=your-secret-key
```
## 测试要求
- 核心业务逻辑需要单元测试
- API 端点需要集成测试
- 测试覆盖率目标:≥80%
- 使用 pytest(后端)和 vitest(前端)
**测试示例**
```python
def test_device_status_check():
device = create_test_device()
result = check_device_status(device)
assert result.status in ["online", "offline"]
```
## 文档要求
- API 变更及时更新 Swagger 文档
- 复杂业务逻辑添加注释
- 重要配置添加说明
- 保持 README 和设计文档同步
## 开发流程
1. 创建功能分支:`git checkout -b feature/xxx`
2. 开发并提交代码
3. 运行测试:`pytest` / `npm run test`
4. 提交 PR 并等待审核
5. 合并到主分支
-55
View File
@@ -1,55 +0,0 @@
# 部署规则
## 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天
- 重要操作前手动备份
- 定期测试恢复流程
Submodule .claude/skills/create-ex deleted from 54d0dceab6
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/frontend-design
+162 -29
View File
@@ -1,43 +1,176 @@
# Python
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
venv/
env/
*.egg-info/
dist/
build/
# Node
node_modules/
develop-eggs/
dist/
.DS_Store
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Environment
.env
.env.local
# 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
# IDE
.vscode/
.idea/
*.swp
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Logs
logs/
# 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
# Database
*.db
*.sqlite
# Flask stuff:
instance/
.webassets-cache
# Temp
/tmp/
*.tmp
# Added by code-review-graph
.code-review-graph/
# 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/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Claude Code
.claude/
.mcp.json
-531
View File
@@ -1,531 +0,0 @@
# H3C ONU设备管理系统 - Claude开发指南
## 项目概述
这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。
**当前版本**: v0.8.0
**开发状态**: 核心功能已完成,权限与运维功能完善中
**已实现功能**
- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理)
- ✅ Excel 数据导入和批量管理
- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发
- ✅ Casdoor 统一认证和 JWT 令牌管理
- ✅ 设备管理(列表、详情、筛选、分页)
- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图
- ✅ 完整 RBAC 权限管理(角色、权限、用户管理页面)
- ✅ OLT 管理(增删改查、批量导入、区域动态同步)
- ✅ OLT 端口管理(查看端口状态、开关端口)
- ✅ 重复 MAC 检测与清除
- ✅ 新设备发现与信息补全
- ✅ 快速扫描(多线程并发)
- ✅ 环路检测
- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态)
- ✅ 库存管理模块(物料、序列号设备、出入库、盘点)
- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化)
- ✅ 审计日志(全量操作记录、筛选查询、CSV 导出)
- ✅ 设备更换记录(MAC 地址更换历史,与库存序列号联动)
- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配)
**技术栈**
- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko
- 前端:Vue 3 + Element Plus + Pinia + Axios
- 部署:Docker Compose
---
## 已实现的 API 端点
### 认证相关
- `GET /api/auth/login` - 获取 Casdoor 登录 URL
- `POST /api/auth/callback` - Casdoor 登录回调
- `GET /api/auth/profile` - 获取当前用户信息
### 设备管理
- `GET /api/devices` - 获取设备列表(分页、筛选)
- `GET /api/devices/{id}` - 获取设备详情
### 状态检查
- `POST /api/check/status` - 手动触发状态检查
### 数据导入
- `POST /api/import/upload` - 上传并导入 Excel 文件
### 统计信息
- `GET /api/stats/summary` - 获取统计摘要(总数、在线、离线)
### 系统
- `GET /health` - 健康检查
- `GET /docs` - API 文档(Swagger UI
---
## Rules
### 代码规范
#### Python 后端规范
- 遵循 PEP 8 规范
- 使用 Black 进行代码格式化
- 使用 isort 进行导入排序
- 使用类型注解(Type Hints
- 异步函数使用 async/await
- 错误处理使用自定义异常类
#### Vue 前端规范
- 使用 Composition APIsetup script
- 组件使用 PascalCase 命名
- 使用 TypeScript 类型检查
- 遵循 Vue 官方风格指南
- 使用 ESLint + Prettier 格式化
#### Git 提交规范
使用 Conventional Commits 格式:
```
<type>(<scope>): <subject>
类型:feat, fix, docs, style, refactor, test, chore
示例:feat(device): 添加设备导入功能
```
### 架构规则
#### 后端架构
- **分层架构**API → Service → Model
- **API 层**:仅处理请求/响应,调用 Service
- **Service 层**:业务逻辑,不直接操作数据库
- **Model 层**SQLAlchemy 模型定义
- **异步任务**:耗时操作使用 Celery
#### 前端架构
- **组件分类**
- `components/common/`:通用组件
- `components/business/`:业务组件
- `views/`:页面组件
- **状态管理**:使用 Pinia stores
- **API 调用**:统一在 `api/` 目录封装
### 安全规则
#### 数据安全
- SSH 密码必须加密存储(使用 Fernet 加密)
- 敏感信息通过环境变量配置
- 数据库连接使用 SSL
- 定期备份数据(90天历史记录)
#### 应用安全
- 所有 API 端点需要认证(除登录接口)
- 实现 CSRF 保护
- 输入验证使用 Pydantic
- SQL 注入防护(使用 ORM
- XSS 防护(前端转义)
#### 访问控制
- 基于 RBAC 的权限控制
- 数据级权限过滤(按区域/学校)
- 操作审计日志记录
- 设备信息变更需要审核
### 性能规则
#### 数据库优化
- 为常用查询字段添加索引
- 使用连接池(pool_size=20
- 避免 N+1 查询问题
- 定期清理历史数据(保留90天)
#### 缓存策略
- Redis 缓存热点数据(设备状态)
- 缓存过期时间:30分钟
- 手动刷新有5分钟冷却限制
#### 异步处理
- SSH 状态检查使用 Celery 异步任务
- 批量导入使用后台任务
- 定时任务使用 Celery Beat
### 开发规则
#### 环境配置
- 开发环境使用 `.env.development`
- 生产环境使用 `.env.production`
- 不提交 `.env` 文件到 Git
- 提供 `.env.example` 模板
#### 测试要求
- 核心业务逻辑需要单元测试
- API 端点需要集成测试
- 测试覆盖率目标:≥80%
#### 文档要求
- API 变更及时更新 Swagger 文档
- 复杂业务逻辑添加注释
- 重要配置添加说明
### 部署规则
#### Docker 部署
- 使用 Docker Compose 编排
- 外部 PostgreSQL 和 Redis(不在容器内)
- 日志挂载到宿主机
- 使用 Nginx 反向代理
#### 环境变量
必需配置:
- `DATABASE_URL`PostgreSQL 连接字符串
- `REDIS_URL`Redis 连接字符串
- `CASDOOR_*`Casdoor 认证配置
- `SECRET_KEY`:应用密钥
#### 监控告警
- 健康检查端点:`/health`
- 性能指标端点:`/metrics`
- 日志级别:生产环境使用 INFO
---
## 部署陷阱与经验教训
### 修改代码后必须重新构建镜像
**问题**:修改了宿主机上的源码后,直接 `docker compose up -d``docker compose restart` **不会**让容器使用新代码。容器运行的是构建时打包进镜像的旧代码。
**正确流程**
```bash
# 1. 重新构建镜像(必须加 --no-cache 确保不用旧层)
docker compose build --no-cache backend # 或 frontend,或两者
# 2. 重新创建容器(必须用 rm + up,或 up --force-recreate
docker compose rm -f backend
docker compose up -d backend
# 3. 如有数据库模型变更,执行迁移
docker compose exec backend alembic upgrade head
```
**不要用**
- `docker compose restart`:只重启进程,不更新镜像
- `docker compose up -d`(无 build):容器配置变了会重建,但镜像不变
### 端口被占用导致容器启动但无端口映射
**问题**:如果宿主机端口已被其他容器占用,新容器会启动成功(`docker compose up` 不报错),但端口映射为空 `{}`,外部无法访问。
**排查方法**
```bash
# 检查端口映射是否正常
docker inspect <container> --format '{{json .NetworkSettings.Ports}}'
# 查看谁占用了端口
docker ps | grep <port>
```
**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`
### frontend 容器必须配置 VITE_API_PROXY_TARGET
**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。
**必须在 docker-compose.yml 中配置**
```yaml
frontend:
build: ./frontend
ports:
- "5173:5173"
environment:
- VITE_API_PROXY_TARGET=http://backend:8000
```
### 新增数据库模型后必须执行迁移
**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot``SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。
**每次新增模型的完整流程**
```bash
# 1. 创建迁移文件(在宿主机或容器内)
docker compose exec backend alembic revision --autogenerate -m "描述"
# 2. 重建镜像
docker compose build --no-cache backend
# 3. 重启容器
docker compose rm -f backend && docker compose up -d backend
# 4. 执行迁移
docker compose exec backend alembic upgrade head
```
### Alembic 迁移文件 revision ID 不能重复
**问题**:手动创建迁移文件时,如果 `revision` 字段与已有文件重复,`alembic upgrade head` 会报 `Multiple head revisions are present` 错误。
**规则**:手动创建迁移文件时,revision ID 使用不与现有文件冲突的唯一字符串(如 `h8i9j0k1l2m3`),并确认 `down_revision` 指向正确的上一个版本。
### Celery worker 必须重建才能识别新任务
**问题**:新增 Celery 任务模块后,如果只重建 backend 而不重建 celery-workerworker 不会注册新任务,消息会被丢弃并报 `KeyError`
**规则**:新增任务模块后,backend 和 celery-worker 都必须重建:
```bash
docker compose build --no-cache backend celery-worker
docker compose rm -f backend celery-worker
docker compose up -d backend celery-worker
```
验证任务是否注册:
```bash
docker compose exec celery-worker celery -A celery_worker.celery_app inspect registered
```
### 区域管理员多区域过滤必须用 `.in_()` 而非 `==`
**问题**`assigned_area` 字段存储逗号分隔的多个区域(如 `"城区,郊区"`),用 `==` 只能匹配整个字符串,导致多区域管理员只能看到第一个区域的数据。
**规则**:所有涉及 `assigned_area` 的过滤都必须先 split 再用 `.in_()`
```python
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
query = query.filter(Model.region.in_(areas))
```
### Vite dev server 通过反向代理访问需配置 allowedHosts
**问题**:通过域名反向代理访问 Vite dev server 时,会报 `Blocked request. This host is not allowed`
**解决**:在 `vite.config.js` 中设置:
```js
server: {
allowedHosts: ['all', 'your-domain.com'],
}
```
### iOS PWA standalone 模式与 Safari 的 safe area 差异
**问题**`env(safe-area-inset-top)` 在 Safari 浏览器中为 0(有地址栏占位),在 standalone 模式(添加到主屏幕)下为真实刘海高度(44-59px)。直接使用会导致 Safari 中布局正常但 standalone 中顶栏/弹窗重叠。
**规则**:所有 safe area 相关样式必须包在 `@media (display-mode: standalone)` 中,Safari 不受影响:
```css
@media (display-mode: standalone) {
.mobile-topbar {
height: calc(52px + env(safe-area-inset-top));
}
}
```
`ElMessage` 的 offset 通过 `src/utils/message.js` 封装统一处理,所有页面从该文件导入而非直接从 `element-plus` 导入。
### Docker healthcheck 镜像内无 curl
**问题**backend 镜像基于 Python slim,没有 `curl`healthcheck 用 `curl` 会导致所有依赖服务启动失败。
**规则**healthcheck 使用 Python 内置模块:
```yaml
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 10s
retries: 5
start_period: 40s
```
---
## 项目结构
```
H3ConuMS2/
├── backend/ # Python 后端
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic 模式
│ │ ├── services/ # 业务逻辑
│ │ ├── tasks/ # Celery 任务
│ │ └── utils/ # 工具函数
│ ├── alembic/ # 数据库迁移
│ └── tests/ # 测试代码
├── frontend/ # Vue 前端
│ └── src/
│ ├── api/ # API 调用
│ ├── components/ # 组件
│ ├── router/ # 路由
│ ├── stores/ # 状态管理
│ ├── views/ # 页面
│ └── utils/ # 工具函数
├── deploy/ # 部署配置
│ ├── docker-compose.yml
│ └── nginx.conf
└── docs/ # 文档
```
---
## 核心业务逻辑
### SSH 状态检查流程
1. 从数据库获取 OLT 设备配置
2. 建立 SSH 连接(使用连接池)
3. 执行命令:`display onu slot {slot_number}`
4. 解析返回结果:
- 包含 "Up" → 在线
- 包含 "Offline" → 离线
5. 更新设备状态到数据库
6. 记录历史状态
### 权限控制逻辑
- **超级管理员**:所有权限
- **管理员**:管理所有设备和用户
- **区域管理员**:管理指定区域的设备
- **学校管理员**:管理指定学校的设备
- **普通用户**:只读权限
### 数据导入流程
1. 上传 Excel 文件
2. 使用 Pandas 解析数据
3. 数据验证(MAC 地址格式、必填字段)
4. 批量插入数据库
5. 返回导入结果(成功/失败记录)
---
## 开发指南
### 快速启动
**后端**
```bash
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
**前端**
```bash
cd frontend
npm install
npm run dev
```
**Celery Worker**
```bash
cd backend
celery -A app.core.celery_app worker --loglevel=info
```
**Celery Beat**
```bash
cd backend
celery -A app.core.celery_app beat --loglevel=info
```
**Docker 部署**
```bash
# 配置环境变量
cp backend/.env.example backend/.env
# 启动所有服务
docker-compose up -d
# 初始化数据库
docker-compose exec backend python scripts/init_db.py
```
### 数据库迁移
```bash
# 创建迁移
alembic revision --autogenerate -m "描述"
/home/v6ole/pyproject/H3ConuMS2/CLAUDE.md
# 执行迁移
alembic upgrade head
# 回滚
alembic downgrade -1
```
### 添加新功能
1. **后端 API**
-`app/api/v1/` 创建路由文件
-`app/services/` 创建服务文件
-`app/schemas/` 定义请求/响应模式
-`app/models/` 定义数据模型(如需要)
2. **前端页面**
-`views/` 创建页面组件
-`api/` 添加 API 调用
-`router/` 添加路由配置
-`stores/` 添加状态管理(如需要)
---
## 常见问题
### SSH 连接失败
- 检查网络连通性
- 验证 SSH 凭证
- 检查防火墙设置
- 查看日志:`docker-compose logs backend`
### 数据库连接失败
- 检查 `DATABASE_URL` 配置
- 验证数据库服务状态
- 检查网络权限
### Casdoor 登录失败
- 检查 Casdoor 服务状态
- 验证 `CASDOOR_*` 配置
- 检查回调地址配置
---
## 参考文档
- [FastAPI 文档](https://fastapi.tiangolo.com/)
- [Vue 3 文档](https://vuejs.org/)
- [Element Plus 文档](https://element-plus.org/)
- [Casdoor 文档](https://casdoor.org/)
- [项目详细设计](./系统设计文档.md)
- [开发计划](./开发计划.md)
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
| ------ | ---------- |
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
+235
View File
@@ -0,0 +1,235 @@
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/>.
-21
View File
@@ -1,21 +0,0 @@
H3C OLT 批量配置脚本(直接复制执行)
```
system-view
undo ntp-service unicast-server 172.16.0.254
ntp-service unicast-server 172.16.1.252
clock timezone Beijing add 08:00:00
quit
save force
```
作用说明:
第2行:删掉旧的内网 NTP(172.16.0.254,因为它用的是本地假时间)。
第3行:指向新配好的 Windows 时间服务器(172.16.1.252,它直连国家授时中心)。
第4行:把设备显示时区改成北京时间(东八区),解决时间少8小时的问题。
第6行:强制保存配置,防止重启丢失(加 force 是为了跳过确认提示,方便批量执行)。
验证方法:
全部刷完等大概 1到2分钟后,执行以下两条命令看结果:
display clock
display ntp-service sessions
display clock 必须看到带有 Beijing 字样,且时间与当前实际北京时间一致。
display ntp-service sessions 必须看到 172.16.1.252 前面带有 [12345] 标记,且 offset(偏差)在几毫秒以内。
注意:如果某些 OLT 之前没有配过 172.16.0.254,执行第2行时可能会报错提示“找不到该配置”,直接忽略该报错即可,不影响后续命令执行。
-580
View File
@@ -1,580 +0,0 @@
# 项目结构详细说明
## 后端目录结构 (backend/)
```
backend/
├── app/ # 主应用目录
│ ├── __init__.py
│ ├── main.py # FastAPI应用入口
│ ├── api/ # API路由
│ │ ├── __init__.py
│ │ ├── v1/ # API版本1
│ │ │ ├── __init__.py
│ │ │ ├── auth.py # 认证相关API
│ │ │ ├── devices.py # 设备管理API
│ │ │ ├── check.py # 状态检查API
│ │ │ ├── stats.py # 统计API
│ │ │ ├── users.py # 用户管理API
│ │ │ ├── import.py # 数据导入API
│ │ │ └── system.py # 系统设置API
│ │ └── dependencies.py # 依赖注入
│ ├── core/ # 核心配置
│ │ ├── __init__.py
│ │ ├── config.py # 应用配置
│ │ ├── security.py # 安全相关
│ │ ├── database.py # 数据库配置
│ │ ├── casdoor.py # Casdoor配置
│ │ └── celery_app.py # Celery配置
│ ├── models/ # SQLAlchemy模型
│ │ ├── __init__.py
│ │ ├── base.py # 基础模型
│ │ ├── device.py # 设备相关模型
│ │ ├── user.py # 用户相关模型
│ │ ├── permission.py # 权限相关模型
│ │ └── history.py # 历史记录模型
│ ├── schemas/ # Pydantic模式
│ │ ├── __init__.py
│ │ ├── device.py # 设备模式
│ │ ├── user.py # 用户模式
│ │ ├── auth.py # 认证模式
│ │ └── common.py # 通用模式
│ ├── services/ # 业务逻辑服务
│ │ ├── __init__.py
│ │ ├── device_service.py # 设备服务
│ │ ├── ssh_service.py # SSH连接服务
│ │ ├── check_service.py # 状态检查服务
│ │ ├── import_service.py # 数据导入服务
│ │ ├── user_service.py # 用户服务
│ │ ├── permission_service.py # 权限服务
│ │ └── casdoor_service.py # Casdoor服务
│ ├── tasks/ # Celery任务
│ │ ├── __init__.py
│ │ ├── check_tasks.py # 状态检查任务
│ │ ├── import_tasks.py # 数据导入任务
│ │ └── notification_tasks.py # 通知任务
│ ├── utils/ # 工具函数
│ │ ├── __init__.py
│ │ ├── excel_parser.py # Excel解析工具
│ │ ├── ssh_utils.py # SSH工具函数
│ │ ├── validators.py # 数据验证
│ │ ├── encryption.py # 加密工具
│ │ └── logger.py # 日志配置
│ └── middleware/ # 中间件
│ ├── __init__.py
│ ├── auth_middleware.py # 认证中间件
│ ├── permission_middleware.py # 权限中间件
│ └── logging_middleware.py # 日志中间件
├── alembic/ # 数据库迁移
│ ├── versions/ # 迁移版本
│ ├── env.py
│ └── alembic.ini
├── tests/ # 测试代码
│ ├── __init__.py
│ ├── conftest.py # 测试配置
│ ├── test_api/ # API测试
│ ├── test_services/ # 服务测试
│ └── test_utils/ # 工具测试
├── scripts/ # 脚本文件
│ ├── init_db.py # 数据库初始化
│ ├── create_admin.py # 创建管理员
│ └── backup_data.py # 数据备份
├── requirements/ # 依赖管理
│ ├── base.txt # 基础依赖
│ ├── dev.txt # 开发依赖
│ └── prod.txt # 生产依赖
├── logs/ # 日志目录
├── static/ # 静态文件
├── Dockerfile # Docker构建文件
├── docker-entrypoint.sh # Docker入口脚本
├── requirements.txt # 依赖文件
├── .env.example # 环境变量示例
└── pyproject.toml # Python项目配置
```
## 前端目录结构 (frontend/)
```
frontend/
├── public/ # 静态资源
│ ├── index.html # 主HTML文件
│ ├── favicon.ico # 网站图标
│ └── robots.txt # 搜索引擎配置
├── src/ # 源代码
│ ├── main.js # 应用入口
│ ├── App.vue # 根组件
│ ├── api/ # API调用
│ │ ├── index.js # API配置
│ │ ├── auth.js # 认证API
│ │ ├── device.js # 设备API
│ │ ├── check.js # 状态检查API
│ │ ├── user.js # 用户API
│ │ └── import.js # 数据导入API
│ ├── assets/ # 资源文件
│ │ ├── css/ # 样式文件
│ │ │ ├── main.css # 主样式
│ │ │ ├── variables.css # CSS变量
│ │ │ └── components.css # 组件样式
│ │ └── images/ # 图片资源
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件
│ │ │ ├── Layout/ # 布局组件
│ │ │ │ ├── AppLayout.vue
│ │ │ │ ├── Header.vue
│ │ │ │ ├── Sidebar.vue
│ │ │ │ └── Footer.vue
│ │ │ ├── Table/ # 表格组件
│ │ │ │ ├── DataTable.vue
│ │ │ │ └── Pagination.vue
│ │ │ ├── Form/ # 表单组件
│ │ │ │ ├── SearchForm.vue
│ │ │ │ └── FilterForm.vue
│ │ │ ├── Chart/ # 图表组件
│ │ │ │ ├── StatusChart.vue
│ │ │ │ └── TrendChart.vue
│ │ │ └── Dialog/ # 对话框组件
│ │ │ ├── ConfirmDialog.vue
│ │ │ └── ImportDialog.vue
│ │ └── business/ # 业务组件
│ │ ├── Device/ # 设备相关组件
│ │ ├── User/ # 用户相关组件
│ │ └── System/ # 系统相关组件
│ ├── router/ # 路由配置
│ │ ├── index.js # 路由主文件
│ │ ├── routes.js # 路由定义
│ │ └── guards.js # 路由守卫
│ ├── stores/ # 状态管理 (Pinia)
│ │ ├── index.js # Store配置
│ │ ├── auth.js # 认证状态
│ │ ├── device.js # 设备状态
│ │ ├── user.js # 用户状态
│ │ └── system.js # 系统状态
│ ├── views/ # 页面组件
│ │ ├── Auth/ # 认证页面
│ │ │ ├── Login.vue # 登录页面
│ │ │ └── Callback.vue # 回调页面
│ │ ├── Dashboard/ # 仪表板
│ │ │ └── index.vue # 仪表板主页
│ │ ├── Device/ # 设备管理
│ │ │ ├── List.vue # 设备列表
│ │ │ ├── Detail.vue # 设备详情
│ │ │ └── Import.vue # 数据导入
│ │ ├── Check/ # 状态检查
│ │ │ ├── Status.vue # 状态页面
│ │ │ └── History.vue # 检查历史
│ │ ├── User/ # 用户管理
│ │ │ ├── List.vue # 用户列表
│ │ │ ├── Profile.vue # 用户资料
│ │ │ └── Permission.vue # 权限管理
│ │ ├── System/ # 系统设置
│ │ │ ├── OltConfig.vue # OLT配置
│ │ │ ├── TaskConfig.vue # 任务配置
│ │ │ └── Audit.vue # 审核中心
│ │ └── Error/ # 错误页面
│ │ ├── 404.vue # 404页面
│ │ └── 500.vue # 500页面
│ ├── utils/ # 工具函数
│ │ ├── auth.js # 认证工具
│ │ ├── request.js # 请求工具
│ │ ├── permission.js # 权限工具
│ │ ├── format.js # 格式化工具
│ │ ├── validate.js # 验证工具
│ │ └── storage.js # 存储工具
│ ├── directives/ # 自定义指令
│ │ ├── permission.js # 权限指令
│ │ └── loading.js # 加载指令
│ └── plugins/ # 插件
│ ├── element-plus.js # Element Plus插件
│ └── echarts.js # ECharts插件
├── tests/ # 测试文件
│ ├── unit/ # 单元测试
│ └── e2e/ # 端到端测试
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .env.example # 环境变量示例
├── package.json # 项目配置
├── package-lock.json # 依赖锁文件
├── vite.config.js # Vite配置
├── index.html # HTML入口
└── Dockerfile # Docker构建文件
```
## 部署目录结构 (deploy/)
```
deploy/
├── docker-compose.yml # Docker Compose配置
├── docker-compose.dev.yml # 开发环境配置
├── docker-compose.prod.yml # 生产环境配置
├── nginx/ # Nginx配置
│ ├── nginx.conf # 主配置文件
│ ├── conf.d/ # 站点配置
│ │ └── h3c-onu-ms.conf # 应用配置
│ └── ssl/ # SSL证书
├── scripts/ # 部署脚本
│ ├── deploy.sh # 部署脚本
│ ├── backup.sh # 备份脚本
│ ├── restore.sh # 恢复脚本
│ └── monitor.sh # 监控脚本
├── config/ # 配置文件
│ ├── backend.env # 后端环境变量
│ ├── frontend.env # 前端环境变量
│ └── celery.env # Celery环境变量
└── .env.example # 环境变量示例
```
## 文档目录结构 (docs/)
```
docs/
├── api/ # API文档
│ ├── overview.md # API概述
│ ├── auth.md # 认证API文档
│ ├── device.md # 设备API文档
│ ├── check.md # 状态检查API文档
│ └── user.md # 用户API文档
├── deployment/ # 部署文档
│ ├── requirements.md # 环境要求
│ ├── installation.md # 安装指南
│ ├── configuration.md # 配置说明
│ ├── docker.md # Docker部署
│ └── troubleshooting.md # 故障排除
├── user-guide/ # 用户指南
│ ├── getting-started.md # 快速开始
│ ├── device-management.md # 设备管理指南
│ ├── data-import.md # 数据导入指南
│ ├── permission-guide.md # 权限管理指南
│ └── faq.md # 常见问题
├── development/ # 开发文档
│ ├── setup.md # 开发环境搭建
│ ├── architecture.md # 架构说明
│ ├── coding-standards.md # 编码规范
│ ├── testing.md # 测试指南
│ └── contributing.md # 贡献指南
└── images/ # 文档图片
```
## 脚本目录结构 (scripts/)
```
scripts/
├── database/ # 数据库脚本
│ ├── init.sql # 初始化SQL
│ ├── backup.sh # 数据库备份
│ ├── restore.sh # 数据库恢复
│ └── migrate.sh # 迁移脚本
├── monitoring/ # 监控脚本
│ ├── health-check.sh # 健康检查
│ ├── log-rotate.sh # 日志轮转
│ └── alert.sh # 告警脚本
├── deployment/ # 部署脚本
│ ├── build.sh # 构建脚本
│ ├── deploy.sh # 部署脚本
│ └── rollback.sh # 回滚脚本
└── utils/ # 工具脚本
├── cleanup.sh # 清理脚本
├── generate-cert.sh # 证书生成
└── update-config.sh # 配置更新
```
## 环境变量说明
### 后端环境变量 (.env)
```bash
# 应用配置
APP_NAME=H3C-ONU-MS
APP_ENV=production
DEBUG=false
SECRET_KEY=your-secret-key-here
# 数据库配置
DATABASE_URL=postgresql://user:password@host:5432/dbname
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=40
# Redis配置
REDIS_URL=redis://host:6379/0
REDIS_POOL_SIZE=10
# Casdoor配置
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
CASDOOR_CLIENT_SECRET=your_client_secret
CASDOOR_CERTIFICATE=your_certificate
CASDOOR_ORG_NAME=your_org
CASDOOR_APP_NAME=h3c-onu-ms
# SSH配置
SSH_TIMEOUT=30
SSH_MAX_CONNECTIONS=10
SSH_RETRY_COUNT=3
# 任务配置
CHECK_INTERVAL=1800 # 30分钟(秒)
MANUAL_COOLDOWN=300 # 5分钟(秒)
HISTORY_RETENTION=90 # 90天
# 日志配置
LOG_LEVEL=INFO
LOG_FILE=/app/logs/app.log
LOG_ROTATION=10MB
LOG_RETENTION=30
```
### 前端环境变量 (.env)
```bash
# 应用配置
VITE_APP_TITLE=H3C ONU设备管理系统
VITE_APP_VERSION=1.0.0
# API配置
VITE_API_BASE_URL=http://localhost:8000
VITE_API_TIMEOUT=30000
# Casdoor配置
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
VITE_CASDOOR_CLIENT_ID=your_client_id
VITE_CASDOOR_ORG_NAME=your_org
VITE_CASDOOR_APP_NAME=h3c-onu-ms
# 功能开关
VITE_ENABLE_SSO=true
VITE_ENABLE_WEBSOCKET=true
VITE_ENABLE_ANALYTICS=false
```
## 开发工作流
### 1. 环境准备
```bash
# 克隆项目
git clone <repository-url>
cd H3ConuMS2
# 后端环境
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements/dev.txt
# 前端环境
cd ../frontend
npm install
```
### 2. 数据库初始化
```bash
# 创建数据库
createdb h3c_onu_ms
# 运行迁移
cd backend
alembic upgrade head
# 初始化数据
python scripts/init_data.py
```
### 3. 启动开发服务
```bash
# 启动后端
cd backend
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 启动前端(新终端)
cd frontend
npm run dev
# 启动Celery Worker(新终端)
cd backend
celery -A app.core.celery_app worker --loglevel=info
# 启动Celery Beat(新终端)
cd backend
celery -A app.core.celery_app beat --loglevel=info
```
### 4. 访问应用
- 前端开发服务器: http://localhost:5173
- 后端API服务器: http://localhost:8000
- API文档: http://localhost:8000/docs
- 管理界面: http://localhost:8000/admin
## 代码规范
### Python代码规范
- 遵循PEP 8规范
- 使用Black进行代码格式化
- 使用isort进行导入排序
- 使用Flake8进行代码检查
- 使用mypy进行类型检查
### Vue代码规范
- 使用ESLint + Prettier
- 遵循Vue官方风格指南
- 使用TypeScript进行类型检查
- 组件使用PascalCase命名
- 单文件组件结构规范
### Git提交规范
- 使用Conventional Commits规范
- 提交信息格式: `<type>(<scope>): <subject>`
- 类型: feat, fix, docs, style, refactor, test, chore
- 示例: `feat(device): 添加设备导入功能`
## 测试策略
### 单元测试
```bash
# 后端单元测试
cd backend
pytest tests/unit/
# 前端单元测试
cd frontend
npm run test:unit
```
### 集成测试
```bash
# 后端集成测试
cd backend
pytest tests/integration/
# API测试
pytest tests/api/
```
### 端到端测试
```bash
# 前端E2E测试
cd frontend
npm run test:e2e
```
## 部署流程
### 开发环境部署
```bash
cd deploy
docker-compose -f docker-compose.dev.yml up -d
```
### 生产环境部署
```bash
# 构建镜像
docker-compose -f docker-compose.prod.yml build
# 启动服务
docker-compose -f docker-compose.prod.yml up -d
# 查看日志
docker-compose -f docker-compose.prod.yml logs -f
```
### 持续集成/持续部署
1. 代码推送到Git仓库
2. 自动运行测试
3. 构建Docker镜像
4. 推送到镜像仓库
5. 部署到服务器
6. 运行健康检查
## 监控和告警
### 应用监控
- 健康检查端点: `/health`
- 性能指标: `/metrics` (Prometheus格式)
- 请求统计: 中间件记录
- 错误跟踪: Sentry集成
### 系统监控
- 服务器资源使用率
- 数据库连接池状态
- Redis内存使用情况
- 网络连接状态
### 告警规则
- 设备离线率超过阈值
- 系统资源使用率过高
- 服务不可用
- 安全相关事件
## 安全考虑
### 数据安全
- SSH密码加密存储
- 数据库连接加密
- 敏感信息环境变量管理
- 定期数据备份
### 应用安全
- CSRF保护
- XSS防护
- SQL注入防护
- 速率限制
- 输入验证
### 访问安全
- 基于角色的访问控制
- 会话管理
- 双因素认证支持
- 登录尝试限制
## 性能优化
### 数据库优化
- 合理使用索引
- 查询优化
- 连接池配置
- 定期清理历史数据
### 缓存策略
- Redis缓存热点数据
- 浏览器缓存静态资源
- CDN加速前端资源
### 异步处理
- Celery处理耗时任务
- WebSocket实时更新
- 批量操作优化
## 扩展性设计
### 水平扩展
- 无状态API服务
- 数据库读写分离
- Redis集群支持
- 负载均衡配置
### 功能扩展
- 插件化架构设计
- 模块化代码组织
- 配置驱动功能开关
- API版本管理
## 维护计划
### 日常维护
- 日志监控和分析
- 数据库备份验证
- 系统更新和补丁
- 性能监控和优化
### 定期维护
- 每月安全审计
- 每季度性能评估
- 每年架构评审
- 数据归档和清理
## 文档更新
- API变更及时更新文档
- 部署流程变更记录
- 故障处理经验总结
- 用户反馈整理
---
**文档版本**: v1.0
**最后更新**: 2026年4月1日
**维护人员**: 系统开发团队
-39
View File
@@ -1,39 +0,0 @@
# H3C ONU设备管理系统 - 快速开始
## 项目状态
✅ 第一阶段:基础框架已完成
## 已完成功能
- ✅ 后端 FastAPI 框架
- ✅ 前端 Vue 3 框架
- ✅ SSH 连接和 ONU 状态解析
- ✅ 数据库模型设计
- ✅ Docker 配置
## 快速启动
### 1. 配置环境变量
```bash
cp backend/.env.example backend/.env
# 编辑 backend/.env 配置数据库等信息
```
### 2. 启动后端
```bash
./start-backend.sh
```
### 3. 启动前端
```bash
./start-frontend.sh
```
### 4. 访问系统
- 前端:http://localhost:5173
- 后端 APIhttp://localhost:8000
- API 文档:http://localhost:8000/docs
## 下一步开发
参考 `开发计划.md` 继续第一阶段第2周任务
1. 定期检测还需要加入环路检测功能,并且将环路的状态记录到数据库中,在OLT界面上显示
+1 -147
View File
@@ -1,148 +1,2 @@
# H3C ONU 设备管理系统
# H3ConuMS-v2
基于 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 |
| 认证 | CasdoorOIDC |
| 部署 | 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 导入、库存管理等核心功能
---
**注意**:生产环境部署前请修改默认密码和密钥,并配置适当的防火墙规则。
-37
View File
@@ -1,37 +0,0 @@
# H3C ONU 管理系统
H3C ONU 管理系统(H3ConuMS)是一套面向校园网络运维团队的 ONU 设备集中管理平台,支持对接多台 H3C OLT 设备,实现大规模 ONU 的状态监控、信息管理与运维操作。
## 主要功能
**设备监控**
系统通过 SSH 定期轮询 OLT 设备,自动采集所有 ONU 的在线状态、光功率、距离、端口等信息,并以列表和卡片两种视图展示。支持手动触发刷新,状态变化实时可见。
**设备管理**
支持按区域、学校、楼宇、场所类型等维度对 ONU 设备进行分类管理。可通过 Excel 批量导入设备信息,也可在界面中逐条编辑。设备更换时记录完整的 MAC 地址变更历史。
**OLT 管理**
统一管理多台 OLT 设备的连接信息,支持端口级别的设备发现。OLT 扫描到的新设备会进入待入库列表,由运维人员补全信息后正式纳管。
**统计分析**
提供实时在线率统计、7 天趋势折线图、区域分布饼图等可视化报表,帮助运维团队快速掌握全网设备健康状况。
**权限管理**
基于 Casdoor 统一认证,支持管理员、区域管理员、学校管理员、普通用户等多级角色,数据访问范围按角色自动隔离。
**审计日志**
记录所有用户的写操作,包括设备编辑、更换、导入、权限变更等,保留 90 天供追溯查询。
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端 | Python · FastAPI · SQLAlchemy · Celery · Redis |
| 前端 | Vue 3 · Element Plus · ECharts |
| 数据库 | PostgreSQL |
| 认证 | CasdoorOIDC |
| 部署 | Docker Compose · Nginx |
## 版本
当前版本:v0.8.0
-26
View File
@@ -1,26 +0,0 @@
# 应用配置
APP_NAME=H3C-ONU-MS
DEBUG=false
SECRET_KEY=your-secret-key-change-this
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
# Redis配置
REDIS_URL=redis://localhost:6379/0
# Casdoor配置
CASDOOR_ENDPOINT=https://casdoor.example.com
CASDOOR_CLIENT_ID=your_client_id
CASDOOR_CLIENT_SECRET=your_client_secret
CASDOOR_ORG_NAME=your_org
CASDOOR_APP_NAME=h3c-onu-ms
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
# SSH配置
SSH_TIMEOUT=30
# 任务配置
CHECK_INTERVAL=1800
MANUAL_COOLDOWN=300
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# 使用清华镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-36
View File
@@ -1,36 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
-40
View File
@@ -1,40 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core.config import settings
from app.core.database import Base
from app.models import device
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-26
View File
@@ -1,26 +0,0 @@
"""${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"}
@@ -1,47 +0,0 @@
"""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 ###
@@ -1,47 +0,0 @@
"""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 ###
@@ -1,66 +0,0 @@
"""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 ###
@@ -1,107 +0,0 @@
"""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')
@@ -1,32 +0,0 @@
"""添加距离、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')
@@ -1,28 +0,0 @@
"""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')")
@@ -1,192 +0,0 @@
"""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'")
@@ -1,26 +0,0 @@
"""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')
@@ -1,32 +0,0 @@
"""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')
@@ -1,33 +0,0 @@
"""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')
@@ -1,55 +0,0 @@
"""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')
@@ -1,38 +0,0 @@
"""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')
View File
View File
View File
-157
View File
@@ -1,157 +0,0 @@
"""审计日志 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/{log_id}")
def get_audit_log_detail(
log_id: int,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""获取单条审计日志详情"""
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
if not log:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="日志不存在")
return _fmt(log, detail=True)
@router.get("/stats")
def get_audit_stats(
days: int = Query(7, ge=1, le=90),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""审计日志统计(最近N天)"""
from datetime import timedelta
from sqlalchemy import func
since = datetime.utcnow() - timedelta(days=days)
rows = (
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
.filter(AuditLog.action_time >= since)
.group_by(AuditLog.action_type, AuditLog.status)
.all()
)
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
by_type = {}
for row in rows:
if row.action_type not in by_type:
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
by_type[row.action_type][row.status] = row.cnt
return {"total": total, "days": days, "by_type": by_type}
@router.get("/logs/export/csv")
def export_audit_logs(
start_time: Optional[datetime] = Query(None),
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}"},
)
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
-117
View File
@@ -1,117 +0,0 @@
"""认证 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("name") or casdoor_user.get("preferred_username", ""),
email=casdoor_user.get("email"),
role="user"
)
db.add(user)
user.last_login = datetime.utcnow()
db.commit()
jwt_token = create_access_token({
"sub": str(user.id),
"username": user.username,
"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(..., alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户的权限码列表"""
from app.middleware.permission_middleware import get_role_permissions
if 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(..., alias="Authorization"),
db: Session = Depends(get_db)
):
"""获取当前用户信息"""
if 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
-98
View File
@@ -1,98 +0,0 @@
"""状态检查 API"""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Depends
from celery.result import AsyncResult
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.services.check_service import CheckService
from app.middleware.permission_middleware import require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/check", tags=["状态检查"])
class CheckResult(BaseModel):
olt_id: int
olt_name: str
online: Optional[int] = 0
offline: Optional[int] = 0
success: bool
class CheckError(BaseModel):
olt_id: int
olt_name: str
error: str
@router.post("/status")
def trigger_check(_: 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))
-663
View File
@@ -1,663 +0,0 @@
"""设备管理 API"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.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,
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 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("/{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
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
# 若该设备 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 接口无响应")
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
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
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)}")
finally:
ssh.close()
-82
View File
@@ -1,82 +0,0 @@
"""数据导入 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']
}
-196
View File
@@ -1,196 +0,0 @@
"""库存管理 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)))
-605
View File
@@ -1,605 +0,0 @@
"""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.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="端口不在重复记录中")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ssh.clear_onu_port(body.port_id)
except Exception as e:
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
finally:
ssh.close()
# 从 ports 列表移除已清除的端口
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
if remaining:
record.ports = remaining
else:
# 所有端口都清除了,删除整条记录
db.delete(record)
db.commit()
return {"message": f"端口 Onu{body.port_id} 已清除", "remaining_ports": remaining}
@router.get("/new-devices")
def get_new_devices(
db: Session = Depends(get_db),
_: 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):
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
detection = ssh.detect_loopback()
except Exception as e:
return {
"olt_id": olt.id,
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"error": str(e),
"has_loop": False,
"loop_interfaces": [],
}
finally:
ssh.close()
loop_interfaces = []
for iface in detection.get("interfaces", []):
port_id = iface.removeprefix("Onu")
onu = onu_map.get((olt.id, port_id))
loop_interfaces.append({
"interface": iface,
"port_id": port_id,
"mac_address": onu.mac_address if onu else None,
"region": onu.region if onu else None,
"school_name": onu.school_name if onu else None,
"building": onu.building if onu else None,
"room_number": onu.room_number if onu else None,
})
return {
"olt_id": olt.id,
"olt_ip": olt.ip_address,
"olt_location": olt.location or olt.ip_address,
"has_loop": detection["has_loop"],
"loop_interfaces": loop_interfaces,
"error": None,
}
results_map = {}
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
futures = {executor.submit(check_one, olt): olt.id for olt in olts}
for future in as_completed(futures):
olt_id = futures[future]
results_map[olt_id] = future.result()
# 按原始顺序返回
return [results_map[olt.id] for olt in olts]
class SyncNTPRequest(BaseModel):
old_server: str = "172.16.0.254"
new_server: str = "172.16.1.252"
@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):
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
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),
}
finally:
ssh.close()
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 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ports = ssh.get_olt_ports()
return {"ports": ports}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()
@router.post("/devices/{olt_id}/ports/toggle")
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 不存在")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
ssh.toggle_olt_port(port_name, body.action)
return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
ssh.close()
-62
View File
@@ -1,62 +0,0 @@
"""业务下发 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
-110
View File
@@ -1,110 +0,0 @@
"""角色权限配置 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": "权限更新成功"}
-100
View File
@@ -1,100 +0,0 @@
"""系统设置 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}
-248
View File
@@ -1,248 +0,0 @@
"""统计 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')
start_of_today = datetime.combine(today, datetime.min.time())
daily_latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at >= start_of_today)
.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(
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()
)
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
-81
View File
@@ -1,81 +0,0 @@
"""用户管理 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.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}
View File
-12
View File
@@ -1,12 +0,0 @@
"""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,
)
-43
View File
@@ -1,43 +0,0 @@
"""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'},
},
},
)
-58
View File
@@ -1,58 +0,0 @@
"""应用配置"""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
# 项目根目录(config.py 位于 backend/app/core/parent.parent.parent 即 backend/
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class Settings(BaseSettings):
APP_NAME: str = "H3C-ONU-MS"
DEBUG: bool = False
SECRET_KEY: str
DATABASE_URL: str
REDIS_URL: str
CASDOOR_ENDPOINT: str
CASDOOR_CLIENT_ID: str
CASDOOR_CLIENT_SECRET: str
CASDOOR_ORG_NAME: str
CASDOOR_APP_NAME: str
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
SSH_TIMEOUT: int = 30
CHECK_INTERVAL: int = 1800
MANUAL_COOLDOWN: int = 300
# iMC API 配置(用于 ONU 远程重启和光功率查询)
IMC_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()
-17
View File
@@ -1,17 +0,0 @@
"""数据库配置"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
-22
View File
@@ -1,22 +0,0 @@
"""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
-35
View File
@@ -1,35 +0,0 @@
"""FastAPI 主应用"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
from app.middleware.audit_middleware import AuditMiddleware
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
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.get("/health")
def health_check():
return {"status": "ok"}
-109
View File
@@ -1,109 +0,0 @@
"""审计日志中间件:拦截所有 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
@@ -1,96 +0,0 @@
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
import json
from fastapi import HTTPException, Depends, Header
from sqlalchemy.orm import Session
from sqlalchemy import text
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(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
if 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)
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(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
"""仅验证登录状态,不检查具体权限"""
if 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
View File
-52
View File
@@ -1,52 +0,0 @@
"""审计日志数据库模型"""
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"),
)
-110
View File
@@ -1,110 +0,0 @@
"""设备数据模型"""
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class OLTDevice(Base):
__tablename__ = "olt_devices"
id = Column(BigInteger, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False)
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
slot_command = Column(String(50), nullable=False)
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) # 备注
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 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())
-117
View File
@@ -1,117 +0,0 @@
"""库存管理数据模型"""
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())
-21
View File
@@ -1,21 +0,0 @@
"""权限数据模型"""
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)
-13
View File
@@ -1,13 +0,0 @@
"""系统设置模型"""
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())
-19
View File
@@ -1,19 +0,0 @@
"""用户数据模型"""
from sqlalchemy import Column, BigInteger, String, Boolean, TIMESTAMP
from sqlalchemy.sql import func
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(BigInteger, primary_key=True, index=True)
casdoor_id = Column(String(100), unique=True, nullable=False)
username = Column(String(100), nullable=False)
email = Column(String(255))
role = Column(String(50), default="user")
assigned_area = Column(String(100))
assigned_school = Column(String(200))
is_active = Column(Boolean, default=True)
last_login = Column(TIMESTAMP)
created_at = Column(TIMESTAMP, server_default=func.now())
View File
-20
View File
@@ -1,20 +0,0 @@
"""认证相关 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
-51
View File
@@ -1,51 +0,0 @@
"""设备相关 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
-238
View File
@@ -1,238 +0,0 @@
"""库存管理 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]
-30
View File
@@ -1,30 +0,0 @@
"""用户相关 Schema"""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserListItem(BaseModel):
id: int
username: str
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
View File
-183
View File
@@ -1,183 +0,0 @@
"""审计日志服务"""
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
-299
View File
@@ -1,299 +0,0 @@
"""设备状态检查服务"""
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, DuplicateMac, NewDevice
from datetime import datetime
import re
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
if not distance_str:
return None
m = re.search(r'\d+', distance_str)
return int(m.group()) if m else None
class CheckService:
"""设备状态检查服务"""
def __init__(self, db: Session):
self.db = db
def update_status_only(self, olt_id: int) -> Dict:
"""扫描 OLT,仅更新已有设备的在线状态和距离(按全局 MAC 匹配)。
不修改 olt_id/端口等字段,不标记 unknown,不入库新设备。
"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, _ = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
# 全局 MAC → (id, 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,
}
finally:
ssh.close()
def check_single_device(self, device_id: int) -> Dict:
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
命令格式: display onu slot {slot} | include {mac}
"""
device = self.db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
if not device:
raise Exception("设备不存在")
if not device.olt_id:
raise Exception("该设备未关联 OLT,无法查询")
olt = self.db.query(OLTDevice).filter(OLTDevice.id == device.olt_id).first()
if not olt:
raise Exception("关联的 OLT 不存在")
mac = device.mac_address.lower()
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 = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
checked_at = datetime.utcnow()
online_count = 0
offline_count = 0
new_count = 0
existing = {
row.mac_address.lower(): (row.id, 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,
}
finally:
ssh.close()
async def scan_olt(self, olt_id: int) -> Dict:
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
olt = self.db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
# 对比全局 MAC
existing_macs = {
onu.mac_address.lower()
for onu in self.db.query(ONUDevice.mac_address).all()
}
devices = []
for mac, info in onu_info_dict.items():
devices.append({
"mac_address": mac,
"status": info.status,
"distance_m": info.distance_str,
"slot_number": info.slot_number,
"port_number": info.port_number,
"port_id": info.port_id,
"loid": info.loid,
"model": info.model,
"is_new": mac not in existing_macs,
})
duplicates = []
for mac, records in duplicate_dict.items():
duplicates.append({
"mac_address": mac,
"ports": [{"port_id": r.port_id, "status": r.status} for r in records],
})
return {
"olt_id": olt_id,
"olt_ip": olt.ip_address,
"total": len(devices),
"new": sum(1 for d in devices if d["is_new"]),
"devices": devices,
"duplicates": duplicates,
}
finally:
ssh.close()
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
for mac, records in duplicate_dict.items():
ports = [{"port_id": r.port_id, "status": r.status} for r in records]
existing = self.db.query(DuplicateMac).filter(
DuplicateMac.olt_id == olt_id,
DuplicateMac.mac_address == mac
).first()
if existing:
existing.ports = ports
existing.last_seen_at = datetime.utcnow()
else:
self.db.add(DuplicateMac(olt_id=olt_id, mac_address=mac, ports=ports))
-227
View File
@@ -1,227 +0,0 @@
"""
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
-115
View File
@@ -1,115 +0,0 @@
"""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}
-543
View File
@@ -1,543 +0,0 @@
"""库存管理业务逻辑"""
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}
-114
View File
@@ -1,114 +0,0 @@
"""业务下发服务"""
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,
}
-433
View File
@@ -1,433 +0,0 @@
"""SSH 连接服务"""
import paramiko
import re
import time
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from app.core.config import settings
@dataclass
class ONUInfo:
"""ONU 设备完整信息"""
mac_address: str
status: str # online/offline
distance_m: Optional[int] = None # 距离(米)
distance_str: Optional[str] = None # 距离原始字符串,如 "<1000"
slot_number: Optional[int] = None # 插槽号
port_number: Optional[int] = None # 端口号
port_id: Optional[str] = None # 完整端口标识,如 "1/0/1:1"
loid: Optional[str] = None # LOID
model: Optional[str] = None # 设备型号
class SSHService:
"""SSH 连接和命令执行服务"""
def __init__(self, host: str, username: str, password: str, port: int = 22):
self.host = host
self.username = username
self.password = password
self.port = port
self.client: Optional[paramiko.SSHClient] = None
self.shell = None
def connect(self) -> bool:
"""建立 SSH 连接,等待初始 banner 输出完毕"""
try:
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
hostname=self.host,
port=self.port,
username=self.username,
password=self.password,
timeout=30
)
self.shell = self.client.invoke_shell(width=200, height=50)
# 等待登录 banner 输出完毕,直到出现命令提示符 ">"
deadline = time.time() + 10
buf = ""
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if ">" in buf:
break
else:
time.sleep(0.2)
return True
except Exception as e:
raise Exception(f"SSH 连接失败: {str(e)}")
def execute_command(self, command: str) -> str:
"""执行命令并处理 More 分页,等待命令提示符出现后返回"""
if not self.shell:
raise Exception("SSH 未连接")
self.shell.send(command + "\n")
output = ""
# 等待命令回显出现,再开始收集输出
time.sleep(0.5)
deadline = time.time() + 60
while time.time() < deadline:
if self.shell.recv_ready():
chunk = self.shell.recv(4096).decode('utf-8', errors='ignore')
output += chunk
if "---- More ----" in chunk:
self.shell.send(" ")
time.sleep(0.3)
elif ">" in chunk:
# 命令提示符出现,说明输出完毕
break
else:
time.sleep(0.2)
return output
def clear_onu_port(self, port_id: str) -> bool:
"""清除指定端口的 ONU 配置(恢复默认)
流程: system-view -> interface Onu{port_id} -> default -> Y
"""
if not self.shell:
raise Exception("SSH 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
# 进入系统视图
out = send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
# 进入端口
out = send_and_wait(f"interface Onu{port_id}", "]")
if "]" not in out:
raise Exception(f"进入端口 Onu{port_id} 失败")
# 执行 default,等待确认提示
self.shell.send("default\n")
buf = ""
deadline = time.time() + 10
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if "[Y/N]" in buf or "[y/n]" in buf:
break
else:
time.sleep(0.2)
if "[Y/N]" not in buf and "[y/n]" not in buf:
raise Exception("未收到确认提示")
# 确认
self.shell.send("Y\n")
time.sleep(1)
# 排空缓冲区
if self.shell.recv_ready():
self.shell.recv(4096)
# 退出到用户视图
send_and_wait("quit", "]", timeout=5)
send_and_wait("quit", ">", timeout=5)
return True
def detect_loopback(self) -> dict:
"""执行环路检测,返回 {has_loop: bool, interfaces: [str]}"""
output = self.execute_command("display loopback-detection")
has_loop = "Loop is detected on following interfaces" in output
interfaces = []
if has_loop:
for line in output.splitlines():
m = re.match(r'\s+(Onu\S+)\s+', line)
if m:
interfaces.append(m.group(1))
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
def parse_onu_status(self, output: str) -> Dict[str, str]:
"""解析 ONU 状态输出"""
devices = {}
lines = output.split('\n')
for line in lines:
# 匹配包含 MAC 地址的行
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
if 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 ----[^\n]*', '', 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 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
out = send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
out = send_and_wait(f"interface {port_name}", "]")
if "]" not in out:
raise Exception(f"进入端口 {port_name} 失败")
out = send_and_wait(action, "]")
if "]" not in out:
raise Exception(f"执行 {action} 失败")
send_and_wait("quit", "]", timeout=5)
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 未连接")
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
self.shell.send(cmd + "\n")
buf = ""
deadline = time.time() + timeout
while time.time() < deadline:
if self.shell.recv_ready():
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
if expect in buf:
return buf
else:
time.sleep(0.2)
return buf
out = send_and_wait("system-view", "]")
if "]" not in out:
raise Exception("进入 system-view 失败")
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
# 添加新 NTP 服务器
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
if "]" not in out:
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
# 设置时区为北京时间
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
if "]" not in out:
raise Exception("配置时区失败")
# 退出系统视图
send_and_wait("quit", ">")
# 强制保存配置
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 StatusUp/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 close(self):
"""关闭 SSH 连接"""
if self.client:
self.client.close()
-4
View File
@@ -1,4 +0,0 @@
"""Celery 任务模块"""
from app.tasks.check_tasks import check_all_devices # noqa: F401
__all__ = ['check_all_devices']
-62
View File
@@ -1,62 +0,0 @@
"""审计日志 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()
-192
View File
@@ -1,192 +0,0 @@
"""状态检查任务"""
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': '检查完成'})
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()
-6
View File
@@ -1,6 +0,0 @@
"""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 - 导入以注册任务
__all__ = ['celery_app']
-20
View File
@@ -1,20 +0,0 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
alembic==1.13.1
psycopg2-binary==2.9.9
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
celery==5.3.6
redis==5.0.1
paramiko==3.4.0
pandas==2.1.4
openpyxl==3.1.2
cryptography==42.0.0
casdoor==1.18.0
aiohttp>=3.9.0
PyJWT>=2.8.0
requests>=2.31.0
-11
View File
@@ -1,11 +0,0 @@
"""数据库初始化脚本"""
from app.core.database import engine, Base
from app.models import device, user, permission
def init_db():
"""创建所有表"""
Base.metadata.create_all(bind=engine)
print("数据库表创建完成")
if __name__ == "__main__":
init_db()
Binary file not shown.
Binary file not shown.
-30
View File
@@ -1,30 +0,0 @@
"""测试 ONU 状态解析"""
import re
def parse_onu_status(output: str):
"""解析 ONU 状态"""
devices = {}
lines = output.split('\n')
for line in lines:
mac_match = re.search(r'([0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4})', line, re.IGNORECASE)
if mac_match:
mac = mac_match.group(1).lower()
if 'Up' in line:
devices[mac] = 'online'
elif 'Offline' in line:
devices[mac] = 'offline'
return devices
# 测试数据
test_output = """
1484-7790-5200 12 <1000 Onu1/0/1:1 WA6520H-EGPON/A 106/ Up N/A
1484-7790-4e20 13 <1000 Onu1/0/1:2 WA6520H-EGPON/A 106/ Up N/A
1484-7790-3900 N/A N/A Onu1/0/1:3 N/A N/A Offline N/A
"""
result = parse_onu_status(test_output)
print("解析结果:")
for mac, status in result.items():
print(f" {mac}: {status}")
-28
View File
@@ -1,28 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIE2TCCAsGgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCYxDjAMBgNVBAoTBWFk
bWluMRQwEgYDVQQDDAtjZXJ0X3drMWVlZjAeFw0yNTEyMTAxMDAzMzVaFw00NTEy
MTAxMDAzMzVaMCYxDjAMBgNVBAoTBWFkbWluMRQwEgYDVQQDDAtjZXJ0X3drMWVl
ZjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK78ajnNnwnutdZ48l92
hmqa2c8lP1IcpyB7CYVTKurQxSz5iQorYOVhR1UzluSpU8yiPPeFyTRD0pH+DzqG
otc+Alvxnka5DfP1z/P0asogkALJXouRR+YtUY6i1oo5tKlDbIGJNO2aQcOOak9b
YoqSRd9y/pq/bPjte7oww29hGQc03LgbNXmIb9n5NGznWCte1c88NUrTz9Dlpn2H
r3ncOmHpqpzg5NtXughnHsF4YCF+pPIgWlC0C6MtKk0fuJysCY5wpuA620pGL4zO
6QFv920MiWsdVWxcNo0aNQkXKGjEcy3LraXux2k/sH+E0e3GRXGhWYnkrK7i8kg+
V+Nm1YFgyGbY7V3yZ0mXPxb+iMlIvz885ViGsRnKqlR0pN1v8NbmzXPu9EkAO3wi
T/L4fxnETW1hd/ph+AdQ0jEpeyAMRjcl3kMjauOBqfU/THl7L6aUMXB3d05+JK0Y
uTc1nrZ0Qjh/5EG5XyvRSuKNVxpCB1XcAlAaBTuE5art6jQkCJINvFoOjoHZB65K
HJVvfMtMuVr7dLSbYOPHH6YJB2fUijNKoXnclAbxldX3fEALsC/SW7zMFF+3FThg
Seq3OEpNdbkRlQp5sORHeTkWtMO9A60GHvTqDamIqppC4fk71zIRpmEPlfGsOno5
gNhH8+NKH5Cvmfj4s7/S98D/AgMBAAGjEDAOMAwGA1UdEwEB/wQCMAAwDQYJKoZI
hvcNAQELBQADggIBAJLW6zjDKZdkfq00r88lM1IxaxRWmUEPQjINOa0GZH/DyAtd
fgYf35AN+NDEiIiDnJQTR3N8jbX1JySDClEOzv3wLXRiKhWcX8z4HOoieNmMu9lZ
6pf1lEaMX8WNxB475nfOueEEyHegbsbfpmYM/aVAiSxaKb87if7uDnxJeGqK2Ba0
c3LJnQ+H87gcAb84G78KXn/XwjRtwaf+Gy06EzycEnckEeN0vni0pRdw8coSudcX
726Kb+kZ4961G/XxQx4fKX+E4lXkx1CdxPBAws/uOjfciLnuVdT4yp6URXJKRw+/
R+flPtfvHXNDXB8BHj9ul4UJ4oSHGppx0BR80CmWWNDVdaoO+o0WieFOXG2U2PB+
p2pleJzckTDuOh5wIe2GFj9WKZTIn4S7ZA6hS5j5ea4bOX5m6cxXVaS5FPHHfHcN
FyUOEE4f61tv9omsFyl23YD/gmQzotFY+5YPS8DTb1soSzydKQODCq7SobyK8QFo
3FnOw1cnKuWDZbqkZS6TYHFc3doNaH77bBxgSgIBPlJewd98eZZCv+m8/mCOXCbo
+7HOgmOHJ1cOEtweCtgrCc2VNvV0w6RSwcZ+WmUAZMajvyEqikVVQzR2LtOOhOyr
REkkNRAX3PX69GDQLM9QSgGLv0f6nA5uTX9SFj71Iik6YAuUsvIK6uPpn/2B
-----END CERTIFICATE-----
-185
View File
@@ -1,185 +0,0 @@
# H3C ONU设备管理系统 - 环境变量配置示例
# 复制此文件为 .env 并修改相应配置
# ============================================
# 应用基础配置
# ============================================
# 应用环境 (development, production)
APP_ENV=production
# 调试模式 (true/false)
DEBUG=false
# 应用密钥 (使用 openssl rand -hex 32 生成)
SECRET_KEY=your-secret-key-change-in-production
# 时区设置
TZ=Asia/Shanghai
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
# PostgreSQL连接字符串
# 格式: postgresql://username:password@host:port/database
DATABASE_URL=postgresql://h3c_user:your_password@postgres-host:5432/h3c_onu_ms
# 数据库连接池配置
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=40
DATABASE_POOL_RECYCLE=3600
# ============================================
# Redis配置
# ============================================
# Redis连接URL (如果使用外部Redis,修改为外部地址)
REDIS_URL=redis://:redispass@redis:6379/0
# Redis密码 (容器内Redis使用)
REDIS_PASSWORD=redispass
# Redis连接池大小
REDIS_POOL_SIZE=10
# ============================================
# Casdoor认证配置
# ============================================
# Casdoor服务地址
CASDOOR_ENDPOINT=https://casdoor.example.com
# 应用Client ID
CASDOOR_CLIENT_ID=your_casdoor_client_id
# 应用Client Secret
CASDOOR_CLIENT_SECRET=your_casdoor_client_secret
# 应用Certificate
CASDOOR_CERTIFICATE=your_casdoor_certificate
# 组织名称
CASDOOR_ORG_NAME=your_organization
# 应用名称
CASDOOR_APP_NAME=h3c-onu-ms
# ============================================
# SSH连接配置
# ============================================
# SSH连接超时时间(秒)
SSH_TIMEOUT=30
# 最大并发连接数
SSH_MAX_CONNECTIONS=10
# SSH重试次数
SSH_RETRY_COUNT=3
# SSH连接保持时间(秒)
SSH_KEEPALIVE=60
# ============================================
# 任务调度配置
# ============================================
# 自动检查间隔(秒) - 30分钟
CHECK_INTERVAL=1800
# 手动刷新冷却时间(秒) - 5分钟
MANUAL_COOLDOWN=300
# 历史记录保留天数
HISTORY_RETENTION=90
# 批量检查设备数量
BATCH_CHECK_SIZE=100
# ============================================
# 前端配置
# ============================================
# API基础URL (前端访问后端地址)
VITE_API_BASE_URL=http://localhost:8000
# Casdoor前端配置
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id
VITE_CASDOOR_ORG_NAME=your_organization
VITE_CASDOOR_APP_NAME=h3c-onu-ms
# 应用标题
VITE_APP_TITLE=H3C ONU设备管理系统
# ============================================
# 日志配置
# ============================================
# 日志级别 (DEBUG, INFO, WARNING, ERROR)
LOG_LEVEL=INFO
# 日志文件路径
LOG_FILE=/app/logs/app.log
# 日志轮转大小
LOG_ROTATION=10MB
# 日志保留天数
LOG_RETENTION=30
# ============================================
# 邮件通知配置 (可选)
# ============================================
# SMTP服务器
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=your_email@example.com
# SMTP_PASSWORD=your_email_password
# SMTP_FROM=noreply@example.com
# ============================================
# 监控配置
# ============================================
# 健康检查端口
HEALTH_CHECK_PORT=8000
# 监控指标端口
METRICS_PORT=8000
# ============================================
# 安全配置
# ============================================
# CORS允许的域名 (逗号分隔)
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
# 速率限制配置
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_PER_HOUR=1000
# ============================================
# 备份配置
# ============================================
# 数据库备份目录
BACKUP_DIR=/app/backups
# 备份保留天数
BACKUP_RETENTION=30
# 自动备份时间 (cron格式)
BACKUP_SCHEDULE="0 2 * * *"
# ============================================
# 开发环境覆盖配置
# ============================================
# 如果是开发环境,取消注释以下配置
# DEBUG=true
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
# REDIS_URL=redis://localhost:6379/0
# VITE_API_BASE_URL=http://localhost:8000
-104
View File
@@ -1,104 +0,0 @@
services:
# 后端API服务
backend:
build:
context: ../backend
dockerfile: Dockerfile
container_name: h3c-onu-ms-backend
ports:
- "8001:8000"
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- SECRET_KEY=${SECRET_KEY}
- DEBUG=${DEBUG:-false}
volumes:
- ../backend/logs:/app/logs
- ../backend/static:/app/static
restart: unless-stopped
healthcheck:
test: ["CMD", "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}
volumes:
- ../backend/logs:/app/logs
depends_on:
- backend
restart: unless-stopped
# Celery Beat - 定时任务调度
celery-beat:
build:
context: ../backend
dockerfile: Dockerfile
container_name: h3c-onu-ms-celery-beat
command: celery -A app.core.celery_app beat --loglevel=info
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- CASDOOR_ENDPOINT=${CASDOOR_ENDPOINT}
- CASDOOR_CLIENT_ID=${CASDOOR_CLIENT_ID}
- CASDOOR_CLIENT_SECRET=${CASDOOR_CLIENT_SECRET}
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- SECRET_KEY=${SECRET_KEY}
volumes:
- ../backend/logs:/app/logs
depends_on:
- backend
restart: unless-stopped
# 前端应用
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
container_name: h3c-onu-ms-frontend
ports:
- "5173:5173"
environment:
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
- VITE_API_PROXY_TARGET=http://backend:8000
- VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT}
- VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID}
- VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME}
- VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME}
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:5173"]
interval: 30s
timeout: 10s
retries: 3
networks:
default:
name: h3c-onu-ms-network
-165
View File
@@ -1,165 +0,0 @@
# H3C ONU设备管理系统 - Nginx站点配置
# 前端静态文件服务
server {
listen 80;
server_name localhost;
# 根目录指向前端构建文件
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip_static on;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 前端路由支持 (Vue Router history模式)
location / {
try_files $uri $uri/ /index.html;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# API代理到后端
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲区设置
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
# 禁用代理缓存
proxy_cache off;
}
# WebSocket支持
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket超时设置
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 静态文件代理 (后端静态文件)
location /static/ {
alias /usr/share/nginx/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 健康检查端点
location /health {
proxy_pass http://backend:8000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
access_log off;
}
# 监控指标端点
location /metrics {
proxy_pass http://backend:8000/metrics;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
access_log off;
}
# API文档
location /docs {
proxy_pass http://backend:8000/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /redoc {
proxy_pass http://backend:8000/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 错误页面
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
# HTTPS配置 (需要SSL证书)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# # SSL证书配置
# ssl_certificate /etc/nginx/ssl/your-domain.com.crt;
# ssl_certificate_key /etc/nginx/ssl/your-domain.com.key;
#
# # SSL优化配置
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
# ssl_prefer_server_ciphers off;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
#
# # HSTS头
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
#
# # 其他配置与HTTP相同
# root /usr/share/nginx/html;
# index index.html;
#
# location / {
# try_files $uri $uri/ /index.html;
# }
#
# location /api/ {
# proxy_pass http://backend:8000;
# # ... 其他代理配置
# }
#
# # 强制HTTP重定向到HTTPS
# # if ($scheme != "https") {
# # return 301 https://$host$request_uri;
# # }
# }
# 重定向HTTP到HTTPS (如果启用HTTPS)
# server {
# listen 80;
# server_name your-domain.com;
# return 301 https://$server_name$request_uri;
# }
-56
View File
@@ -1,56 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# 基础配置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 包含站点配置
include /etc/nginx/conf.d/*.conf;
}
-362
View File
@@ -1,362 +0,0 @@
#!/bin/bash
# H3C ONU设备管理系统 - 部署脚本
# 使用方法: ./deploy.sh [环境] [操作]
# 环境: dev, prod (默认: prod)
# 操作: up, down, restart, logs, build (默认: up)
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 默认值
ENV=${1:-prod}
ACTION=${2:-up}
COMPOSE_FILE="docker-compose.yml"
# 根据环境选择配置文件
if [ "$ENV" = "dev" ]; then
COMPOSE_FILE="docker-compose.dev.yml"
echo -e "${BLUE}使用开发环境配置${NC}"
elif [ "$ENV" = "prod" ]; then
COMPOSE_FILE="docker-compose.yml"
echo -e "${BLUE}使用生产环境配置${NC}"
else
echo -e "${RED}错误: 未知环境 '$ENV'${NC}"
echo "可用环境: dev, prod"
exit 1
fi
# 检查Docker Compose文件是否存在
if [ ! -f "$COMPOSE_FILE" ]; then
echo -e "${RED}错误: Docker Compose文件 '$COMPOSE_FILE' 不存在${NC}"
exit 1
fi
# 检查环境变量文件
if [ ! -f ".env" ]; then
echo -e "${YELLOW}警告: .env 文件不存在,从 .env.example 复制${NC}"
if [ -f ".env.example" ]; then
cp .env.example .env
echo -e "${YELLOW}请编辑 .env 文件并设置正确的配置${NC}"
exit 1
else
echo -e "${RED}错误: .env.example 文件也不存在${NC}"
exit 1
fi
fi
# 加载环境变量
set -a
source .env
set +a
# 函数:显示帮助信息
show_help() {
echo "H3C ONU设备管理系统 - 部署脚本"
echo ""
echo "使用方法: $0 [环境] [操作]"
echo ""
echo "环境:"
echo " dev 开发环境"
echo " prod 生产环境 (默认)"
echo ""
echo "操作:"
echo " up 启动服务 (默认)"
echo " down 停止服务"
echo " restart 重启服务"
echo " logs 查看日志"
echo " build 构建镜像"
echo " ps 查看服务状态"
echo " exec 进入容器"
echo " backup 备份数据"
echo " restore 恢复数据"
echo ""
echo "示例:"
echo " $0 prod up # 启动生产环境"
echo " $0 dev logs # 查看开发环境日志"
echo " $0 prod restart # 重启生产环境"
}
# 函数:检查Docker是否运行
check_docker() {
if ! docker info > /dev/null 2>&1; then
echo -e "${RED}错误: Docker未运行或当前用户无权限${NC}"
exit 1
fi
}
# 函数:检查Docker Compose是否安装
check_docker_compose() {
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}错误: Docker Compose未安装${NC}"
exit 1
fi
}
# 函数:启动服务
start_services() {
echo -e "${GREEN}启动服务...${NC}"
docker-compose -f "$COMPOSE_FILE" up -d
# 等待服务启动
echo -e "${BLUE}等待服务启动...${NC}"
sleep 10
# 检查服务状态
check_services_status
}
# 函数:停止服务
stop_services() {
echo -e "${YELLOW}停止服务...${NC}"
docker-compose -f "$COMPOSE_FILE" down
}
# 函数:重启服务
restart_services() {
echo -e "${YELLOW}重启服务...${NC}"
docker-compose -f "$COMPOSE_FILE" restart
}
# 函数:查看日志
show_logs() {
echo -e "${BLUE}查看日志 (Ctrl+C退出)...${NC}"
docker-compose -f "$COMPOSE_FILE" logs -f
}
# 函数:构建镜像
build_images() {
echo -e "${GREEN}构建Docker镜像...${NC}"
docker-compose -f "$COMPOSE_FILE" build --no-cache
# 清理未使用的镜像
echo -e "${BLUE}清理未使用的镜像...${NC}"
docker image prune -f
}
# 函数:查看服务状态
show_status() {
echo -e "${BLUE}服务状态:${NC}"
docker-compose -f "$COMPOSE_FILE" ps
echo -e "\n${BLUE}容器资源使用:${NC}"
docker stats --no-stream $(docker-compose -f "$COMPOSE_FILE" ps -q)
}
# 函数:进入容器
exec_container() {
echo -e "${BLUE}选择要进入的容器:${NC}"
echo "1) backend (后端API)"
echo "2) frontend (前端)"
echo "3) celery-worker (Celery Worker)"
echo "4) celery-beat (Celery Beat)"
echo "5) nginx (Nginx)"
echo "6) redis (Redis)"
read -p "请输入数字 (1-6): " choice
case $choice in
1) CONTAINER="backend" ;;
2) CONTAINER="frontend" ;;
3) CONTAINER="celery-worker" ;;
4) CONTAINER="celery-beat" ;;
5) CONTAINER="nginx" ;;
6) CONTAINER="redis" ;;
*)
echo -e "${RED}无效的选择${NC}"
exit 1
;;
esac
echo -e "${GREEN}进入 $CONTAINER 容器...${NC}"
docker-compose -f "$COMPOSE_FILE" exec "$CONTAINER" sh
}
# 函数:备份数据
backup_data() {
BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
echo -e "${GREEN}开始备份数据...${NC}"
# 备份数据库
echo -e "${BLUE}备份数据库...${NC}"
DB_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q backend)
if [ -n "$DB_CONTAINER" ]; then
docker exec "$DB_CONTAINER" pg_dump "$DATABASE_URL" > "$BACKUP_DIR/database.sql"
echo "数据库备份完成: $BACKUP_DIR/database.sql"
else
echo -e "${YELLOW}警告: 数据库容器未运行${NC}"
fi
# 备份Redis数据
echo -e "${BLUE}备份Redis数据...${NC}"
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
if [ -n "$REDIS_CONTAINER" ]; then
docker exec "$REDIS_CONTAINER" redis-cli --rdb /data/dump.rdb
docker cp "$REDIS_CONTAINER:/data/dump.rdb" "$BACKUP_DIR/redis.rdb"
echo "Redis备份完成: $BACKUP_DIR/redis.rdb"
else
echo -e "${YELLOW}警告: Redis容器未运行${NC}"
fi
# 备份配置文件
echo -e "${BLUE}备份配置文件...${NC}"
cp .env "$BACKUP_DIR/"
cp "$COMPOSE_FILE" "$BACKUP_DIR/"
# 创建备份信息文件
echo "备份时间: $(date)" > "$BACKUP_DIR/backup.info"
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
# 压缩备份文件
echo -e "${BLUE}压缩备份文件...${NC}"
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
rm -rf "$BACKUP_DIR"
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}"
}
# 函数:恢复数据
restore_data() {
echo -e "${YELLOW}警告: 恢复数据将覆盖现有数据${NC}"
read -p "请输入要恢复的备份文件路径: " BACKUP_FILE
if [ ! -f "$BACKUP_FILE" ]; then
echo -e "${RED}错误: 备份文件不存在${NC}"
exit 1
fi
# 解压备份文件
BACKUP_DIR="${BACKUP_FILE%.tar.gz}"
echo -e "${BLUE}解压备份文件...${NC}"
tar -xzf "$BACKUP_FILE"
# 停止服务
echo -e "${BLUE}停止服务...${NC}"
docker-compose -f "$COMPOSE_FILE" down
# 恢复数据库
if [ -f "$BACKUP_DIR/database.sql" ]; then
echo -e "${BLUE}恢复数据库...${NC}"
# 这里需要根据实际情况调整数据库恢复命令
# 例如: psql -d database -f backup.sql
echo "数据库恢复命令需要根据实际情况配置"
fi
# 恢复Redis
if [ -f "$BACKUP_DIR/redis.rdb" ]; then
echo -e "${BLUE}恢复Redis数据...${NC}"
# 这里需要根据实际情况调整Redis恢复命令
echo "Redis恢复命令需要根据实际情况配置"
fi
# 恢复配置文件
if [ -f "$BACKUP_DIR/.env" ]; then
echo -e "${BLUE}恢复配置文件...${NC}"
cp "$BACKUP_DIR/.env" .
fi
# 清理临时文件
rm -rf "$BACKUP_DIR"
# 启动服务
echo -e "${BLUE}启动服务...${NC}"
docker-compose -f "$COMPOSE_FILE" up -d
echo -e "${GREEN}数据恢复完成${NC}"
}
# 函数:检查服务状态
check_services_status() {
echo -e "${BLUE}检查服务健康状态...${NC}"
# 检查后端服务
if curl -f http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ 后端服务运行正常${NC}"
else
echo -e "${RED}✗ 后端服务异常${NC}"
fi
# 检查前端服务
if curl -f http://localhost:8080 > /dev/null 2>&1; then
echo -e "${GREEN}✓ 前端服务运行正常${NC}"
else
echo -e "${RED}✗ 前端服务异常${NC}"
fi
# 检查Redis
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis)
if [ -n "$REDIS_CONTAINER" ] && docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
echo -e "${GREEN}✓ Redis服务运行正常${NC}"
else
echo -e "${RED}✗ Redis服务异常${NC}"
fi
echo -e "\n${GREEN}部署完成!${NC}"
echo -e "前端访问: ${BLUE}http://localhost:8080${NC}"
echo -e "API文档: ${BLUE}http://localhost:8000/docs${NC}"
echo -e "健康检查: ${BLUE}http://localhost:8000/health${NC}"
}
# 主程序
main() {
# 显示标题
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} H3C ONU设备管理系统 - 部署工具${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查依赖
check_docker
check_docker_compose
# 根据操作执行相应命令
case "$ACTION" in
up)
start_services
;;
down)
stop_services
;;
restart)
restart_services
;;
logs)
show_logs
;;
build)
build_images
;;
ps)
show_status
;;
exec)
exec_container
;;
backup)
backup_data
;;
restore)
restore_data
;;
help|--help|-h)
show_help
;;
*)
echo -e "${RED}错误: 未知操作 '$ACTION'${NC}"
show_help
exit 1
;;
esac
}
# 执行主程序
main "$@"
-389
View File
@@ -1,389 +0,0 @@
#!/bin/bash
# H3C ONU设备管理系统 - 数据库初始化脚本
# 用于初始化数据库和创建基础数据
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 加载环境变量
if [ -f "../.env" ]; then
set -a
source ../.env
set +a
else
echo -e "${RED}错误: .env 文件不存在${NC}"
echo "请先复制 .env.example 为 .env 并配置数据库连接"
exit 1
fi
# 检查数据库连接字符串
if [ -z "$DATABASE_URL" ]; then
echo -e "${RED}错误: DATABASE_URL 未设置${NC}"
exit 1
fi
# 从DATABASE_URL解析连接信息
parse_db_url() {
local url="$1"
# 格式: postgresql://username:password@host:port/database
local pattern="postgresql://([^:]+):([^@]+)@([^:]+):([^/]+)/(.+)"
if [[ $url =~ $pattern ]]; then
DB_USER="${BASH_REMATCH[1]}"
DB_PASSWORD="${BASH_REMATCH[2]}"
DB_HOST="${BASH_REMATCH[3]}"
DB_PORT="${BASH_REMATCH[4]}"
DB_NAME="${BASH_REMATCH[5]}"
else
echo -e "${RED}错误: 无法解析DATABASE_URL${NC}"
exit 1
fi
}
parse_db_url "$DATABASE_URL"
# 函数:检查PostgreSQL连接
check_postgres_connection() {
echo -e "${BLUE}检查PostgreSQL连接...${NC}"
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "SELECT 1;" > /dev/null 2>&1; then
echo -e "${GREEN}✓ PostgreSQL连接成功${NC}"
return 0
else
echo -e "${RED}✗ PostgreSQL连接失败${NC}"
return 1
fi
}
# 函数:检查数据库是否存在
check_database_exists() {
echo -e "${BLUE}检查数据库 '$DB_NAME' 是否存在...${NC}"
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then
echo -e "${GREEN}✓ 数据库已存在${NC}"
return 0
else
echo -e "${YELLOW}数据库不存在${NC}"
return 1
fi
}
# 函数:创建数据库
create_database() {
echo -e "${BLUE}创建数据库 '$DB_NAME'...${NC}"
# 创建数据库
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres <<EOF
CREATE DATABASE $DB_NAME;
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ 数据库创建成功${NC}"
else
echo -e "${RED}✗ 数据库创建失败${NC}"
exit 1
fi
}
# 函数:创建数据库用户(如果不存在)
create_database_user() {
echo -e "${BLUE}检查数据库用户 '$DB_USER'...${NC}"
# 检查用户是否存在
USER_EXISTS=$(PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")
if [ "$USER_EXISTS" = "1" ]; then
echo -e "${GREEN}✓ 用户已存在${NC}"
else
echo -e "${BLUE}创建用户 '$DB_USER'...${NC}"
# 需要以postgres用户创建新用户
# 这里假设有postgres用户的密码或者使用trust认证
echo -e "${YELLOW}注意: 需要以postgres用户权限创建新用户${NC}"
echo "请手动执行以下命令:"
echo " psql -h $DB_HOST -p $DB_PORT -U postgres -d postgres"
echo " CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
echo " GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
read -p "是否已手动创建用户? (y/n): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}跳过用户创建${NC}"
fi
fi
}
# 函数:授予权限
grant_privileges() {
echo -e "${BLUE}授予用户权限...${NC}"
# 授予数据库权限
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres <<EOF
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
EOF
# 授予schema权限
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOF
GRANT ALL ON SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
EOF
echo -e "${GREEN}✓ 权限授予成功${NC}"
}
# 函数:运行数据库迁移
run_migrations() {
echo -e "${BLUE}运行数据库迁移...${NC}"
# 检查后端目录是否存在
if [ ! -d "../../backend" ]; then
echo -e "${YELLOW}警告: 后端目录不存在,跳过迁移${NC}"
return
fi
# 进入后端目录
cd "../../backend"
# 检查Alembic配置
if [ ! -f "alembic.ini" ]; then
echo -e "${YELLOW}警告: alembic.ini 不存在,跳过迁移${NC}"
cd - > /dev/null
return
fi
# 运行迁移
if command -v alembic &> /dev/null; then
# 设置数据库URL环境变量
export DATABASE_URL
# 初始化迁移(如果第一次运行)
if [ ! -d "alembic/versions" ]; then
echo -e "${BLUE}初始化Alembic迁移...${NC}"
alembic init alembic
# 需要手动配置alembic.ini中的sqlalchemy.url
fi
# 创建迁移版本
echo -e "${BLUE}创建迁移版本...${NC}"
alembic revision --autogenerate -m "初始迁移"
# 应用迁移
echo -e "${BLUE}应用迁移...${NC}"
alembic upgrade head
echo -e "${GREEN}✓ 数据库迁移完成${NC}"
else
echo -e "${YELLOW}警告: alembic 命令未找到,跳过迁移${NC}"
echo "请安装: pip install alembic"
fi
# 返回原目录
cd - > /dev/null
}
# 函数:创建基础数据
create_base_data() {
echo -e "${BLUE}创建基础数据...${NC}"
# 检查数据库连接
if ! check_database_exists; then
echo -e "${RED}错误: 数据库不存在,无法创建基础数据${NC}"
return 1
fi
# 创建基础数据SQL
BASE_DATA_SQL=$(cat <<EOF
-- 创建系统配置
INSERT INTO system_config (config_key, config_value, description) VALUES
('check_interval', '1800', '自动检查间隔(秒)'),
('manual_cooldown', '300', '手动刷新冷却时间(秒)'),
('history_retention', '90', '历史记录保留天数'),
('ssh_timeout', '30', 'SSH连接超时时间'),
('ssh_max_connections', '10', 'SSH最大连接数'),
('batch_check_size', '100', '批量检查设备数量')
ON CONFLICT (config_key) DO NOTHING;
-- 创建默认角色
INSERT INTO roles (name, description, is_system) VALUES
('super_admin', '超级管理员', true),
('admin', '管理员', true),
('area_admin', '区域管理员', true),
('school_admin', '学校管理员', true),
('operator', '操作员', true),
('viewer', '查看员', true)
ON CONFLICT (name) DO NOTHING;
-- 创建基础权限
INSERT INTO permissions (name, code, module, description) VALUES
-- 设备模块权限
('设备查看', 'device_view', 'device', '查看设备信息'),
('设备编辑', 'device_edit', 'device', '编辑设备信息'),
('设备删除', 'device_delete', 'device', '删除设备'),
('设备导入', 'device_import', 'device', '导入设备数据'),
('设备检查', 'device_check', 'device', '检查设备状态'),
-- 用户模块权限
('用户查看', 'user_view', 'user', '查看用户信息'),
('用户管理', 'user_manage', 'user', '管理用户'),
('角色管理', 'role_manage', 'user', '管理角色'),
('权限管理', 'permission_manage', 'user', '管理权限'),
-- 系统模块权限
('系统配置', 'system_config', 'system', '系统配置管理'),
('OLT管理', 'olt_manage', 'system', 'OLT设备管理'),
('任务管理', 'task_manage', 'system', '任务配置管理'),
('审核管理', 'audit_manage', 'system', '审核流程管理'),
-- 统计模块权限
('统计查看', 'stats_view', 'stats', '查看统计信息'),
('报表导出', 'report_export', 'stats', '导出报表')
ON CONFLICT (code) DO NOTHING;
-- 为超级管理员角色分配所有权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'super_admin'
ON CONFLICT DO NOTHING;
-- 为管理员角色分配大部分权限(除了用户管理相关)
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'admin'
AND p.code NOT IN ('user_manage', 'role_manage', 'permission_manage')
ON CONFLICT DO NOTHING;
-- 为区域管理员分配基础权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'area_admin'
AND p.code IN ('device_view', 'device_edit', 'device_check', 'stats_view')
ON CONFLICT DO NOTHING;
-- 为学校管理员分配基础权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'school_admin'
AND p.code IN ('device_view', 'device_edit', 'device_check')
ON CONFLICT DO NOTHING;
-- 为操作员分配基础权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'operator'
AND p.code IN ('device_view', 'device_edit', 'device_check')
ON CONFLICT DO NOTHING;
-- 为查看员分配只读权限
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.name = 'viewer'
AND p.code IN ('device_view', 'stats_view')
ON CONFLICT DO NOTHING;
-- 创建默认系统用户(通过Casdoor同步,这里只是占位)
INSERT INTO users (casdoor_id, username, email, role, is_active) VALUES
('system', '系统用户', 'system@example.com', 'super_admin', true)
ON CONFLICT (casdoor_id) DO NOTHING;
EOF
)
# 执行SQL
echo "$BASE_DATA_SQL" | PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME"
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ 基础数据创建成功${NC}"
else
echo -e "${YELLOW}警告: 基础数据创建可能失败,表可能不存在${NC}"
echo "请先运行数据库迁移"
fi
}
# 函数:显示数据库信息
show_database_info() {
echo -e "\n${GREEN}========================================${NC}"
echo -e "${GREEN} 数据库初始化完成${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "${BLUE}数据库连接信息:${NC}"
echo " 主机: $DB_HOST"
echo " 端口: $DB_PORT"
echo " 数据库: $DB_NAME"
echo " 用户: $DB_USER"
echo ""
echo -e "${BLUE}连接字符串:${NC}"
echo " $DATABASE_URL"
echo ""
echo -e "${BLUE}下一步:${NC}"
echo " 1. 启动后端服务运行数据库迁移"
echo " 2. 配置Casdoor认证"
echo " 3. 导入设备数据"
echo ""
}
# 主程序
main() {
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} H3C ONU设备管理系统 - 数据库初始化${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查PostgreSQL连接
if ! check_postgres_connection; then
echo -e "${RED}请检查PostgreSQL服务是否运行,以及连接信息是否正确${NC}"
exit 1
fi
# 检查数据库是否存在
if check_database_exists; then
echo -e "${YELLOW}数据库已存在,是否重新初始化?${NC}"
read -p "重新初始化将删除现有数据! (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}删除数据库 '$DB_NAME'...${NC}"
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;"
create_database
else
echo -e "${YELLOW}跳过数据库创建${NC}"
fi
else
create_database
fi
# 创建用户和授予权限
create_database_user
grant_privileges
# 运行迁移(如果后端代码已存在)
run_migrations
# 创建基础数据
create_base_data
# 显示完成信息
show_database_info
}
# 执行主程序
main "$@"
-368
View File
@@ -1,368 +0,0 @@
#!/bin/bash
# H3C ONU设备管理系统 - 系统监控脚本
# 用于监控系统状态、资源使用和性能指标
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 默认值
INTERVAL=${1:-5} # 监控间隔(秒)
DURATION=${2:-0} # 监控时长(0表示无限)
COMPOSE_FILE="docker-compose.yml"
# 加载环境变量
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
# 函数:显示帮助信息
show_help() {
echo "H3C ONU设备管理系统 - 系统监控脚本"
echo ""
echo "使用方法: $0 [间隔] [时长]"
echo ""
echo "参数:"
echo " 间隔 监控间隔(秒,默认: 5)"
echo " 时长 监控总时长(秒,0表示无限,默认: 0)"
echo ""
echo "示例:"
echo " $0 # 每5秒监控一次,无限时长"
echo " $0 10 60 # 每10秒监控一次,持续60秒"
echo ""
}
# 函数:检查Docker Compose服务状态
check_services_status() {
echo -e "${BLUE}=== 服务状态 ===${NC}"
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}错误: docker-compose 未安装${NC}"
return
fi
# 获取服务状态
SERVICES=$(docker-compose -f "$COMPOSE_FILE" ps --services)
for service in $SERVICES; do
STATUS=$(docker-compose -f "$COMPOSE_FILE" ps --filter "name=$service" --format "table {{.Status}}" | tail -1)
CONTAINER_ID=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service")
if [ -n "$CONTAINER_ID" ]; then
# 检查容器是否运行
if docker inspect --format='{{.State.Status}}' "$CONTAINER_ID" | grep -q "running"; then
# 检查健康状态
HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}no healthcheck{{end}}' "$CONTAINER_ID")
if [ "$HEALTH" = "healthy" ]; then
echo -e "${GREEN}$service: 运行中 (健康)${NC}"
elif [ "$HEALTH" = "no healthcheck" ]; then
echo -e "${GREEN}$service: 运行中${NC}"
else
echo -e "${YELLOW}$service: 运行中 ($HEALTH)${NC}"
fi
else
echo -e "${RED}$service: 未运行${NC}"
fi
else
echo -e "${RED}$service: 容器不存在${NC}"
fi
done
}
# 函数:检查系统资源使用
check_system_resources() {
echo -e "\n${BLUE}=== 系统资源 ===${NC}"
# CPU使用率
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)
echo -e "CPU使用率: ${CYAN}${CPU_USAGE}%${NC}"
# 内存使用
MEM_TOTAL=$(free -m | awk '/Mem:/ {print $2}')
MEM_USED=$(free -m | awk '/Mem:/ {print $3}')
MEM_PERCENT=$((MEM_USED * 100 / MEM_TOTAL))
if [ $MEM_PERCENT -lt 70 ]; then
echo -e "内存使用: ${GREEN}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
elif [ $MEM_PERCENT -lt 90 ]; then
echo -e "内存使用: ${YELLOW}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
else
echo -e "内存使用: ${RED}${MEM_USED}MB/${MEM_TOTAL}MB (${MEM_PERCENT}%)${NC}"
fi
# 磁盘使用
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | cut -d'%' -f1)
DISK_TOTAL=$(df -h / | awk 'NR==2 {print $2}')
DISK_USED=$(df -h / | awk 'NR==2 {print $3}')
if [ $DISK_USAGE -lt 70 ]; then
echo -e "磁盘使用: ${GREEN}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
elif [ $DISK_USAGE -lt 90 ]; then
echo -e "磁盘使用: ${YELLOW}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
else
echo -e "磁盘使用: ${RED}${DISK_USED}/${DISK_TOTAL} (${DISK_USAGE}%)${NC}"
fi
}
# 函数:检查容器资源使用
check_container_resources() {
echo -e "\n${BLUE}=== 容器资源 ===${NC}"
if ! command -v docker &> /dev/null; then
echo -e "${RED}错误: docker 未安装${NC}"
return
fi
# 获取所有运行中的容器
CONTAINERS=$(docker ps --format "{{.Names}}")
for container in $CONTAINERS; do
# 获取容器资源使用
STATS=$(docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}} {{.MemPerc}}" "$container" 2>/dev/null || echo "0% 0B/0B 0%")
CPU=$(echo "$STATS" | awk '{print $1}')
MEM_USAGE=$(echo "$STATS" | awk '{print $2}')
MEM_PERCENT=$(echo "$STATS" | awk '{print $3}' | cut -d'%' -f1)
# 检查是否属于本项目
if echo "$container" | grep -q "h3c-onu-ms"; then
COLOR="$CYAN"
else
COLOR="$PURPLE"
fi
echo -e "${COLOR}$container${NC}: CPU=${CPU}, 内存=${MEM_USAGE} (${MEM_PERCENT}%)"
done
}
# 函数:检查应用健康状态
check_application_health() {
echo -e "\n${BLUE}=== 应用健康 ===${NC}"
# 检查后端API
if curl -s -f http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${GREEN}✓ 后端API: 健康${NC}"
# 获取详细健康信息
HEALTH_INFO=$(curl -s http://localhost:8000/health)
echo " 详细信息: $HEALTH_INFO"
else
echo -e "${RED}✗ 后端API: 不可用${NC}"
fi
# 检查前端
if curl -s -f http://localhost:8080 > /dev/null 2>&1; then
echo -e "${GREEN}✓ 前端应用: 可访问${NC}"
else
echo -e "${RED}✗ 前端应用: 不可访问${NC}"
fi
# 检查Redis
REDIS_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q redis 2>/dev/null || echo "")
if [ -n "$REDIS_CONTAINER" ]; then
if docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; then
echo -e "${GREEN}✓ Redis: 运行正常${NC}"
else
echo -e "${RED}✗ Redis: 连接失败${NC}"
fi
fi
# 检查数据库连接
if [ -n "$DATABASE_URL" ]; then
# 简单检查数据库连接
if command -v pg_isready &> /dev/null; then
# 从DATABASE_URL解析主机和端口
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's/.*@\([^:]*\).*/\1/p')
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
if pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; then
echo -e "${GREEN}✓ 数据库: 可连接${NC}"
else
echo -e "${RED}✗ 数据库: 连接失败${NC}"
fi
fi
fi
}
# 函数:检查网络连接
check_network() {
echo -e "\n${BLUE}=== 网络连接 ===${NC}"
# 检查互联网连接
if ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1; then
echo -e "${GREEN}✓ 互联网: 可连接${NC}"
else
echo -e "${YELLOW}⚠ 互联网: 连接失败${NC}"
fi
# 检查DNS
if nslookup google.com > /dev/null 2>&1; then
echo -e "${GREEN}✓ DNS: 工作正常${NC}"
else
echo -e "${YELLOW}⚠ DNS: 解析失败${NC}"
fi
# 检查端口监听
echo -e "端口监听:"
PORTS="80 443 8000 8080 5432 6379"
for port in $PORTS; do
if ss -tuln | grep -q ":$port "; then
SERVICE=$(ss -tuln | grep ":$port " | awk '{print $5}' | cut -d':' -f2)
echo -e " ${GREEN}✓ 端口 $port: 被监听 ($SERVICE)${NC}"
else
echo -e " ${YELLOW}⚠ 端口 $port: 未监听${NC}"
fi
done
}
# 函数:检查日志错误
check_log_errors() {
echo -e "\n${BLUE}=== 日志检查 ===${NC}"
# 检查最近错误日志
LOG_FILES="/var/log/syslog /var/log/messages /var/log/docker/*.log ../../backend/logs/*.log"
for log_file in $LOG_FILES; do
if [ -f "$log_file" ]; then
ERROR_COUNT=$(tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | wc -l)
if [ "$ERROR_COUNT" -gt 0 ]; then
echo -e "${YELLOW}$log_file: 最近有 $ERROR_COUNT 个错误${NC}"
# 显示最近3个错误
tail -100 "$log_file" | grep -i "error\|exception\|failed\|critical" | tail -3 | while read line; do
echo " - $line"
done
fi
fi
done
}
# 函数:检查定时任务
check_scheduled_tasks() {
echo -e "\n${BLUE}=== 定时任务 ===${NC}"
# 检查Celery Beat
BEAT_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-beat 2>/dev/null || echo "")
if [ -n "$BEAT_CONTAINER" ]; then
if docker logs --tail 10 "$BEAT_CONTAINER" 2>/dev/null | grep -q "beat"; then
echo -e "${GREEN}✓ Celery Beat: 运行中${NC}"
else
echo -e "${YELLOW}⚠ Celery Beat: 可能异常${NC}"
fi
fi
# 检查Celery Worker
WORKER_CONTAINER=$(docker-compose -f "$COMPOSE_FILE" ps -q celery-worker 2>/dev/null || echo "")
if [ -n "$WORKER_CONTAINER" ]; then
ACTIVE_TASKS=$(docker exec "$WORKER_CONTAINER" celery -A app.core.celery_app inspect active 2>/dev/null | grep -c "worker")
if [ "$ACTIVE_TASKS" -gt 0 ]; then
echo -e "${GREEN}✓ Celery Worker: 有 $ACTIVE_TASKS 个活跃任务${NC}"
else
echo -e "${CYAN} Celery Worker: 无活跃任务${NC}"
fi
fi
}
# 函数:生成监控报告
generate_report() {
local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
echo -e "\n${PURPLE}========================================${NC}"
echo -e "${PURPLE} 监控报告 - $timestamp${NC}"
echo -e "${PURPLE}========================================${NC}"
check_services_status
check_system_resources
check_container_resources
check_application_health
check_network
check_log_errors
check_scheduled_tasks
echo -e "\n${PURPLE}========================================${NC}"
}
# 函数:连续监控
continuous_monitoring() {
local start_time=$(date +%s)
local iteration=1
echo -e "${GREEN}开始监控,间隔: ${INTERVAL}${NC}"
if [ "$DURATION" -gt 0 ]; then
echo -e "监控时长: ${DURATION}${NC}"
else
echo -e "监控时长: 无限${NC}"
fi
echo "按 Ctrl+C 停止监控"
echo ""
while true; do
# 检查是否达到监控时长
if [ "$DURATION" -gt 0 ]; then
local current_time=$(date +%s)
local elapsed=$((current_time - start_time))
if [ $elapsed -ge $DURATION ]; then
echo -e "\n${GREEN}监控时长达到,停止监控${NC}"
break
fi
fi
# 清屏并显示报告
clear
echo -e "${BLUE}监控迭代 #$iteration - $(date "+%H:%M:%S")${NC}"
generate_report
# 等待间隔
sleep "$INTERVAL"
iteration=$((iteration + 1))
done
}
# 函数:单次监控
single_monitoring() {
generate_report
}
# 主程序
main() {
# 显示标题
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} H3C ONU设备管理系统 - 系统监控${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# 检查参数
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_help
exit 0
fi
# 检查Docker Compose文件
if [ ! -f "$COMPOSE_FILE" ]; then
echo -e "${YELLOW}警告: $COMPOSE_FILE 不存在,使用默认配置${NC}"
fi
# 选择监控模式
if [ "$DURATION" -eq 0 ] && [ "$INTERVAL" -eq 5 ]; then
# 默认单次监控
single_monitoring
else
# 连续监控
continuous_monitoring
fi
}
# 执行主程序
main "$@"
-49
View File
@@ -1,49 +0,0 @@
services:
backend:
build: ./backend
ports:
- "8000:8000"
env_file:
- ./backend/.env
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 15s
timeout: 10s
retries: 5
start_period: 40s
celery-worker:
build: ./backend
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms
env_file:
- ./backend/.env
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
celery-beat:
build: ./backend
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
env_file:
- ./backend/.env
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
frontend:
build: ./frontend
ports:
- "18002:5173"
environment:
- VITE_API_PROXY_TARGET=http://backend:8000
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
-899
View File
@@ -1,899 +0,0 @@
# H3ConuMS2 现场ONU故障排查指南
## 文档说明
**版本**: v1.0
**适用对象**: 新入职维护人员
**适用场景**: 用户报修处理、定期巡检发现问题
**关联系统**: H3ConuMS2 OLT设备监控管理系统
**最后更新**: 2026年4月8日
---
## 第一章:准备工作
### 1.1 工具准备清单
#### 必备工具
1. **笔记本电脑**
- 安装SSH客户端(如PuTTY、SecureCRT
- 安装H3C设备管理软件(如有)
- 确保能连接公司VPN或内网
2. **网络测试工具**
- 网线测试仪
- 网络抓包工具(Wireshark
- Ping工具
3. **光纤测试设备**
- 光功率计(带适配器)
- 红光笔(可视故障定位仪)
- 光纤清洁工具(清洁笔、酒精棉片)
- 备用光纤跳线(SC/LC接口)
4. **维修工具**
- 十字/一字螺丝刀套装
- 尖嘴钳、斜口钳
- 剥线钳
- 电工胶布、扎带
- 防静电手环
5. **备用设备**
- 同型号ONU设备(至少1台)
- 电源适配器(12V/1A
- 网线(Cat5e/Cat6,不同长度)
- 光纤跳线(SC/LC,不同长度)
#### 软件准备
1. **H3ConuMS2系统访问**
- 确保有系统登录权限
- 熟悉设备查询界面
- 了解历史状态查看功能
2. **文档资料**
- 本指南(打印版或电子版)
- ONU设备技术手册
- OLT设备配置手册
- 网络拓扑图
### 1.2 出发前检查
#### 信息收集
1. **从H3ConuMS2系统获取信息**
```bash
# 查询设备当前状态
设备名称: [从系统获取]
OLT位置: [从系统获取]
最后在线时间: [从系统获取]
历史故障记录: [从系统获取]
```
2. **联系用户了解情况**
- 故障现象描述
- 故障发生时间
- 受影响范围(单用户/多用户)
- 是否尝试过重启
3. **确认现场信息**
- 具体地址和联系人
- 设备安装位置
- 现场环境(机房/用户端)
- 预计到达时间
#### 安全准备
1. **个人防护**
- 穿戴工作服、安全鞋
- 携带手电筒
- 准备口罩、手套
2. **工作许可**
- 确认是否有进入权限
- 是否需要用户陪同
- 工作时间限制
### 1.3 故障预判
根据系统信息和用户描述,初步判断可能的问题:
| 故障现象 | 可能原因 | 优先级 |
|---------|---------|--------|
| 完全无信号 | 电源故障、光纤断裂 | 高 |
| 信号弱/不稳定 | 光纤衰减大、连接器脏污 | 中 |
| 有光信号但无法上网 | 配置错误、网线问题 | 中 |
| 间歇性断线 | 设备过热、干扰 | 低 |
---
## 第二章:现场排查流程
### 2.1 到达现场后的标准操作流程
#### 步骤1:现场环境评估
1. **安全确认**
- 检查现场是否有安全隐患
- 确认电源插座接地良好
- 注意防静电措施
2. **设备定位**
- 找到故障ONU设备
- 确认设备型号与系统记录一致
- 检查设备安装位置是否合适
3. **环境检查**
- 温度是否过高(>40°C)
- 湿度是否合适(30%-70%
- 通风是否良好
- 是否有灰尘堆积
#### 步骤2:初步外观检查
1. **电源检查**
- 电源指示灯状态
- 电源适配器是否发热
- 电源线连接是否牢固
- 用万用表测量输出电压(应为12V±5%)
2. **指示灯状态分析**
| 指示灯 | 正常状态 | 故障状态 | 可能原因 |
|-------|---------|---------|---------|
| PWR | 常亮 | 不亮/闪烁 | 电源故障 |
| PON | 常亮 | 闪烁/不亮 | 光路问题 |
| LOS | 不亮 | 常亮/闪烁 | 光信号丢失 |
| LAN | 常亮/闪烁 | 不亮 | 网线/配置问题 |
3. **物理连接检查**
- 光纤连接器是否插紧
- 网线连接是否牢固
- 所有接口是否有松动
- 检查线缆是否有破损
#### 步骤3:光路测试
1. **使用红光笔检查**
- 在OLT端发射红光
- 在ONU端观察是否有红光
- 确认光纤通路是否完整
2. **光功率测量**
- 连接光功率计到ONU接收端
- 记录接收光功率
- 参考标准值:-8dBm ~ -27dBm
**光功率判断标准:**
- 优于-8dBm:光功率过强,可能损坏设备
- -8dBm ~ -27dBm:正常范围
- -27dBm ~ -30dBm:临界值,可能不稳定
- 低于-30dBm:光功率不足
3. **光纤清洁**
- 使用专用清洁笔清洁光纤端面
- 检查连接器是否有划伤
- 重新插拔光纤连接器
#### 步骤4:网络连接测试
1. **本地连接测试**
- 笔记本电脑直连ONU LAN口
- 设置静态IP(如192.168.1.100
- Ping ONU管理地址(通常192.168.1.1
2. **上网测试**
- 如果能够Ping通ONU,尝试访问网页
- 测试DNS解析(nslookup www.baidu.com
- 测试下载速度
3. **抓包分析(如需要)**
- 使用Wireshark抓取网络包
- 分析DHCP、PPPoE等协议交互
- 查找异常流量
#### 步骤5:设备诊断
1. **登录ONU管理界面**
- 默认地址:192.168.1.1
- 用户名/密码:admin/admin 或 查看设备标签
- 检查设备状态信息
2. **查看关键信息**
- 光模块信息(发射/接收功率)
- 温度信息
- 运行时间
- 错误日志
3. **配置检查**
- VLAN配置是否正确
- 业务VLAN是否匹配
- MAC地址学习情况
#### 步骤6OLT端检查
1. **通过H3ConuMS2系统检查**
- 登录系统查看设备状态
- 检查OLT端口状态
- 查看ONU注册状态
2. **SSH登录OLT(如需要)**
```bash
# 连接OLT
ssh admin@olt_ip
# 查看ONU状态
display onu slot X/X
# 查看端口信息
display interface gigabitethernet X/X/X
# 查看告警信息
display alarm active
```
3. **OLT端诊断命令**
- `display ont info` - 查看ONU信息
- `display ont optical-info` - 查看光信息
- `display ont version` - 查看版本信息
- `display ont statistic` - 查看统计信息
### 2.2 故障决策树
```mermaid
graph TD
A[开始排查] --> B{电源指示灯亮?}
B -->|否| C[检查电源]
B -->|是| D{PON指示灯状态?}
D -->|不亮| E[检查光路]
D -->|闪烁| F[检查注册状态]
D -->|常亮| G{LAN指示灯状态?}
G -->|不亮| H[检查网线/配置]
G -->|正常| I[测试网络连接]
C --> J[更换电源适配器]
J --> K{问题解决?}
K -->|是| L[记录并结束]
K -->|否| M[更换ONU设备]
E --> N[测试光功率]
N --> O{光功率正常?}
O -->|否| P[检查光纤/清洁连接器]
O -->|是| Q[检查OLT配置]
F --> R[检查ONU注册]
R --> S{注册成功?}
S -->|否| T[检查SN/MAC绑定]
S -->|是| U[检查业务配置]
H --> V[更换网线]
V --> W{问题解决?}
W -->|是| X[记录并结束]
W -->|否| Y[检查交换机配置]
I --> Z[Ping测试]
Z --> AA{能Ping通?}
AA -->|否| BB[检查IP配置]
AA -->|是| CC[测试上网]
CC --> DD{能上网?}
DD -->|否| EE[检查PPPoE/DHCP]
DD -->|是| FF[故障排除完成]
```
---
## 第三章:常见故障处理
### 3.1 电源类故障
#### 故障现象
- 所有指示灯不亮
- 设备无任何反应
- 电源适配器发热异常
#### 处理步骤
1. **检查电源插座**
- 使用其他设备测试插座
- 检查插座电压(220V±10%)
2. **检查电源适配器**
- 测量输出电压(应为12V
- 检查输出电流(应≥1A
- 观察指示灯状态
3. **检查设备电源接口**
- 检查接口是否松动
- 检查内部保险丝
- 检查电源电路
#### 解决方案
1. **更换电源适配器**
2. **更换电源线**
3. **更换ONU设备**
### 3.2 光路类故障
#### 故障现象
- PON指示灯不亮或闪烁
- LOS指示灯亮
- 光功率异常
#### 处理步骤
1. **检查光纤连接**
- 确认光纤类型(单模/多模)
- 检查连接器类型(SC/LC)
- 清洁光纤端面
2. **测量光功率**
- 在ONU端测量接收功率
- 在分光器端测量输出功率
- 计算链路衰减
3. **检查光纤质量**
- 检查光纤是否有弯折
- 检查接头是否有损伤
- 检查光纤长度是否超限
#### 解决方案
1. **重新制作光纤接头**
2. **更换光纤跳线**
3. **调整光功率(增加衰减器)**
4. **重新熔接光纤**
### 3.3 注册类故障
#### 故障现象
- PON指示灯闪烁
- OLT无法发现ONU
- 注册超时
#### 处理步骤
1. **检查SN/MAC地址**
- 核对ONU标签上的SN
- 检查OLT端SN绑定
- 检查MAC地址过滤
2. **检查OLT配置**
- 检查端口使能状态
- 检查ONU模板
- 检查带宽配置
3. **检查距离限制**
- 确认光纤长度
- 检查光功率预算
- 确认分光比
#### 解决方案
1. **重新配置SN/MAC**
2. **重启ONU设备**
3. **重置ONU到出厂设置**
4. **修改OLT配置**
### 3.4 业务类故障
#### 故障现象
- PON灯常亮但无法上网
- 能获取IP但无法访问外网
- 网速慢或不稳定
#### 处理步骤
1. **检查VLAN配置**
- 检查业务VLAN ID
- 检查VLAN透传
- 检查优先级标记
2. **检查IP配置**
- 检查DHCP获取情况
- 检查PPPoE拨号
- 检查静态IP配置
3. **检查路由配置**
- 检查默认网关
- 检查DNS服务器
- 检查路由表
#### 解决方案
1. **重新配置VLAN**
2. **重启网络服务**
3. **更新设备固件**
4. **优化网络配置**
### 3.5 性能类故障
#### 故障现象
- 网速达不到标称值
- 延迟高、抖动大
- 频繁掉线
#### 处理步骤
1. **进行网络测试**
- 测试上下行带宽
- 测试网络延迟
- 测试丢包率
2. **检查设备负载**
- 检查CPU使用率
- 检查内存使用率
- 检查温度状态
3. **检查干扰因素**
- 检查同频干扰
- 检查电源干扰
- 检查环境干扰
#### 解决方案
1. **优化设备位置**
2. **更换更高性能设备**
3. **调整信道/频率**
4. **增加散热措施**
---
## 第四章:记录与报告
### 4.1 故障记录规范
#### 基本信息
```yaml
故障单号: [系统生成]
报告人: [维护人员姓名]
报告时间: [YYYY-MM-DD HH:MM]
故障地点: [详细地址]
联系人: [用户姓名电话]
```
#### 故障信息
```yaml
设备信息:
- 设备型号: [H3C XXX]
- 设备SN: [XXXXXXXX]
- MAC地址: [XX:XX:XX:XX:XX:XX]
- 安装日期: [YYYY-MM-DD]
故障现象:
- 用户描述: [用户原话]
- 实际现象: [现场观察]
- 影响范围: [单用户/多用户]
处理过程:
- 到达时间: [HH:MM]
- 开始处理: [HH:MM]
- 解决时间: [HH:MM]
- 总耗时: [X小时X分钟]
```
#### 技术数据
```yaml
测试数据:
- 光功率: [接收: -XX dBm, 发射: -XX dBm]
- 网络测试: [下载: XX Mbps, 上传: XX Mbps]
- 延迟测试: [平均: XX ms, 最大: XX ms]
- 丢包率: [X%]
更换部件:
- 部件名称: [如: 电源适配器]
- 部件型号: [如: H3C-12V-1A]
- 更换原因: [如: 输出电压不稳定]
- 旧件SN: [XXXXXXXX]
- 新件SN: [XXXXXXXX]
```
### 4.2 H3ConuMS2系统更新
#### 设备状态更新
1. **登录系统**
- 进入设备管理界面
- 找到故障设备
2. **更新信息**
- 更新设备状态(在线/离线)
- 更新最后检查时间
- 更新维护记录
3. **添加备注**
- 描述故障现象
- 记录处理过程
- 添加技术数据
#### 生成报告
1. **导出数据**
- 导出设备历史状态
- 导出故障处理记录
- 导出性能测试数据
2. **生成报告**
- 使用系统报告功能
- 选择报告模板
- 自定义报告内容
3. **提交报告**
- 提交给上级主管
- 发送给用户确认
- 归档到知识库
### 4.3 知识积累
#### 故障案例库
1. **记录典型案例**
- 罕见故障现象
- 复杂处理过程
- 创新解决方案
2. **分析故障原因**
- 根本原因分析
- 预防措施建议
- 改进方案设计
3. **分享经验**
- 团队内部分享
- 编写技术文档
- 培训新员工
#### 持续改进
1. **统计分析**
- 统计故障类型分布
- 分析故障发生频率
- 计算平均修复时间
2. **优化流程**
- 根据数据优化排查步骤
- 改进工具配置
- 更新技术标准
3. **预防维护**
- 制定定期检查计划
- 预测性维护
- 设备生命周期管理
---
## 第五章:预防措施
### 5.1 定期维护计划
#### 月度检查
1. **设备清洁**
- 清洁设备表面灰尘
- 清洁散热孔
- 检查风扇运转
2. **连接检查**
- 检查所有连接器
- 检查线缆状态
- 检查标签完整性
3. **性能测试**
- 测试网络速度
- 测试光功率
- 测试设备温度
#### 季度检查
1. **全面诊断**
- 完整的功能测试
- 详细的性能测试
- 安全漏洞扫描
2. **配置备份**
- 备份设备配置
- 备份系统数据
- 验证备份完整性
3. **固件更新**
- 检查固件版本
- 评估更新必要性
- 制定更新计划
#### 年度检查
1. **设备评估**
- 评估设备性能
- 预测剩余寿命
- 制定更换计划
2. **环境评估**
- 评估安装环境
- 检查配套设施
- 提出改进建议
3. **文档更新**
- 更新技术文档
- 更新操作手册
- 更新维护记录
### 5.2 设备安装规范
#### 环境要求
1. **温度控制**
- 工作温度:0°C ~ 40°C
- 存储温度:-40°C ~ 70°C
- 避免阳光直射
2. **湿度控制**
- 工作湿度:10% ~ 90% RH
- 避免冷凝
- 保持通风
3. **电源要求**
- 稳定电压:220V ±10%
- 可靠接地
- 防雷保护
#### 安装规范
1. **设备固定**
- 使用合适支架
- 保持水平安装
- 预留散热空间
2. **线缆管理**
- 分类绑扎线缆
- 预留适当余量
- 避免弯折过度
3. **标签标识**
- 清晰设备标签
- 完整线缆标签
- 准确位置标签
### 5.3 备件管理
#### 备件库存
1. **常用备件**
- ONU设备:按设备总数10%储备
- 电源适配器:按设备总数15%储备
- 光纤跳线:按设备总数20%储备
- 网线:按需储备
2. **关键备件**
- 光模块
- 分光器
- 交换机
3. **工具备件**
- 测试工具
- 维修工具
- 安全装备
#### 库存管理
1. **入库管理**
- 记录备件信息
- 测试备件功能
- 分类存放
2. **出库管理**
- 记录领用信息
- 跟踪使用情况
- 及时补充库存
3. **定期检查**
- 检查备件状态
- 测试备件功能
- 更新库存记录
### 5.4 培训计划
#### 新员工培训
1. **基础培训(第1周)**
- 公司制度介绍
- 安全规范培训
- 工具使用培训
2. **技术培训(第2-3周)**
- 网络基础知识
- 光纤通信原理
- 设备操作培训
3. **实操培训(第4周)**
- 跟随老员工现场学习
- 模拟故障处理
- 独立完成简单任务
#### 在职培训
1. **月度培训**
- 新技术介绍
- 案例分享
- 经验交流
2. **季度培训**
- 技能考核
- 应急演练
- 安全培训
3. **年度培训**
- 技术更新培训
- 认证考试
- 职业发展规划
---
## 第六章:应急处理
### 6.1 紧急情况处理
#### 重大故障
1. **大面积断网**
- 立即上报主管
- 启动应急预案
- 协调多方资源
2. **设备损坏**
- 评估影响范围
- 准备替代方案
- 紧急采购设备
3. **安全事故**
- 确保人员安全
- 保护现场证据
- 按规定报告
#### 沟通协调
1. **内部沟通**
- 建立应急联络群
- 定期更新进展
- 协调技术支持
2. **用户沟通**
- 及时通知用户
- 说明处理进展
- 提供临时方案
3. **供应商沟通**
- 联系技术支持
- 协调备件供应
- 请求现场支持
### 6.2 应急预案
#### 预案制定
1. **风险评估**
- 识别潜在风险
- 评估影响程度
- 制定应对措施
2. **资源准备**
- 准备应急设备
- 准备应急人员
- 准备应急资金
3. **流程设计**
- 设计处理流程
- 明确责任分工
- 设定时间要求
#### 预案演练
1. **桌面演练**
- 模拟故障场景
- 讨论处理方案
- 优化处理流程
2. **实战演练**
- 模拟真实故障
- 实际处理操作
- 评估处理效果
3. **演练总结**
- 分析演练过程
- 发现存在问题
- 提出改进建议
---
## 第七章:附录
### 7.1 常用命令参考
#### OLT常用命令
```bash
# 查看ONU状态
display onu slot X/X
# 查看光信息
display ont optical-info X/X/X X
# 查看版本信息
display ont version X/X/X X
# 查看统计信息
display ont statistic X/X/X X
# 重启ONU
ont reboot X/X/X X
# 查看告警
display alarm active
# 查看端口状态
display interface gigabitethernet X/X/X
```
#### ONU管理命令
```bash
# 登录ONUTelnet/SSH
telnet 192.168.1.1
# 查看系统信息
show system
# 查看接口状态
show interface
# 查看光模块信息
show optical-module
# 查看日志
show log
# 重启设备
reboot
# 恢复出厂设置
factory-reset
```
### 7.2 技术参数参考
#### 光功率标准
| 项目 | 标准值 | 警告值 | 危险值 |
|------|--------|--------|--------|
| 接收光功率 | -8 ~ -27 dBm | -27 ~ -30 dBm | < -30 dBm |
| 发射光功率 | 0 ~ +5 dBm | +5 ~ +7 dBm | > +7 dBm |
| 链路衰减 | < 25 dB | 25 ~ 28 dB | > 28 dB |
#### 温度标准
| 项目 | 正常范围 | 警告范围 | 危险范围 |
|------|----------|----------|----------|
| 工作温度 | 0°C ~ 40°C | 40°C ~ 50°C | > 50°C |
| 存储温度 | -40°C ~ 70°C | - | - |
| 表面温度 | < 60°C | 60°C ~ 70°C | > 70°C |
### 7.3 联系信息
#### 内部联系人
| 部门 | 联系人 | 电话 | 职责 |
|------|--------|------|------|
| 技术支持 | 张三 | 138XXXXXXX | 技术指导 |
| 备件管理 | 李四 | 139XXXXXXX | 备件供应 |
| 项目管理 | 王五 | 137XXXXXXX | 项目协调 |
#### 供应商联系人
| 供应商 | 产品 | 联系人 | 电话 | 支持时间 |
|--------|------|--------|------|----------|
| H3C | OLT设备 | 技术支持 | 400-XXXX | 7x24 |
| 华为 | ONU设备 | 技术支持 | 400-XXXX | 7x24 |
| 中兴 | 光模块 | 技术支持 | 400-XXXX | 工作日 |
### 7.4 模板下载
#### 故障报告模板
[下载链接:故障报告模板.docx]
#### 维护记录模板
[下载链接:维护记录模板.xlsx]
#### 备件清单模板
[下载链接:备件清单模板.xlsx]
---
## 第八章:更新记录
| 版本 | 更新日期 | 更新内容 | 更新人 |
|------|----------|----------|--------|
| v1.0 | 2026-04-08 | 初始版本创建 | 系统团队 |
| v1.1 | - | 计划更新:添加更多案例 | - |
| v1.2 | - | 计划更新:优化流程图 | - |
---
## 使用说明
1. **新员工必读**:入职后一周内完成学习
2. **现场携带**:建议打印关键章节随身携带
3. **定期复习**:每季度复习一次
4. **反馈建议**:发现不足及时反馈
5. **持续改进**:根据实际情况更新内容
## 版权声明
本指南版权归H3ConuMS2项目组所有,未经许可不得外传。
---
**祝您工作顺利,排查无忧!**
如有任何问题或建议,请联系:
- 邮箱:support@h3conums2.com
- 电话:400-XXX-XXXX
- 内部通讯:企业微信/钉钉群
-12
View File
@@ -1,12 +0,0 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
-25
View File
@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>H3C ONU设备管理系统</title>
<!-- PWA / iOS 主屏幕 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="ONU管理">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0a0e1a">
<!-- iOS 图标 -->
<link rel="apple-touch-icon" href="/icon-180.png">
<!-- Web App Manifest -->
<link rel="manifest" href="/manifest.json">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

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