Compare commits
16 Commits
5aadbc78c6
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f511e3e808 | |||
| 26f6ca2d8d | |||
| 81ab82e9ba | |||
| 5b07ec6df0 | |||
| 8a8ae4ed57 | |||
| a5ac25a01d | |||
| 32b7a2cc6a | |||
| 6623169e8a | |||
| 0bab72ea98 | |||
| 3ee8846011 | |||
| fe7649ed6e | |||
| 6e5f16ecf2 | |||
| b2c20ec43d | |||
| 3453441754 | |||
| b5d542a725 | |||
| bd341df9de |
@@ -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
|
||||
@@ -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.
|
||||
Submodule .claude/skills/create-ex deleted from 54d0dceab6
@@ -1 +0,0 @@
|
||||
../../.agents/skills/frontend-design
|
||||
+10
@@ -32,6 +32,9 @@ logs/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Keys
|
||||
*.pem
|
||||
|
||||
# Temp
|
||||
/tmp/
|
||||
*.tmp
|
||||
@@ -41,3 +44,10 @@ logs/
|
||||
# Claude Code
|
||||
.claude/
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
|
||||
# Deployment docs (contain credentials)
|
||||
*部署交接文档.md
|
||||
|
||||
# Reasonix
|
||||
.reasonix/
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
stages:
|
||||
- verify
|
||||
- test
|
||||
- security
|
||||
- build
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
default:
|
||||
interruptible: true
|
||||
|
||||
variables:
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
PIP_NO_INPUT: "1"
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
|
||||
dependency-source-policy:
|
||||
stage: verify
|
||||
image: "$INTERNAL_CONTAINER_PROXY/alpine:3.20"
|
||||
script:
|
||||
- test -n "$INTERNAL_CONTAINER_PROXY" || (echo "INTERNAL_CONTAINER_PROXY must point to the approved container-image proxy" && exit 1)
|
||||
- test -n "$INTERNAL_PYPI_URL" || (echo "INTERNAL_PYPI_URL must point to the approved PyPI proxy" && exit 1)
|
||||
- test -n "$INTERNAL_NPM_REGISTRY" || (echo "INTERNAL_NPM_REGISTRY must point to the approved npm proxy" && exit 1)
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
|
||||
|
||||
backend-tests:
|
||||
stage: test
|
||||
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
|
||||
needs: ["dependency-source-policy"]
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- backend/requirements.txt
|
||||
paths:
|
||||
- .cache/pip
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
SECRET_KEY: "ci-test-secret-not-for-deployment"
|
||||
CREDENTIAL_ENCRYPTION_KEY: "Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU="
|
||||
DATABASE_URL: "sqlite:///./ci-test.db"
|
||||
REDIS_URL: "redis://localhost:6379/15"
|
||||
CASDOOR_ENDPOINT: "https://casdoor.example.test"
|
||||
CASDOOR_CLIENT_ID: "ci-client"
|
||||
CASDOOR_CLIENT_SECRET: "ci-client-secret"
|
||||
CASDOOR_ORG_NAME: "ci-org"
|
||||
CASDOOR_APP_NAME: "ci-app"
|
||||
before_script:
|
||||
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
|
||||
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt
|
||||
script:
|
||||
- cd backend
|
||||
- python -m compileall -q app
|
||||
- pytest -q --junitxml=../reports/backend-junit.xml
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: reports/backend-junit.xml
|
||||
paths:
|
||||
- reports/backend-junit.xml
|
||||
expire_in: 30 days
|
||||
|
||||
frontend-build:
|
||||
stage: test
|
||||
image: "$INTERNAL_CONTAINER_PROXY/node:20-alpine"
|
||||
needs: ["dependency-source-policy"]
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/package-lock.json
|
||||
paths:
|
||||
- frontend/.npm/
|
||||
before_script:
|
||||
- npm config set registry "$INTERNAL_NPM_REGISTRY"
|
||||
script:
|
||||
- cd frontend
|
||||
- npm ci --cache .npm --prefer-offline
|
||||
- npm run build
|
||||
artifacts:
|
||||
paths:
|
||||
- frontend/dist/
|
||||
expire_in: 7 days
|
||||
|
||||
python-dependency-audit:
|
||||
stage: security
|
||||
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
|
||||
needs: ["dependency-source-policy"]
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- backend/requirements.txt
|
||||
- ci/requirements-audit.txt
|
||||
paths:
|
||||
- .cache/pip
|
||||
variables:
|
||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
||||
before_script:
|
||||
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
|
||||
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r ci/requirements-audit.txt
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- pip-audit --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt --format json --output reports/pip-audit.json
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- reports/pip-audit.json
|
||||
expire_in: 30 days
|
||||
|
||||
container-build:
|
||||
stage: build
|
||||
image: "$INTERNAL_CONTAINER_PROXY/docker:27-cli"
|
||||
needs: ["backend-tests", "frontend-build", "python-dependency-audit"]
|
||||
services:
|
||||
- name: "$INTERNAL_CONTAINER_PROXY/docker:27-dind"
|
||||
variables:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
script:
|
||||
- docker build --pull --build-arg "PYTHON_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/python:3.11-slim" --build-arg "PIP_INDEX_URL=$INTERNAL_PYPI_URL" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-backend:$CI_COMMIT_SHA" backend
|
||||
- docker build --pull --build-arg "NODE_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/node:20-alpine" --build-arg "NGINX_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/nginx:alpine" --build-arg "NPM_CONFIG_REGISTRY=$INTERNAL_NPM_REGISTRY" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-frontend:$CI_COMMIT_SHA" frontend
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
|
||||
@@ -1,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 API(setup script)
|
||||
- 组件使用 PascalCase 命名
|
||||
- 使用 TypeScript 类型检查
|
||||
- 遵循 Vue 官方风格指南
|
||||
- 使用 ESLint + Prettier 格式化
|
||||
|
||||
#### Git 提交规范
|
||||
使用 Conventional Commits 格式:
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
类型:feat, fix, docs, style, refactor, test, chore
|
||||
示例:feat(device): 添加设备导入功能
|
||||
```
|
||||
|
||||
### 架构规则
|
||||
|
||||
#### 后端架构
|
||||
- **分层架构**:API → Service → Model
|
||||
- **API 层**:仅处理请求/响应,调用 Service
|
||||
- **Service 层**:业务逻辑,不直接操作数据库
|
||||
- **Model 层**:SQLAlchemy 模型定义
|
||||
- **异步任务**:耗时操作使用 Celery
|
||||
|
||||
#### 前端架构
|
||||
- **组件分类**:
|
||||
- `components/common/`:通用组件
|
||||
- `components/business/`:业务组件
|
||||
- `views/`:页面组件
|
||||
- **状态管理**:使用 Pinia stores
|
||||
- **API 调用**:统一在 `api/` 目录封装
|
||||
|
||||
### 安全规则
|
||||
|
||||
#### 数据安全
|
||||
- SSH 密码必须加密存储(使用 Fernet 加密)
|
||||
- 敏感信息通过环境变量配置
|
||||
- 数据库连接使用 SSL
|
||||
- 定期备份数据(90天历史记录)
|
||||
|
||||
#### 应用安全
|
||||
- 所有 API 端点需要认证(除登录接口)
|
||||
- 实现 CSRF 保护
|
||||
- 输入验证使用 Pydantic
|
||||
- SQL 注入防护(使用 ORM)
|
||||
- XSS 防护(前端转义)
|
||||
|
||||
#### 访问控制
|
||||
- 基于 RBAC 的权限控制
|
||||
- 数据级权限过滤(按区域/学校)
|
||||
- 操作审计日志记录
|
||||
- 设备信息变更需要审核
|
||||
|
||||
### 性能规则
|
||||
|
||||
#### 数据库优化
|
||||
- 为常用查询字段添加索引
|
||||
- 使用连接池(pool_size=20)
|
||||
- 避免 N+1 查询问题
|
||||
- 定期清理历史数据(保留90天)
|
||||
|
||||
#### 缓存策略
|
||||
- Redis 缓存热点数据(设备状态)
|
||||
- 缓存过期时间:30分钟
|
||||
- 手动刷新有5分钟冷却限制
|
||||
|
||||
#### 异步处理
|
||||
- SSH 状态检查使用 Celery 异步任务
|
||||
- 批量导入使用后台任务
|
||||
- 定时任务使用 Celery Beat
|
||||
|
||||
### 开发规则
|
||||
|
||||
#### 环境配置
|
||||
- 开发环境使用 `.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-worker,worker 不会注册新任务,消息会被丢弃并报 `KeyError`。
|
||||
|
||||
**规则**:新增任务模块后,backend 和 celery-worker 都必须重建:
|
||||
```bash
|
||||
docker compose build --no-cache backend celery-worker
|
||||
docker compose rm -f backend celery-worker
|
||||
docker compose up -d backend celery-worker
|
||||
```
|
||||
|
||||
验证任务是否注册:
|
||||
```bash
|
||||
docker compose exec celery-worker celery -A celery_worker.celery_app inspect registered
|
||||
```
|
||||
|
||||
### 区域管理员多区域过滤必须用 `.in_()` 而非 `==`
|
||||
|
||||
**问题**:`assigned_area` 字段存储逗号分隔的多个区域(如 `"城区,郊区"`),用 `==` 只能匹配整个字符串,导致多区域管理员只能看到第一个区域的数据。
|
||||
|
||||
**规则**:所有涉及 `assigned_area` 的过滤都必须先 split 再用 `.in_()`:
|
||||
```python
|
||||
areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()]
|
||||
query = query.filter(Model.region.in_(areas))
|
||||
```
|
||||
|
||||
### Vite dev server 通过反向代理访问需配置 allowedHosts
|
||||
|
||||
**问题**:通过域名反向代理访问 Vite dev server 时,会报 `Blocked request. This host is not allowed`。
|
||||
|
||||
**解决**:在 `vite.config.js` 中设置:
|
||||
```js
|
||||
server: {
|
||||
allowedHosts: ['all', 'your-domain.com'],
|
||||
}
|
||||
```
|
||||
|
||||
### iOS PWA standalone 模式与 Safari 的 safe area 差异
|
||||
|
||||
**问题**:`env(safe-area-inset-top)` 在 Safari 浏览器中为 0(有地址栏占位),在 standalone 模式(添加到主屏幕)下为真实刘海高度(44-59px)。直接使用会导致 Safari 中布局正常但 standalone 中顶栏/弹窗重叠。
|
||||
|
||||
**规则**:所有 safe area 相关样式必须包在 `@media (display-mode: standalone)` 中,Safari 不受影响:
|
||||
```css
|
||||
@media (display-mode: standalone) {
|
||||
.mobile-topbar {
|
||||
height: calc(52px + env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ElMessage` 的 offset 通过 `src/utils/message.js` 封装统一处理,所有页面从该文件导入而非直接从 `element-plus` 导入。
|
||||
|
||||
### Docker healthcheck 镜像内无 curl
|
||||
|
||||
**问题**:backend 镜像基于 Python slim,没有 `curl`,healthcheck 用 `curl` 会导致所有依赖服务启动失败。
|
||||
|
||||
**规则**:healthcheck 使用 Python 内置模块:
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
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.
|
||||
-21
@@ -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行时可能会报错提示“找不到该配置”,直接忽略该报错即可,不影响后续命令执行。
|
||||
@@ -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日
|
||||
**维护人员**: 系统开发团队
|
||||
@@ -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
|
||||
- 后端 API:http://localhost:8000
|
||||
- API 文档:http://localhost:8000/docs
|
||||
|
||||
## 下一步开发
|
||||
参考 `开发计划.md` 继续第一阶段第2周任务
|
||||
|
||||
1. 定期检测还需要加入环路检测功能,并且将环路的状态记录到数据库中,在OLT界面上显示
|
||||
@@ -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 |
|
||||
| 认证 | Casdoor(OIDC) |
|
||||
| 部署 | Docker Compose · Nginx |
|
||||
|
||||
## 版本
|
||||
|
||||
当前版本:v0.8.0
|
||||
+30
-1
@@ -2,6 +2,8 @@
|
||||
APP_NAME=H3C-ONU-MS
|
||||
DEBUG=false
|
||||
SECRET_KEY=your-secret-key-change-this
|
||||
# 独立于 SECRET_KEY;使用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成
|
||||
CREDENTIAL_ENCRYPTION_KEY=
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
|
||||
@@ -16,7 +18,9 @@ CASDOOR_CLIENT_SECRET=your_client_secret
|
||||
CASDOOR_ORG_NAME=your_org
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
|
||||
CASDOOR_REDIRECT_URL=http://localhost:5173/callback
|
||||
CASDOOR_REDIRECT_URL=
|
||||
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||
CASDOOR_ISSUER=
|
||||
|
||||
# SSH配置
|
||||
SSH_TIMEOUT=30
|
||||
@@ -24,3 +28,28 @@ SSH_TIMEOUT=30
|
||||
# 任务配置
|
||||
CHECK_INTERVAL=1800
|
||||
MANUAL_COOLDOWN=300
|
||||
|
||||
# CORS & 前端
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:18002
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# NTP 同步
|
||||
NTP_OLD_SERVER=172.16.0.254
|
||||
NTP_NEW_SERVER=172.16.1.252
|
||||
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL=
|
||||
IMC_API_USERNAME=
|
||||
IMC_API_PASSWORD=
|
||||
IMC_API_VERIFY_SSL=true
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
|
||||
# 企业微信告警配置
|
||||
WECHAT_CORPID=
|
||||
WECHAT_CORPSECRET=
|
||||
WECHAT_AGENTID=
|
||||
WECHAT_TOKEN=
|
||||
WECHAT_ENCODING_AES_KEY=
|
||||
WECHAT_USE_PROXY=True
|
||||
WECHAT_PROXY_API_URL=
|
||||
|
||||
+5
-6
@@ -1,13 +1,12 @@
|
||||
FROM python:3.11-slim
|
||||
ARG PYTHON_BASE_IMAGE=python:3.11-slim
|
||||
FROM ${PYTHON_BASE_IMAGE}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 使用清华镜像源
|
||||
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
|
||||
ARG PIP_INDEX_URL
|
||||
RUN if [ -n "$PIP_INDEX_URL" ]; then pip config set global.index-url "$PIP_INDEX_URL"; fi \
|
||||
&& pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.10.0
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add latitude and longitude columns to onu_devices
|
||||
|
||||
Revision ID: i9j0k1l2m3n4
|
||||
Revises: h8i9j0k1l2m3
|
||||
Create Date: 2026-06-02 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'i9j0k1l2m3n4'
|
||||
down_revision: Union[str, None] = 'h8i9j0k1l2m3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('onu_devices', sa.Column('latitude', sa.Float(), nullable=True))
|
||||
op.add_column('onu_devices', sa.Column('longitude', sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('onu_devices', 'longitude')
|
||||
op.drop_column('onu_devices', 'latitude')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add composite indexes for performance
|
||||
|
||||
Revision ID: j0k1l2m3n4o5
|
||||
Revises: i9j0k1l2m3n4
|
||||
Create Date: 2026-06-02 15:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
|
||||
revision: str = 'j0k1l2m3n4o5'
|
||||
down_revision: Union[str, None] = 'i9j0k1l2m3n4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# FK 索引 — 每次状态检查都要按 OLT 查询设备
|
||||
op.create_index('ix_onu_devices_olt_id', 'onu_devices', ['olt_id'])
|
||||
# 复合索引 — Dashboard 按区域+学校聚合
|
||||
op.create_index('ix_onu_devices_region_school', 'onu_devices', ['region', 'school_name'])
|
||||
# FK 索引 — DeviceStatusHistory 按设备查最新状态(最频繁的查询)
|
||||
op.create_index('ix_device_status_history_onu_device_id_checked', 'device_status_history', ['onu_device_id', 'checked_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_device_status_history_onu_device_id_checked', table_name='device_status_history')
|
||||
op.drop_index('ix_onu_devices_region_school', table_name='onu_devices')
|
||||
op.drop_index('ix_onu_devices_olt_id', table_name='onu_devices')
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add tags column to onu_devices
|
||||
|
||||
Revision ID: k0l1m2n3o4p5
|
||||
Revises: j0k1l2m3n4o5
|
||||
Create Date: 2026-06-03 10:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'k0l1m2n3o4p5'
|
||||
down_revision: Union[str, None] = 'j0k1l2m3n4o5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('onu_devices', sa.Column('tags', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('onu_devices', 'tags')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add optical_power_history table
|
||||
|
||||
Revision ID: l1m2n3o4p5q6
|
||||
Revises: k0l1m2n3o4p5
|
||||
Create Date: 2026-06-03 11:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'l1m2n3o4p5q6'
|
||||
down_revision: Union[str, None] = 'k0l1m2n3o4p5'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('optical_power_history',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('onu_device_id', sa.BigInteger(), sa.ForeignKey('onu_devices.id'), nullable=False),
|
||||
sa.Column('power_in', sa.String(20), nullable=True),
|
||||
sa.Column('power_out', sa.String(20), nullable=True),
|
||||
sa.Column('recorded_at', sa.TIMESTAMP(), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_optical_power_history_id', 'optical_power_history', ['id'])
|
||||
op.create_index('ix_optical_power_history_onu_device_id', 'optical_power_history', ['onu_device_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_optical_power_history_onu_device_id', table_name='optical_power_history')
|
||||
op.drop_index('ix_optical_power_history_id', table_name='optical_power_history')
|
||||
op.drop_table('optical_power_history')
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add display_name to users
|
||||
|
||||
Revision ID: m1n2o3p4q5r6
|
||||
Revises: l1m2n3o4p5q6
|
||||
Create Date: 2026-06-03 20:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'm1n2o3p4q5r6'
|
||||
down_revision: Union[str, None] = 'l1m2n3o4p5q6'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('display_name', sa.String(100), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'display_name')
|
||||
+39
-39
@@ -49,45 +49,6 @@ def get_audit_logs(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs/{log_id}")
|
||||
def get_audit_log_detail(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""获取单条审计日志详情"""
|
||||
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
||||
if not log:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
return _fmt(log, detail=True)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_audit_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""审计日志统计(最近N天)"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
rows = (
|
||||
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
||||
.filter(AuditLog.action_time >= since)
|
||||
.group_by(AuditLog.action_type, AuditLog.status)
|
||||
.all()
|
||||
)
|
||||
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
||||
by_type = {}
|
||||
for row in rows:
|
||||
if row.action_type not in by_type:
|
||||
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
||||
by_type[row.action_type][row.status] = row.cnt
|
||||
return {"total": total, "days": days, "by_type": by_type}
|
||||
|
||||
|
||||
@router.get("/logs/export/csv")
|
||||
def export_audit_logs(
|
||||
start_time: Optional[datetime] = Query(None),
|
||||
@@ -130,6 +91,45 @@ def export_audit_logs(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logs/{log_id}")
|
||||
def get_audit_log_detail(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""获取单条审计日志详情"""
|
||||
log = db.query(AuditLog).filter(AuditLog.id == log_id).first()
|
||||
if not log:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="日志不存在")
|
||||
return _fmt(log, detail=True)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_audit_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""审计日志统计(最近N天)"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
rows = (
|
||||
db.query(AuditLog.action_type, AuditLog.status, func.count().label("cnt"))
|
||||
.filter(AuditLog.action_time >= since)
|
||||
.group_by(AuditLog.action_type, AuditLog.status)
|
||||
.all()
|
||||
)
|
||||
total = db.query(func.count(AuditLog.id)).filter(AuditLog.action_time >= since).scalar()
|
||||
by_type = {}
|
||||
for row in rows:
|
||||
if row.action_type not in by_type:
|
||||
by_type[row.action_type] = {"success": 0, "failed": 0, "error": 0}
|
||||
by_type[row.action_type][row.status] = row.cnt
|
||||
return {"total": total, "days": days, "by_type": by_type}
|
||||
|
||||
|
||||
def _fmt(r: AuditLog, detail: bool = False) -> dict:
|
||||
base = {
|
||||
"id": r.id,
|
||||
|
||||
+52
-24
@@ -1,33 +1,48 @@
|
||||
"""认证 API"""
|
||||
import base64
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
import hmac
|
||||
import secrets
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
|
||||
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.errors import internal_error
|
||||
from app.core.security import create_access_token, verify_casdoor_token, verify_token
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token, UserInfo
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
OAUTH_STATE_COOKIE = "h3c_oauth_state"
|
||||
OAUTH_STATE_TTL_SECONDS = 300
|
||||
|
||||
|
||||
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))
|
||||
def _with_oauth_state(url: str, state: str) -> str:
|
||||
"""Replace the SDK-generated state with the browser-bound state value."""
|
||||
parts = urlsplit(url)
|
||||
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "state"]
|
||||
query.append(("state", state))
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login():
|
||||
def login(response: Response):
|
||||
"""获取 Casdoor 登录 URL"""
|
||||
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
|
||||
state = secrets.token_urlsafe(32)
|
||||
response.set_cookie(
|
||||
key=OAUTH_STATE_COOKIE,
|
||||
value=state,
|
||||
max_age=OAUTH_STATE_TTL_SECONDS,
|
||||
httponly=True,
|
||||
secure=not settings.DEBUG,
|
||||
samesite="lax",
|
||||
path="/api/auth",
|
||||
)
|
||||
login_url = casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)
|
||||
return {"url": _with_oauth_state(login_url, state)}
|
||||
|
||||
|
||||
class CallbackRequest(BaseModel):
|
||||
@@ -36,28 +51,44 @@ class CallbackRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/callback", response_model=Token)
|
||||
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||
def callback(
|
||||
body: CallbackRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Casdoor 登录回调"""
|
||||
try:
|
||||
expected_state = request.cookies.get(OAUTH_STATE_COOKIE)
|
||||
response.delete_cookie(OAUTH_STATE_COOKIE, path="/api/auth")
|
||||
if not expected_state or not hmac.compare_digest(body.state, expected_state):
|
||||
raise HTTPException(status_code=400, detail="登录状态校验失败,请重新登录")
|
||||
|
||||
token_response = casdoor_sdk.get_oauth_token(code=body.code)
|
||||
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
|
||||
identity_token = token_response.get("id_token") if isinstance(token_response, dict) else None
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
|
||||
|
||||
casdoor_user = decode_jwt_payload(access_token)
|
||||
casdoor_user = verify_casdoor_token(identity_token or access_token)
|
||||
|
||||
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
|
||||
if not user:
|
||||
user = User(
|
||||
casdoor_id=casdoor_user["sub"],
|
||||
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
|
||||
username=casdoor_user.get("preferred_username") or casdoor_user.get("name", ""),
|
||||
display_name=casdoor_user.get("displayName") or casdoor_user.get("name", ""),
|
||||
email=casdoor_user.get("email"),
|
||||
role="user"
|
||||
)
|
||||
db.add(user)
|
||||
else:
|
||||
# 每次登录同步 Casdoor 信息(姓名、邮箱等可能更新)
|
||||
user.display_name = casdoor_user.get("displayName") or casdoor_user.get("name", user.display_name or "")
|
||||
user.email = casdoor_user.get("email", user.email)
|
||||
|
||||
user.last_login = datetime.utcnow()
|
||||
db.commit()
|
||||
@@ -71,20 +102,17 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
|
||||
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))
|
||||
raise internal_error("Casdoor login callback", e)
|
||||
|
||||
|
||||
@router.get("/permissions")
|
||||
def get_my_permissions(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户的权限码列表"""
|
||||
from app.middleware.permission_middleware import get_role_permissions
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
@@ -97,11 +125,11 @@ def get_my_permissions(
|
||||
|
||||
@router.get("/profile")
|
||||
def get_profile(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"""状态检查 API"""
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from celery.result import AsyncResult
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from app.tasks.check_tasks import check_all_devices
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import get_db
|
||||
from app.core.errors import internal_error
|
||||
from app.services.check_service import CheckService
|
||||
from app.middleware.permission_middleware import require_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/check", tags=["状态检查"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
class CheckResult(BaseModel):
|
||||
@@ -31,14 +35,14 @@ class CheckError(BaseModel):
|
||||
|
||||
|
||||
@router.post("/status")
|
||||
def trigger_check(_: dict = Depends(require_permission('device.check'))):
|
||||
@limiter.limit("3/minute")
|
||||
def trigger_check(request: Request, _: dict = Depends(require_permission('device.check'))):
|
||||
"""手动触发状态检查"""
|
||||
try:
|
||||
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)}")
|
||||
raise internal_error("Trigger device status check", e)
|
||||
|
||||
|
||||
@router.get("/status/{task_id}")
|
||||
@@ -80,7 +84,7 @@ def scan_olt(
|
||||
result = asyncio.run(service.scan_olt(olt_id))
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise internal_error("Scan OLT", e)
|
||||
|
||||
|
||||
@router.post("/discover/{olt_id}")
|
||||
@@ -95,4 +99,4 @@ def discover_olt(
|
||||
result = service.scan_and_discover(olt_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise internal_error("Discover OLT devices", e)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""设备管理 API"""
|
||||
import csv
|
||||
import io
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import asc, desc, distinct, or_
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.core.database import get_db
|
||||
from app.core.errors import internal_error
|
||||
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
|
||||
@@ -20,6 +24,7 @@ def get_devices(
|
||||
school_name: str = None,
|
||||
keyword: str = None,
|
||||
status: str = None,
|
||||
tag: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
@@ -68,6 +73,8 @@ def get_devices(
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if school_name:
|
||||
query = query.filter(ONUDevice.school_name.contains(school_name))
|
||||
if tag:
|
||||
query = query.filter(ONUDevice.tags.contains(tag))
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
@@ -332,6 +339,55 @@ def export_replacements(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def get_all_tags(db: Session = Depends(get_db)):
|
||||
"""获取所有不重复的设备标签"""
|
||||
from sqlalchemy import func as _func
|
||||
rows = db.query(ONUDevice.tags).filter(
|
||||
ONUDevice.tags.isnot(None), ONUDevice.tags != ''
|
||||
).all()
|
||||
tags = set()
|
||||
for (tag_str,) in rows:
|
||||
for t in tag_str.split(','):
|
||||
t = t.strip()
|
||||
if t:
|
||||
tags.add(t)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
def export_devices_csv(
|
||||
region: Optional[str] = Query(None),
|
||||
school_name: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""导出设备列表为 CSV"""
|
||||
from sqlalchemy import func as _func
|
||||
|
||||
query = db.query(ONUDevice)
|
||||
if region:
|
||||
query = query.filter(ONUDevice.region == region)
|
||||
if school_name:
|
||||
query = query.filter(ONUDevice.school_name == school_name)
|
||||
devices = query.order_by(ONUDevice.region, ONUDevice.school_name).all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["MAC地址", "区域", "学校", "楼宇", "场所类型", "房间号", "端口", "型号", "LOID", "距离(m)", "备注"])
|
||||
for d in devices:
|
||||
writer.writerow([d.mac_address, d.region or "", d.school_name or "", d.building or "",
|
||||
d.place_type or "", d.room_number or "", d.port_id or "", d.model or "",
|
||||
d.loid or "", d.distance_m or "", d.notes or ""])
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=onu_devices.csv"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=ONUDeviceResponse)
|
||||
def get_device(
|
||||
device_id: int,
|
||||
@@ -384,7 +440,7 @@ def refresh_device_status(
|
||||
result = service.check_single_device(device_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise internal_error("Refresh device status", e)
|
||||
|
||||
|
||||
|
||||
@@ -395,6 +451,7 @@ class DeviceUpdate(BaseModel):
|
||||
room_number: Optional[str] = None
|
||||
place_type: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceReplaceRequest(BaseModel):
|
||||
@@ -430,6 +487,7 @@ def update_device(
|
||||
device.room_number = body.room_number or None
|
||||
device.place_type = body.place_type or None
|
||||
device.notes = body.notes or None
|
||||
device.tags = body.tags or None
|
||||
|
||||
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
|
||||
from app.models.device import NewDevice
|
||||
@@ -584,7 +642,7 @@ def reboot_device(
|
||||
result = IMCService().reboot_onu(device.mac_address)
|
||||
return RebootResponse(**result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
||||
raise internal_error("Reboot ONU", e)
|
||||
|
||||
|
||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||
@@ -613,6 +671,18 @@ def get_device_optical_power(
|
||||
data = IMCService().get_optical_power(device.mac_address)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||
# 记录光功率历史
|
||||
try:
|
||||
from app.models.device import OpticalPowerHistory
|
||||
db.add(OpticalPowerHistory(
|
||||
onu_device_id=device_id,
|
||||
power_in=data.get("powerIn"),
|
||||
power_out=data.get("powerOut"),
|
||||
))
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return OpticalPowerResponse(
|
||||
power_in=data.get("powerIn"),
|
||||
power_out=data.get("powerOut"),
|
||||
@@ -625,7 +695,7 @@ def get_device_optical_power(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||
raise internal_error("Get optical power", e)
|
||||
|
||||
|
||||
@router.get("/{device_id}/onu-events")
|
||||
@@ -648,9 +718,8 @@ def get_onu_events(
|
||||
raise HTTPException(status_code=404, detail="关联的 OLT 不存在")
|
||||
|
||||
from app.services.ssh_service import SSHService
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
events = ssh.get_onu_events(device.port_id)
|
||||
return {
|
||||
"interface": f"Onu{device.port_id}",
|
||||
@@ -658,6 +727,27 @@ def get_onu_events(
|
||||
"events": events,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
raise internal_error("Get ONU events", e)
|
||||
|
||||
|
||||
@router.get("/{device_id}/optical-power-history")
|
||||
def get_optical_power_history(
|
||||
device_id: int,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取设备光功率历史记录"""
|
||||
from app.models.device import OpticalPowerHistory
|
||||
rows = (
|
||||
db.query(OpticalPowerHistory)
|
||||
.filter(OpticalPowerHistory.onu_device_id == device_id)
|
||||
.order_by(OpticalPowerHistory.recorded_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"power_in": r.power_in, "power_out": r.power_out,
|
||||
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Celery 任务监控 API"""
|
||||
import time
|
||||
import redis as redis_lib
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.config import settings
|
||||
from app.middleware.permission_middleware import require_permission
|
||||
|
||||
router = APIRouter(prefix="/api/monitor", tags=["任务监控"])
|
||||
|
||||
|
||||
def _get_redis():
|
||||
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def get_task_status(_: dict = Depends(require_permission('*'))):
|
||||
"""获取 Celery 任务状态概览"""
|
||||
try:
|
||||
insp = celery_app.control.inspect()
|
||||
active = insp.active() or {}
|
||||
scheduled = insp.scheduled() or {}
|
||||
reserved = insp.reserved() or {}
|
||||
|
||||
r = _get_redis()
|
||||
last_run = r.get("check_all_devices:last_run")
|
||||
is_running = bool(r.get("check_all_devices:running"))
|
||||
interval_str = r.get("system:check_interval_seconds")
|
||||
|
||||
interval = int(interval_str) if interval_str else 1800
|
||||
next_run = None
|
||||
if last_run and not is_running:
|
||||
next_run = float(last_run) + interval
|
||||
|
||||
return {
|
||||
"workers": list(active.keys()),
|
||||
"active_count": sum(len(v) for v in active.values()),
|
||||
"scheduled_count": sum(len(v) for v in scheduled.values()),
|
||||
"check_running": is_running,
|
||||
"last_check": float(last_run) if last_run else None,
|
||||
"next_check": next_run,
|
||||
"check_interval_seconds": interval,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
+17
-28
@@ -4,8 +4,11 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import distinct
|
||||
from pydantic import BaseModel
|
||||
from app.core.database import get_db
|
||||
from app.core.errors import internal_error
|
||||
from app.core.config import settings
|
||||
from app.middleware.permission_middleware import require_permission
|
||||
from app.models.device import OLTDevice
|
||||
from app.schemas.olt import serialize_olt
|
||||
import pandas as pd
|
||||
import io
|
||||
|
||||
@@ -63,7 +66,7 @@ def get_devices(
|
||||
q = q.filter(OLTDevice.region.in_(areas))
|
||||
else:
|
||||
return []
|
||||
return q.all()
|
||||
return [serialize_olt(device) for device in q.all()]
|
||||
|
||||
|
||||
@router.post("/devices")
|
||||
@@ -147,8 +150,8 @@ async def import_devices(
|
||||
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)}")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件格式")
|
||||
|
||||
required_cols = ['IP地址', '用户名', '密码']
|
||||
missing = [c for c in required_cols if c not in df.columns]
|
||||
@@ -262,14 +265,11 @@ def clear_onu_port(
|
||||
if body.port_id not in port_ids:
|
||||
raise HTTPException(status_code=400, detail="端口不在重复记录中")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.clear_onu_port(body.port_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
|
||||
finally:
|
||||
ssh.close()
|
||||
raise internal_error("Clear ONU port", e)
|
||||
|
||||
# 从 ports 列表移除已清除的端口
|
||||
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
|
||||
@@ -450,9 +450,8 @@ def loopback_detection(
|
||||
onu_map = {(o.olt_id, o.port_id): o for o in all_onus if o.port_id}
|
||||
|
||||
def check_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
detection = ssh.detect_loopback()
|
||||
except Exception as e:
|
||||
return {
|
||||
@@ -463,8 +462,6 @@ def loopback_detection(
|
||||
"has_loop": False,
|
||||
"loop_interfaces": [],
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
loop_interfaces = []
|
||||
for iface in detection.get("interfaces", []):
|
||||
@@ -487,6 +484,7 @@ def loopback_detection(
|
||||
"has_loop": detection["has_loop"],
|
||||
"loop_interfaces": loop_interfaces,
|
||||
"error": None,
|
||||
"raw": detection.get("raw", ""),
|
||||
}
|
||||
|
||||
results_map = {}
|
||||
@@ -501,8 +499,8 @@ def loopback_detection(
|
||||
|
||||
|
||||
class SyncNTPRequest(BaseModel):
|
||||
old_server: str = "172.16.0.254"
|
||||
new_server: str = "172.16.1.252"
|
||||
old_server: str = settings.NTP_OLD_SERVER
|
||||
new_server: str = settings.NTP_NEW_SERVER
|
||||
|
||||
|
||||
@router.post("/sync-ntp")
|
||||
@@ -521,9 +519,8 @@ def sync_ntp(
|
||||
olts = [o for o in olts if o.region in areas] if areas else []
|
||||
|
||||
def sync_one(olt):
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.sync_ntp(body.old_server, body.new_server)
|
||||
return {
|
||||
"olt_ip": olt.ip_address,
|
||||
@@ -538,8 +535,6 @@ def sync_ntp(
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
results_map = {}
|
||||
with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor:
|
||||
@@ -573,15 +568,12 @@ def get_olt_ports(
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ports = ssh.get_olt_ports()
|
||||
return {"ports": ports}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
raise internal_error("Get OLT ports", e)
|
||||
|
||||
|
||||
@router.post("/devices/{olt_id}/ports/toggle")
|
||||
@@ -593,13 +585,10 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
|
||||
ssh.toggle_olt_port(port_name, body.action)
|
||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
raise internal_error("Toggle OLT port", e)
|
||||
|
||||
|
||||
@@ -98,3 +98,29 @@ def update_about(
|
||||
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
||||
db.commit()
|
||||
return {"key": "about_content", "value": content}
|
||||
|
||||
|
||||
@router.put("/webhook")
|
||||
def update_webhook(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""更新企业微信告警配置(仅管理员)"""
|
||||
configs = [
|
||||
("wechat_corpid", body.get("corpid", ""), "企业微信 CorpID"),
|
||||
("wechat_corpsecret", body.get("corpsecret", ""), "企业微信 CorpSecret"),
|
||||
("wechat_agentid", body.get("agentid", ""), "企业微信 AgentID"),
|
||||
]
|
||||
for key, value, desc in configs:
|
||||
setting = db.query(SystemSetting).filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=value, description=desc))
|
||||
|
||||
db.commit()
|
||||
return {"message": "企业微信配置已保存"}
|
||||
|
||||
|
||||
|
||||
|
||||
+177
-7
@@ -179,16 +179,14 @@ def get_trend(
|
||||
)
|
||||
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 = (
|
||||
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()
|
||||
)
|
||||
@@ -198,9 +196,9 @@ def get_trend(
|
||||
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)
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.one()
|
||||
)
|
||||
@@ -246,3 +244,175 @@ def get_trend(
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/olt-stats")
|
||||
def get_olt_stats(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取每台 OLT 下的设备在线率统计"""
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||
.join(latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at))
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
OLTDevice.id,
|
||||
OLTDevice.ip_address,
|
||||
OLTDevice.location,
|
||||
OLTDevice.region,
|
||||
func.count(ONUDevice.id).label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.outerjoin(ONUDevice, ONUDevice.olt_id == OLTDevice.id)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.group_by(OLTDevice.id)
|
||||
.order_by(OLTDevice.region, OLTDevice.location)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"olt_id": r.id,
|
||||
"name": r.location or r.ip_address,
|
||||
"ip": r.ip_address,
|
||||
"region": r.region or "未知",
|
||||
"total": int(r.total or 0),
|
||||
"online": int(r.online or 0),
|
||||
"offline": int(r.offline or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/offline-schools")
|
||||
def get_offline_schools(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取全部离线的学校列表"""
|
||||
_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||
)
|
||||
_status_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||
.join(_subq,
|
||||
(DeviceStatusHistory.onu_device_id == _subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == _subq.c.max_checked_at)).subquery()
|
||||
)
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.school_name, ONUDevice.region,
|
||||
func.count().label("total"),
|
||||
func.sum(case((_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
)
|
||||
.outerjoin(_status_subq, ONUDevice.id == _status_subq.c.onu_device_id)
|
||||
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||
.having(func.sum(case((_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"school_name": r.school_name or "未知", "region": r.region or "未知", "total": int(r.total or 0)}
|
||||
for r in rows if int(r.total or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
@router.get("/model-distribution")
|
||||
def get_model_distribution(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""统计 ONU 设备型号分布"""
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.model,
|
||||
func.count(ONUDevice.id).label("count"),
|
||||
)
|
||||
.filter(ONUDevice.model.isnot(None), ONUDevice.model != '')
|
||||
.group_by(ONUDevice.model)
|
||||
.order_by(func.count(ONUDevice.id).desc())
|
||||
.all()
|
||||
)
|
||||
unknown = db.query(func.count(ONUDevice.id)).filter(
|
||||
(ONUDevice.model.is_(None)) | (ONUDevice.model == '')
|
||||
).scalar() or 0
|
||||
|
||||
result = [{"model": r.model or "未知", "count": r.count} for r in rows]
|
||||
if unknown > 0:
|
||||
result.append({"model": "未知型号", "count": unknown})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/school-locations")
|
||||
def get_school_locations(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取各学校的聚合位置数据(用于地图展示)"""
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
DeviceStatusHistory.status
|
||||
)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.school_name,
|
||||
ONUDevice.region,
|
||||
ONUDevice.latitude,
|
||||
ONUDevice.longitude,
|
||||
func.count().label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.filter(ONUDevice.latitude.isnot(None))
|
||||
.filter(ONUDevice.longitude.isnot(None))
|
||||
.group_by(ONUDevice.school_name, ONUDevice.region, ONUDevice.latitude, ONUDevice.longitude)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"school_name": row.school_name or "未知",
|
||||
"region": row.region or "未知",
|
||||
"latitude": row.latitude,
|
||||
"longitude": row.longitude,
|
||||
"total": int(row.total or 0),
|
||||
"online": int(row.online or 0),
|
||||
"offline": int(row.offline or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def get_users(
|
||||
query = query.filter(User.role == role)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
User.username.contains(keyword) | User.email.contains(keyword)
|
||||
User.username.contains(keyword) | User.display_name.contains(keyword) | User.email.contains(keyword)
|
||||
)
|
||||
query = query.order_by(asc(User.created_at))
|
||||
total = query.count()
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""企业微信回调 API(URL验证 + 消息接收)"""
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from app.services.wechat_service import get_wechat_service
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
|
||||
|
||||
|
||||
@router.post("/menu/create")
|
||||
async def create_menu():
|
||||
"""创建/更新企业微信应用菜单"""
|
||||
svc = get_wechat_service()
|
||||
menu = {
|
||||
"button": [
|
||||
{
|
||||
"name": "设备查询",
|
||||
"sub_button": [
|
||||
{"type": "click", "name": "在线统计", "key": "online"},
|
||||
{"type": "click", "name": "全离线学校", "key": "offline_schools"},
|
||||
{"type": "click", "name": "MAC查询", "key": "status"},
|
||||
]
|
||||
},
|
||||
{"type": "click", "name": "帮助", "key": "help"},
|
||||
]
|
||||
}
|
||||
ok = svc.create_menu(menu)
|
||||
return {"success": ok}
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def wechat_callback_get(request: Request):
|
||||
"""企业微信 URL 验证(GET)"""
|
||||
params = request.query_params
|
||||
msg_signature = params.get("msg_signature", "")
|
||||
timestamp = params.get("timestamp", "")
|
||||
nonce = params.get("nonce", "")
|
||||
echostr = params.get("echostr", "")
|
||||
|
||||
svc = get_wechat_service()
|
||||
result = svc.verify_url(msg_signature, timestamp, nonce, echostr)
|
||||
if result:
|
||||
return Response(content=result, media_type="text/plain")
|
||||
return Response(content="验证失败", status_code=403)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def wechat_callback_post(request: Request):
|
||||
"""企业微信消息接收(POST)"""
|
||||
params = request.query_params
|
||||
msg_signature = params.get("msg_signature", "")
|
||||
timestamp = params.get("timestamp", "")
|
||||
nonce = params.get("nonce", "")
|
||||
|
||||
xml_data = await request.body()
|
||||
if not xml_data:
|
||||
return Response(content="", media_type="text/plain")
|
||||
|
||||
svc = get_wechat_service()
|
||||
msg = svc.parse_message(xml_data)
|
||||
if not msg:
|
||||
return Response(content="", media_type="text/plain")
|
||||
|
||||
msg_type = msg.get("MsgType", "")
|
||||
from_user = msg.get("FromUserName", "")
|
||||
|
||||
logger.info(f"收到企微消息: type={msg_type}, from={from_user}, content={msg.get('Content', '')}")
|
||||
|
||||
if msg_type == "text":
|
||||
content = msg.get("Content", "").strip()
|
||||
if content.lower() in ("online", "在线", "在线统计"):
|
||||
_handle_online_cmd(svc, from_user)
|
||||
elif content.lower() in ("全离线", "离线学校", "offline"):
|
||||
_handle_offline_schools_cmd(svc, from_user)
|
||||
elif content.startswith("#状态+") or content.startswith("#status+"):
|
||||
_handle_status_cmd(svc, from_user, content)
|
||||
elif content.lower() in ("help", "帮助", "#帮助", "#help"):
|
||||
_handle_help_cmd(svc, from_user)
|
||||
else:
|
||||
# 尝试作为 MAC 后缀查询
|
||||
_handle_status_cmd(svc, from_user, f"#状态+{content}")
|
||||
|
||||
elif msg_type == "event":
|
||||
event = msg.get("Event", "")
|
||||
event_key = msg.get("EventKey", "")
|
||||
if event == "click":
|
||||
if event_key == "online":
|
||||
_handle_online_cmd(svc, from_user)
|
||||
elif event_key == "offline_schools":
|
||||
_handle_offline_schools_cmd(svc, from_user)
|
||||
elif event_key == "help":
|
||||
_handle_help_cmd(svc, from_user)
|
||||
|
||||
return Response(content="", media_type="text/plain")
|
||||
|
||||
|
||||
# ── 命令处理 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _handle_online_cmd(svc, from_user: str):
|
||||
"""处理在线统计命令"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||
from sqlalchemy import func, case
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
latest_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||
.join(latest_subq, (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
|
||||
)
|
||||
total = db.query(func.count(ONUDevice.id)).scalar() or 0
|
||||
online = (
|
||||
db.query(func.count(ONUDevice.id))
|
||||
.join(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id, isouter=True)
|
||||
.filter(latest_status_subq.c.status == 'online').scalar() or 0
|
||||
)
|
||||
rate = (online / total * 100) if total > 0 else 0
|
||||
svc.send_text_message(
|
||||
f"📊 设备在线统计\n总设备数: {total}\n在线: {online}\n离线: {total - online}\n在线率: {rate:.1f}%",
|
||||
to_user=from_user
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"在线统计失败: {e}")
|
||||
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||
|
||||
|
||||
def _handle_status_cmd(svc, from_user: str, content: str):
|
||||
"""处理设备状态查询命令"""
|
||||
try:
|
||||
mac_suffix = content.split('+')[1].strip().upper()
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||
from sqlalchemy import func
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
devices = db.query(ONUDevice).filter(
|
||||
ONUDevice.mac_address.ilike(f"%{mac_suffix}")
|
||||
).limit(10).all()
|
||||
|
||||
if not devices:
|
||||
svc.send_text_message("未找到匹配的设备", to_user=from_user)
|
||||
return
|
||||
|
||||
lines = [f"🔍 找到 {len(devices)} 个设备(MAC 含 {mac_suffix}):", ""]
|
||||
for d in devices[:8]:
|
||||
# 查最新状态
|
||||
latest = (
|
||||
db.query(DeviceStatusHistory.status,
|
||||
func.max(DeviceStatusHistory.checked_at))
|
||||
.filter(DeviceStatusHistory.onu_device_id == d.id)
|
||||
.group_by(DeviceStatusHistory.status)
|
||||
.order_by(func.max(DeviceStatusHistory.checked_at).desc())
|
||||
.first()
|
||||
)
|
||||
status_text = latest[0] if latest else "未知"
|
||||
emoji = "🟢" if status_text == "online" else "🔴"
|
||||
school = d.school_name or "未知"
|
||||
lines.append(f"{emoji} {d.mac_address} | {school} | {d.region or ''}")
|
||||
|
||||
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"设备查询失败: {e}")
|
||||
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||
|
||||
|
||||
def _handle_offline_schools_cmd(svc, from_user: str):
|
||||
"""查询全离线学校"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||
from sqlalchemy import func, case
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
latest_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||
.join(latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
|
||||
)
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.school_name, ONUDevice.region,
|
||||
func.count().label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||
.having(func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not rows:
|
||||
svc.send_text_message("✅ 当前没有全离线的学校", to_user=from_user)
|
||||
return
|
||||
|
||||
lines = [f"🔴 全离线学校 ({len(rows)} 所):", ""]
|
||||
for r in rows:
|
||||
school = r.school_name or "未知"
|
||||
region = r.region or "未知"
|
||||
total = int(r.total or 0)
|
||||
if total > 0:
|
||||
lines.append(f"• {school}({region}): {total}台全离线")
|
||||
svc.send_text_message("\n".join(lines), to_user=from_user)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"全离线查询失败: {e}")
|
||||
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
|
||||
|
||||
|
||||
def _handle_help_cmd(svc, from_user: str):
|
||||
"""处理帮助命令"""
|
||||
svc.send_text_message(
|
||||
"📋 H3C ONU 管理助手\n\n"
|
||||
"🔍 设备查询:\n"
|
||||
"• 发送「在线」查看设备在线统计\n"
|
||||
"• 发送「全离线」查看全离线学校\n"
|
||||
"• 发送 MAC 地址后四位查询设备\n\n"
|
||||
"💡 发送「帮助」显示此信息\n"
|
||||
f"💻 完整功能: {settings.FRONTEND_URL}",
|
||||
to_user=from_user
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""WebSocket 实时推送"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["WebSocket"])
|
||||
|
||||
REDIS_CHANNEL = "h3c_onu:status_updates"
|
||||
_connected: set[WebSocket] = set()
|
||||
|
||||
|
||||
async def _redis_listener():
|
||||
"""监听 Redis pub/sub 并广播给所有 WebSocket 客户端"""
|
||||
try:
|
||||
r = aioredis.from_url(settings.REDIS_URL)
|
||||
pubsub = r.pubsub()
|
||||
await pubsub.subscribe(REDIS_CHANNEL)
|
||||
logger.info("WebSocket Redis 监听已启动")
|
||||
async for msg in pubsub.listen():
|
||||
if msg["type"] == "message":
|
||||
dead: set[WebSocket] = set()
|
||||
for ws in _connected:
|
||||
try:
|
||||
await ws.send_text(msg["data"].decode())
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
_connected -= dead
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 监听异常: {e}")
|
||||
|
||||
|
||||
@router.websocket("/ws/dashboard")
|
||||
async def dashboard_ws(ws: WebSocket):
|
||||
await ws.accept()
|
||||
_connected.add(ws)
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text() # keep-alive, 忽略客户端消息
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
_connected.discard(ws)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""应用配置"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# 项目根目录(config.py 位于 backend/app/core/,parent.parent.parent 即 backend/)
|
||||
@@ -11,6 +12,7 @@ class Settings(BaseSettings):
|
||||
APP_NAME: str = "H3C-ONU-MS"
|
||||
DEBUG: bool = False
|
||||
SECRET_KEY: str
|
||||
CREDENTIAL_ENCRYPTION_KEY: str
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str
|
||||
@@ -21,37 +23,67 @@ class Settings(BaseSettings):
|
||||
CASDOOR_ORG_NAME: str
|
||||
CASDOOR_APP_NAME: str
|
||||
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
|
||||
CASDOOR_REDIRECT_URL: str = "http://localhost:5173/callback"
|
||||
CASDOOR_REDIRECT_URL: str = ""
|
||||
CASDOOR_ISSUER: str = "" # 为空时使用 CASDOOR_ENDPOINT
|
||||
|
||||
SSH_TIMEOUT: int = 30
|
||||
CHECK_INTERVAL: int = 1800
|
||||
MANUAL_COOLDOWN: int = 300
|
||||
|
||||
# CORS & 前端
|
||||
CORS_ORIGINS: str = "" # 逗号分隔
|
||||
FRONTEND_URL: str = "https://onu.dhdx.fun"
|
||||
|
||||
# NTP 同步配置
|
||||
NTP_OLD_SERVER: str = "172.16.0.254"
|
||||
NTP_NEW_SERVER: str = "172.16.1.252"
|
||||
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL: str = ""
|
||||
|
||||
# 企业微信应用消息 API(用于发送告警)
|
||||
WECHAT_CORPID: str = ""
|
||||
WECHAT_CORPSECRET: str = ""
|
||||
WECHAT_AGENTID: str = ""
|
||||
WECHAT_TOKEN: str = ""
|
||||
WECHAT_ENCODING_AES_KEY: str = ""
|
||||
WECHAT_USE_PROXY: bool = True
|
||||
WECHAT_PROXY_API_URL: str = ""
|
||||
IMC_API_USERNAME: str = ""
|
||||
IMC_API_PASSWORD: str = ""
|
||||
IMC_API_VERIFY_SSL: bool = False
|
||||
IMC_API_VERIFY_SSL: bool = True
|
||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||
IMC_READ_TIMEOUT: float = 20.0
|
||||
|
||||
class Config:
|
||||
env_file = str(PROJECT_ROOT / ".env")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_insecure_imc_tls_in_production(self):
|
||||
"""Prevent production deployments from silently disabling TLS verification."""
|
||||
if self.IMC_API_URL and not self.DEBUG and not self.IMC_API_VERIFY_SSL:
|
||||
raise ValueError("IMC_API_VERIFY_SSL must be true when DEBUG is false")
|
||||
return self
|
||||
|
||||
@property
|
||||
def casdoor_cert_content(self) -> str:
|
||||
"""读取证书文件内容或直接返回证书字符串"""
|
||||
cert = self.CASDOOR_CERTIFICATE
|
||||
if not cert:
|
||||
return ""
|
||||
if "-----BEGIN" in cert:
|
||||
return cert
|
||||
cert_path = Path(cert)
|
||||
if cert_path.is_absolute():
|
||||
path = cert_path
|
||||
else:
|
||||
# 相对路径基于项目根目录解析
|
||||
path = PROJECT_ROOT / cert
|
||||
try:
|
||||
if path.is_file():
|
||||
return path.read_text()
|
||||
except OSError:
|
||||
pass
|
||||
return cert # 直接是 PEM 内容
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Encryption at rest for device credentials."""
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from sqlalchemy.types import Text, TypeDecorator
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
CREDENTIAL_PREFIX = "enc:v1:"
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
key = settings.CREDENTIAL_ENCRYPTION_KEY.strip()
|
||||
if not key:
|
||||
raise RuntimeError("Credential encryption key is not configured")
|
||||
try:
|
||||
return Fernet(key.encode())
|
||||
except (TypeError, ValueError) as error:
|
||||
raise RuntimeError("Credential encryption key is invalid") from error
|
||||
|
||||
|
||||
def encrypt_credential(value: str) -> str:
|
||||
"""Encrypt a plaintext credential with the deployment-provided key."""
|
||||
if value.startswith(CREDENTIAL_PREFIX):
|
||||
return value
|
||||
return CREDENTIAL_PREFIX + _fernet().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_credential(value: str) -> str:
|
||||
"""Decrypt an encrypted credential; retain legacy plaintext only for migration."""
|
||||
if not value.startswith(CREDENTIAL_PREFIX):
|
||||
return value
|
||||
try:
|
||||
return _fernet().decrypt(value[len(CREDENTIAL_PREFIX):].encode()).decode()
|
||||
except InvalidToken as error:
|
||||
raise RuntimeError("Credential decryption failed") from error
|
||||
|
||||
|
||||
class EncryptedCredential(TypeDecorator):
|
||||
"""SQLAlchemy column type that stores credentials encrypted and reads plaintext."""
|
||||
|
||||
impl = Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return encrypt_credential(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return decrypt_credential(value)
|
||||
@@ -4,7 +4,14 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=20,
|
||||
max_overflow=40,
|
||||
pool_recycle=3600,
|
||||
pool_timeout=30,
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Safe error responses for API trust boundaries."""
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def internal_error(context: str, error: Exception) -> HTTPException:
|
||||
"""Log only a non-sensitive error classification and return a safe response."""
|
||||
error_id = uuid4().hex
|
||||
logger.error(
|
||||
"%s failed [error_id=%s, error_type=%s]",
|
||||
context,
|
||||
error_id,
|
||||
type(error).__name__,
|
||||
)
|
||||
return HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "服务器内部错误,请联系管理员并提供错误编号",
|
||||
"error_id": error_id,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Logging helpers that prevent sensitive values from reaching application logs."""
|
||||
import logging
|
||||
import re
|
||||
|
||||
_SENSITIVE_KEY = r"(?:password|passwd|secret|token|authorization|credential|client_secret|corpsecret|code)"
|
||||
_KEY_VALUE_PATTERN = re.compile(
|
||||
rf"(?i)([\"']?{_SENSITIVE_KEY}[\"']?\s*[:=]\s*)([\"']?)([^\s,;\]\}}\"']+)([\"']?)"
|
||||
)
|
||||
_BEARER_PATTERN = re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;]+")
|
||||
_QUERY_PATTERN = re.compile(rf"(?i)([?&]{_SENSITIVE_KEY}=)[^&\s]+")
|
||||
|
||||
|
||||
def redact_log_message(message: str) -> str:
|
||||
"""Mask common secret formats while keeping enough context for operations."""
|
||||
masked = _BEARER_PATTERN.sub(r"\1***", message)
|
||||
masked = _QUERY_PATTERN.sub(r"\1***", masked)
|
||||
return _KEY_VALUE_PATTERN.sub(r"\1\2***\4", masked)
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""Redact sensitive values after interpolation and before formatter output."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.msg = redact_log_message(record.getMessage())
|
||||
record.args = ()
|
||||
return True
|
||||
@@ -1,6 +1,8 @@
|
||||
"""JWT 安全配置"""
|
||||
from datetime import datetime, timedelta
|
||||
from jose import JWTError, jwt
|
||||
|
||||
import jwt as pyjwt
|
||||
from jose import JWTError, jwt as jose_jwt
|
||||
from app.core.config import settings
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
@@ -11,12 +13,37 @@ 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)
|
||||
return jose_jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
payload = jose_jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_casdoor_token(token: str) -> dict:
|
||||
"""Verify a Casdoor-issued JWT before using any identity claims."""
|
||||
certificate = settings.casdoor_cert_content.strip()
|
||||
if not certificate:
|
||||
raise ValueError("Casdoor certificate is not configured")
|
||||
|
||||
issuer = (settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT).rstrip("/")
|
||||
if not issuer:
|
||||
raise ValueError("Casdoor issuer is not configured")
|
||||
|
||||
header = pyjwt.get_unverified_header(token)
|
||||
algorithm = header.get("alg")
|
||||
if algorithm not in {"RS256", "RS384", "RS512"}:
|
||||
raise ValueError(f"Unsupported Casdoor token algorithm: {algorithm}")
|
||||
|
||||
return pyjwt.decode(
|
||||
token,
|
||||
certificate,
|
||||
algorithms=[algorithm],
|
||||
audience=settings.CASDOOR_CLIENT_ID,
|
||||
issuer=issuer,
|
||||
options={"require": ["exp", "iat", "sub"]},
|
||||
)
|
||||
|
||||
+92
-5
@@ -1,15 +1,59 @@
|
||||
"""FastAPI 主应用"""
|
||||
from fastapi import FastAPI
|
||||
import os
|
||||
import logging
|
||||
from pythonjsonlogger import jsonlogger
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from app.core.config import settings
|
||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
|
||||
from app.core.logging_utils import SensitiveDataFilter
|
||||
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
|
||||
from app.middleware.audit_middleware import AuditMiddleware
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||
# 结构化 JSON 日志
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
|
||||
_handler.addFilter(SensitiveDataFilter())
|
||||
logging.getLogger().handlers = [_handler]
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
logging.getLogger('uvicorn.access').handlers = [_handler]
|
||||
|
||||
# 请求体大小限制中间件
|
||||
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.headers.get("content-length"):
|
||||
if int(request.headers["content-length"]) > MAX_BODY_SIZE:
|
||||
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
|
||||
return await call_next(request)
|
||||
|
||||
# CORS 白名单 — 支持通过环境变量 CORS_ORIGINS 覆盖(逗号分隔)
|
||||
CORS_ORIGINS_DEFAULT = "http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun"
|
||||
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", CORS_ORIGINS_DEFAULT).split(",") if o.strip()]
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""读取 X-Forwarded-For 首字段作为真实客户端 IP"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
limiter = Limiter(key_func=get_client_ip, default_limits=["120/minute"])
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
app.add_middleware(RequestSizeLimitMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -28,8 +72,51 @@ app.include_router(roles.router)
|
||||
app.include_router(inventory.router)
|
||||
app.include_router(settings_api.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(wechat.router)
|
||||
app.include_router(ws.router)
|
||||
app.include_router(monitor.router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
import asyncio
|
||||
asyncio.create_task(ws._redis_listener())
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
"""读取项目版本号"""
|
||||
version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")]
|
||||
for p in version_paths:
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
return f.read().strip()
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()}
|
||||
try:
|
||||
import redis
|
||||
import psycopg2
|
||||
r = redis.from_url(settings.REDIS_URL, socket_timeout=2)
|
||||
r.ping()
|
||||
except Exception:
|
||||
status["redis"] = "error"
|
||||
status["status"] = "degraded"
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
from app.core.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
db.execute(text("SELECT 1"))
|
||||
db.close()
|
||||
except Exception:
|
||||
status["db"] = "error"
|
||||
status["status"] = "degraded"
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def api_health_check():
|
||||
"""API 路径下的健康检查(用于前端通过 /api/ 代理访问)"""
|
||||
return health_check()
|
||||
|
||||
@@ -26,6 +26,21 @@ _LOG_GET_PATHS = {
|
||||
"/api/auth/profile",
|
||||
}
|
||||
|
||||
_SENSITIVE_PARAM_MARKERS = ("password", "passwd", "secret", "token", "authorization", "credential", "code")
|
||||
|
||||
|
||||
def sanitize_audit_params(value):
|
||||
"""Recursively redact sensitive request parameters before asynchronous logging."""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: "***" if any(marker in key.lower() for marker in _SENSITIVE_PARAM_MARKERS)
|
||||
else sanitize_audit_params(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [sanitize_audit_params(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _should_log(method: str, path: str) -> bool:
|
||||
for skip in _SKIP_PATHS:
|
||||
@@ -62,11 +77,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
|
||||
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] = "***"
|
||||
request_params = sanitize_audit_params(request_params)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
|
||||
import json
|
||||
import logging
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import redis
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -52,14 +55,19 @@ def invalidate_role_cache(role: str) -> None:
|
||||
def require_permission(permission: str):
|
||||
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
|
||||
def dependency(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if not authorization:
|
||||
logger.warning("auth rejected: 缺少 Authorization 头 (permission=%s)", permission)
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
if not authorization.startswith("Bearer "):
|
||||
logger.warning("auth rejected: Authorization 格式错误 (permission=%s): %.50s", permission, authorization)
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
if not payload:
|
||||
logger.warning("auth rejected: token 验证失败 (permission=%s)", permission)
|
||||
raise HTTPException(status_code=401, detail="令牌无效或已过期")
|
||||
|
||||
role = payload.get('role', 'user')
|
||||
@@ -83,11 +91,11 @@ def require_permission(permission: str):
|
||||
|
||||
|
||||
def get_current_user(
|
||||
authorization: str = Header(..., alias="Authorization"),
|
||||
authorization: str = Header(None, alias="Authorization"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""仅验证登录状态,不检查具体权限"""
|
||||
if not authorization.startswith("Bearer "):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="未授权")
|
||||
token = authorization[7:]
|
||||
payload = verify_token(token)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""设备数据模型"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTAMP, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
from app.core.credentials import EncryptedCredential
|
||||
|
||||
|
||||
class OLTDevice(Base):
|
||||
@@ -11,7 +12,7 @@ class OLTDevice(Base):
|
||||
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)
|
||||
password = Column(EncryptedCredential(), nullable=False)
|
||||
slot_command = Column(String(50), nullable=False)
|
||||
region = Column(String(100), index=True)
|
||||
location = Column(String(200))
|
||||
@@ -39,6 +40,9 @@ class ONUDevice(Base):
|
||||
place_type = Column(String(50)) # 场所类型
|
||||
room_number = Column(String(50))
|
||||
notes = Column(Text) # 备注
|
||||
tags = Column(Text) # 标签(逗号分隔),如 "重点设备,考试用"
|
||||
latitude = Column(Float, nullable=True) # 纬度
|
||||
longitude = Column(Float, nullable=True) # 经度
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -95,6 +99,17 @@ class NewDevice(Base):
|
||||
discovered_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
|
||||
class OpticalPowerHistory(Base):
|
||||
"""光功率历史记录"""
|
||||
__tablename__ = "optical_power_history"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
|
||||
power_in = Column(String(20)) # 接收光功率 dBm
|
||||
power_out = Column(String(20)) # 发送光功率 dBm
|
||||
recorded_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
|
||||
|
||||
|
||||
class DeviceReplacement(Base):
|
||||
"""设备更换记录"""
|
||||
__tablename__ = "device_replacements"
|
||||
|
||||
@@ -10,6 +10,7 @@ class User(Base):
|
||||
id = Column(BigInteger, primary_key=True, index=True)
|
||||
casdoor_id = Column(String(100), unique=True, nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
display_name = Column(String(100)) # 中文姓名
|
||||
email = Column(String(255))
|
||||
role = Column(String(50), default="user")
|
||||
assigned_area = Column(String(100))
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""OLT response serialization."""
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
|
||||
def serialize_olt(device: OLTDevice) -> dict:
|
||||
"""Return an OLT record without its device credential."""
|
||||
return {
|
||||
"id": device.id,
|
||||
"ip_address": device.ip_address,
|
||||
"username": device.username,
|
||||
"slot_command": device.slot_command,
|
||||
"region": device.region,
|
||||
"location": device.location,
|
||||
"description": device.description,
|
||||
"created_at": device.created_at,
|
||||
"updated_at": device.updated_at,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
class UserListItem(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: str
|
||||
assigned_area: Optional[str] = None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""设备状态检查服务"""
|
||||
import time
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from app.services.ssh_service import SSHService
|
||||
@@ -6,6 +7,26 @@ from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, Duplica
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
# SSH 连接池缓存,TTL 5 分钟
|
||||
_conn_pool: Dict[int, tuple[SSHService, float]] = {}
|
||||
_POOL_TTL = 300
|
||||
|
||||
def _get_cached_ssh(olt_ip: str, olt_user: str, olt_pass: str, olt_id: int) -> SSHService:
|
||||
"""获取缓存的 SSH 连接,过期自动重连"""
|
||||
entry = _conn_pool.get(olt_id)
|
||||
if entry:
|
||||
ssh, ts = entry
|
||||
if time.time() - ts < _POOL_TTL:
|
||||
return ssh
|
||||
try:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
pass
|
||||
ssh = SSHService(olt_ip, olt_user, olt_pass)
|
||||
ssh.connect()
|
||||
_conn_pool[olt_id] = (ssh, time.time())
|
||||
return ssh
|
||||
|
||||
|
||||
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
|
||||
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
|
||||
@@ -29,9 +50,8 @@ class CheckService:
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, _ = ssh.parse_onu_info(output)
|
||||
|
||||
@@ -83,8 +103,10 @@ class CheckService:
|
||||
"online": online_count,
|
||||
"offline": offline_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
# 连接异常时清除缓存,下次自动重连
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
def check_single_device(self, device_id: int) -> Dict:
|
||||
"""通过 SSH 单独查询一台 ONU 设备的当前状态和距离。
|
||||
@@ -160,9 +182,8 @@ class CheckService:
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||
|
||||
@@ -232,8 +253,9 @@ class CheckService:
|
||||
"offline": offline_count,
|
||||
"new_discovered": new_count,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
async def scan_olt(self, olt_id: int) -> Dict:
|
||||
"""仅扫描 OLT,返回发现的设备列表(不写入数据库)"""
|
||||
@@ -241,9 +263,8 @@ class CheckService:
|
||||
if not olt:
|
||||
raise Exception(f"OLT 设备不存在: {olt_id}")
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(olt.slot_command)
|
||||
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
|
||||
|
||||
@@ -282,8 +303,9 @@ class CheckService:
|
||||
"devices": devices,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
finally:
|
||||
ssh.close()
|
||||
except Exception:
|
||||
_conn_pool.pop(olt_id, None)
|
||||
raise
|
||||
|
||||
def _save_duplicate_macs(self, olt_id: int, duplicate_dict: dict):
|
||||
for mac, records in duplicate_dict.items():
|
||||
|
||||
@@ -39,16 +39,52 @@ class IMCService:
|
||||
"""iMC REST API 服务封装"""
|
||||
|
||||
def __init__(self):
|
||||
import os
|
||||
import tempfile
|
||||
import certifi
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
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
|
||||
self._ca_bundle_file = None # temp file for certifi + iMC CA
|
||||
|
||||
if settings.IMC_API_VERIFY_SSL:
|
||||
imc_ca_path = os.path.join(os.path.dirname(__file__), "..", "..", "imc_ca.pem")
|
||||
if os.path.isfile(imc_ca_path):
|
||||
# The iMC self-signed cert uses a non-DNS CN and zero SAN entries,
|
||||
# so hostname matching is impossible. We still enforce full
|
||||
# certificate-chain verification via a combined CA bundle, then
|
||||
# tell urllib3 to skip its own hostname check.
|
||||
with open(imc_ca_path, "rb") as fh:
|
||||
imc_pem = fh.read()
|
||||
self._ca_bundle_file = tempfile.NamedTemporaryFile(suffix=".pem", delete=False)
|
||||
with open(certifi.where(), "rb") as fh:
|
||||
self._ca_bundle_file.write(fh.read())
|
||||
self._ca_bundle_file.write(b"\n")
|
||||
self._ca_bundle_file.write(imc_pem)
|
||||
self._ca_bundle_file.flush()
|
||||
|
||||
_ca_bundle = self._ca_bundle_file.name
|
||||
|
||||
class _IMCAdapter(HTTPAdapter):
|
||||
def cert_verify(self, conn, url, verify, cert):
|
||||
super().cert_verify(conn, url, verify=_ca_bundle, cert=cert)
|
||||
conn.assert_hostname = False
|
||||
|
||||
self.session.mount("https://", _IMCAdapter())
|
||||
self.verify_ssl = True
|
||||
logger.info("iMC TLS verification enabled (hostname check relaxed)")
|
||||
else:
|
||||
self.verify_ssl = True
|
||||
else:
|
||||
self.verify_ssl = False
|
||||
|
||||
# ── Digest 认证 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import re
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ONUInfo:
|
||||
@@ -36,7 +34,7 @@ class SSHService:
|
||||
"""建立 SSH 连接,等待初始 banner 输出完毕"""
|
||||
try:
|
||||
self.client = paramiko.SSHClient()
|
||||
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
self.client.set_missing_host_key_policy(paramiko.WarningPolicy())
|
||||
self.client.connect(
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
@@ -51,7 +49,7 @@ class SSHService:
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if ">" in buf:
|
||||
if re.search(r'<[^>]+>', buf):
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
@@ -77,22 +75,16 @@ class SSHService:
|
||||
if "---- More ----" in chunk:
|
||||
self.shell.send(" ")
|
||||
time.sleep(0.3)
|
||||
elif ">" in chunk:
|
||||
# 命令提示符出现,说明输出完毕
|
||||
elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE):
|
||||
# 匹配整行为 <DEVICE_NAME> 的提示符行(不匹配回显中的 ">")
|
||||
break
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
|
||||
return output
|
||||
|
||||
def clear_onu_port(self, port_id: str) -> bool:
|
||||
"""清除指定端口的 ONU 配置(恢复默认)
|
||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
def _send_and_wait(self, cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
"""发送命令并等待期望字符串出现,超时返回已收集的输出"""
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
@@ -105,13 +97,20 @@ class SSHService:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
def clear_onu_port(self, port_id: str) -> bool:
|
||||
"""清除指定端口的 ONU 配置(恢复默认)
|
||||
流程: system-view -> interface Onu{port_id} -> default -> Y
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
# 进入系统视图
|
||||
out = send_and_wait("system-view", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 进入端口
|
||||
out = send_and_wait(f"interface Onu{port_id}", "]")
|
||||
out = self._send_and_wait(f"interface Onu{port_id}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 Onu{port_id} 失败")
|
||||
|
||||
@@ -138,8 +137,8 @@ class SSHService:
|
||||
self.shell.recv(4096)
|
||||
|
||||
# 退出到用户视图
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
self._send_and_wait("quit", "]", timeout=5)
|
||||
self._send_and_wait("quit", ">", timeout=5)
|
||||
|
||||
return True
|
||||
|
||||
@@ -150,7 +149,7 @@ class SSHService:
|
||||
interfaces = []
|
||||
if has_loop:
|
||||
for line in output.splitlines():
|
||||
m = re.match(r'\s+(Onu\S+)\s+', line)
|
||||
m = re.match(r'\s+(Onu\S+)', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return {"has_loop": has_loop, "interfaces": interfaces, "raw": output}
|
||||
@@ -176,8 +175,8 @@ class SSHService:
|
||||
"""清理终端控制字符和 More 分页标记,避免污染解析"""
|
||||
# 移除 ANSI 转义序列
|
||||
output = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', output)
|
||||
# 移除 ---- More ---- 行(含前后控制字符)
|
||||
output = re.sub(r'---- More ----[^\n]*', '', output)
|
||||
# 移除 ---- More ---- 标记(仅标记本身,保留同行后续设备数据)
|
||||
output = re.sub(r'---- More ----', '', output)
|
||||
# 将独立的 \r(不跟 \n)替换为空,避免覆盖行内容
|
||||
output = re.sub(r'\r(?!\n)', '', output)
|
||||
return output
|
||||
@@ -319,33 +318,20 @@ class SSHService:
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
out = send_and_wait("system-view", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
out = send_and_wait(f"interface {port_name}", "]")
|
||||
out = self._send_and_wait(f"interface {port_name}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 {port_name} 失败")
|
||||
|
||||
out = send_and_wait(action, "]")
|
||||
out = self._send_and_wait(action, "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"执行 {action} 失败")
|
||||
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
self._send_and_wait("quit", "]", timeout=5)
|
||||
self._send_and_wait("quit", ">", timeout=5)
|
||||
return True
|
||||
|
||||
def sync_ntp(self, old_server: str, new_server: str) -> bool:
|
||||
@@ -356,41 +342,28 @@ class SSHService:
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 15) -> str:
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
out = send_and_wait("system-view", "]")
|
||||
out = self._send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
# 删除旧 NTP 服务器(若不存在会报错,忽略即可)
|
||||
send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||
self._send_and_wait(f"undo ntp-service unicast-server {old_server}", "]")
|
||||
|
||||
# 添加新 NTP 服务器
|
||||
out = send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||
out = self._send_and_wait(f"ntp-service unicast-server {new_server}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"配置 NTP 服务器 {new_server} 失败")
|
||||
|
||||
# 设置时区为北京时间
|
||||
out = send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||
out = self._send_and_wait("clock timezone Beijing add 08:00:00", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("配置时区失败")
|
||||
|
||||
# 退出系统视图
|
||||
send_and_wait("quit", ">")
|
||||
self._send_and_wait("quit", ">")
|
||||
|
||||
# 强制保存配置
|
||||
send_and_wait("save force", ">", timeout=30)
|
||||
self._send_and_wait("save force", ">", timeout=30)
|
||||
|
||||
return True
|
||||
|
||||
@@ -427,6 +400,16 @@ class SSHService:
|
||||
})
|
||||
return events
|
||||
|
||||
def __enter__(self):
|
||||
"""上下文管理器入口,自动连接"""
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""上下文管理器出口,自动关闭连接"""
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""企业微信 (WeChat Work) 应用消息服务"""
|
||||
import time
|
||||
import hashlib
|
||||
import base64
|
||||
import socket
|
||||
import struct
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
import logging
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_db_setting(key: str, default: str = "") -> str:
|
||||
"""从数据库读取 SystemSetting,失败时返回 default"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.setting import SystemSetting
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.query(SystemSetting).filter_by(key=key).first()
|
||||
return row.value if row else default
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
class WeChatService:
|
||||
"""企业微信应用消息服务"""
|
||||
|
||||
def __init__(self):
|
||||
# 优先读环境变量,fallback 到数据库配置
|
||||
self.corpid = settings.WECHAT_CORPID or _get_db_setting("wechat_corpid")
|
||||
self.corpsecret = settings.WECHAT_CORPSECRET or _get_db_setting("wechat_corpsecret")
|
||||
self.agentid = settings.WECHAT_AGENTID or _get_db_setting("wechat_agentid")
|
||||
self.token = settings.WECHAT_TOKEN
|
||||
self.encoding_aes_key = settings.WECHAT_ENCODING_AES_KEY
|
||||
self.use_proxy = settings.WECHAT_USE_PROXY
|
||||
self.proxy_api_url = settings.WECHAT_PROXY_API_URL
|
||||
self._access_token = None
|
||||
self._token_expires_at = 0
|
||||
|
||||
# ── access_token 管理 ───────────────────────────────────────────────────
|
||||
|
||||
def get_access_token(self) -> str | None:
|
||||
"""获取企业微信 access_token,自动缓存和续期"""
|
||||
now = time.time()
|
||||
if self._access_token and now < self._token_expires_at:
|
||||
return self._access_token
|
||||
|
||||
if not self.corpid or not self.corpsecret:
|
||||
logger.warning("WECHAT_CORPID 或 WECHAT_CORPSECRET 未配置")
|
||||
return None
|
||||
|
||||
try:
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
|
||||
|
||||
resp = requests.get(url, timeout=10)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self._access_token = result.get("access_token")
|
||||
expires_in = result.get("expires_in", 7200) - 300 # 提前5分钟过期
|
||||
self._token_expires_at = now + expires_in
|
||||
logger.info(f"企业微信 access_token 获取成功,过期时间: {time.strftime('%H:%M:%S', time.localtime(self._token_expires_at))}")
|
||||
return self._access_token
|
||||
elif result.get("errcode") == 60020 and not self.use_proxy:
|
||||
logger.info("IP受限,尝试使用代理获取 access_token")
|
||||
self.use_proxy = True
|
||||
return self.get_access_token()
|
||||
else:
|
||||
logger.error(f"获取 access_token 失败: {result}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取 access_token 异常: {e}")
|
||||
return None
|
||||
|
||||
# ── 消息分条 ────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _split_long_message(content: str, max_chars: int = 1800) -> list[str]:
|
||||
"""将长消息按换行边界拆分为多条,避免企微截断"""
|
||||
if len(content) <= max_chars:
|
||||
return [content]
|
||||
chunks = []
|
||||
lines = content.split('\n')
|
||||
current = ''
|
||||
for line in lines:
|
||||
if len(current) + len(line) + 1 > max_chars and current:
|
||||
chunks.append(current.strip())
|
||||
current = line
|
||||
else:
|
||||
current += ('\n' + line) if current else line
|
||||
if current.strip():
|
||||
chunks.append(current.strip())
|
||||
return chunks
|
||||
|
||||
# ── 发送消息 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _send_markdown_single(self, content: str, to_user: str) -> bool:
|
||||
"""发送单条 Markdown 消息(内部方法)"""
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return False
|
||||
|
||||
data = {
|
||||
"touser": to_user, "toparty": "", "totag": "",
|
||||
"msgtype": "markdown",
|
||||
"agentid": int(self.agentid),
|
||||
"markdown": {"content": content}
|
||||
}
|
||||
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
resp = requests.post(url, json=data, timeout=15)
|
||||
result = resp.json()
|
||||
errcode = result.get("errcode", -1)
|
||||
if errcode == 0:
|
||||
return True
|
||||
elif errcode == 40014:
|
||||
self._access_token = None
|
||||
self._token_expires_at = 0
|
||||
raise Exception("token_expired")
|
||||
elif errcode == 60020 and not self.use_proxy:
|
||||
self.use_proxy = True
|
||||
raise Exception("ip_restricted")
|
||||
else:
|
||||
logger.warning(f"企业微信消息发送失败: {result.get('errmsg')} (errcode={errcode})")
|
||||
return False
|
||||
|
||||
def send_markdown(self, content: str, to_user: str = "@all") -> bool:
|
||||
"""发送 Markdown 消息,自动分条"""
|
||||
if not self.corpid or not self.corpsecret or not self.agentid:
|
||||
logger.warning("企业微信未配置,跳过发送")
|
||||
return False
|
||||
|
||||
chunks = self._split_long_message(content)
|
||||
success = True
|
||||
for i, chunk in enumerate(chunks):
|
||||
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
|
||||
for attempt in range(3):
|
||||
try:
|
||||
ok = self._send_markdown_single(prefix + chunk, to_user)
|
||||
if ok:
|
||||
break
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
logger.warning(f"发送分片 {i+1}/{len(chunks)} 异常: {e}")
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
continue
|
||||
success = False
|
||||
if i < len(chunks) - 1:
|
||||
time.sleep(0.5) # 避免频率限制
|
||||
return success
|
||||
|
||||
|
||||
# ── 发送文本消息 ────────────────────────────────────────────────────────
|
||||
|
||||
def send_text_message(self, content: str, to_user: str = "@all") -> bool:
|
||||
"""通过应用消息 API 发送文本消息,自动分条"""
|
||||
if not self.corpid or not self.corpsecret or not self.agentid:
|
||||
return False
|
||||
|
||||
chunks = self._split_long_message(content, max_chars=1800)
|
||||
success = True
|
||||
for i, chunk in enumerate(chunks):
|
||||
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
|
||||
try:
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return False
|
||||
data = {
|
||||
"touser": to_user, "toparty": "", "totag": "",
|
||||
"msgtype": "text",
|
||||
"agentid": int(self.agentid),
|
||||
"text": {"content": prefix + chunk}
|
||||
}
|
||||
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}" if self.use_proxy else f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
resp = requests.post(url, json=data, timeout=15)
|
||||
if resp.json().get("errcode") != 0:
|
||||
success = False
|
||||
except Exception as e:
|
||||
logger.error(f"发送文本消息分片 {i+1}/{len(chunks)} 失败: {e}")
|
||||
success = False
|
||||
if i < len(chunks) - 1:
|
||||
time.sleep(0.5)
|
||||
return success
|
||||
|
||||
# ── 菜单管理 ────────────────────────────────────────────────────────────
|
||||
|
||||
def create_menu(self, menu_data: dict) -> bool:
|
||||
"""创建/更新企业微信应用菜单"""
|
||||
try:
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return False
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
|
||||
resp = requests.post(url, json=menu_data, timeout=15)
|
||||
result = resp.json()
|
||||
if result.get("errcode") == 0:
|
||||
logger.info("企业微信菜单创建成功")
|
||||
return True
|
||||
logger.warning(f"创建菜单失败: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"创建菜单异常: {e}")
|
||||
return False
|
||||
|
||||
# ── 获取用户信息 ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_user_info(self, userid: str) -> dict:
|
||||
"""获取企业微信用户信息"""
|
||||
try:
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
return {"errcode": -1, "errmsg": "无 access_token"}
|
||||
if self.use_proxy:
|
||||
url = f"{self.proxy_api_url}/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||
else:
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
|
||||
resp = requests.get(url, timeout=10)
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户信息失败: {e}")
|
||||
return {"errcode": -1, "errmsg": str(e)}
|
||||
|
||||
# ── URL 验证(GET 回调)──────────────────────────────────────────────────
|
||||
|
||||
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None:
|
||||
"""验证企业微信回调 URL"""
|
||||
try:
|
||||
echostr = urllib.parse.unquote(echostr)
|
||||
temp_list = [self.token, timestamp, nonce, echostr]
|
||||
temp_list.sort()
|
||||
temp_str = ''.join(temp_list)
|
||||
hash_str = hashlib.sha1(temp_str.encode('utf-8')).hexdigest()
|
||||
|
||||
if hash_str != msg_signature:
|
||||
logger.error(f"URL验证签名不匹配: expected={msg_signature}, got={hash_str}")
|
||||
return None
|
||||
|
||||
if self.encoding_aes_key:
|
||||
return self._decrypt_echostr(echostr)
|
||||
return echostr
|
||||
except Exception as e:
|
||||
logger.error(f"URL验证异常: {e}")
|
||||
return None
|
||||
|
||||
def _decrypt_echostr(self, echostr: str) -> str | None:
|
||||
"""解密 echostr"""
|
||||
try:
|
||||
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||
encrypted = base64.b64decode(echostr)
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
|
||||
content = decrypted[16:]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
xml_content = content[4:xml_len + 4]
|
||||
received_id = content[xml_len + 4:].decode('utf-8')
|
||||
if received_id != self.corpid:
|
||||
logger.error(f"企业ID验证失败: {received_id} != {self.corpid}")
|
||||
return None
|
||||
return xml_content.decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"解密echostr失败: {e}")
|
||||
return None
|
||||
|
||||
# ── 消息解密(POST 回调)─────────────────────────────────────────────────
|
||||
|
||||
def parse_message(self, xml_data: bytes) -> dict | None:
|
||||
"""解析企业微信回调的加密 XML 消息"""
|
||||
try:
|
||||
root = ET.fromstring(xml_data)
|
||||
msg = {child.tag: child.text for child in root}
|
||||
|
||||
if 'Encrypt' in msg:
|
||||
decrypted = self._decrypt_message(msg['Encrypt'])
|
||||
decrypted_root = ET.fromstring(decrypted)
|
||||
msg = {child.tag: child.text for child in decrypted_root}
|
||||
|
||||
return msg
|
||||
except Exception as e:
|
||||
logger.error(f"解析消息失败: {e}")
|
||||
return None
|
||||
|
||||
def _decrypt_message(self, encrypted_msg: str) -> str:
|
||||
"""解密企业微信推送的消息"""
|
||||
aes_key = base64.b64decode(self.encoding_aes_key + '=')
|
||||
encrypted = base64.b64decode(encrypted_msg)
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
|
||||
decrypted = cipher.decrypt(encrypted)
|
||||
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
|
||||
content = decrypted[16:]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
xml_content = content[4:xml_len + 4]
|
||||
received_id = content[xml_len + 4:].decode('utf-8')
|
||||
if received_id != self.corpid:
|
||||
raise Exception(f"企业ID验证失败: {received_id} != {self.corpid}")
|
||||
return xml_content.decode('utf-8')
|
||||
|
||||
|
||||
# 模块级单例和便捷函数
|
||||
_service: WeChatService | None = None
|
||||
|
||||
|
||||
def get_wechat_service() -> WeChatService:
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = WeChatService()
|
||||
return _service
|
||||
|
||||
|
||||
def send_wechat_markdown(content: str) -> bool:
|
||||
"""便捷函数:发送企业微信 Markdown 消息"""
|
||||
return get_wechat_service().send_markdown(content)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""告警相关 Celery 任务"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func, case
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
|
||||
|
||||
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
|
||||
def check_school_offline_alerts():
|
||||
"""
|
||||
检查是否有学校全部离线(在线率为 0%),如有则发送企业微信告警。
|
||||
该任务在每次全量状态检查完成后异步调用。
|
||||
"""
|
||||
from app.models.device import ONUDevice, DeviceStatusHistory
|
||||
from app.services.wechat_service import send_wechat_markdown
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 每台设备最新状态的子查询
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
DeviceStatusHistory.status
|
||||
)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# 按学校聚合在线率,只查询在线数为 0 的学校
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.school_name,
|
||||
ONUDevice.region,
|
||||
func.count().label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||
.having(
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return {"alerted": False, "reason": "没有全离线的学校"}
|
||||
|
||||
# 构造告警消息
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
lines = [
|
||||
"## <font color=\"warning\">[告警] 学校全部离线</font>",
|
||||
f"> 检查时间:{now_str}",
|
||||
"> 以下学校所有设备均处于离线状态:",
|
||||
"",
|
||||
]
|
||||
for row in rows:
|
||||
school = row.school_name or "未知"
|
||||
region = row.region or "未知"
|
||||
total = int(row.total or 0)
|
||||
if total > 0:
|
||||
lines.append(f"- **{school}**({region}): {total} 台设备全离线")
|
||||
|
||||
content = "\n".join(lines)
|
||||
send_wechat_markdown(content)
|
||||
return {"alerted": True, "schools": len(rows)}
|
||||
except Exception as e:
|
||||
return {"alerted": False, "error": "告警任务失败", "error_type": type(e).__name__}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1,5 +1,4 @@
|
||||
"""审计日志 Celery 任务"""
|
||||
import traceback
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
|
||||
@@ -57,6 +56,6 @@ def cleanup_audit_logs_task():
|
||||
deleted = cleanup_old_logs(db)
|
||||
return {'success': True, 'deleted': deleted}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||
return {'success': False, 'error': '审计日志任务失败', 'error_type': type(e).__name__}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""状态检查任务"""
|
||||
import time
|
||||
import traceback
|
||||
import redis as redis_lib
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import func, case
|
||||
@@ -86,17 +85,29 @@ def check_all_devices(self):
|
||||
errors.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'error': str(e)
|
||||
'error': 'OLT 状态检查失败',
|
||||
'error_type': type(e).__name__,
|
||||
})
|
||||
results.append({
|
||||
'olt_id': olt.id,
|
||||
'olt_name': olt.location or olt.ip_address,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
'error': 'OLT 状态检查失败',
|
||||
'error_type': type(e).__name__,
|
||||
})
|
||||
|
||||
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
|
||||
|
||||
# 通知 WebSocket 客户端状态已更新
|
||||
try:
|
||||
import json as _json
|
||||
r.publish("h3c_onu:status_updates", _json.dumps({
|
||||
"type": "check_complete", "total_online": total_online,
|
||||
"total_offline": total_offline, "total_olts": total
|
||||
}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'total_olts': total,
|
||||
@@ -108,8 +119,8 @@ def check_all_devices(self):
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'traceback': traceback.format_exc()
|
||||
'error': '设备状态检查任务失败',
|
||||
'error_type': type(e).__name__,
|
||||
}
|
||||
finally:
|
||||
# 任务完成后记录时间、清除运行标记
|
||||
@@ -171,7 +182,7 @@ def aggregate_daily_snapshot():
|
||||
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()}
|
||||
return {'success': False, 'error': '设备状态检查任务失败', 'error_type': type(e).__name__}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
from app.core.celery_app import celery_app
|
||||
from app.tasks import check_tasks # noqa: F401 - 导入以注册任务
|
||||
from app.tasks import audit_tasks # noqa: F401 - 导入以注册任务
|
||||
from app.tasks import alert_tasks # noqa: F401 - 导入以注册任务
|
||||
|
||||
__all__ = ['celery_app']
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC+TCCAeGgAwIBAgIEHno+KjANBgkqhkiG9w0BAQsFADAtMQwwCgYDVQQLDANS
|
||||
JkQxHTAbBgNVBAMTFGlNQyBEZXZlbG9wbWVudCBUZWFtMB4XDTE5MDExOTA2Mzkx
|
||||
OVoXDTM5MDExNDA2MzkxOVowLTEMMAoGA1UECwwDUiZEMR0wGwYDVQQDExRpTUMg
|
||||
RGV2ZWxvcG1lbnQgVGVhbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
|
||||
AIVGUvmtUD6Gx0AQLi+/voDIuSERGLyTLcQQOBmWVWLfxr0i1PGxuGODylN+30Ud
|
||||
pBY1faRijvUZUWN51dxjXMRLnHUecBuPVyWOv1jPFMd1jcc7KmQ4KY6s43t1cC2R
|
||||
pBcfyBhVmN+tArhcbDJb6MUuN1P5ZGi3E1vor9EY0Gt8TvJ/GgmqgWwlveAC0soH
|
||||
yTTk8UU9m2OikbaZ0dKgQbshna4wrSWw9iyJ5Bao4rZgus3hGZUnznWpPD0/jHC4
|
||||
lI3znCKQPAsnJculZcK/8dm1UYKFx7C3L5z4SnzsLY+rTYXvmGfb2x06wKkkJTIt
|
||||
oepc1U5U/jNlUVbNdA9yhP0CAwEAAaMhMB8wHQYDVR0OBBYEFGErff6AvZVRmgqK
|
||||
DLQxMBGOr0ObMA0GCSqGSIb3DQEBCwUAA4IBAQA9HhuDvrVI4ICyddy5g4DM+bv4
|
||||
NaKoBS0Y5nhANbN/0f0J0Zj32OYtbsJlUZViFQ42LIR7b37x6OgMXRjHJIDg3q+9
|
||||
tkH5S8Q5xmb5Tqq0WcOWQmg0o1OjW5iJNAfKiTffGtc5DjmrOBuCf1P23G3polRB
|
||||
34QxGgnRygEDKy5aYumaXyiL1yMgDLkZ2adg2lWvsBSpvmgzMF5H75Spq4WvK60Z
|
||||
o3EyCpB5ZS4SxwF3JH+LdpsyCc+UsyYW4/v/FVidtCp+nNTgsTA1yI8vcPVyPxPQ
|
||||
SvVFIvuLYpo9cbHLcmQ95LBKjMbhPVHMzsBRVd85xKVjBspRMWeUp7F94W8V
|
||||
-----END CERTIFICATE-----
|
||||
@@ -14,7 +14,12 @@ paramiko==3.4.0
|
||||
pandas==2.1.4
|
||||
openpyxl==3.1.2
|
||||
cryptography==42.0.0
|
||||
pycryptodome==3.20.0
|
||||
slowapi==0.1.9
|
||||
python-json-logger==2.0.7
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
casdoor==1.18.0
|
||||
aiohttp>=3.9.0
|
||||
PyJWT>=2.8.0
|
||||
requests>=2.31.0
|
||||
aiohttp==3.9.5
|
||||
PyJWT==2.8.0
|
||||
requests==2.31.0
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""One-time migration that encrypts legacy plaintext OLT credentials."""
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.device import OLTDevice
|
||||
from app.core.credentials import CREDENTIAL_PREFIX
|
||||
|
||||
|
||||
def migrate(batch_size: int = 100) -> int:
|
||||
"""Encrypt every legacy credential and return the number of migrated records."""
|
||||
db = SessionLocal()
|
||||
migrated = 0
|
||||
try:
|
||||
devices = db.query(OLTDevice).yield_per(batch_size)
|
||||
for device in devices:
|
||||
if device.password.startswith(CREDENTIAL_PREFIX):
|
||||
continue
|
||||
# Reading a legacy row yields plaintext. Mark it dirty so the column type encrypts it on flush.
|
||||
device.password = device.password
|
||||
flag_modified(device, "password")
|
||||
migrated += 1
|
||||
db.commit()
|
||||
return migrated
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Migrated {migrate()} OLT credential(s).")
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""pytest fixtures"""
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.database import Base
|
||||
|
||||
TEST_DB_URL = "sqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""数据导入服务测试"""
|
||||
import io
|
||||
import pytest
|
||||
from app.services.import_service import ImportService
|
||||
|
||||
|
||||
class TestImportService:
|
||||
def test_validate_mac(self, db_session):
|
||||
svc = ImportService(db_session)
|
||||
valid = [{"mac_address": "1484-778f-aa60", "region": "城区", "school_name": "测试学校"}]
|
||||
result = svc.validate_data(valid)
|
||||
assert len(result["valid"]) == 1
|
||||
assert len(result["invalid"]) == 0
|
||||
|
||||
def test_invalid_mac_rejected(self, db_session):
|
||||
svc = ImportService(db_session)
|
||||
data = [{"mac_address": "invalid", "region": "城区", "school_name": "测试学校"}]
|
||||
result = svc.validate_data(data)
|
||||
assert len(result["invalid"]) > 0
|
||||
|
||||
def test_missing_required_fields(self, db_session):
|
||||
svc = ImportService(db_session)
|
||||
# MAC format valid but empty region/school may or may not be rejected
|
||||
# depending on validation rules — just verify it doesn't crash
|
||||
data = [{"mac_address": "1484-778f-aa60", "region": "", "school_name": ""}]
|
||||
result = svc.validate_data(data)
|
||||
assert "valid" in result or "invalid" in result
|
||||
|
||||
|
||||
class TestCleanValue:
|
||||
def test_strips_whitespace(self):
|
||||
from app.services.import_service import clean_value
|
||||
assert clean_value(" test ") == "test"
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
from app.services.import_service import clean_value
|
||||
assert clean_value(None) == ''
|
||||
|
||||
def test_nan_returns_empty(self):
|
||||
from app.services.import_service import clean_value
|
||||
import math
|
||||
assert clean_value(float('nan')) == ''
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Security regression tests for P0 remediations."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.errors import internal_error
|
||||
from app.core.credentials import CREDENTIAL_PREFIX, decrypt_credential, encrypt_credential
|
||||
from app.core.logging_utils import redact_log_message
|
||||
from app.core.security import verify_casdoor_token
|
||||
from app.middleware.audit_middleware import sanitize_audit_params
|
||||
|
||||
|
||||
def test_production_rejects_disabled_imc_tls_verification():
|
||||
from app.core.config import Settings
|
||||
|
||||
with pytest.raises(ValidationError, match="IMC_API_VERIFY_SSL"):
|
||||
Settings(
|
||||
SECRET_KEY="test-secret",
|
||||
CREDENTIAL_ENCRYPTION_KEY="Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU=",
|
||||
DATABASE_URL="sqlite:///./test.db",
|
||||
REDIS_URL="redis://localhost:6379/15",
|
||||
CASDOOR_ENDPOINT="https://casdoor.example.test",
|
||||
CASDOOR_CLIENT_ID="test-client",
|
||||
CASDOOR_CLIENT_SECRET="test-client-secret",
|
||||
CASDOOR_ORG_NAME="test-org",
|
||||
CASDOOR_APP_NAME="test-app",
|
||||
IMC_API_URL="https://imc.example.test",
|
||||
IMC_API_VERIFY_SSL=False,
|
||||
DEBUG=False,
|
||||
)
|
||||
|
||||
|
||||
def test_audit_params_redacts_nested_sensitive_values():
|
||||
params = {
|
||||
"corpsecret": "corp-secret",
|
||||
"profile": {"access_token": "access-token", "name": "operator"},
|
||||
"items": [{"password": "device-password"}],
|
||||
"normal": "kept",
|
||||
}
|
||||
|
||||
assert sanitize_audit_params(params) == {
|
||||
"corpsecret": "***",
|
||||
"profile": {"access_token": "***", "name": "operator"},
|
||||
"items": [{"password": "***"}],
|
||||
"normal": "kept",
|
||||
}
|
||||
|
||||
|
||||
def test_internal_error_does_not_expose_exception_text():
|
||||
exception = internal_error("test operation", RuntimeError("database password=should-not-leak"))
|
||||
|
||||
assert isinstance(exception, HTTPException)
|
||||
assert exception.status_code == 500
|
||||
assert exception.detail["code"] == "INTERNAL_ERROR"
|
||||
assert "should-not-leak" not in str(exception.detail)
|
||||
assert exception.detail["error_id"]
|
||||
|
||||
|
||||
def test_log_redaction_masks_common_credential_formats():
|
||||
message = "Authorization: Bearer abc.def password=hunter2&access_token=token-value corpsecret: corp-secret"
|
||||
redacted = redact_log_message(message)
|
||||
|
||||
assert "abc.def" not in redacted
|
||||
assert "hunter2" not in redacted
|
||||
assert "token-value" not in redacted
|
||||
assert "corp-secret" not in redacted
|
||||
|
||||
|
||||
def test_olt_credential_encryption_round_trip(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
encrypted = encrypt_credential("olt-device-password")
|
||||
|
||||
assert encrypted.startswith(CREDENTIAL_PREFIX)
|
||||
assert "olt-device-password" not in encrypted
|
||||
assert decrypt_credential(encrypted) == "olt-device-password"
|
||||
|
||||
|
||||
def test_olt_response_never_contains_device_password():
|
||||
from app.schemas.olt import serialize_olt
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
device = OLTDevice(
|
||||
id=1,
|
||||
ip_address="10.0.0.1",
|
||||
username="operator",
|
||||
password="olt-device-password",
|
||||
slot_command="display onu slot",
|
||||
region="城区",
|
||||
)
|
||||
|
||||
response = serialize_olt(device)
|
||||
assert "password" not in response
|
||||
assert "olt-device-password" not in str(response)
|
||||
|
||||
|
||||
def test_olt_password_is_encrypted_in_database(monkeypatch):
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
session.add(
|
||||
OLTDevice(
|
||||
id=1,
|
||||
ip_address="10.0.0.1",
|
||||
username="operator",
|
||||
password="olt-device-password",
|
||||
slot_command="display onu slot",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
stored_password = session.execute(text("SELECT password FROM olt_devices")).scalar_one()
|
||||
assert stored_password.startswith(CREDENTIAL_PREFIX)
|
||||
assert "olt-device-password" not in stored_password
|
||||
|
||||
session.expire_all()
|
||||
assert session.query(OLTDevice).one().password == "olt-device-password"
|
||||
|
||||
|
||||
def test_verify_casdoor_token_requires_expected_signature_and_claims(monkeypatch):
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "CASDOOR_CERTIFICATE", public_pem)
|
||||
monkeypatch.setattr(settings, "CASDOOR_ENDPOINT", "https://casdoor.example.test")
|
||||
monkeypatch.setattr(settings, "CASDOOR_ISSUER", "")
|
||||
monkeypatch.setattr(settings, "CASDOOR_CLIENT_ID", "h3c-client")
|
||||
|
||||
token = jwt.encode(
|
||||
{
|
||||
"sub": "user-1",
|
||||
"iss": "https://casdoor.example.test",
|
||||
"aud": "h3c-client",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=5),
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
assert verify_casdoor_token(token)["sub"] == "user-1"
|
||||
|
||||
invalid_token = jwt.encode(
|
||||
{
|
||||
"sub": "user-1",
|
||||
"iss": "https://casdoor.example.test",
|
||||
"aud": "other-client",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=5),
|
||||
},
|
||||
private_key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
with pytest.raises(jwt.InvalidAudienceError):
|
||||
verify_casdoor_token(invalid_token)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""SSH 输出解析测试"""
|
||||
import re
|
||||
import pytest
|
||||
from app.services.ssh_service import SSHService
|
||||
|
||||
|
||||
def _make_svc():
|
||||
return SSHService("10.0.0.1", "admin", "pass")
|
||||
|
||||
|
||||
class TestParseOnuInfo:
|
||||
def test_single_online(self):
|
||||
svc = _make_svc()
|
||||
output = """
|
||||
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||
Port MAC Status OAM State LOID Model Distance
|
||||
0/0/1 1484-778f-aa60 Up OAM_Up test_loid H3C_ET704 1234m
|
||||
"""
|
||||
onu_dict, unknown = svc.parse_onu_info(output)
|
||||
assert len(onu_dict) == 1
|
||||
assert "1484-778f-aa60" in onu_dict
|
||||
assert onu_dict["1484-778f-aa60"].status == "online"
|
||||
|
||||
def test_mixed_online_offline(self):
|
||||
svc = _make_svc()
|
||||
output = """
|
||||
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||
Port MAC Status OAM State LOID Model Distance
|
||||
0/0/1 1484-778f-aa60 Up OAM_Up loid_a H3C_ET704 500m
|
||||
0/0/2 1484-778f-bb70 Down OAM_Down loid_b Unknown <1000m
|
||||
"""
|
||||
onu_dict, unknown = svc.parse_onu_info(output)
|
||||
mac_a = "1484-778f-aa60"
|
||||
mac_b = "1484-778f-bb70"
|
||||
assert onu_dict[mac_a].status == "online"
|
||||
assert onu_dict[mac_b].status == "offline"
|
||||
|
||||
def test_more_marker_removal(self):
|
||||
svc = _make_svc()
|
||||
output = """
|
||||
Flags: S-Switched L-Loopback N-Not exist U-Up D-Down
|
||||
Port MAC Status OAM State LOID Model Distance
|
||||
---- More ----
|
||||
0/0/1 1484-778f-aa60 Up OAM_Up loid H3C_ET704 500m
|
||||
---- More ----
|
||||
0/0/2 1484-778f-bb70 Up OAM_Up loid2 H3C_ET704 800m
|
||||
"""
|
||||
onu_dict, _ = svc.parse_onu_info(output)
|
||||
assert len(onu_dict) == 2
|
||||
|
||||
def test_more_inline_with_device_line(self):
|
||||
"""More 标记与下一条设备数据同行时,不应丢弃该行"""
|
||||
svc = _make_svc()
|
||||
output = """
|
||||
---- More ---- 1484-778f-aa60 Up OAM_Up loid H3C_ET704 500m
|
||||
"""
|
||||
onu_dict, _ = svc.parse_onu_info(output)
|
||||
mac = "1484-778f-aa60"
|
||||
assert mac in onu_dict
|
||||
|
||||
def test_empty_output(self):
|
||||
svc = _make_svc()
|
||||
onu_dict, unknown = svc.parse_onu_info("")
|
||||
assert len(onu_dict) == 0
|
||||
|
||||
def test_header_only(self):
|
||||
svc = _make_svc()
|
||||
output = " Flags: S-Switched L-Loopback N-Not exist U-Up D-Down\n Port MAC Status"
|
||||
onu_dict, _ = svc.parse_onu_info(output)
|
||||
assert len(onu_dict) == 0
|
||||
|
||||
|
||||
class TestCleanOutput:
|
||||
def test_strips_ansi_codes(self):
|
||||
svc = _make_svc()
|
||||
cleaned = svc._clean_output("\x1b[37D\x1b[K 1484-778f-aa60 Up")
|
||||
assert "\x1b[37D" not in cleaned
|
||||
assert "\x1b[K" not in cleaned
|
||||
assert "1484-778f-aa60" in cleaned
|
||||
|
||||
def test_removes_more_marker(self):
|
||||
svc = _make_svc()
|
||||
output = "---- More ----\n1484-778f-aa60 Up"
|
||||
cleaned = svc._clean_output(output)
|
||||
assert "---- More ----" not in cleaned
|
||||
assert "1484-778f-aa60" in cleaned
|
||||
|
||||
def test_preserves_device_line_after_more(self):
|
||||
"""More 标记同行后续设备数据不应被删除"""
|
||||
svc = _make_svc()
|
||||
output = "---- More ----\r\r 1484-778f-aa60 Up"
|
||||
cleaned = svc._clean_output(output)
|
||||
assert "1484-778f-aa60" in cleaned
|
||||
assert "---- More ----" not in cleaned
|
||||
|
||||
|
||||
class TestDetectLoopback:
|
||||
"""环路检测输出解析测试"""
|
||||
|
||||
def _parse(self, output: str):
|
||||
"""模拟 detect_loopback 中的解析逻辑"""
|
||||
has_loop = "Loop is detected on following interfaces" in output
|
||||
interfaces = []
|
||||
if has_loop:
|
||||
for line in output.splitlines():
|
||||
m = re.match(r'\s+(Onu\S+)', line)
|
||||
if m:
|
||||
interfaces.append(m.group(1))
|
||||
return has_loop, interfaces
|
||||
|
||||
def test_no_loop(self):
|
||||
output = """
|
||||
Loopback detection is enabled.
|
||||
Loopback detection interval is 30 second(s).
|
||||
No loopback is detected.
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert not has_loop
|
||||
assert interfaces == []
|
||||
|
||||
def test_has_loop_single(self):
|
||||
output = """
|
||||
Loopback detection is enabled.
|
||||
Loopback detection interval is 30 second(s).
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1"]
|
||||
|
||||
def test_has_loop_multiple(self):
|
||||
output = """
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
Onu1/0/2:3
|
||||
Onu2/0/5:10
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1", "Onu1/0/2:3", "Onu2/0/5:10"]
|
||||
|
||||
def test_has_loop_with_extra_whitespace(self):
|
||||
"""接口行有多余空白字符"""
|
||||
output = """
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1"]
|
||||
|
||||
def test_no_false_positive_on_prompt(self):
|
||||
"""确保设备提示符不被误识别为接口"""
|
||||
output = """
|
||||
Loop is detected on following interfaces:
|
||||
Onu1/0/1:1
|
||||
<H3C_Device>
|
||||
"""
|
||||
has_loop, interfaces = self._parse(output)
|
||||
assert has_loop
|
||||
assert interfaces == ["Onu1/0/1:1"]
|
||||
+14
-28
@@ -1,28 +1,14 @@
|
||||
-----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-----
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArvxqOc2fCe611njyX3aG
|
||||
aprZzyU/UhynIHsJhVMq6tDFLPmJCitg5WFHVTOW5KlTzKI894XJNEPSkf4POoai
|
||||
1z4CW/GeRrkN8/XP8/RqyiCQAslei5FH5i1RjqLWijm0qUNsgYk07ZpBw45qT1ti
|
||||
ipJF33L+mr9s+O17ujDDb2EZBzTcuBs1eYhv2fk0bOdYK17Vzzw1StPP0OWmfYev
|
||||
edw6YemqnODk21e6CGcewXhgIX6k8iBaULQLoy0qTR+4nKwJjnCm4DrbSkYvjM7p
|
||||
AW/3bQyJax1VbFw2jRo1CRcoaMRzLcutpe7HaT+wf4TR7cZFcaFZieSsruLySD5X
|
||||
42bVgWDIZtjtXfJnSZc/Fv6IyUi/PzzlWIaxGcqqVHSk3W/w1ubNc+70SQA7fCJP
|
||||
8vh/GcRNbWF3+mH4B1DSMSl7IAxGNyXeQyNq44Gp9T9MeXsvppQxcHd3Tn4krRi5
|
||||
NzWetnRCOH/kQblfK9FK4o1XGkIHVdwCUBoFO4Tlqu3qNCQIkg28Wg6OgdkHrkoc
|
||||
lW98y0y5Wvt0tJtg48cfpgkHZ9SKM0qhedyUBvGV1fd8QAuwL9JbvMwUX7cVOGBJ
|
||||
6rc4Sk11uRGVCnmw5Ed5ORa0w70DrQYe9OoNqYiqmkLh+TvXMhGmYQ+V8aw6ejmA
|
||||
2Efz40ofkK+Z+Pizv9L3wP8CAwEAAQ==
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# CI-only security tooling. Keep this file pinned so the scan itself is reproducible.
|
||||
pip-audit==2.7.3
|
||||
+46
-13
@@ -13,10 +13,17 @@ DEBUG=false
|
||||
|
||||
# 应用密钥 (使用 openssl rand -hex 32 生成)
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
# OLT SSH 密码加密密钥;请通过受控密钥服务或受限环境变量注入,切勿提交至仓库
|
||||
# 生成示例:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CREDENTIAL_ENCRYPTION_KEY=
|
||||
|
||||
# 时区设置
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# 宿主机发布端口。并行验证时可改为未占用端口,确认后再切换为正式端口。
|
||||
BACKEND_PORT=8001
|
||||
FRONTEND_PORT=18062
|
||||
|
||||
# ============================================
|
||||
# 数据库配置 (PostgreSQL)
|
||||
# ============================================
|
||||
@@ -65,6 +72,11 @@ CASDOOR_ORG_NAME=your_organization
|
||||
# 应用名称
|
||||
CASDOOR_APP_NAME=h3c-onu-ms
|
||||
|
||||
# Casdoor 回调地址(部署后改为实际域名)
|
||||
CASDOOR_REDIRECT_URL=
|
||||
# 为空时使用 CASDOOR_ENDPOINT;JWT 的 iss 必须与此值一致
|
||||
CASDOOR_ISSUER=
|
||||
|
||||
# ============================================
|
||||
# SSH连接配置
|
||||
# ============================================
|
||||
@@ -98,20 +110,35 @@ HISTORY_RETENTION=90
|
||||
BATCH_CHECK_SIZE=100
|
||||
|
||||
# ============================================
|
||||
# 前端配置
|
||||
# 前端 & CORS 配置
|
||||
# ============================================
|
||||
|
||||
# API基础URL (前端访问后端地址)
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
# 前端访问地址
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
# Casdoor前端配置
|
||||
VITE_CASDOOR_ENDPOINT=https://casdoor.example.com
|
||||
VITE_CASDOOR_CLIENT_ID=your_casdoor_client_id
|
||||
VITE_CASDOOR_ORG_NAME=your_organization
|
||||
VITE_CASDOOR_APP_NAME=h3c-onu-ms
|
||||
# CORS允许的域名 (逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=H3C ONU设备管理系统
|
||||
# ============================================
|
||||
# NTP 同步配置
|
||||
# ============================================
|
||||
|
||||
# NTP 旧服务器 IP
|
||||
NTP_OLD_SERVER=172.16.0.254
|
||||
# NTP 新服务器 IP
|
||||
NTP_NEW_SERVER=172.16.1.252
|
||||
|
||||
# ============================================
|
||||
# iMC 网管 API 配置(ONU 远程重启/光功率查询)
|
||||
# ============================================
|
||||
|
||||
IMC_API_URL=
|
||||
IMC_API_USERNAME=
|
||||
IMC_API_PASSWORD=
|
||||
# 本地认证不需要 SSL 验证
|
||||
IMC_API_VERIFY_SSL=true
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
@@ -154,9 +181,6 @@ METRICS_PORT=8000
|
||||
# 安全配置
|
||||
# ============================================
|
||||
|
||||
# CORS允许的域名 (逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:8080,http://localhost:5173
|
||||
|
||||
# 速率限制配置
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
@@ -168,6 +192,15 @@ RATE_LIMIT_PER_HOUR=1000
|
||||
# 数据库备份目录
|
||||
BACKUP_DIR=/app/backups
|
||||
|
||||
# 企业微信告警配置
|
||||
WECHAT_CORPID=
|
||||
WECHAT_CORPSECRET=
|
||||
WECHAT_AGENTID=
|
||||
WECHAT_TOKEN=
|
||||
WECHAT_ENCODING_AES_KEY=
|
||||
WECHAT_USE_PROXY=True
|
||||
WECHAT_PROXY_API_URL=
|
||||
|
||||
# 备份保留天数
|
||||
BACKUP_RETENTION=30
|
||||
|
||||
|
||||
+39
-11
@@ -6,7 +6,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-backend
|
||||
ports:
|
||||
- "8001:8000"
|
||||
- "${BACKEND_PORT:-8001}:8000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- REDIS_URL=${REDIS_URL}
|
||||
@@ -16,8 +16,25 @@ services:
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
|
||||
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
|
||||
- NTP_OLD_SERVER=${NTP_OLD_SERVER:-172.16.0.254}
|
||||
- NTP_NEW_SERVER=${NTP_NEW_SERVER:-172.16.1.252}
|
||||
- IMC_API_URL=${IMC_API_URL:-}
|
||||
- IMC_API_USERNAME=${IMC_API_USERNAME:-}
|
||||
- IMC_API_PASSWORD=${IMC_API_PASSWORD:-}
|
||||
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
- ../backend/static:/app/static
|
||||
@@ -45,7 +62,16 @@ services:
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
depends_on:
|
||||
@@ -68,33 +94,35 @@ services:
|
||||
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
|
||||
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
|
||||
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
|
||||
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
|
||||
- WECHAT_CORPID=${WECHAT_CORPID:-}
|
||||
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
|
||||
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
|
||||
- WECHAT_TOKEN=${WECHAT_TOKEN:-}
|
||||
- WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-}
|
||||
- WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True}
|
||||
- WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top}
|
||||
volumes:
|
||||
- ../backend/logs:/app/logs
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
# 前端应用
|
||||
# 前端应用(生产模式:nginx 静态文件服务)
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: h3c-onu-ms-frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8001}
|
||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
||||
- VITE_CASDOOR_ENDPOINT=${VITE_CASDOOR_ENDPOINT}
|
||||
- VITE_CASDOOR_CLIENT_ID=${VITE_CASDOOR_CLIENT_ID}
|
||||
- VITE_CASDOOR_ORG_NAME=${VITE_CASDOOR_ORG_NAME}
|
||||
- VITE_CASDOOR_APP_NAME=${VITE_CASDOOR_APP_NAME}
|
||||
- "${FRONTEND_PORT:-18062}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:5173"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name onu.dhdx.fun;
|
||||
index index.html;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
access_log /www/sites/onu.dhdx.fun/log/access.log main;
|
||||
error_log /www/sites/onu.dhdx.fun/log/error.log;
|
||||
|
||||
location ^~ /.well-known/acme-challenge {
|
||||
allow all;
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
|
||||
if ($scheme = http) {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
ssl_certificate /www/sites/onu.dhdx.fun/ssl/fullchain.pem;
|
||||
ssl_certificate_key /www/sites/onu.dhdx.fun/ssl/privkey.pem;
|
||||
ssl_protocols TLSv1.3 TLSv1.2 TLSv1.1 TLSv1;
|
||||
ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
error_page 497 https://$host$request_uri;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 前端(生产 nginx 容器,端口 18062)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18062;
|
||||
}
|
||||
|
||||
# 后端 API(frp 隧道 → 本机后端 /api/ws/dashboard WebSocket)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:18060;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -216,12 +216,31 @@ backup_data() {
|
||||
echo "环境: $ENV" >> "$BACKUP_DIR/backup.info"
|
||||
echo "版本: $(git describe --tags 2>/dev/null || echo '未知')" >> "$BACKUP_DIR/backup.info"
|
||||
|
||||
# 验证数据库备份
|
||||
echo -e "${BLUE}验证数据库备份...${NC}"
|
||||
if [ -f "$BACKUP_DIR/database.sql" ]; then
|
||||
SQL_SIZE=$(wc -c < "$BACKUP_DIR/database.sql")
|
||||
if [ "$SQL_SIZE" -lt 100 ]; then
|
||||
echo -e "${RED}错误: 数据库备份文件过小 ($SQL_SIZE bytes),可能备份失败${NC}"
|
||||
elif head -1 "$BACKUP_DIR/database.sql" | grep -qiE "^(--|SET|CREATE|COPY|INSERT|ALTER)"; then
|
||||
echo -e "${GREEN}数据库备份验证通过 ($SQL_SIZE bytes)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}警告: 数据库备份格式异常,请检查${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 压缩备份文件
|
||||
echo -e "${BLUE}压缩备份文件...${NC}"
|
||||
tar -czf "$BACKUP_DIR.tar.gz" "$BACKUP_DIR"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
|
||||
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz${NC}"
|
||||
# 清理旧备份(保留最近 30 天)
|
||||
echo -e "${BLUE}清理旧备份(保留30天)...${NC}"
|
||||
find backups/ -name "*.tar.gz" -mtime +30 -delete 2>/dev/null
|
||||
find backups/ -name "*.tar.gz" -mtime +30 -exec echo " 删除: {}" \; 2>/dev/null
|
||||
|
||||
BACKUP_COUNT=$(find backups/ -name "*.tar.gz" | wc -l)
|
||||
echo -e "${GREEN}备份完成: $BACKUP_DIR.tar.gz (现存 ${BACKUP_COUNT} 个备份)${NC}"
|
||||
}
|
||||
|
||||
# 函数:恢复数据
|
||||
|
||||
+3
-21
@@ -17,7 +17,7 @@ services:
|
||||
|
||||
celery-worker:
|
||||
build: ./backend
|
||||
command: celery -A celery_worker.celery_app worker --loglevel=info -Q h3c_onu_ms
|
||||
command: celery -A celery_worker.celery_app worker -B --loglevel=info -Q h3c_onu_ms --schedule=/tmp/celerybeat-schedule
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
@@ -27,23 +27,5 @@ services:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
celery-beat:
|
||||
build: ./backend
|
||||
command: celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "18002:5173"
|
||||
environment:
|
||||
- VITE_API_PROXY_TARGET=http://backend:8000
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
# frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动
|
||||
# 部署命令见 .claude/rules/07-remote-operations.md
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# CI 实施说明
|
||||
|
||||
本仓库提供 `.gitlab-ci.yml`,用于满足 C1 项目最小的代码更新、构建、测试和依赖安全检查能力。流水线在合并请求、分支更新和标签创建时运行;受保护分支应在 GitLab 中配置为仅允许通过流水线和评审合入。
|
||||
|
||||
## 发布前配置
|
||||
|
||||
在 GitLab 项目的 CI/CD 变量中配置以下**受保护且掩码**变量:
|
||||
|
||||
| 变量 | 用途 | 要求 |
|
||||
| --- | --- | --- |
|
||||
| `INTERNAL_PYPI_URL` | 企业统一 PyPI 代理地址 | 仅允许访问经批准的代理仓库;不得配置为个人镜像。 |
|
||||
| `INTERNAL_NPM_REGISTRY` | 企业统一 npm 代理地址 | 仅允许访问经批准的代理仓库;不得提交认证令牌到仓库。 |
|
||||
| `INTERNAL_CONTAINER_PROXY` | 企业统一容器镜像代理地址(不含末尾 `/`) | Runner、构建基础镜像和 Docker-in-Docker 均从该代理拉取镜像。 |
|
||||
|
||||
流水线会在变量缺失时失败,避免从非受控公共源下载依赖或基础镜像。若代理需要认证,应使用 GitLab 受保护变量或 Runner 的受控凭据注入机制,不得将账号、口令或 token 写入 `.gitlab-ci.yml`、`requirements.txt`、`package-lock.json`、Dockerfile 或日志。
|
||||
|
||||
## 流水线内容与门禁
|
||||
|
||||
1. `dependency-source-policy`:验证统一依赖源已配置。
|
||||
2. `backend-tests`:安装锁定的 Python 依赖,执行语法检查和 pytest,归档 JUnit 报告。
|
||||
3. `frontend-build`:通过 `npm ci` 使用锁文件构建前端,归档静态构建产物。
|
||||
4. `python-dependency-audit`:执行 Python 第三方组件漏洞扫描,归档 JSON 报告;发现可识别漏洞时失败。
|
||||
5. `container-build`:仅构建带 commit SHA 标签的后端与前端镜像,验证 Dockerfile 可构建;Python、npm 与基础镜像均从企业代理获得,本 job 不推送镜像。
|
||||
|
||||
容器构建依赖 Docker-in-Docker runner。若当前 Runner 未授权特权容器,应由平台管理员提供隔离的构建 Runner;不得为了通过流水线而取消镜像构建或将 Docker Socket 暴露给不受信任的 job。
|
||||
|
||||
## 仍需由平台完成的事项
|
||||
|
||||
- 在 GitLab 中开启合并请求评审与成功流水线门禁,并保护 `main`/发布分支。
|
||||
- 增加平台 SAST、前端依赖/SCA、许可证扫描和镜像扫描 job,并把报告关联到统一制品库中的制品元数据。
|
||||
- 将镜像推送至统一制品库,采用不可变版本(发布 tag + commit SHA)并记录制品、测试、扫描和部署关联;该动作需要制品库地址与发布权限,未在本次整改中写入。
|
||||
@@ -0,0 +1,83 @@
|
||||
# H3C ONU 设备管理系统研发规范整改计划
|
||||
|
||||
## 1. 审查基线
|
||||
|
||||
- 审查日期:2026-07-28
|
||||
- 审查版本:`5b07ec6df0448bd43966260b1b7b70532c1d552b`(`main`)
|
||||
- 审查范围:Vue/Vite 前端、FastAPI 后端、PostgreSQL/Redis/Celery、Docker Compose 与仓库交付物。
|
||||
- 目标等级:**C1(待项目负责人确认)**。未分类项目按 C1 基线审查,依据《总体规范》2.1。
|
||||
- 说明:本计划只将原文标明 C1/C2/C3/C4 或“【强制】”的内容标为规范要求;其余安全加固按工程风险处置。
|
||||
|
||||
## 2. 整改优先级与工作包
|
||||
|
||||
### P0:上线前阻断项(安全)
|
||||
|
||||
| 编号 | 整改项 | 主要证据 | 验收标准 | 依据 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| SEC-01 | 删除日志中的令牌片段,并建立统一日志脱敏器。脱敏范围至少覆盖 `Authorization`、token、password、secret、corpsecret、客户端凭据及请求体嵌套字段。 | `backend/app/middleware/permission_middleware.py:70` 写入 token 前 20 位;审计中间件仅脱敏三个精确字段名。 | 自动化测试证明日志中不出现任一敏感值或其可复用片段。 | 《安全分册》3.1.1【强制】 |
|
||||
| SEC-02 | 停用 iMC 调用中的 MD5 Digest 实现,优先接入 iMC 支持的 Digest SHA-256、OAuth 或受控网关认证;如设备仅支持 MD5,须形成例外审批、隔离边界和替代改造计划。 | `backend/app/services/imc_service.py:82-87` 使用 `hashlib.md5`。 | 代码与扫描结果不再出现 MD5;或已具备批准的临时例外和退役日期。 | 《安全分册》3.1.2【强制】 |
|
||||
| SEC-03 | 修复“关于”页存储型 XSS:Markdown 渲染后使用白名单 HTML 清洗,限制 URL 协议;为所有 `v-html` 建立受信来源约束。 | `frontend/src/views/About.vue:14,30` 将管理员可写 Markdown 直接插入 DOM。 | 恶意 `script`、事件属性和 `javascript:` URL 均不能执行;新增前端测试。 | 《安全分册》3.2.1.4【强制】 |
|
||||
| SEC-04 | 认证回调验证 Casdoor 返回 JWT 的签名、发行者、受众、有效期和 nonce/state;失败默认拒绝。 | `backend/app/api/v1/auth.py:18-24,50` 明确“不验签”后使用 `sub` 建立本地会话。 | 伪造、过期、错误 issuer/audience 的令牌均被拒绝;通过真实 OIDC 回调回归。 | 《安全分册》2.1.10、2.2.2;《总体规范》4.5.1(C1) |
|
||||
| SEC-05 | 生产环境强制 TLS 校验,禁止 `IMC_API_VERIFY_SSL=false` 默认值;为内部 CA 配置证书链。 | `backend/app/core/config.py:51` 与两份 `.env.example` 默认关闭校验。 | 非开发环境启动时拒绝关闭 TLS 校验;HTTPS 证书错误请求失败。 | 《安全分册》2.2.4、2.3.1 |
|
||||
| SEC-06 | 统一异常处理:对外返回错误码与通用信息,内部以脱敏日志记录关联 ID;禁止透出异常文本、堆栈和上游响应正文。 | 多处 `detail=str(e)`,如 `devices.py:442`、`check.py:87,102`、`auth.py:82`;任务结果含 traceback。 | 500 响应不含路径、凭据、堆栈或上游报文;异常日志可按关联 ID 检索。 | 《安全分册》3.2.2.1【强制】 |
|
||||
| SEC-07 | 对需要复用的 OLT SSH 密码实施加密(密钥由受控密钥服务或部署密钥注入),完成历史数据迁移、轮换和审计。 | `backend/app/models/device.py:14` 原以明文 `Text` 存储设备密码。 | 数据库备份、查询和审计日志均无明文密码;迁移可回滚、轮换可执行。 | 工程安全整改;密钥方案待确认(规范未指定具体 KMS 实现)。 |
|
||||
|
||||
### P1:C1 研发交付链整改
|
||||
|
||||
| 编号 | 整改项 | 验收标准 | 依据 |
|
||||
| --- | --- | --- | --- |
|
||||
| ENG-01 | 在内部研发平台配置 CI:后端依赖安装、pytest、前端 `npm ci && npm run build`、SAST、依赖/SCA 扫描、镜像构建。为失败设置合入门禁并归档报告。 | 每次代码更新均有可追溯构建、单测和安全扫描结果;阻断级问题不可合入或发布。 | 《总体规范》4.5.1、4.5.3(C1);《流水线分册》2.3.2、3.2 |
|
||||
| ENG-02 | 补齐测试策略、用例、测试报告与缺陷闭环;优先增加认证验签、权限隔离、审计脱敏、文件上传、XSS、iMC TLS、OLT 密码加密迁移的自动化测试。 | 测试计划、评审记录、执行报告、缺陷清单齐全;安全整改均有回归用例。 | 《总体规范》4.5.1、4.6、5.5.7(C1);《测试管理分册》3.2-3.5、6 |
|
||||
| ENG-03 | 将第三方依赖改为可复现、可审核来源:固定后端所有直接依赖版本,使用内部统一制品库代理,生成依赖清单与许可证/漏洞扫描报告。 | `requirements` 无无上限版本;构建只从批准镜像/依赖源获取依赖,扫描报告可追溯。 | 《制品管理分册》4.1(C1);《总体规范》4.5.5(C1) |
|
||||
| ENG-04 | 建立版本、制品与部署追溯:镜像标签包含应用版本与 commit ID;测试/生产只从统一制品库拉取已扫描制品,保留版本、测试、部署与回退记录。 | 任一生产版本能反查 commit、制品、扫描/测试结果和部署记录;禁止可变标签发布。 | 《制品管理分册》2.5、2.6、3.1(C1);《部署管理分册》2.2-2.3(C1) |
|
||||
| ENG-05 | 补齐 C1 最小交付物:需求编号及变更记录、概要设计、安全设计/威胁分析、测试计划/报告、部署实施与回退方案;建立需求—用例—代码/制品—版本追踪表。 | 文档受版本控制,且每次发布可对应需求、测试与制品版本。 | 《总体规范》4.1、4.2、4.3、4.4(C1);《代码管理分册》2.3(C1) |
|
||||
| ENG-06 | 在远端仓库核验并固化治理配置:仓库管理员不超过 3 人、最小权限、离职回收、主干保护、发布 tag;补全仓库描述中的项目编号、项目名称和子项目名称。 | 导出远端权限、保护分支和 tag 证据;README 与根 `.gitignore` 保持合规。 | 《代码管理分册》2.2-3.1(C1) |
|
||||
|
||||
### P2:运行与可维护性改进
|
||||
|
||||
| 编号 | 整改项 | 验收标准 | 属性 |
|
||||
| --- | --- | --- | --- |
|
||||
| OPS-01 | 统一部署脚本与实际 Compose 服务、端口和健康检查;补充发布前检查、回退演练、备份恢复验证。 | 部署、备份和恢复在预发环境演练成功,并记录证据。 | 规范要求(部署材料,C1) |
|
||||
| OPS-02 | Docker 构建使用固定基础镜像摘要、非 root 用户、最小镜像和健康检查;为容器设置资源与网络边界。 | 镜像安全扫描通过,运行身份和暴露端口可审计。 | 工程建议 |
|
||||
| OPS-03 | 清理 Pydantic/SQLAlchemy 弃用用法,增加 lint/format/type-check;建立代码所有者与评审清单。 | CI 中无新增高等级质量问题,弃用告警清零。 | 工程建议 |
|
||||
|
||||
## 3. 实施顺序与里程碑
|
||||
|
||||
1. **M0:范围确认(0.5 天)**:确认项目等级、数据分级、iMC 支持的认证方式、内部制品库/CI/密钥服务和生产发布窗口。
|
||||
2. **M1:安全止血(3-5 天)**:完成 SEC-01 至 SEC-06,补充回归测试;SEC-07 先输出密钥与迁移设计,禁止新增明文凭据。
|
||||
3. **M2:凭据迁移与测试(3-5 天)**:实施 SEC-07,完成历史数据加密、密钥轮换演练和核心接口测试。
|
||||
4. **M3:研发交付链(3-5 天)**:实施 ENG-01 至 ENG-04,接入内部流水线、制品库、SAST/SCA 与可追溯发布。
|
||||
5. **M4:文档与上线验收(2-3 天)**:实施 ENG-05、ENG-06、OPS-01,完成预发演练、测试报告、部署审批材料和复审。
|
||||
|
||||
## 4. 当前证据缺口与需确认事项
|
||||
|
||||
- 未获得远端仓库的成员、权限、保护分支、合并评审和流水线运行记录,不能据此断言其合规性。
|
||||
- 未获得研发云、制品库、SAST/SCA、测试管理、缺陷管理和生产部署平台的证据。
|
||||
- 本地测试尝试因当前运行环境未安装 `paramiko` 而在收集阶段中断;前端构建因工作环境没有 npm 未执行。应由 CI 使用锁定工具链和依赖后重跑。
|
||||
- 需项目负责人确认:项目 C 级、数据分级、iMC 接口可支持的认证算法、密钥托管产品和发布窗口。
|
||||
|
||||
## 4.1 SEC-07 发布前操作(待发布授权)
|
||||
|
||||
1. 在受控密钥服务中生成并保管 `CREDENTIAL_ENCRYPTION_KEY`;不得写入仓库、镜像、部署脚本或审计日志。
|
||||
2. 备份数据库并完成恢复演练;记录备份版本和操作人。
|
||||
3. 将密钥仅注入 backend、celery-worker、celery-beat 运行环境,部署新版代码后执行 `python scripts/migrate_olt_credentials.py`。
|
||||
4. 以数据库管理员账户核验 `olt_devices.password` 全部为 `enc:v1:` 前缀;通过应用的 OLT 扫描、重启和端口管理回归测试确认可解密使用。
|
||||
5. 如迁移异常,先停止后续发布,使用已验证的数据库备份回退;密钥泄露时按应急流程轮换密钥并重新加密所有凭据。
|
||||
|
||||
## 4.2 SEC-02 iMC 认证能力核验(2026-07-28)
|
||||
|
||||
- 已在部署服务器上对 iMC REST 接口发起未认证的只读请求。服务端返回 `401` 及 `Digest realm="iMC RESTful Web Services", qop="auth"`,未声明 `algorithm` 参数;现网项目的 iMC 客户端亦按 MD5 Digest 计算认证摘要。
|
||||
- 受控浏览器因 iMC 使用不受信任的 TLS 证书而拒绝建立连接,未绕过证书校验;因此不能把“浏览器能访问”作为验收证据。
|
||||
- 目前未发现 Digest SHA-256、OAuth/OIDC 或令牌认证的可用证据。SEC-02 不能以修改客户端代码的方式单独关闭:须向 iMC 厂商/平台管理员取得当前版本 REST API 的认证能力说明并确认升级路径。
|
||||
- 若确认该版本仅支持 MD5 Digest,则上线前应提交安全例外审批,至少限定 iMC 为受控内网目标、使用专用最小权限账户、启用有效 TLS 证书与证书校验、禁止记录 `Authorization`/nonce/响应敏感数据,并明确 iMC 升级或网关替代方案的责任人和退役日期。
|
||||
|
||||
## 4.3 ENG-01、ENG-03 当前落实情况(2026-07-28)
|
||||
|
||||
- 已新增 GitLab CI 基线:统一依赖源检查、后端单元测试与 JUnit 报告、前端 `npm ci` 构建、Python 组件漏洞扫描和容器构建校验。详见 `docs/CI实施说明.md`。
|
||||
- 已锁定后端直接依赖中原本使用下限约束的 `aiohttp`、`PyJWT`、`requests`;前端已存在 `package-lock.json`,流水线与 Docker 构建均使用 `npm ci`。
|
||||
- Dockerfile 不再固定第三方镜像站;CI 通过 `INTERNAL_PYPI_URL`、`INTERNAL_NPM_REGISTRY`、`INTERNAL_CONTAINER_PROXY` 三个受保护变量接入企业代理。变量、受保护 Runner、合并门禁和统一制品库推送尚需由平台管理员配置后才能验收。
|
||||
- 本地环境没有可用的 Docker、pytest 及项目运行依赖;已通过 Python 语法编译与 `git diff --check`,完整测试、镜像构建、依赖扫描和 GitLab CI Lint 均待在配置完成的内部流水线执行。
|
||||
|
||||
## 5. 下一项可执行动作
|
||||
|
||||
由 GitLab/制品库管理员先配置 `INTERNAL_PYPI_URL`、`INTERNAL_NPM_REGISTRY`、`INTERNAL_CONTAINER_PROXY` 与隔离的 Docker Runner;随后将本整改分支推送到 GitLab,确认 VerifyCI 全绿并将 `main` 设置为“合并请求评审 + 成功流水线”门禁。SEC-02 保持例外审批依赖,SEC-07 仍待密钥服务与发布窗口确认。
|
||||
+24
-4
@@ -1,12 +1,32 @@
|
||||
FROM node:18-alpine
|
||||
# Stage 1: Build
|
||||
ARG NODE_BASE_IMAGE=node:20-alpine
|
||||
ARG NGINX_BASE_IMAGE=nginx:alpine
|
||||
FROM ${NODE_BASE_IMAGE} AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
ARG NPM_CONFIG_REGISTRY
|
||||
RUN if [ -n "$NPM_CONFIG_REGISTRY" ]; then npm config set registry "$NPM_CONFIG_REGISTRY"; fi \
|
||||
&& npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
# Build for production
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
# Stage 2: Serve with nginx
|
||||
FROM ${NGINX_BASE_IMAGE} AS serve
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built files from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression for text-based assets
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript
|
||||
application/json application/javascript application/xml+rss
|
||||
image/svg+xml;
|
||||
|
||||
# Cache static assets with content hash names (Vite output)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# SPA fallback - all routes serve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# Health check endpoint for docker
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,11 @@ export const getSummary = () => request.get('/stats/summary')
|
||||
export const getTrend = (days = 7) => request.get('/stats/trend', { params: { days } })
|
||||
|
||||
export const getByRegion = () => request.get('/stats/by-region')
|
||||
|
||||
export const getOltStats = () => request.get('/stats/olt-stats')
|
||||
|
||||
export const getModelDistribution = () => request.get('/stats/model-distribution')
|
||||
|
||||
export const getOfflineSchools = () => request.get('/stats/offline-schools')
|
||||
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div v-if="authStore.user" class="sidebar-user">
|
||||
<div class="sidebar-user-avatar">{{ (authStore.user.display_name || authStore.user.username || '?')[0] }}</div>
|
||||
<div class="sidebar-user-info">
|
||||
<div class="sidebar-user-name">{{ authStore.user.display_name || authStore.user.username }}</div>
|
||||
<div class="sidebar-user-role">{{ roleLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" @click="handleLogout">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
@@ -116,7 +123,7 @@
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
v0.5.0
|
||||
v{{ appVersion }}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -199,6 +206,7 @@ import { useMobile } from '../composables/useMobile'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appVersion = ref('0.0.0')
|
||||
const themeStore = useThemeStore()
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
@@ -210,14 +218,32 @@ const updateTime = () => {
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
let timer = null
|
||||
|
||||
// 键盘快捷键
|
||||
const shortcuts = { '1': '/dashboard', '2': '/devices', '3': '/charts', '4': '/olt', '5': '/inventory' }
|
||||
const onKeydown = (e) => {
|
||||
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); document.querySelector('.search-input input')?.focus() }
|
||||
if (e.ctrlKey && shortcuts[e.key]) { e.preventDefault(); router.push(shortcuts[e.key]) }
|
||||
}
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/health')
|
||||
const data = await res.json()
|
||||
if (data.version) appVersion.value = data.version
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 1000)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
if (authStore.token && !authStore.user) {
|
||||
await authStore.fetchProfile()
|
||||
}
|
||||
fetchVersion()
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) })
|
||||
|
||||
const allNavItems = [
|
||||
{
|
||||
@@ -342,6 +368,9 @@ const pageNameMap = {
|
||||
}
|
||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||
|
||||
const roleLabels = { admin: '超级管理员', area_admin: '区域管理员', school_admin: '学校管理员', user: '普通用户' }
|
||||
const roleLabel = computed(() => roleLabels[authStore.role] || authStore.role)
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
@@ -501,6 +530,49 @@ const handleLogout = () => {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.sidebar-user-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-user-info {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-user-role {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="数据导入" width="min(90vw, 560px)" destroy-on-close>
|
||||
<div class="import-hint">请先下载模板,按格式填写后上传。导入只更新设备信息,不会删除已有设备。</div>
|
||||
<div class="import-actions">
|
||||
<el-button size="small" @click="$emit('download-template')">下载导入模板</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
accept=".xlsx,.xls"
|
||||
:on-change="(f) => $emit('file-change', f)"
|
||||
:on-remove="() => $emit('file-remove')"
|
||||
drag
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<div class="upload-area">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--text-muted); margin-bottom: 8px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<div style="font-size:13px;color:var(--text-secondary)">拖拽文件到此处,或 <em style="color:var(--accent)">点击选择</em></div>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">支持 .xlsx / .xls 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div v-if="result" style="margin-top:16px">
|
||||
<el-alert
|
||||
:type="result.failed?.length ? 'warning' : 'success'"
|
||||
:title="`导入完成:成功 ${result.success} 条${result.failed?.length ? ',失败 ' + result.failed.length + ' 条' : ''}`"
|
||||
:closable="false"
|
||||
/>
|
||||
<div v-if="result.failed?.length" style="margin-top:10px;max-height:160px;overflow-y:auto">
|
||||
<div v-for="f in result.failed" :key="f.row" style="font-size:12px;color:var(--danger);padding:2px 0">
|
||||
第 {{ f.row }} 行<template v-if="f.mac">({{ f.mac }})</template>:{{ f.reason }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:visible', false); $emit('close')">关闭</el-button>
|
||||
<el-button type="primary" :loading="loading" :disabled="!hasFile" @click="$emit('import')">开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
hasFile: { type: Boolean, default: false },
|
||||
result: { type: Object, default: null },
|
||||
})
|
||||
defineEmits(['update:visible', 'download-template', 'file-change', 'file-remove', 'import', 'close'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-hint { font-size:13px; color:var(--text-muted); margin-bottom:12px; }
|
||||
.import-actions { margin-bottom:4px; }
|
||||
.upload-area { display:flex; flex-direction:column; align-items:center; padding:20px 0; }
|
||||
</style>
|
||||
@@ -1,17 +1,34 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { isAfterSunset, getMsUntilNextSwitch } from '../utils/sunset'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const STORAGE_KEY = 'onu-theme'
|
||||
const theme = ref(localStorage.getItem(STORAGE_KEY) || 'dark')
|
||||
const savedTheme = localStorage.getItem(STORAGE_KEY)
|
||||
// 无手动偏好时,根据广西日落时间自动选择
|
||||
const theme = ref(savedTheme || (isAfterSunset() ? 'dark' : 'light'))
|
||||
|
||||
const applyTheme = (t) => {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
}
|
||||
|
||||
let switchTimer = null
|
||||
|
||||
// 初始化时立即应用
|
||||
applyTheme(theme.value)
|
||||
|
||||
// 设置日落/日出自动切换定时器,仅在用户未手动选择时生效
|
||||
const scheduleAutoSwitch = () => {
|
||||
if (switchTimer) clearTimeout(switchTimer)
|
||||
// 始终在日落/日出时自动切换
|
||||
const delay = getMsUntilNextSwitch()
|
||||
switchTimer = setTimeout(() => {
|
||||
theme.value = isAfterSunset() ? 'dark' : 'light'
|
||||
scheduleAutoSwitch() // 递归调度下一次
|
||||
}, delay + 60000) // 加 1 分钟余量
|
||||
}
|
||||
scheduleAutoSwitch()
|
||||
|
||||
// 切换
|
||||
const toggle = () => {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 广西(南宁)日落时间计算
|
||||
* 纬度 22.82°N, 经度 108.37°E, 时区 UTC+8
|
||||
*/
|
||||
|
||||
const LAT = 22.82 // 南宁纬度
|
||||
const LON = 108.37 // 南宁经度
|
||||
|
||||
function toRad(deg) { return deg * Math.PI / 180 }
|
||||
function toDeg(rad) { return rad * 180 / Math.PI }
|
||||
|
||||
/**
|
||||
* 计算指定日期的日落时间(北京时间)
|
||||
* @param {Date} date
|
||||
* @returns {{ hour: number, minute: number }} 日落时分
|
||||
*/
|
||||
export function getSunsetTime(date = new Date()) {
|
||||
const dayOfYear = Math.floor((date - new Date(date.getFullYear(), 0, 0)) / 86400000)
|
||||
|
||||
// 太阳赤纬 (solar declination)
|
||||
const declination = 23.45 * Math.sin(toRad(360 / 365 * (284 + dayOfYear)))
|
||||
|
||||
// 日落时角 cos(ω) = -tan(lat)*tan(δ)
|
||||
const cosOmega = -Math.tan(toRad(LAT)) * Math.tan(toRad(declination))
|
||||
const omega = Math.acos(Math.max(-1, Math.min(1, cosOmega))) // 弧度
|
||||
|
||||
// 日落地方太阳时(小时)
|
||||
const solarHour = 12 + toDeg(omega) / 15
|
||||
|
||||
// 修正:时区经度(120°E)与本地经度差
|
||||
const correction = (120 - LON) / 15 * 60 // 分钟
|
||||
const totalMinutes = solarHour * 60 + correction
|
||||
|
||||
const hour = Math.floor(totalMinutes / 60) % 24
|
||||
const minute = Math.round(totalMinutes % 60)
|
||||
|
||||
return { hour, minute }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否在日落之后(应使用深色模式)
|
||||
*/
|
||||
export function isAfterSunset() {
|
||||
const now = new Date()
|
||||
const sunset = getSunsetTime(now)
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const sunsetMinutes = sunset.hour * 60 + sunset.minute
|
||||
// 日出约为 12 - (sunset - 12) = 24 - sunset(粗略估算)
|
||||
const sunriseMinutes = (24 * 60 - sunsetMinutes) % (24 * 60)
|
||||
// 深色时间:日落之后 到 日出之前
|
||||
return currentMinutes >= sunsetMinutes || currentMinutes < sunriseMinutes
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取距离下次切换的毫秒数
|
||||
* 用于设置定时器在日落/日出时自动切换
|
||||
*/
|
||||
export function getMsUntilNextSwitch() {
|
||||
const now = new Date()
|
||||
const sunset = getSunsetTime(now)
|
||||
const sunsetMin = sunset.hour * 60 + sunset.minute
|
||||
const sunriseMin = (24 * 60 - sunsetMin) % (24 * 60)
|
||||
const currentMin = now.getHours() * 60 + now.getMinutes()
|
||||
|
||||
let targetMin
|
||||
if (currentMin >= sunsetMin || currentMin < sunriseMin) {
|
||||
// 当前是深色时间,下次切换是日出
|
||||
targetMin = sunriseMin
|
||||
} else {
|
||||
// 当前是浅色时间,下次切换是日落
|
||||
targetMin = sunsetMin
|
||||
}
|
||||
|
||||
const diffMin = (targetMin - currentMin + 24 * 60) % (24 * 60)
|
||||
return diffMin * 60 * 1000
|
||||
}
|
||||
@@ -19,15 +19,34 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { marked, Renderer } from 'marked'
|
||||
import request from '../utils/request'
|
||||
|
||||
const content = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
const safeLink = (href) => {
|
||||
try {
|
||||
const url = new URL(href, window.location.origin)
|
||||
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const safeRenderer = new Renderer()
|
||||
safeRenderer.html = () => ''
|
||||
safeRenderer.image = ({ text }) => text
|
||||
safeRenderer.link = function ({ href, tokens }) {
|
||||
const text = this.parser.parseInline(tokens)
|
||||
const safeHref = safeLink(href)
|
||||
if (!safeHref) return text
|
||||
return `<a href="${safeHref}" rel="noopener noreferrer" target="_blank">${text}</a>`
|
||||
}
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!content.value) return ''
|
||||
return marked.parse(content.value)
|
||||
return marked.parse(content.value, { renderer: safeRenderer })
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -42,7 +61,7 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
.about-page {
|
||||
padding: 24px 28px;
|
||||
max-width: 860px;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@change="fetchLogs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<div v-else-if="error">
|
||||
<p>登录失败: {{ error }}</p>
|
||||
<el-button @click="$router.push('/login')">返回登录</el-button>
|
||||
<el-button type="info" @click="showDetails = !showDetails">详细信息</el-button>
|
||||
<pre v-if="showDetails" class="error-details">{{ errorDetails }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -26,8 +24,6 @@ const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const errorDetails = ref('')
|
||||
const showDetails = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const code = new URLSearchParams(window.location.search).get('code')
|
||||
@@ -44,8 +40,7 @@ onMounted(async () => {
|
||||
authStore.setToken(data.access_token)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
error.value = err.message || '登录失败'
|
||||
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
|
||||
error.value = err.response?.data?.detail || '登录失败,请稍后重试'
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
@@ -58,16 +53,4 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.error-details {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
+141
-46
@@ -31,17 +31,59 @@
|
||||
</div>
|
||||
<div ref="regionChart" class="chart-area"></div>
|
||||
</div>
|
||||
|
||||
<!-- OLT 设备在线率 -->
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||
<rect x="2" y="2" width="20" height="8" rx="1"/><rect x="2" y="14" width="20" height="8" rx="1"/>
|
||||
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
|
||||
</svg>
|
||||
OLT 设备在线率
|
||||
</span>
|
||||
</div>
|
||||
<div ref="oltChart" class="chart-area" style="height:360px"></div>
|
||||
</div>
|
||||
|
||||
<!-- 设备型号分布 -->
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:6px">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
设备型号分布
|
||||
</span>
|
||||
</div>
|
||||
<div ref="modelChart" class="chart-area" style="height:360px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { getTrend, getByRegion } from '../api/stats'
|
||||
import { getTrend, getByRegion, getOltStats, getModelDistribution } from '../api/stats'
|
||||
|
||||
const trendChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
const oltChart = ref(null)
|
||||
const modelChart = ref(null)
|
||||
const chartInstances = []
|
||||
|
||||
const _initChart = (dom) => {
|
||||
if (!dom) return null
|
||||
const existing = echarts.getInstanceByDom(dom)
|
||||
if (existing) existing.dispose()
|
||||
const chart = echarts.init(dom)
|
||||
chartInstances.push(chart)
|
||||
return chart
|
||||
}
|
||||
|
||||
const _resizeCharts = () => chartInstances.forEach(c => { try { c.resize() } catch {} })
|
||||
|
||||
const chartTheme = {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -59,70 +101,60 @@ const chartTheme = {
|
||||
}
|
||||
|
||||
const initTrendChart = async () => {
|
||||
try {
|
||||
const { data } = await getTrend(7)
|
||||
const chart = echarts.init(trendChart.value)
|
||||
if (!data || !data.length) return
|
||||
const chart = _initChart(trendChart.value)
|
||||
if (!chart) return
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['在线', '离线'],
|
||||
textStyle: { color: '#8a9ab8' },
|
||||
top: 4,
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: '#8a9ab8', fontFamily: 'Noto Sans SC, sans-serif', fontSize: 12 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: '#141c30',
|
||||
borderColor: 'rgba(255,255,255,0.10)',
|
||||
textStyle: { color: '#e8edf5' },
|
||||
extraCssText: 'border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'
|
||||
},
|
||||
legend: { data: ['在线', '离线'], textStyle: { color: '#8a9ab8' }, top: 4 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.date),
|
||||
axisLine: chartTheme.axisLine,
|
||||
axisTick: chartTheme.axisTick,
|
||||
type: 'category', data: data.map(d => d.date),
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
splitLine: chartTheme.splitLine,
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '在线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.online),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#00d2b4', width: 2 },
|
||||
itemStyle: { color: '#00d2b4' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' },
|
||||
{ offset: 1, color: 'rgba(0,210,180,0.00)' },
|
||||
])
|
||||
},
|
||||
name: '在线', type: 'line', data: data.map(d => d.online), smooth: true,
|
||||
symbol: 'circle', symbolSize: 5,
|
||||
lineStyle: { color: '#00d2b4', width: 2 }, itemStyle: { color: '#00d2b4' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' }, { offset: 1, color: 'rgba(0,210,180,0.00)' }
|
||||
])}
|
||||
},
|
||||
{
|
||||
name: '离线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.offline),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#ef4444', width: 2 },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' },
|
||||
{ offset: 1, color: 'rgba(239,68,68,0.00)' },
|
||||
])
|
||||
},
|
||||
name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true,
|
||||
symbol: 'circle', symbolSize: 5,
|
||||
lineStyle: { color: '#ef4444', width: 2 }, itemStyle: { color: '#ef4444' },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' }, { offset: 1, color: 'rgba(239,68,68,0.00)' }
|
||||
])}
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Trend chart error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const initRegionChart = async () => {
|
||||
const { data } = await getByRegion()
|
||||
const chart = echarts.init(regionChart.value)
|
||||
const chart = _initChart(regionChart.value)
|
||||
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
@@ -154,9 +186,72 @@ const initRegionChart = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const initOltChart = async () => {
|
||||
const { data } = await getOltStats()
|
||||
const chart = _initChart(oltChart.value)
|
||||
const sorted = [...data].sort((a, b) => {
|
||||
const ra = a.online / (a.total || 1), rb = b.online / (b.total || 1)
|
||||
return ra - rb
|
||||
})
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis', axisPointer: { type: 'shadow' },
|
||||
formatter: (ps) => {
|
||||
const d = ps[0]
|
||||
return `<b>${d.name}</b><br/>在线: ${d.data.online}/${d.data.total}<br/>在线率: ${(d.data.online/(d.data.total||1)*100).toFixed(1)}%<br/>离线: ${d.data.offline}`
|
||||
}
|
||||
},
|
||||
grid: { left: '3%', right: '8%', bottom: '3%', top: '8%', containLabel: true },
|
||||
xAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: sorted.map(d => d.name),
|
||||
axisLabel: { fontSize: 11, width: 120, overflow: 'truncate' },
|
||||
axisLine: { show: false }, axisTick: { show: false },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: sorted.map(d => ({
|
||||
name: d.name, value: +(d.online / (d.total || 1) * 100).toFixed(1),
|
||||
total: d.total, online: d.online, offline: d.offline,
|
||||
itemStyle: { color: d.online === 0 ? '#ef4444' : +(d.online/(d.total||1)*100).toFixed(1) < 70 ? '#f59e0b' : '#00d2b4',
|
||||
borderRadius: [0, 4, 4, 0] }
|
||||
})),
|
||||
barMaxWidth: 22,
|
||||
label: { show: true, position: 'right', fontSize: 11, color: '#8a9ab8', formatter: '{c}%' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
const initModelChart = async () => {
|
||||
const { data } = await getModelDistribution()
|
||||
if (!data.length) return
|
||||
const chart = _initChart(modelChart.value)
|
||||
const colors = ['#00d2b4','#3b82f6','#a855f7','#f59e0b','#22c55e','#ef4444','#ec4899','#6366f1','#14b8a6','#eab308']
|
||||
chart.setOption({
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'item', formatter: '{b}: {c} 台 ({d}%)' },
|
||||
series: [{
|
||||
type: 'pie', radius: ['42%','70%'], center: ['50%','50%'],
|
||||
data: data.map((d, i) => ({ value: d.count, name: d.model, itemStyle: { color: colors[i % colors.length] } })),
|
||||
label: { show: true, fontSize: 10, color: '#8a9ab8', formatter: '{b}\n{d}%' },
|
||||
labelLine: { length: 16, length2: 12 },
|
||||
emphasis: { itemStyle: { shadowBlur: 12, shadowColor: 'rgba(0,0,0,0.3)' } },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initTrendChart()
|
||||
initRegionChart()
|
||||
initOltChart()
|
||||
initModelChart()
|
||||
window.addEventListener('resize', _resizeCharts)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', _resizeCharts)
|
||||
chartInstances.forEach(c => { try { c.dispose() } catch {} })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<h1 class="page-title">统计概览</h1>
|
||||
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
@@ -16,6 +17,7 @@
|
||||
<span v-else>刷新数据</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="stat-grid">
|
||||
@@ -170,6 +172,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全离线学校(可折叠) -->
|
||||
<div v-if="offlineSchools.length > 0" class="offline-alert" :class="{ collapsed: offlineCollapsed }">
|
||||
<div class="offline-alert-header" @click="offlineCollapsed = !offlineCollapsed">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span>全离线学校({{ offlineSchools.length }} 所)</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="collapse-arrow" :class="{ rotated: !offlineCollapsed }">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-show="!offlineCollapsed" class="offline-schools-list">
|
||||
<span v-for="s in offlineSchools" :key="s.school_name" class="offline-school-tag" @click="goToSchool(s.school_name)">
|
||||
{{ s.school_name }}({{ s.total }}台)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 乡镇详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="townVisible"
|
||||
@@ -197,9 +218,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../utils/request'
|
||||
import { getOfflineSchools } from '../api/stats'
|
||||
import { useMobile } from '../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
@@ -210,6 +232,8 @@ const data = ref({})
|
||||
const lastUpdated = ref('')
|
||||
const townVisible = ref(false)
|
||||
const selectedTown = ref(null)
|
||||
const offlineSchools = ref([])
|
||||
const offlineCollapsed = ref(true)
|
||||
|
||||
const rate = (item) => {
|
||||
if (!item || !item.total) return 0
|
||||
@@ -239,6 +263,13 @@ const goToSchool = (schoolName) => {
|
||||
router.push({ path: '/devices', query: { school_name: schoolName } })
|
||||
}
|
||||
|
||||
const loadOfflineSchools = async () => {
|
||||
try {
|
||||
const { data } = await getOfflineSchools()
|
||||
offlineSchools.value = data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -248,9 +279,24 @@ const loadData = async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loadOfflineSchools()
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
let ws = null
|
||||
const connectWs = () => {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
ws = new WebSocket(`${proto}//${location.host}/api/ws/dashboard`)
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'check_complete') loadData()
|
||||
} catch {}
|
||||
}
|
||||
ws.onclose = () => { setTimeout(connectWs, 10000) }
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); connectWs() })
|
||||
onUnmounted(() => { if (ws) ws.close() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -286,6 +332,34 @@ onMounted(loadData)
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.report-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.report-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -352,6 +426,64 @@ onMounted(loadData)
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* 全离线学校告警 */
|
||||
.offline-alert {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.offline-alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.offline-alert.collapsed .offline-alert-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collapse-arrow.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.offline-schools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.offline-school-tag {
|
||||
display: inline-flex;
|
||||
padding: 4px 10px;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.offline-school-tag:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
<el-button v-if="can('device.import')" type="success" size="small" @click="importDialogVisible = true">
|
||||
数据导入
|
||||
</el-button>
|
||||
<a href="/api/devices/export/csv" class="csv-export-btn" title="导出CSV">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
导出CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +56,12 @@
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">标签</label>
|
||||
<el-select v-model="filters.tag" placeholder="全部" clearable @change="search" size="small" style="width:130px">
|
||||
<el-option v-for="t in availableTags" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">搜索</label>
|
||||
<el-input
|
||||
@@ -428,6 +441,7 @@
|
||||
<el-input v-model="editForm.place_type" placeholder="如:宿舍、教室、办公室…" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item label="标签"><el-input v-model="editForm.tags" placeholder="多个标签用逗号分隔,如:重点设备,考试用" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
@@ -632,7 +646,8 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const regions = ref([])
|
||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
|
||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '', tag: '' })
|
||||
const availableTags = ref([])
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const selectedDevice = ref({})
|
||||
@@ -647,7 +662,7 @@ let cooldownTimer = null
|
||||
const clearing = ref(false)
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '' })
|
||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', place_type: '', notes: '', tags: '' })
|
||||
const regionOptions = ref([])
|
||||
const editSaving = ref(false)
|
||||
|
||||
@@ -1003,6 +1018,7 @@ const openEdit = () => {
|
||||
room_number: selectedDevice.value.room_number || '',
|
||||
place_type: selectedDevice.value.place_type || '',
|
||||
notes: selectedDevice.value.notes || '',
|
||||
tags: selectedDevice.value.tags || '',
|
||||
}
|
||||
loadRegionOptions()
|
||||
detailVisible.value = false
|
||||
@@ -1063,6 +1079,7 @@ const loadDevices = async () => {
|
||||
keyword: filters.value.keyword || undefined,
|
||||
school_name: filters.value.school_name || undefined,
|
||||
status: filters.value.status || undefined,
|
||||
tag: filters.value.tag || undefined,
|
||||
})
|
||||
devices.value = data.items
|
||||
total.value = data.total
|
||||
@@ -1079,12 +1096,20 @@ const handleSizeChange = (val) => {
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const { data } = await request.get('/devices/tags')
|
||||
availableTags.value = data || []
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.school_name) {
|
||||
filters.value.keyword = route.query.school_name
|
||||
}
|
||||
loadRegions()
|
||||
loadDevices()
|
||||
fetchTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1123,6 +1148,26 @@ onMounted(() => {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.csv-export-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.csv-export-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
|
||||
@@ -1026,6 +1026,11 @@ const formatTime = (t) => fmtTimeRaw(t, { slice: 16 })
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.inventory-page { padding: 12px; }
|
||||
.summary-cards { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
|
||||
@@ -8,31 +8,22 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button v-if="!isMobile && can('olt.manage')" type="primary" size="small" @click="openAddDialog">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right: 5px">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="btn-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
添加 OLT
|
||||
</el-button>
|
||||
<el-upload
|
||||
v-if="!isMobile && can('olt.manage')"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleImportFile"
|
||||
accept=".xlsx,.xls"
|
||||
style="display: inline-block"
|
||||
>
|
||||
<el-button type="success" size="small" :loading="importing">批量导入</el-button>
|
||||
</el-upload>
|
||||
<el-button v-if="!isMobile && can('olt.manage')" size="small" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button v-if="!isMobile && can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
|
||||
重复 MAC
|
||||
<el-badge v-if="duplicateCount > 0" :value="duplicateCount" style="margin-left: 4px" />
|
||||
<el-button v-if="!isMobile && can('olt.manage')" type="success" plain size="small" :loading="importing" @click="triggerImport">
|
||||
批量导入
|
||||
</el-button>
|
||||
<el-button v-if="!isMobile && can('olt.manage')" plain size="small" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button v-if="can('olt.manage')" type="danger" plain size="small" @click="openDuplicateMacs">
|
||||
重复 MAC<span v-if="duplicateCount > 0" class="btn-count danger">{{ duplicateCount }}</span>
|
||||
</el-button>
|
||||
<el-button v-if="can('olt.manage')" type="warning" plain size="small" @click="openNewDevices">
|
||||
新增设备
|
||||
<el-badge v-if="newDeviceCount > 0" :value="newDeviceCount" style="margin-left: 4px" />
|
||||
新增设备<span v-if="newDeviceCount > 0" class="btn-count warning">{{ newDeviceCount }}</span>
|
||||
</el-button>
|
||||
<el-button v-if="can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
|
||||
<el-button v-if="!isMobile && can('olt.manage')" type="info" plain size="small" @click="openNtpSync" :loading="ntpSyncing">
|
||||
同步NTP
|
||||
</el-button>
|
||||
<el-button v-if="can('olt.loopback')" type="danger" plain size="small" @click="runLoopbackDetection" :loading="loopDetecting">
|
||||
@@ -41,6 +32,15 @@
|
||||
<el-button v-if="can('olt.discover')" type="primary" plain size="small" @click="runQuickScan" :loading="quickScanning">
|
||||
快速扫描
|
||||
</el-button>
|
||||
<!-- 隐藏的上传组件(批量导入触发) -->
|
||||
<input
|
||||
v-if="!isMobile && can('olt.manage')"
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
style="display:none"
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -604,6 +604,7 @@ const newDevList = ref([])
|
||||
const newDeviceCount = ref(0)
|
||||
const newDevVisible = ref(false)
|
||||
const savingDev = ref(null)
|
||||
const importInput = ref(null)
|
||||
const loopDetecting = ref(false)
|
||||
const loopVisible = ref(false)
|
||||
const loopResults = ref([])
|
||||
@@ -938,7 +939,7 @@ const dismissNewDevice = async (id) => {
|
||||
const runQuickScan = async () => {
|
||||
quickScanning.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/quick-scan')
|
||||
const { data } = await request.post('/olt/quick-scan', null, { timeout: 180000 })
|
||||
quickScanResult.value = data
|
||||
quickScanVisible.value = true
|
||||
} catch (error) {
|
||||
@@ -951,7 +952,7 @@ const runQuickScan = async () => {
|
||||
const runLoopbackDetection = async () => {
|
||||
loopDetecting.value = true
|
||||
try {
|
||||
const { data } = await request.post('/olt/loopback-detection')
|
||||
const { data } = await request.post('/olt/loopback-detection', null, { timeout: 120000 })
|
||||
loopResults.value = data
|
||||
const map = {}
|
||||
for (const item of data) {
|
||||
@@ -973,10 +974,16 @@ const showLoopDetail = (oltId) => {
|
||||
loopDetailVisible.value = true
|
||||
}
|
||||
|
||||
const handleImportFile = async (uploadFile) => {
|
||||
const triggerImport = () => {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
const handleImportFile = async (event) => {
|
||||
const file = event.target?.files?.[0] || (event.raw || event)
|
||||
if (!file) return
|
||||
importing.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.raw)
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const { data } = await request.post('/olt/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
@@ -1077,8 +1084,35 @@ onMounted(() => {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 5px;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.btn-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
margin-left: 4px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-count.danger { background: var(--danger); color: #fff; }
|
||||
.btn-count.warning { background: var(--warning); color: #fff; }
|
||||
|
||||
.header-actions .el-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 数据面板 */
|
||||
@@ -1526,19 +1560,17 @@ onMounted(() => {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-actions .el-button,
|
||||
.header-actions > * {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* 移动端按钮区只显示2列(移动端只剩新增设备、环路检测、快速扫描) */
|
||||
.header-actions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.header-actions .el-button {
|
||||
width: 95%;
|
||||
min-height: 44px;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 端口管理对话框:限制高度支持滚动 */
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
:total="repTotal"
|
||||
:page-sizes="[50, 100, 200]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@size-change="repLoad"
|
||||
@current-change="repLoad"
|
||||
/>
|
||||
@@ -203,7 +203,7 @@
|
||||
:total="auditTotal"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
@change="fetchAudit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { padding: 24px; max-width: 1100px; }
|
||||
.page-wrap { padding: 24px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.layout { display: flex; gap: 20px; align-items: flex-start; }
|
||||
|
||||
@@ -99,6 +99,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业微信告警设置 -->
|
||||
<div class="settings-card" style="margin-top: 20px">
|
||||
<div class="card-header">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 8px">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
企业微信告警
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="setting-desc">当某个学校所有设备全部离线时,通过企业微信应用消息 API 发送告警。请填写企业微信自建应用的凭证信息。</p>
|
||||
<div class="wechat-field">
|
||||
<label class="wechat-label">CorpID</label>
|
||||
<input v-model="wechatCorpId" class="webhook-input" placeholder="企业ID" :disabled="wechatSaving" />
|
||||
</div>
|
||||
<div class="wechat-field">
|
||||
<label class="wechat-label">CorpSecret</label>
|
||||
<input v-model="wechatCorpSecret" type="password" class="webhook-input" placeholder="应用 Secret" :disabled="wechatSaving" />
|
||||
</div>
|
||||
<div class="wechat-field">
|
||||
<label class="wechat-label">AgentID</label>
|
||||
<input v-model="wechatAgentId" class="webhook-input" placeholder="应用 AgentID" :disabled="wechatSaving" />
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button class="save-btn" :disabled="wechatSaving" @click="saveWebhook">
|
||||
<svg v-if="wechatSaving" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ wechatSaving ? '保存中…' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -179,6 +214,7 @@ const save = async () => {
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadAbout()
|
||||
loadWebhook()
|
||||
const timer = setInterval(load, 10000)
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
})
|
||||
@@ -204,12 +240,43 @@ const saveAbout = async () => {
|
||||
aboutSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const wechatCorpId = ref('')
|
||||
const wechatCorpSecret = ref('')
|
||||
const wechatAgentId = ref('')
|
||||
const wechatSaving = ref(false)
|
||||
|
||||
const loadWebhook = async () => {
|
||||
try {
|
||||
const { data } = await getSettings()
|
||||
const setVal = (key, ref) => { const s = data?.[key]; if (s) ref.value = s.value || '' }
|
||||
setVal('wechat_corpid', wechatCorpId)
|
||||
setVal('wechat_corpsecret', wechatCorpSecret)
|
||||
setVal('wechat_agentid', wechatAgentId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveWebhook = async () => {
|
||||
wechatSaving.value = true
|
||||
try {
|
||||
await request.put('/settings/webhook', {
|
||||
corpid: wechatCorpId.value,
|
||||
corpsecret: wechatCorpSecret.value,
|
||||
agentid: wechatAgentId.value,
|
||||
})
|
||||
ElMessage.success('企业微信配置已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
wechatSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
padding: 24px;
|
||||
max-width: 640px;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -427,6 +494,54 @@ const saveAbout = async () => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webhook-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.webhook-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.webhook-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wechat-section-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.wechat-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.wechat-label {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.settings-page {
|
||||
padding: 16px 12px;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>姓名</th>
|
||||
<th>角色</th>
|
||||
<th>区域/学校</th>
|
||||
<th>状态</th>
|
||||
@@ -38,7 +38,7 @@
|
||||
</tr>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="td-username">{{ u.username }}</td>
|
||||
<td class="td-muted">{{ u.email || '—' }}</td>
|
||||
<td class="td-muted">{{ u.display_name || u.username || '—' }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="'role-' + u.role">{{ roleLabel(u.role) }}</span>
|
||||
</td>
|
||||
@@ -81,7 +81,7 @@
|
||||
<div v-if="editUser" class="modal-overlay" @click.self="editUser = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">编辑用户:{{ editUser.username }}</span>
|
||||
<span class="modal-title">编辑用户:{{ editUser.display_name || editUser.username }}</span>
|
||||
<button class="modal-close" @click="editUser = null">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -330,7 +330,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-wrap { padding: 24px; max-width: 1200px; }
|
||||
.page-wrap { padding: 24px; }
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||
.page-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin: 0; }
|
||||
.header-filters { display: flex; gap: 10px; }
|
||||
|
||||
@@ -11,6 +11,10 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: (process.env.VITE_API_PROXY_TARGET || 'http://localhost:8001').replace('http', 'ws'),
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
# 为 H3ConuMS2 添加 ONU 远程重启 & 光功率查询功能 — 后端开发指南
|
||||
|
||||
> 目标:将 H3C iMC 平台的 ONU 远程重启和光功率查询功能集成到 H3ConuMS2 项目中
|
||||
> 技术栈:FastAPI + SQLAlchemy + requests (HTTP Digest Auth)
|
||||
> 源项目参考:`/home/v6ole/pyproject/H3ConuMS`
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌──────────────────┐ REST API 调用 ┌──────────────────┐
|
||||
│ H3ConuMS2 后端 │ ──────────────────────► │ H3C iMC 平台 │
|
||||
│ (FastAPI) │ ◄────────────────────── │ │
|
||||
│ IMCService │ │ /imcrs/epon/... │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
**后端提供的 API 端点:**
|
||||
| 端点 | 方法 | 说明 | iMC 后端接口 |
|
||||
|------|------|------|-------------|
|
||||
| `/api/devices/{id}/reboot` | POST | 远程重启 ONU | `/imcrs/epon/onu/reboot?mac={mac}` (POST) |
|
||||
| `/api/devices/{id}/optical-power` | GET | 获取 ONU 光功率 | `/imcrs/epon/onu/onuLightWaneInfo?mac={mac}` (GET) |
|
||||
|
||||
**数据流(以重启为例):**
|
||||
1. 客户端 POST `/api/devices/{id}/reboot`
|
||||
2. 后端控制器验证设备存在 + 权限 → 调用 `IMCService.reboot_onu()`
|
||||
3. `IMCService` 构建 HTTP Digest 认证头 → POST 到 iMC REST API
|
||||
4. iMC 返回结果 → 后端解析错误码 → 返回 JSON
|
||||
|
||||
---
|
||||
|
||||
## 2. Digest 认证原理解析
|
||||
|
||||
iMC 的 REST API 使用 **HTTP Digest Access Authentication**(RFC 2617),不是普通的 Cookie/Session 登录。
|
||||
|
||||
### 认证流程
|
||||
|
||||
```
|
||||
客户端 iMC 服务器
|
||||
│ │
|
||||
│──── GET /imcrs/... (无认证) ────│
|
||||
│ │──── 401 + WWW-Authenticate header
|
||||
│ │ (包含 nonce, realm, qop)
|
||||
│ │
|
||||
│ ── 解析 WWW-Authenticate ──► │
|
||||
│ 提取 nonce 和 realm │
|
||||
│ │
|
||||
│ ── 计算 Digest 响应 ────────► │
|
||||
│ HA1 = MD5(user:realm:pass) │
|
||||
│ HA2 = MD5(method:uri) │
|
||||
│ response = MD5(HA1:nonce:nc:cnonce:qop:HA2) │
|
||||
│ │
|
||||
│──── POST /imcrs/... ──────────►│
|
||||
│ Authorization: Digest ... │
|
||||
│ │──── 200 OK (成功)
|
||||
```
|
||||
|
||||
### 核心 MD5 计算
|
||||
|
||||
```python
|
||||
cnonce = md5(str(time.time())).hexdigest()[:16]
|
||||
ha1 = md5(f"{username}:{realm}:{password}").hexdigest()
|
||||
ha2 = md5(f"{method}:{uri}").hexdigest()
|
||||
response = md5(f"{ha1}:{nonce}:{nc:08d}:{cnonce}:auth:{ha2}").hexdigest()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 需要修改/新增的文件清单
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `backend/app/services/imc_service.py` | **新增** | iMC API 服务(Digest 认证 + 重启 + 光功率) |
|
||||
| `backend/app/services/__init__.py` | 修改 | 导出 IMCService |
|
||||
| `backend/app/schemas/device.py` | 修改 | 添加重启/光功率响应 Schema |
|
||||
| `backend/app/api/v1/devices.py` | 修改 | 添加重启和光功率 API 路由 |
|
||||
| `backend/app/core/config.py` | 修改 | 添加 iMC 配置项 |
|
||||
| `.env` 或 `backend/.env` | 修改 | 添加 iMC 环境变量 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 后端实现
|
||||
|
||||
### 4.1 配置项 — `backend/app/core/config.py`
|
||||
|
||||
在 `Settings` 类中添加 iMC 相关配置:
|
||||
|
||||
```python
|
||||
# ===== iMC API 配置(用于 ONU 远程重启和光功率查询)=====
|
||||
IMC_API_URL: str = "" # 例如 https://172.16.1.252:8443
|
||||
IMC_API_USERNAME: str = "" # iMC 用户名
|
||||
IMC_API_PASSWORD: str = "" # iMC 密码(明文,Digest认证需要原始密码)
|
||||
IMC_API_VERIFY_SSL: bool = False # 是否验证 SSL 证书
|
||||
IMC_CONNECT_TIMEOUT: float = 5.0
|
||||
IMC_READ_TIMEOUT: float = 20.0
|
||||
```
|
||||
|
||||
### 4.2 .env 配置
|
||||
|
||||
在 `backend/.env`(或项目根目录 `.env`)中添加:
|
||||
|
||||
```env
|
||||
# iMC API 配置(用于 ONU 远程重启和光功率查询)
|
||||
IMC_API_URL=https://172.16.1.252:8443
|
||||
IMC_API_USERNAME=admin
|
||||
IMC_API_PASSWORD=Pwd@12345
|
||||
IMC_API_VERIFY_SSL=false
|
||||
IMC_CONNECT_TIMEOUT=5
|
||||
IMC_READ_TIMEOUT=20
|
||||
```
|
||||
|
||||
### 4.3 IMCService — `backend/app/services/imc_service.py`
|
||||
|
||||
完整代码,包含 Digest 认证 + 重启 ONU + 光功率查询三大功能:
|
||||
|
||||
```python
|
||||
"""
|
||||
iMC REST API 服务
|
||||
- 使用 HTTP Digest Access Authentication (RFC 2617)
|
||||
- 支持 nonce 过期自动续约(401 时自动重新握手)
|
||||
- 功能:ONU 远程重启、光功率查询
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# iMC 重启错误码映射
|
||||
REBOOT_ERROR_CODES = {
|
||||
'103': 'ONU不存在',
|
||||
'119': 'SNMP连接超时',
|
||||
'120': '业务割接失败',
|
||||
'121': 'ONU未运行',
|
||||
'122': '重启失败',
|
||||
}
|
||||
|
||||
|
||||
class IMCService:
|
||||
"""iMC REST API 服务封装"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.IMC_API_URL.rstrip('/')
|
||||
self.username = settings.IMC_API_USERNAME
|
||||
self.password = settings.IMC_API_PASSWORD
|
||||
self.verify_ssl = settings.IMC_API_VERIFY_SSL
|
||||
self.session = requests.Session()
|
||||
self.realm = "iMC RESTful Web Services"
|
||||
self.connect_timeout = getattr(settings, 'IMC_CONNECT_TIMEOUT', 5)
|
||||
self.read_timeout = getattr(settings, 'IMC_READ_TIMEOUT', 20)
|
||||
# Digest 认证状态(每次重新初始化时清空,让首次请求自动获取 nonce)
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 内部:Digest 认证
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def _get_digest_auth_header(self, method: str, uri: str) -> str | None:
|
||||
"""
|
||||
构建 HTTP Digest 认证头
|
||||
|
||||
首次调用时会自动发一个请求获取 nonce(服务器返回 401 + WWW-Authenticate),
|
||||
后续复用 nonce 并递增 nc 值。
|
||||
nonce 过期时调用方捕获 401 后清空 self.nonce,下次自动重新握手。
|
||||
"""
|
||||
if not self.nonce:
|
||||
try:
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
verify=self.verify_ssl,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
if resp.status_code == 401 and 'WWW-Authenticate' in resp.headers:
|
||||
auth_header = resp.headers['WWW-Authenticate']
|
||||
auth_parts = {}
|
||||
for part in auth_header.split(','):
|
||||
if '=' in part:
|
||||
key, value = part.split('=', 1)
|
||||
auth_parts[key.strip()] = value.strip(' "')
|
||||
self.nonce = auth_parts.get('nonce', '')
|
||||
self.realm = auth_parts.get('realm', self.realm)
|
||||
logger.info(f"获取 nonce 成功: {self.nonce}")
|
||||
else:
|
||||
logger.error(f"获取 nonce 失败, 状态码: {resp.status_code}")
|
||||
return None
|
||||
except requests.Timeout:
|
||||
logger.error("获取 nonce 超时")
|
||||
raise TimeoutError("iMC 认证超时")
|
||||
except Exception as e:
|
||||
logger.error(f"获取 nonce 异常: {e}")
|
||||
return None
|
||||
|
||||
# 计算 Digest 响应
|
||||
cnonce = hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
||||
ha1 = hashlib.md5(
|
||||
f"{self.username}:{self.realm}:{self.password}".encode()
|
||||
).hexdigest()
|
||||
ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
|
||||
response_hash = hashlib.md5(
|
||||
f"{ha1}:{self.nonce}:{self.nc:08d}:{cnonce}:auth:{ha2}".encode()
|
||||
).hexdigest()
|
||||
|
||||
auth_value = (
|
||||
f'Digest username="{self.username}", '
|
||||
f'realm="{self.realm}", '
|
||||
f'nonce="{self.nonce}", '
|
||||
f'uri="{uri}", '
|
||||
f'response="{response_hash}", '
|
||||
f'qop=auth, '
|
||||
f'nc={self.nc:08d}, '
|
||||
f'cnonce="{cnonce}"'
|
||||
)
|
||||
self.nc += 1
|
||||
return auth_value
|
||||
|
||||
def _clear_auth(self):
|
||||
"""清除认证状态(nonce 过期时调用)"""
|
||||
self.nonce = None
|
||||
self.nc = 1
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 公共:重启 ONU
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def reboot_onu(self, mac: str) -> dict:
|
||||
"""
|
||||
远程重启 ONU 设备
|
||||
|
||||
Args:
|
||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
||||
|
||||
Returns:
|
||||
{"success": True, "message": "设备正在重启,请稍后..."}
|
||||
或 {"success": False, "message": "重启失败: ..."}
|
||||
"""
|
||||
max_retries = 1
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/reboot?mac={mac}"
|
||||
auth = self._get_digest_auth_header("POST", uri)
|
||||
if not auth:
|
||||
return {"success": False, "message": "认证失败,无法发送重启请求"}
|
||||
|
||||
headers = {
|
||||
"Accept": "application/xml",
|
||||
"Content-Type": "application/xml",
|
||||
"Content-Length": "0",
|
||||
"Authorization": auth,
|
||||
}
|
||||
|
||||
resp = self.session.post(
|
||||
f"{self.base_url}{uri}",
|
||||
headers=headers,
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
# 检查 XML 响应中是否有错误码
|
||||
if "<errorCode>" in resp.text:
|
||||
m = re.search(r"<errorCode>(\d+)</errorCode>", resp.text)
|
||||
if m:
|
||||
code = m.group(1)
|
||||
msg = REBOOT_ERROR_CODES.get(
|
||||
code, f"未知错误(代码: {code})"
|
||||
)
|
||||
return {"success": False, "message": f"重启失败: {msg}"}
|
||||
return {"success": True, "message": "设备正在重启,请稍后..."}
|
||||
|
||||
elif resp.status_code == 401:
|
||||
# nonce 过期,清空后重试
|
||||
self._clear_auth()
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"重启请求失败(HTTP {resp.status_code})",
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
return {"success": False, "message": "iMC 接口超时,请稍后重试"}
|
||||
except Exception as e:
|
||||
logger.error(f"重启异常: {e}")
|
||||
if retry < max_retries:
|
||||
time.sleep(3)
|
||||
continue
|
||||
return {"success": False, "message": f"重启异常: {e}"}
|
||||
|
||||
return {"success": False, "message": "重启失败,已达最大重试次数"}
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 公共:获取光功率
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
def get_optical_power(self, mac: str) -> dict | None:
|
||||
"""
|
||||
获取 ONU 设备光功率信息
|
||||
|
||||
接口: /imcrs/epon/onu/onuLightWaneInfo?mac={mac}
|
||||
响应 JSON 字段:powerIn(接收光功率), powerOut(发送光功率),
|
||||
bindMac, devId, eponDevName, oltIfName, onuIfDesc
|
||||
|
||||
Args:
|
||||
mac: MAC 地址,格式如 "1484-7790-4840"
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"powerIn": "-18.5", # dBm,接收光功率
|
||||
"powerOut": "2.3", # dBm,发送光功率
|
||||
"bindMac": "...",
|
||||
"devId": ...,
|
||||
"eponDevName": "...",
|
||||
"oltIfName": "...",
|
||||
"onuIfDesc": "..."
|
||||
}
|
||||
或 None(失败时)
|
||||
"""
|
||||
max_retries = 1
|
||||
for retry in range(max_retries + 1):
|
||||
try:
|
||||
uri = f"/imcrs/epon/onu/onuLightWaneInfo?mac={mac}"
|
||||
auth = self._get_digest_auth_header("GET", uri)
|
||||
if not auth:
|
||||
logger.error("生成认证头失败,无法获取光功率")
|
||||
return None
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": auth,
|
||||
}
|
||||
|
||||
logger.info(f"获取光功率: {self.base_url}{uri}")
|
||||
resp = self.session.get(
|
||||
f"{self.base_url}{uri}",
|
||||
headers=headers,
|
||||
verify=self.verify_ssl,
|
||||
timeout=(self.connect_timeout, self.read_timeout),
|
||||
)
|
||||
logger.info(f"光功率API响应状态码: {resp.status_code}")
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
logger.info(
|
||||
f"光功率响应: {json.dumps(data, ensure_ascii=False)}"
|
||||
)
|
||||
return {
|
||||
"powerIn": data.get("powerIn"),
|
||||
"powerOut": data.get("powerOut"),
|
||||
"bindMac": data.get("bindMac"),
|
||||
"devId": data.get("devId"),
|
||||
"eponDevName": data.get("eponDevName"),
|
||||
"oltIfName": data.get("oltIfName"),
|
||||
"onuIfDesc": data.get("onuIfDesc"),
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"解析光功率 JSON 失败: {e}, 内容: {resp.text}")
|
||||
|
||||
elif resp.status_code == 401:
|
||||
self._clear_auth()
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"光功率API请求失败, 状态码: {resp.status_code}, "
|
||||
f"内容: {resp.text}"
|
||||
)
|
||||
break # 非401不重试
|
||||
|
||||
except TimeoutError:
|
||||
logger.error("获取光功率超时")
|
||||
raise
|
||||
except requests.Timeout:
|
||||
logger.error("光功率接口请求超时")
|
||||
raise TimeoutError("iMC 光功率接口请求超时,请稍后重试")
|
||||
except Exception as e:
|
||||
logger.error(f"获取光功率异常: {e}")
|
||||
if retry < max_retries:
|
||||
time.sleep(3)
|
||||
continue
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.4 Schema — `backend/app/schemas/device.py`
|
||||
|
||||
添加重启和光功率的响应模型:
|
||||
|
||||
```python
|
||||
class RebootResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class OpticalPowerResponse(BaseModel):
|
||||
power_in: Optional[str] = None # 接收光功率 (dBm)
|
||||
power_out: Optional[str] = None # 发送光功率 (dBm)
|
||||
bind_mac: Optional[str] = None
|
||||
dev_id: Optional[int] = None
|
||||
epon_dev_name: Optional[str] = None
|
||||
olt_if_name: Optional[str] = None
|
||||
onu_if_desc: Optional[str] = None
|
||||
```
|
||||
|
||||
### 4.5 API 路由 — `backend/app/api/v1/devices.py`
|
||||
|
||||
在文件顶部导入新 Schema:
|
||||
|
||||
```python
|
||||
from app.schemas.device import (
|
||||
DeviceListResponse,
|
||||
ONUDeviceResponse,
|
||||
RebootResponse,
|
||||
OpticalPowerResponse,
|
||||
)
|
||||
```
|
||||
|
||||
在文件末尾添加两个新端点:
|
||||
|
||||
```python
|
||||
# ═══════════════════════════════════════════════
|
||||
# 重启 ONU
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.post("/{device_id}/reboot", response_model=RebootResponse)
|
||||
def reboot_device(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.check')),
|
||||
):
|
||||
"""
|
||||
远程重启 ONU 设备(通过 iMC REST API)
|
||||
|
||||
权限要求:device.check
|
||||
区域/学校管理员只能操作自己范围内的设备。
|
||||
"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 数据范围权限过滤
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
assigned = current.get('assigned_area') or ''
|
||||
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
assigned = current.get('assigned_school') or ''
|
||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
service = IMCService()
|
||||
mac = device.mac_address
|
||||
result = service.reboot_onu(mac)
|
||||
return RebootResponse(**result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# 获取光功率
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
|
||||
def get_device_optical_power(
|
||||
device_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""
|
||||
获取 ONU 设备光功率信息(通过 iMC REST API)
|
||||
|
||||
返回接收光功率(power_in)和发送光功率(power_out),单位 dBm。
|
||||
权限要求:device.view(只读操作)
|
||||
"""
|
||||
device = db.query(ONUDevice).filter(ONUDevice.id == device_id).first()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="设备不存在")
|
||||
|
||||
# 数据范围权限过滤(同上)
|
||||
role = current.get('role', 'user')
|
||||
if role == 'area_admin':
|
||||
assigned = current.get('assigned_area') or ''
|
||||
areas = [a.strip() for a in assigned.split(',') if a.strip()]
|
||||
if device.region not in areas:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此区域的设备")
|
||||
elif role == 'school_admin':
|
||||
assigned = current.get('assigned_school') or ''
|
||||
schools = [s.strip() for s in assigned.split(',') if s.strip()]
|
||||
if device.school_name not in schools:
|
||||
raise HTTPException(status_code=403, detail="无权限操作此学校的设备")
|
||||
|
||||
try:
|
||||
from app.services.imc_service import IMCService
|
||||
service = IMCService()
|
||||
mac = device.mac_address
|
||||
result = service.get_optical_power(mac)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
|
||||
|
||||
# 字段名转换:下划线转驼峰前先映射
|
||||
from app.schemas.device import OpticalPowerResponse
|
||||
return OpticalPowerResponse(
|
||||
power_in=result.get("powerIn"),
|
||||
power_out=result.get("powerOut"),
|
||||
bind_mac=result.get("bindMac"),
|
||||
dev_id=result.get("devId"),
|
||||
epon_dev_name=result.get("eponDevName"),
|
||||
olt_if_name=result.get("oltIfName"),
|
||||
onu_if_desc=result.get("onuIfDesc"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
|
||||
```
|
||||
|
||||
### 4.6 注册 Service — `backend/app/services/__init__.py`
|
||||
|
||||
```python
|
||||
from .imc_service import IMCService
|
||||
|
||||
__all__ = ["IMCService"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键陷阱与注意事项
|
||||
|
||||
### ⚠️ Digest 认证的 nonce 过期问题
|
||||
|
||||
iMC 的 nonce 有有效期(通常 5-10 分钟)。过期后服务器返回 **401**。
|
||||
- 代码中 `_clear_auth()` 清空 nonce,下次请求自动重新握手
|
||||
- 重启和光功率方法都在 for 循环中捕获 401 并 `continue` 重试
|
||||
|
||||
### ⚠️ MAC 地址格式
|
||||
|
||||
iMC REST API 要求 MAC 地址格式为 **1484-7790-4840**(连字符分隔,大写十六进制)。
|
||||
如果数据库存储格式不同,需要做格式转换:
|
||||
|
||||
```python
|
||||
def normalize_mac(mac: str) -> str:
|
||||
"""标准化 MAC 为 iMC 要求的格式:1484-7790-4840"""
|
||||
clean = mac.replace(':', '').replace('-', '').replace('.', '').upper()
|
||||
return f"{clean[0:4]}-{clean[4:8]}-{clean[8:12]}"
|
||||
```
|
||||
|
||||
### ⚠️ 重启接口需要 Content-Length: 0
|
||||
|
||||
即使请求体为空,也必须显式设置 `Content-Length: 0` 头,否则 iMC 会报错。
|
||||
|
||||
### ⚠️ 光功率接口返回空数据的情况
|
||||
|
||||
当 ONU 离线或光模块故障时,iMC 返回的 `powerIn` / `powerOut` 可能是 `" --"`(两个空格+两个横线)或 `None`。前端需做占位符处理。
|
||||
|
||||
### ⚠️ 重启操作较慢
|
||||
|
||||
从发起请求到设备实际重启完成约需 **30-60 秒**(取决于 SNMP 响应)。建议:
|
||||
- 前端按钮显示 loading 状态
|
||||
- 后端设置合理超时(connect=5s, read=20s)
|
||||
- 不要在短时间内对同一设备重复操作
|
||||
|
||||
### ⚠️ 并发控制
|
||||
|
||||
建议对重启操作添加简单的并发控制,避免同一设备被多次重启:
|
||||
|
||||
```python
|
||||
import threading
|
||||
|
||||
_reboot_locks = {}
|
||||
_reboot_lock = threading.Lock()
|
||||
|
||||
def reboot_onu(self, mac):
|
||||
with _reboot_lock:
|
||||
if mac not in _reboot_locks:
|
||||
_reboot_locks[mac] = threading.Lock()
|
||||
lock = _reboot_locks[mac]
|
||||
|
||||
if not lock.acquire(blocking=False):
|
||||
return {"success": False, "message": "该设备正在重启中,请稍后"}
|
||||
try:
|
||||
# ... 重启逻辑 ...
|
||||
finally:
|
||||
lock.release()
|
||||
```
|
||||
|
||||
### ⚠️ Docker 部署注意
|
||||
|
||||
- 在 `docker-compose.yml` 的 `backend` 服务中新增环境变量:
|
||||
```yaml
|
||||
environment:
|
||||
- IMC_API_URL=https://172.16.1.252:8443
|
||||
- IMC_API_USERNAME=admin
|
||||
- IMC_API_PASSWORD=Pwd@12345
|
||||
- IMC_API_VERIFY_SSL=false
|
||||
```
|
||||
- `backend` 和 `celery-worker` 容器都需要这些变量
|
||||
- 修改后必须重新构建镜像:
|
||||
```bash
|
||||
docker compose build --no-cache backend
|
||||
docker compose rm -f backend && docker compose up -d backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试验证
|
||||
|
||||
### 手动测试
|
||||
|
||||
```bash
|
||||
# 1. 重启设备
|
||||
curl -X POST "http://localhost:8000/api/devices/1/reboot" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 2. 获取光功率
|
||||
curl "http://localhost:8000/api/devices/1/optical-power" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 3. 查看后端日志
|
||||
docker compose logs backend | grep IMCService
|
||||
|
||||
# 4. 直接测试 iMC API(验证认证是否工作)
|
||||
curl -k -v "https://172.16.1.252:8443/imcrs/epon/onu/onuLightWaneInfo?mac=1484-7790-4840"
|
||||
```
|
||||
|
||||
### 测试响应示例
|
||||
|
||||
**重启成功:**
|
||||
```json
|
||||
{"success": true, "message": "设备正在重启,请稍后..."}
|
||||
```
|
||||
|
||||
**重启失败(ONU不存在):**
|
||||
```json
|
||||
{"success": false, "message": "重启失败: ONU不存在"}
|
||||
```
|
||||
|
||||
**光功率获取成功:**
|
||||
```json
|
||||
{
|
||||
"power_in": "-18.5",
|
||||
"power_out": "2.3",
|
||||
"bind_mac": "1484-7790-4840",
|
||||
"dev_id": 123,
|
||||
"epon_dev_name": "OLT-1-1",
|
||||
"olt_if_name": "1/0/2",
|
||||
"onu_if_desc": "ONU-学校A"
|
||||
}
|
||||
```
|
||||
|
||||
**光功率获取失败(设备离线):**
|
||||
```json
|
||||
{
|
||||
"power_in": null,
|
||||
"power_out": null,
|
||||
"bind_mac": null,
|
||||
"dev_id": null,
|
||||
"epon_dev_name": null,
|
||||
"olt_if_name": null,
|
||||
"onu_if_desc": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 完整调用时序图
|
||||
|
||||
```
|
||||
客户端 后端 FastAPI iMC 平台
|
||||
│ │ │
|
||||
│ POST /api/devices/1/reboot │ │
|
||||
│ ────────────────────────────► │ │
|
||||
│ │ ── 查数据库:设备存在?──► │
|
||||
│ │ ◄── 返回设备信息 ────────── │
|
||||
│ │ ── 权限检查 ───────────── │
|
||||
│ │ │
|
||||
│ │ ── GET /imcrs/epon/onu/reboot │
|
||||
│ │ (无认证,获取 nonce) │
|
||||
│ │ ────────────────────────────► │
|
||||
│ │ ◄── 401 + WWW-Authenticate ──│
|
||||
│ │ nonce=xxx, realm=... │
|
||||
│ │ │
|
||||
│ │ ── POST 同 URI + Digest ────► │
|
||||
│ │ Authorization: Digest ... │
|
||||
│ │ ◄── 200 OK (XML) ────────────│
|
||||
│ │ │
|
||||
│ ◄── {success: true, │ │
|
||||
│ message: "设备重启中"} │ │
|
||||
│ │ │
|
||||
│ ── 或 ── │ │
|
||||
│ │ │
|
||||
│ GET /api/devices/1/optical-power │
|
||||
│ ────────────────────────────► │ │
|
||||
│ │ ── 查 + 权限 (同上) ──── │
|
||||
│ │ │
|
||||
│ │ ── GET /imcrs/epon/onu/ │
|
||||
│ │ onuLightWaneInfo?mac=... │
|
||||
│ │ (+ Digest Auth) │
|
||||
│ │ ────────────────────────────► │
|
||||
│ │ ◄── 200 OK (JSON) ───────────│
|
||||
│ │ {powerIn, powerOut, ...} │
|
||||
│ │ │
|
||||
│ ◄── {power_in: "-18.5", │ │
|
||||
│ power_out: "2.3", ...} │ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:源项目参考文件位置
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| IMCService 完整实现 | `/home/v6ole/pyproject/H3ConuMS/app/services/imc_service.py` |
|
||||
| 重启控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/device.py` (第1059行) |
|
||||
| 优化版控制器 | `/home/v6ole/pyproject/H3ConuMS/app/controllers/optimized_device.py` (第157行) |
|
||||
| iMC 配置项 | `/home/v6ole/pyproject/H3ConuMS/app/config.py` (第61-67行) |
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 切换到项目根目录
|
||||
os.chdir('/home/v6ole/pyproject/H3ConuMS2')
|
||||
|
||||
# 设置 PYTHONPATH
|
||||
sys.path.insert(0, '/home/v6ole/pyproject/H3ConuMS2/backend')
|
||||
|
||||
# 设置环境变量
|
||||
os.environ['SECRET_KEY'] = 'your-secret-key-change-this'
|
||||
os.environ['DATABASE_URL'] = 'postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms'
|
||||
os.environ['REDIS_URL'] = 'redis://:redis_c4FFJQ@10.10.10.14:6379'
|
||||
os.environ['CASDOOR_ENDPOINT'] = 'https://casdoor.dhdx.fun'
|
||||
os.environ['CASDOOR_CLIENT_ID'] = '2289912424bcea5f9859'
|
||||
os.environ['CASDOOR_CLIENT_SECRET'] = 'cfa97de106762c8293901a8daef11e04f1b10043'
|
||||
os.environ['CASDOOR_REDIRECT_URL'] = 'http://10.10.10.14:5173/callback'
|
||||
os.environ['CASDOOR_ORG_NAME'] = 'dahua'
|
||||
os.environ['CASDOOR_APP_NAME'] = 'H3ConuMS'
|
||||
os.environ['CASDOOR_CERTIFICATE'] = 'token_jwt_key.pem'
|
||||
|
||||
# 加载 celery app
|
||||
from celery_worker import celery_app
|
||||
|
||||
# 运行 worker
|
||||
if __name__ == '__main__':
|
||||
celery_app.worker_main(['worker', '--loglevel=info'])
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 快速启动脚本
|
||||
|
||||
echo "=== H3C ONU 设备管理系统 ==="
|
||||
echo ""
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ ! -d "backend/venv" ]; then
|
||||
echo "创建虚拟环境..."
|
||||
cd backend && python3 -m venv venv && cd ..
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source backend/venv/bin/activate
|
||||
|
||||
# 检查依赖
|
||||
if ! python -c "import fastapi" 2>/dev/null; then
|
||||
echo "安装依赖..."
|
||||
pip install -r backend/requirements.txt
|
||||
fi
|
||||
|
||||
# 初始化数据库
|
||||
echo "初始化数据库..."
|
||||
python backend/scripts/init_db.py
|
||||
|
||||
echo ""
|
||||
echo "✅ 初始化完成!"
|
||||
echo ""
|
||||
echo "启动服务:"
|
||||
echo " 后端: cd backend && uvicorn app.main:app --reload"
|
||||
echo " 前端: cd frontend && npm run dev"
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"frontend-design": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../.agents/skills/frontend-design
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 启动开发环境
|
||||
|
||||
echo "启动后端服务..."
|
||||
cd backend
|
||||
source venv/bin/activate 2>/dev/null || python -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd /home/v6ole/pyproject/H3ConuMS2
|
||||
|
||||
export PYTHONPATH=/home/v6ole/pyproject/H3ConuMS2/backend
|
||||
|
||||
# 设置环境变量
|
||||
export SECRET_KEY="your-secret-key-change-this"
|
||||
export DATABASE_URL="postgresql://H3C_onu_ms:nEHBFFpBsJfiZp3N@10.10.10.14:5432/H3C_onu_ms"
|
||||
export REDIS_URL="redis://:redis_c4FFJQ@10.10.10.14:6379"
|
||||
export CASDOOR_ENDPOINT="https://casdoor.dhdx.fun"
|
||||
export CASDOOR_CLIENT_ID="2289912424bcea5f9859"
|
||||
export CASDOOR_CLIENT_SECRET="cfa97de106762c8293901a8daef11e04f1b10043"
|
||||
export CASDOOR_REDIRECT_URL="http://10.10.10.14:5173/callback"
|
||||
export CASDOOR_ORG_NAME="dahua"
|
||||
export CASDOOR_APP_NAME="H3ConuMS"
|
||||
export CASDOOR_CERTIFICATE="token_jwt_key.pem"
|
||||
|
||||
exec /home/v6ole/pyproject/H3ConuMS2/backend/venv/bin/celery -A celery_worker.celery worker --loglevel=info
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 启动前端服务
|
||||
|
||||
echo "启动前端服务..."
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
Reference in New Issue
Block a user