From fcfa5af614db1dce7e449d76b1f435bfcc6a65cd Mon Sep 17 00:00:00 2001 From: v6ole Date: Thu, 11 Jun 2026 19:00:08 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20v0.10.0=20=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E4=BC=98=E5=8C=96=20=E2=80=94=20HTTPS?= =?UTF-8?q?=E3=80=81=E5=89=8D=E7=AB=AF=E7=94=9F=E4=BA=A7=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E3=80=81=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(deploy): 前端多阶段构建 (vite build + nginx:alpine),移除 Vite 开发模式 - feat(deploy): OpenResty HTTPS 配置 (SSL + HSTS + 安全头) - fix(ws): WebSocket 路由添加 /api 前缀,修正前后端路径不匹配 - security: SSH AutoAddPolicy → WarningPolicy - security: CORS 来源环境变量化 (CORS_ORIGINS) - security: 限流器使用 X-Forwarded-For 真实客户端 IP - perf(db): 数据库连接池配置 (pool_size=20, max_overflow=40) - refactor: 移除硬编码 URL/IP (NTP、域名、微信代理),改为环境变量 - chore: 更新 .env.example 模板,补充新增配置项 - chore: 清理 .reasonix/、scripts/、guide.md 无用文件 - docs: 更新 CLAUDE.md 至 v0.10.0,补充生产架构文档 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 6 + CLAUDE.md | 134 ++++- backend/.env.example | 20 +- backend/app/api/v1/olt.py | 5 +- backend/app/api/v1/wechat.py | 3 +- backend/app/api/v1/ws.py | 2 +- backend/app/core/config.py | 12 +- backend/app/core/database.py | 9 +- backend/app/main.py | 25 +- backend/app/services/ssh_service.py | 2 +- deploy/.env.example | 43 +- deploy/docker-compose.yml | 23 +- deploy/openresty/onu.dhdx.fun.conf | 54 +++ docker-compose.yml | 9 +- frontend/Dockerfile | 22 +- frontend/nginx.conf | 36 ++ guide.md | 726 ---------------------------- 17 files changed, 340 insertions(+), 791 deletions(-) create mode 100644 deploy/openresty/onu.dhdx.fun.conf create mode 100644 frontend/nginx.conf delete mode 100644 guide.md diff --git a/.gitignore b/.gitignore index 0464772..7272784 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ logs/ # Claude Code .claude/ .mcp.json + +# Deployment docs (contain credentials) +*部署交接文档.md + +# Reasonix +.reasonix/ diff --git a/CLAUDE.md b/CLAUDE.md index 1ffce7e..48119c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,8 +4,8 @@ 这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。 -**当前版本**: v0.9.0 -**开发状态**: 核心功能已完成,运维增强功能持续迭代 +**当前版本**: v0.10.0 +**开发状态**: 生产环境优化完成,运维增强功能持续迭代 **已实现功能**: - ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理) @@ -29,7 +29,12 @@ - ✅ 操作记录日志(查看指定设备的上下线事件历史) - ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT) - ✅ IMC 网管服务集成(ONU 远程重启、光功率查询) +- ✅ 企业微信告警通知(设备离线、全离线学校检测) +- ✅ WebSocket 实时推送(仪表板状态更新、检查进度) - ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配) +- ✅ 生产环境 HTTPS 部署(OpenResty + SSL + 安全头) +- ✅ 前端生产构建(多阶段 Dockerfile,nginx:alpine 静态服务) +- ✅ 数据库连接池优化(pool_size=20, max_overflow=40) **技术栈**: - 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko @@ -115,6 +120,17 @@ - `GET /api/settings` - 获取系统设置 - `PUT /api/settings` - 更新系统设置 +### 企业微信 +- `GET /api/wechat/callback` - 企业微信回调 URL 验证 +- `POST /api/wechat/callback` - 企业微信消息接收 +- `POST /api/wechat/menu/create` - 创建企业微信菜单 + +### WebSocket +- `WS /api/ws/dashboard` - 仪表板实时状态推送(Redis pub/sub) + +### 任务监控 +- `GET /api/monitor/tasks` - 查看正在运行的后台任务 + ### 系统 - `GET /health` - 健康检查 - `GET /docs` - API 文档(Swagger UI) @@ -242,9 +258,17 @@ 必需配置: - `DATABASE_URL`:PostgreSQL 连接字符串 - `REDIS_URL`:Redis 连接字符串 -- `CASDOOR_*`:Casdoor 认证配置 +- `CASDOOR_*`:Casdoor 认证配置(`ENDPOINT`, `CLIENT_ID`, `CLIENT_SECRET`, `ORG_NAME`, `APP_NAME`) +- `CASDOOR_REDIRECT_URL`:Casdoor 登录回调地址(生产必填,无默认值) - `SECRET_KEY`:应用密钥 +可选配置: +- `CORS_ORIGINS`:CORS 允许的来源(逗号分隔) +- `FRONTEND_URL`:前端访问地址(用于微信帮助消息等,默认 `https://onu.dhdx.fun`) +- `NTP_OLD_SERVER` / `NTP_NEW_SERVER`:NTP 同步服务器 IP +- `IMC_API_*`:iMC 网管 API 配置(ONU 重启/光功率查询) +- `WECHAT_*`:企业微信告警配置 + #### 监控告警 - 健康检查端点:`/health` - 性能指标端点:`/metrics` @@ -290,20 +314,99 @@ docker ps | grep **解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。 -### frontend 容器必须配置 VITE_API_PROXY_TARGET +### 前端生产构建(v0.10.0+) -**问题**:根目录 `docker-compose.yml` 的 frontend 服务如果没有配置 `VITE_API_PROXY_TARGET`,Vite 代理会默认打到 `http://localhost:8001`,导致所有 `/api` 请求 500 或无法到达后端。 +**问题**:早期版本前端容器运行 `npm run dev`(Vite 开发服务器),存在热更新开销、源码暴露、无压缩等问题。 -**必须在 docker-compose.yml 中配置**: -```yaml -frontend: - build: ./frontend - ports: - - "5173:5173" - environment: - - VITE_API_PROXY_TARGET=http://backend:8000 +**生产构建流程**: +```dockerfile +# 多阶段构建 — frontend/Dockerfile +# Stage 1: vite build → dist/ +# Stage 2: nginx:alpine 静态文件服务 ``` +**生产模式下不需要 `VITE_*` 环境变量**:前端所有 API 调用使用相对路径 `/api`,由 OpenResty/Nginx 在边缘层代理。Vite 的 proxy 仅用于本地开发。 + +**前端部署到远程服务器**: +```bash +cd frontend +docker build -t h3conums2-frontend:latest . +docker save h3conums2-frontend:latest | ssh -p 7072 root@ "docker load" +ssh -p 7072 root@ "docker stop h3conums2-frontend && docker rm h3conums2-frontend && docker run -d --name h3conums2-frontend --restart always -p 18062:80 h3conums2-frontend:latest" +``` + +### 生产部署架构(v0.10.0+) + +**部署拓扑**:前端在远程服务器,后端在本地服务器,通过 frp 隧道通信。 + +``` +用户浏览器 → onu.dhdx.fun (HTTPS) + │ + OpenResty (80/443) + │ + ┌───────────┴───────────┐ + ▼ ▼ +前端容器(:18062) frp 隧道(:18060) +nginx:alpine │ +静态文件服务 frpc → frps → 本机后端(:8000) +``` + +**关键配置**: +- OpenResty 由 1Panel 管理,配置文件位于 `/opt/1panel/apps/openresty/openresty/conf/conf.d/` +- SSL 证书位于 `/www/sites/onu.dhdx.fun/ssl/`(OpenResty 容器内路径) +- 前端容器端口映射:`18062:80` +- frp 后端隧道:远程 `127.0.0.1:18060` → 本机 `127.0.0.1:8000` + +### 新增环境变量(v0.10.0) + +生产环境新增配置项: +```bash +# CORS & 前端 +CORS_ORIGINS=http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun +FRONTEND_URL=https://onu.dhdx.fun + +# NTP 同步 +NTP_OLD_SERVER=172.16.0.254 +NTP_NEW_SERVER=172.16.1.252 + +# iMC 网管 API +IMC_API_URL=https://172.16.1.252:8443 +IMC_API_USERNAME=admin +IMC_API_PASSWORD=... +IMC_API_VERIFY_SSL=false + +# Casdoor 回调(生产必填,无默认值) +CASDOOR_REDIRECT_URL=https://onu.dhdx.fun/callback + +# 企业微信代理(无默认值,按需配置) +WECHAT_PROXY_API_URL=https://api.v6ole.top +``` + +### 数据库连接池 + +**配置**(`backend/app/core/database.py`): +```python +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=20, + max_overflow=40, + pool_recycle=3600, # 1小时回收,防 PostgreSQL 断闲置连接 + pool_timeout=30, +) +``` + +### SSH 主机密钥策略 + +**当前使用 `WarningPolicy`**:记录未知主机密钥警告但允许连接。生产环境 OLT 设备在内网,安全风险可接受。如需严格验证,改为 `RejectPolicy` 并预置 `known_hosts` 文件。 + +### OpenResty / 1Panel 注意事项 + +- 配置文件由 1Panel 管理,直接修改文件后需重载:`docker exec openresty openresty -s reload` +- 1Panel 面板重新保存站点配置会覆盖手动修改 +- WebSocket 通过 HTTP/1.1 升级,需确保 `/api/` location 传递 `Upgrade` 和 `Connection` 头 +- 当前使用自签名证书,可通过 1Panel 面板申请 Let's Encrypt 正式证书 + ### 新增数据库模型后必须执行迁移 **问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。 @@ -429,6 +532,8 @@ H3ConuMS2/ │ ├── scripts/ # 初始化脚本 │ └── templates/ # Excel 导入模板 ├── frontend/ # Vue 3 前端 +│ ├── Dockerfile # 多阶段构建(vite build + nginx:alpine) +│ ├── nginx.conf # 生产 nginx 配置(Gzip、缓存、SPA fallback) │ └── src/ │ ├── api/ # API 调用封装 │ ├── components/ # 公共组件 @@ -440,10 +545,11 @@ H3ConuMS2/ │ └── views/ # 页面组件 ├── deploy/ # 部署配置 │ ├── docker-compose.yml +│ ├── openresty/ # OpenResty 站点配置 │ ├── nginx/ │ └── scripts/ ├── docs/ # 文档 -└── docker-compose.yml # 主部署文件 +└── docker-compose.yml # 本地开发 Docker Compose ``` --- diff --git a/backend/.env.example b/backend/.env.example index 36b79d4..6bf7265 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -16,7 +16,7 @@ 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= # SSH配置 SSH_TIMEOUT=30 @@ -25,6 +25,22 @@ SSH_TIMEOUT=30 CHECK_INTERVAL=1800 MANUAL_COOLDOWN=300 +# CORS & 前端 +CORS_ORIGINS=http://localhost:5173,http://localhost:18002 +FRONTEND_URL=https://your-domain.com + +# NTP 同步 +NTP_OLD_SERVER=172.16.0.254 +NTP_NEW_SERVER=172.16.1.252 + +# iMC API 配置(用于 ONU 远程重启和光功率查询) +IMC_API_URL= +IMC_API_USERNAME= +IMC_API_PASSWORD= +IMC_API_VERIFY_SSL=false +IMC_CONNECT_TIMEOUT=5 +IMC_READ_TIMEOUT=20 + # 企业微信告警配置 WECHAT_CORPID= WECHAT_CORPSECRET= @@ -32,4 +48,4 @@ WECHAT_AGENTID= WECHAT_TOKEN= WECHAT_ENCODING_AES_KEY= WECHAT_USE_PROXY=True -WECHAT_PROXY_API_URL=https://api.v6ole.top +WECHAT_PROXY_API_URL= diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py index bed40e5..1a2fc5d 100644 --- a/backend/app/api/v1/olt.py +++ b/backend/app/api/v1/olt.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from sqlalchemy import distinct from pydantic import BaseModel from app.core.database import get_db +from app.core.config import settings from app.middleware.permission_middleware import require_permission from app.models.device import OLTDevice import pandas as pd @@ -501,8 +502,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") diff --git a/backend/app/api/v1/wechat.py b/backend/app/api/v1/wechat.py index b09c069..73cf8b8 100644 --- a/backend/app/api/v1/wechat.py +++ b/backend/app/api/v1/wechat.py @@ -2,6 +2,7 @@ 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=["企业微信回调"]) @@ -235,6 +236,6 @@ def _handle_help_cmd(svc, from_user: str): "• 发送「全离线」查看全离线学校\n" "• 发送 MAC 地址后四位查询设备\n\n" "💡 发送「帮助」显示此信息\n" - "💻 完整功能: https://onu.dhdx.fun", + f"💻 完整功能: {settings.FRONTEND_URL}", to_user=from_user ) diff --git a/backend/app/api/v1/ws.py b/backend/app/api/v1/ws.py index 7b45c4a..9f707f0 100644 --- a/backend/app/api/v1/ws.py +++ b/backend/app/api/v1/ws.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.core.config import settings logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter(prefix="/api", tags=["WebSocket"]) REDIS_CHANNEL = "h3c_onu:status_updates" _connected: set[WebSocket] = set() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0e43c5f..b363402 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,12 +21,20 @@ 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 = "" 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 = "" @@ -37,7 +45,7 @@ class Settings(BaseSettings): WECHAT_TOKEN: str = "" WECHAT_ENCODING_AES_KEY: str = "" WECHAT_USE_PROXY: bool = True - WECHAT_PROXY_API_URL: str = "https://api.v6ole.top" + WECHAT_PROXY_API_URL: str = "" IMC_API_USERNAME: str = "" IMC_API_PASSWORD: str = "" IMC_API_VERIFY_SSL: bool = False diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0dc12e1..89bf9dc 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index c115fc6..441712e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,10 @@ """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.util import get_remote_address from slowapi.errors import RateLimitExceeded from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import JSONResponse @@ -29,15 +29,20 @@ class RequestSizeLimitMiddleware(BaseHTTPMiddleware): return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413) return await call_next(request) -# CORS 白名单 -ALLOWED_ORIGINS = [ - "http://localhost:5173", - "http://localhost:18002", - "https://onu.dhdx.fun", -] -allowed = [o for o in ALLOWED_ORIGINS if o] +# 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()] -limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"]) + +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 @@ -46,7 +51,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware(RequestSizeLimitMiddleware) app.add_middleware( CORSMiddleware, - allow_origins=allowed if allowed else ["*"], + allow_origins=ALLOWED_ORIGINS if ALLOWED_ORIGINS else ["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/services/ssh_service.py b/backend/app/services/ssh_service.py index 49e0449..0709792 100644 --- a/backend/app/services/ssh_service.py +++ b/backend/app/services/ssh_service.py @@ -36,7 +36,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, diff --git a/deploy/.env.example b/deploy/.env.example index 1da49ce..8460523 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -65,6 +65,9 @@ CASDOOR_ORG_NAME=your_organization # 应用名称 CASDOOR_APP_NAME=h3c-onu-ms +# Casdoor 回调地址(部署后改为实际域名) +CASDOOR_REDIRECT_URL= + # ============================================ # SSH连接配置 # ============================================ @@ -98,20 +101,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=false +IMC_CONNECT_TIMEOUT=5 +IMC_READ_TIMEOUT=20 # ============================================ # 日志配置 @@ -154,9 +172,6 @@ METRICS_PORT=8000 # 安全配置 # ============================================ -# CORS允许的域名 (逗号分隔) -CORS_ORIGINS=http://localhost:8080,http://localhost:5173 - # 速率限制配置 RATE_LIMIT_PER_MINUTE=60 RATE_LIMIT_PER_HOUR=1000 @@ -175,7 +190,7 @@ WECHAT_AGENTID= WECHAT_TOKEN= WECHAT_ENCODING_AES_KEY= WECHAT_USE_PROXY=True -WECHAT_PROXY_API_URL=https://api.v6ole.top +WECHAT_PROXY_API_URL= # 备份保留天数 BACKUP_RETENTION=30 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f864411..d63fd0a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,15 +16,23 @@ services: - CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE} - CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME} - CASDOOR_APP_NAME=${CASDOOR_APP_NAME} + - CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-} - SECRET_KEY=${SECRET_KEY} - DEBUG=${DEBUG:-false} + - CORS_ORIGINS=${CORS_ORIGINS:-} + - FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun} + - NTP_OLD_SERVER=${NTP_OLD_SERVER:-172.16.0.254} + - NTP_NEW_SERVER=${NTP_NEW_SERVER:-172.16.1.252} + - IMC_API_URL=${IMC_API_URL:-} + - IMC_API_USERNAME=${IMC_API_USERNAME:-} + - IMC_API_PASSWORD=${IMC_API_PASSWORD:-} - WECHAT_CORPID=${WECHAT_CORPID:-} - WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-} - WECHAT_AGENTID=${WECHAT_AGENTID:-} - WECHAT_TOKEN=${WECHAT_TOKEN:-} - WECHAT_ENCODING_AES_KEY=${WECHAT_ENCODING_AES_KEY:-} - WECHAT_USE_PROXY=${WECHAT_USE_PROXY:-True} - - WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-https://api.v6ole.top} + - WECHAT_PROXY_API_URL=${WECHAT_PROXY_API_URL:-} volumes: - ../backend/logs:/app/logs - ../backend/static:/app/static @@ -96,26 +104,19 @@ services: - 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} + - "18062:80" depends_on: - backend restart: unless-stopped healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:5173"] + test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] interval: 30s timeout: 10s retries: 3 diff --git a/deploy/openresty/onu.dhdx.fun.conf b/deploy/openresty/onu.dhdx.fun.conf new file mode 100644 index 0000000..99962ee --- /dev/null +++ b/deploy/openresty/onu.dhdx.fun.conf @@ -0,0 +1,54 @@ +server { + listen 80; + listen 443 ssl http2; + server_name onu.dhdx.fun; + index index.html; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $server_name; + proxy_set_header X-Real-IP $remote_addr; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + access_log /www/sites/onu.dhdx.fun/log/access.log main; + error_log /www/sites/onu.dhdx.fun/log/error.log; + + location ^~ /.well-known/acme-challenge { + allow all; + root /usr/share/nginx/html; + } + + if ($scheme = http) { + return 301 https://$host$request_uri; + } + + ssl_certificate /www/sites/onu.dhdx.fun/ssl/fullchain.pem; + ssl_certificate_key /www/sites/onu.dhdx.fun/ssl/privkey.pem; + ssl_protocols TLSv1.3 TLSv1.2 TLSv1.1 TLSv1; + ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256:!aNULL:!eNULL:!EXPORT:!DSS:!DES:!RC4:!3DES:!MD5:!PSK:!KRB5:!SRP:!CAMELLIA:!SEED; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + error_page 497 https://$host$request_uri; + proxy_set_header X-Forwarded-Proto https; + add_header Strict-Transport-Security "max-age=31536000"; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 前端(生产 nginx 容器,端口 18062) + location / { + proxy_pass http://127.0.0.1:18062; + } + + # 后端 API(frp 隧道 → 本机后端 /api/ws/dashboard WebSocket) + location /api/ { + proxy_pass http://127.0.0.1:18060; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 60s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1b27a8c..f7353c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,10 +40,13 @@ services: frontend: build: ./frontend ports: - - "18002:5173" - environment: - - VITE_API_PROXY_TARGET=http://backend:8000 + - "18002:80" restart: unless-stopped depends_on: backend: condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] + interval: 15s + timeout: 10s + retries: 5 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index db6ef60..74cc71f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,5 @@ -FROM node:18-alpine +# Stage 1: Build +FROM node:18-alpine AS build WORKDIR /app @@ -7,6 +8,21 @@ RUN npm install 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:alpine 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;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..08aefde --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/guide.md b/guide.md deleted file mode 100644 index a0c11b0..0000000 --- a/guide.md +++ /dev/null @@ -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 "" in resp.text: - m = re.search(r"(\d+)", 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 " - -# 2. 获取光功率 -curl "http://localhost:8000/api/devices/1/optical-power" \ - -H "Authorization: Bearer " - -# 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行) | -- 2.52.0 From e5d6d843c33a8ae062dff6d33836b7fd87fbf0b1 Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 11:07:29 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=8E=AF=E8=B7=AF?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=A4=B1=E8=B4=A5=20+=20=E5=A4=9A=E9=A1=B9UX?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复: - 环路检测正则 \s+(Onu\S+)\s+ → \s+(Onu\S+) (splitlines移除换行后尾随\s无法匹配) - 权限中间件 Header(...) → Header(None) 避免缺失Auth头返回422而非401 - 环路检测请求超时30s→120s (SSH连接30+台OLT实测需58秒) 重构 (ssh_service.py): - 提取 _send_and_wait 为私有方法,消除3处重复内部函数 - 添加 __enter__/__exit__ 上下文管理器支持 - 加固 execute_command prompt检测 (按行匹配) - 移除未使用的settings import - olt.py/devices.py 调用方改用 with 语法 新功能: - 侧边栏退出登录上方显示当前用户名和角色 - 版本号从VERSION文件自动读取 (后端/health返回,前端动态显示) - 基于广西南宁经纬度计算日落时间,自动切换深色/浅色主题 - /api/olt/loopback-detection 响应增加raw字段便于排查 基础设施: - CLAUDE.md 加入 .gitignore - 新增 .claude/rules/07-remote-operations.md (远程部署操作) - 新增 .claude/rules/08-frp-notes.md (frp隧道注意事项) - 新增 VERSION 文件 (版本号 0.10.0) - 新增环路检测解析测试用例 (5个) Co-Authored-By: Claude --- .gitignore | 1 + CLAUDE.md | 717 ------------------ VERSION | 1 + backend/VERSION | 1 + backend/app/api/v1/devices.py | 7 +- backend/app/api/v1/olt.py | 36 +- backend/app/main.py | 12 +- .../app/middleware/permission_middleware.py | 14 +- backend/app/services/ssh_service.py | 103 ++- backend/tests/test_ssh_parser.py | 69 ++ frontend/src/components/Layout.vue | 65 +- frontend/src/stores/theme.js | 19 +- frontend/src/utils/sunset.js | 76 ++ frontend/src/views/OltManage.vue | 2 +- 14 files changed, 309 insertions(+), 814 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 VERSION create mode 100644 backend/VERSION create mode 100644 frontend/src/utils/sunset.js diff --git a/.gitignore b/.gitignore index 7272784..237bc8a 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ logs/ # Claude Code .claude/ .mcp.json +CLAUDE.md # Deployment docs (contain credentials) *部署交接文档.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 48119c0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,717 +0,0 @@ -# H3C ONU设备管理系统 - Claude开发指南 - -## 项目概述 - -这是一个基于 Python FastAPI + Vue 3 的 H3C OLT 设备监控管理系统,用于监控 4000+ ONU 设备的在线状态。 - -**当前版本**: v0.10.0 -**开发状态**: 生产环境优化完成,运维增强功能持续迭代 - -**已实现功能**: -- ✅ SSH 连接 H3C OLT 设备查询 ONU 状态(支持 More 分页、终端控制字符清理) -- ✅ Excel 数据导入和批量管理 -- ✅ 定时自动状态检查(可配置间隔,最小5分钟)+ 手动触发 -- ✅ Casdoor 统一认证和 JWT 令牌管理 -- ✅ 设备管理(列表、详情、筛选、分页) -- ✅ 统计仪表板(总数、在线、离线)+ 区域分布饼图 + 7天趋势折线图 -- ✅ 完整 RBAC 权限管理(角色、权限、用户管理页面) -- ✅ OLT 管理(增删改查、批量导入、区域动态同步) -- ✅ OLT 端口管理(查看端口状态、开关端口) -- ✅ 重复 MAC 检测与清除 -- ✅ 新设备发现与信息补全(扫描发现 → 补全信息 → 入库) -- ✅ 快速扫描(多线程并发) -- ✅ 环路检测 -- ✅ 系统设置(管理员可配置检查间隔,显示下次扫描时间/扫描中状态) -- ✅ 库存管理模块(物料、序列号设备、出入库、盘点) -- ✅ 每日状态快照(凌晨1点聚合,用于趋势图性能优化) -- ✅ 审计日志(全量操作记录、筛选查询、CSV 导出) -- ✅ 设备更换记录(MAC 地址更换历史,与库存序列号联动) -- ✅ 操作记录日志(查看指定设备的上下线事件历史) -- ✅ OLT 时间同步(NTP 服务器配置同步到所有 OLT) -- ✅ IMC 网管服务集成(ONU 远程重启、光功率查询) -- ✅ 企业微信告警通知(设备离线、全离线学校检测) -- ✅ WebSocket 实时推送(仪表板状态更新、检查进度) -- ✅ iOS PWA 主屏幕支持(standalone 模式 safe area 适配) -- ✅ 生产环境 HTTPS 部署(OpenResty + SSL + 安全头) -- ✅ 前端生产构建(多阶段 Dockerfile,nginx:alpine 静态服务) -- ✅ 数据库连接池优化(pool_size=20, max_overflow=40) - -**技术栈**: -- 后端:Python FastAPI + PostgreSQL + Celery + Redis + Paramiko -- 前端:Vue 3 + Element Plus + Pinia + Axios -- 部署:Docker Compose - ---- - -## 已实现的 API 端点 - -### 认证相关 -- `GET /api/auth/login` - 获取 Casdoor 登录 URL -- `POST /api/auth/callback` - Casdoor 登录回调 -- `GET /api/auth/profile` - 获取当前用户信息 - -### 设备管理 -- `GET /api/devices` - 获取设备列表(分页、筛选) -- `GET /api/devices/{id}` - 获取设备详情 -- `PUT /api/devices/{id}` - 更新设备信息 -- `GET /api/devices/{id}/events` - 获取设备上下线事件记录 -- `POST /api/devices/{id}/reboot` - 远程重启 ONU(IMC) -- `GET /api/devices/{id}/optical-power` - 获取 ONU 光功率(IMC) - -### 状态检查 -- `POST /api/check/status` - 手动触发全量状态检查(Celery 异步) -- `GET /api/check/status/{task_id}` - 查询检查任务进度 -- `POST /api/check/scan/{olt_id}` - 扫描单台 OLT(预览,不入库) -- `POST /api/check/discover/{olt_id}` - 扫描单台 OLT 并入库新设备 - -### OLT 管理 -- `GET /api/olt/devices` - OLT 设备列表 -- `POST /api/olt/devices` - 新增 OLT -- `PUT /api/olt/devices/{id}` - 编辑 OLT -- `DELETE /api/olt/devices/{id}` - 删除 OLT -- `GET /api/olt/regions` - 获取 OLT 区域列表 -- `POST /api/olt/quick-scan` - 多线程快速扫描所有 OLT -- `GET /api/olt/ports/{olt_id}` - 获取 OLT 端口状态 -- `POST /api/olt/ports/{olt_id}/toggle` - 开关 OLT 端口 -- `POST /api/olt/sync-ntp` - 同步 NTP 时间服务器配置 -- `POST /api/olt/loopback-detection` - 环路检测 -- `GET /api/olt/new-devices` - 获取新发现设备列表 -- `PUT /api/olt/new-devices/{id}` - 补全新设备信息 -- `DELETE /api/olt/new-devices/{id}` - 忽略新设备 - -### 数据导入 -- `POST /api/import/upload` - 上传并导入 Excel 文件 -- `GET /api/import/template` - 下载导入模板 - -### 统计信息 -- `GET /api/stats/summary` - 获取统计摘要 -- `GET /api/stats/trend` - 7天趋势数据 -- `GET /api/stats/region-distribution` - 区域分布 - -### 权限管理 -- `GET /api/roles` - 角色列表 -- `POST /api/roles` - 创建角色 -- `PUT /api/roles/{id}` - 编辑角色 -- `DELETE /api/roles/{id}` - 删除角色 -- `GET /api/permissions` - 权限列表 - -### 用户管理 -- `GET /api/users` - 用户列表 -- `PUT /api/users/{id}` - 编辑用户 -- `DELETE /api/users/{id}` - 删除用户 - -### 库存管理 -- `GET /api/inventory/materials` - 物料列表 -- `POST /api/inventory/materials` - 新增物料 -- `GET /api/inventory/serial-devices` - 序列号设备 -- `POST /api/inventory/stock-in` - 入库 -- `POST /api/inventory/stock-out` - 出库 -- `POST /api/inventory/check` - 盘点 - -### 审计日志 -- `GET /api/audit/logs` - 审计日志列表(筛选、分页) -- `GET /api/audit/logs/export` - 导出审计日志 CSV - -### 设备更换记录 -- `GET /api/devices/{id}/replacements` - 查看设备更换历史 -- `GET /api/replacements` - 全量更换记录列表 - -### 系统设置 -- `GET /api/settings` - 获取系统设置 -- `PUT /api/settings` - 更新系统设置 - -### 企业微信 -- `GET /api/wechat/callback` - 企业微信回调 URL 验证 -- `POST /api/wechat/callback` - 企业微信消息接收 -- `POST /api/wechat/menu/create` - 创建企业微信菜单 - -### WebSocket -- `WS /api/ws/dashboard` - 仪表板实时状态推送(Redis pub/sub) - -### 任务监控 -- `GET /api/monitor/tasks` - 查看正在运行的后台任务 - -### 系统 -- `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 格式: -``` -(): - -类型:feat, fix, docs, style, refactor, test, chore -示例:feat(device): 添加设备导入功能 -``` - -### 架构规则 - -#### 后端架构 -- **分层架构**:API → Service → Model -- **API 层**:仅处理请求/响应,调用 Service -- **Service 层**:业务逻辑,不直接操作数据库 -- **Model 层**:SQLAlchemy 模型定义 -- **异步任务**:耗时操作使用 Celery - -#### 前端架构 -- **组件分类**: - - `components/common/`:通用组件 - - `components/business/`:业务组件 - - `views/`:页面组件 -- **状态管理**:使用 Pinia stores -- **API 调用**:统一在 `api/` 目录封装 - -### 安全规则 - -#### 数据安全 -- SSH 密码必须加密存储(使用 Fernet 加密) -- 敏感信息通过环境变量配置 -- 数据库连接使用 SSL -- 定期备份数据(90天历史记录) - -#### 应用安全 -- 所有 API 端点需要认证(除登录接口) -- 实现 CSRF 保护 -- 输入验证使用 Pydantic -- SQL 注入防护(使用 ORM) -- XSS 防护(前端转义) - -#### 访问控制 -- 基于 RBAC 的权限控制 -- 数据级权限过滤(按区域/学校) -- 操作审计日志记录 -- 设备信息变更需要审核 - -### 性能规则 - -#### 数据库优化 -- 为常用查询字段添加索引 -- 使用连接池(pool_size=20) -- 避免 N+1 查询问题 -- 定期清理历史数据(保留90天) - -#### 缓存策略 -- Redis 缓存热点数据(设备状态) -- 缓存过期时间:30分钟 -- 手动刷新有5分钟冷却限制 - -#### 异步处理 -- SSH 状态检查使用 Celery 异步任务 -- 批量导入使用后台任务 -- 定时任务使用 Celery Beat - -### 开发规则 - -#### 提交前清理 -- 每次提交前必须清理项目中不需要的文件 -- 包括:测试文件(`test_*.py`)、调试脚本、不再使用的 shell 脚本、冗余 markdown 文档 -- 检查是否有硬编码的敏感信息(密码、密钥、token) -- 检查 `.gitignore` 是否覆盖了所有不应提交的文件(`*.pem`、`.env`、`__pycache__` 等) - -#### 环境配置 -- 开发环境使用 `.env.development` -- 生产环境使用 `.env.production` -- 不提交 `.env` 文件到 Git -- 提供 `.env.example` 模板 -- 敏感信息(数据库密码、Casdoor 密钥等)仅通过环境变量注入 - -#### 测试要求 -- 核心业务逻辑需要单元测试 -- API 端点需要集成测试 -- 测试覆盖率目标:≥80% - -#### 文档要求 -- API 变更及时更新 Swagger 文档 -- 复杂业务逻辑添加注释 -- 重要配置添加说明 - -### 部署规则 - -#### Docker 部署 -- 使用 Docker Compose 编排 -- 外部 PostgreSQL 和 Redis(不在容器内) -- 日志挂载到宿主机 -- 使用 Nginx 反向代理 - -#### 环境变量 -必需配置: -- `DATABASE_URL`:PostgreSQL 连接字符串 -- `REDIS_URL`:Redis 连接字符串 -- `CASDOOR_*`:Casdoor 认证配置(`ENDPOINT`, `CLIENT_ID`, `CLIENT_SECRET`, `ORG_NAME`, `APP_NAME`) -- `CASDOOR_REDIRECT_URL`:Casdoor 登录回调地址(生产必填,无默认值) -- `SECRET_KEY`:应用密钥 - -可选配置: -- `CORS_ORIGINS`:CORS 允许的来源(逗号分隔) -- `FRONTEND_URL`:前端访问地址(用于微信帮助消息等,默认 `https://onu.dhdx.fun`) -- `NTP_OLD_SERVER` / `NTP_NEW_SERVER`:NTP 同步服务器 IP -- `IMC_API_*`:iMC 网管 API 配置(ONU 重启/光功率查询) -- `WECHAT_*`:企业微信告警配置 - -#### 监控告警 -- 健康检查端点:`/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 --format '{{json .NetworkSettings.Ports}}' - -# 查看谁占用了端口 -docker ps | grep -``` - -**解决方法**:先停掉占用端口的旧容器,再 `docker compose rm -f && docker compose up -d`。 - -### 前端生产构建(v0.10.0+) - -**问题**:早期版本前端容器运行 `npm run dev`(Vite 开发服务器),存在热更新开销、源码暴露、无压缩等问题。 - -**生产构建流程**: -```dockerfile -# 多阶段构建 — frontend/Dockerfile -# Stage 1: vite build → dist/ -# Stage 2: nginx:alpine 静态文件服务 -``` - -**生产模式下不需要 `VITE_*` 环境变量**:前端所有 API 调用使用相对路径 `/api`,由 OpenResty/Nginx 在边缘层代理。Vite 的 proxy 仅用于本地开发。 - -**前端部署到远程服务器**: -```bash -cd frontend -docker build -t h3conums2-frontend:latest . -docker save h3conums2-frontend:latest | ssh -p 7072 root@ "docker load" -ssh -p 7072 root@ "docker stop h3conums2-frontend && docker rm h3conums2-frontend && docker run -d --name h3conums2-frontend --restart always -p 18062:80 h3conums2-frontend:latest" -``` - -### 生产部署架构(v0.10.0+) - -**部署拓扑**:前端在远程服务器,后端在本地服务器,通过 frp 隧道通信。 - -``` -用户浏览器 → onu.dhdx.fun (HTTPS) - │ - OpenResty (80/443) - │ - ┌───────────┴───────────┐ - ▼ ▼ -前端容器(:18062) frp 隧道(:18060) -nginx:alpine │ -静态文件服务 frpc → frps → 本机后端(:8000) -``` - -**关键配置**: -- OpenResty 由 1Panel 管理,配置文件位于 `/opt/1panel/apps/openresty/openresty/conf/conf.d/` -- SSL 证书位于 `/www/sites/onu.dhdx.fun/ssl/`(OpenResty 容器内路径) -- 前端容器端口映射:`18062:80` -- frp 后端隧道:远程 `127.0.0.1:18060` → 本机 `127.0.0.1:8000` - -### 新增环境变量(v0.10.0) - -生产环境新增配置项: -```bash -# CORS & 前端 -CORS_ORIGINS=http://localhost:5173,http://localhost:18002,https://onu.dhdx.fun -FRONTEND_URL=https://onu.dhdx.fun - -# NTP 同步 -NTP_OLD_SERVER=172.16.0.254 -NTP_NEW_SERVER=172.16.1.252 - -# iMC 网管 API -IMC_API_URL=https://172.16.1.252:8443 -IMC_API_USERNAME=admin -IMC_API_PASSWORD=... -IMC_API_VERIFY_SSL=false - -# Casdoor 回调(生产必填,无默认值) -CASDOOR_REDIRECT_URL=https://onu.dhdx.fun/callback - -# 企业微信代理(无默认值,按需配置) -WECHAT_PROXY_API_URL=https://api.v6ole.top -``` - -### 数据库连接池 - -**配置**(`backend/app/core/database.py`): -```python -engine = create_engine( - settings.DATABASE_URL, - pool_pre_ping=True, - pool_size=20, - max_overflow=40, - pool_recycle=3600, # 1小时回收,防 PostgreSQL 断闲置连接 - pool_timeout=30, -) -``` - -### SSH 主机密钥策略 - -**当前使用 `WarningPolicy`**:记录未知主机密钥警告但允许连接。生产环境 OLT 设备在内网,安全风险可接受。如需严格验证,改为 `RejectPolicy` 并预置 `known_hosts` 文件。 - -### OpenResty / 1Panel 注意事项 - -- 配置文件由 1Panel 管理,直接修改文件后需重载:`docker exec openresty openresty -s reload` -- 1Panel 面板重新保存站点配置会覆盖手动修改 -- WebSocket 通过 HTTP/1.1 升级,需确保 `/api/` location 传递 `Upgrade` 和 `Connection` 头 -- 当前使用自签名证书,可通过 1Panel 面板申请 Let's Encrypt 正式证书 - -### 新增数据库模型后必须执行迁移 - -**问题**:新增了 SQLAlchemy 模型(如 `DeviceDailySnapshot`、`SystemSetting`),重建镜像后如果不执行 `alembic upgrade head`,表不存在会导致 500 错误。 - -**每次新增模型的完整流程**: -```bash -# 1. 创建迁移文件(在宿主机或容器内) -docker compose exec backend alembic revision --autogenerate -m "描述" - -# 2. 重建镜像 -docker compose build --no-cache backend - -# 3. 重启容器 -docker compose rm -f backend && docker compose up -d backend - -# 4. 执行迁移 -docker compose exec backend alembic upgrade head -``` - -### Alembic 迁移文件 revision ID 不能重复 - -**问题**:手动创建迁移文件时,如果 `revision` 字段与已有文件重复,`alembic upgrade head` 会报 `Multiple head revisions are present` 错误。 - -**规则**:手动创建迁移文件时,revision ID 使用不与现有文件冲突的唯一字符串(如 `h8i9j0k1l2m3`),并确认 `down_revision` 指向正确的上一个版本。 - -### Celery worker 必须重建才能识别新任务 - -**问题**:新增 Celery 任务模块后,如果只重建 backend 而不重建 celery-worker,worker 不会注册新任务,消息会被丢弃并报 `KeyError`。 - -**规则**:新增任务模块后,backend 和 celery-worker 都必须重建: -```bash -docker compose build --no-cache backend celery-worker -docker compose rm -f backend celery-worker -docker compose up -d backend celery-worker -``` - -验证任务是否注册: -```bash -docker compose exec celery-worker celery -A celery_worker.celery_app inspect registered -``` - -### 区域管理员多区域过滤必须用 `.in_()` 而非 `==` - -**问题**:`assigned_area` 字段存储逗号分隔的多个区域(如 `"城区,郊区"`),用 `==` 只能匹配整个字符串,导致多区域管理员只能看到第一个区域的数据。 - -**规则**:所有涉及 `assigned_area` 的过滤都必须先 split 再用 `.in_()`: -```python -areas = [a.strip() for a in current['assigned_area'].split(',') if a.strip()] -query = query.filter(Model.region.in_(areas)) -``` - -### Vite dev server 通过反向代理访问需配置 allowedHosts - -**问题**:通过域名反向代理访问 Vite dev server 时,会报 `Blocked request. This host is not allowed`。 - -**解决**:在 `vite.config.js` 中设置: -```js -server: { - allowedHosts: ['all', 'your-domain.com'], -} -``` - -### iOS PWA standalone 模式与 Safari 的 safe area 差异 - -**问题**:`env(safe-area-inset-top)` 在 Safari 浏览器中为 0(有地址栏占位),在 standalone 模式(添加到主屏幕)下为真实刘海高度(44-59px)。直接使用会导致 Safari 中布局正常但 standalone 中顶栏/弹窗重叠。 - -**规则**:所有 safe area 相关样式必须包在 `@media (display-mode: standalone)` 中,Safari 不受影响: -```css -@media (display-mode: standalone) { - .mobile-topbar { - height: calc(52px + env(safe-area-inset-top)); - } -} -``` - -`ElMessage` 的 offset 通过 `src/utils/message.js` 封装统一处理,所有页面从该文件导入而非直接从 `element-plus` 导入。 - -### Docker healthcheck 镜像内无 curl - -**问题**:backend 镜像基于 Python slim,没有 `curl`,healthcheck 用 `curl` 会导致所有依赖服务启动失败。 - -**规则**:healthcheck 使用 Python 内置模块: -```yaml -healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] - interval: 15s - timeout: 10s - retries: 5 - start_period: 40s -``` - -### More 分页标记清除不能使用 `[^\n]*` 删除整行 - -**问题**:`_clean_output` 中 `---- More ----[^\n]*` 会将 `---- More ----` 及其后同行所有内容删除。当 OLT 输出的 More 提示与下一页第一条设备数据出现在同一行时(如 `---- More ----\r\r 1484-778f-aa60 ...`),该设备行会被错误丢弃,导致部分设备扫描不到。 - -**规则**:仅移除 More 标记文本本身,保留同行后续内容: -```python -# 错误:删除整行 -output = re.sub(r'---- More ----[^\n]*', '', output) - -# 正确:仅移除标记文本 -output = re.sub(r'---- More ----', '', output) -``` - -此问题在 OLT `172.16.0.18`(大化县新城初中)上发现:93 台设备仅解析出 90 台,丢失 3 台。 - ---- - -## 项目结构 - -``` -H3ConuMS2/ -├── backend/ # Python 后端 -│ ├── app/ -│ │ ├── api/v1/ # API 路由 -│ │ ├── core/ # 核心配置 -│ │ ├── middleware/ # 中间件(权限、审计) -│ │ ├── models/ # 数据模型 -│ │ ├── schemas/ # Pydantic 模式 -│ │ ├── services/ # 业务逻辑(SSH、IMC、导入等) -│ │ └── tasks/ # Celery 任务 -│ ├── alembic/ # 数据库迁移 -│ ├── scripts/ # 初始化脚本 -│ └── templates/ # Excel 导入模板 -├── frontend/ # Vue 3 前端 -│ ├── Dockerfile # 多阶段构建(vite build + nginx:alpine) -│ ├── nginx.conf # 生产 nginx 配置(Gzip、缓存、SPA fallback) -│ └── src/ -│ ├── api/ # API 调用封装 -│ ├── components/ # 公共组件 -│ ├── composables/ # 组合式函数 -│ ├── router/ # 路由 -│ ├── stores/ # Pinia 状态管理 -│ ├── styles/ # 主题样式 -│ ├── utils/ # 工具函数 -│ └── views/ # 页面组件 -├── deploy/ # 部署配置 -│ ├── docker-compose.yml -│ ├── openresty/ # OpenResty 站点配置 -│ ├── nginx/ -│ └── scripts/ -├── docs/ # 文档 -└── docker-compose.yml # 本地开发 Docker Compose -``` - ---- - -## 核心业务逻辑 - -### SSH 状态检查流程 -1. 从数据库获取 OLT 设备配置 -2. 建立 SSH 连接(使用连接池) -3. 执行命令:`display onu slot {slot_number}` -4. 解析返回结果: - - 包含 "Up" → 在线 - - 包含 "Offline" → 离线 -5. 更新设备状态到数据库 -6. 记录历史状态 - -### 权限控制逻辑 -- **超级管理员**:所有权限 -- **管理员**:管理所有设备和用户 -- **区域管理员**:管理指定区域的设备 -- **学校管理员**:管理指定学校的设备 -- **普通用户**:只读权限 - -### 数据导入流程 -1. 上传 Excel 文件 -2. 使用 Pandas 解析数据 -3. 数据验证(MAC 地址格式、必填字段) -4. 批量插入数据库 -5. 返回导入结果(成功/失败记录) - ---- - -## 开发指南 - -### 快速启动 - -**Docker 部署(推荐)**: -```bash -# 1. 配置环境变量 -cp backend/.env.example backend/.env -# 编辑 .env 填入实际配置 - -# 2. 启动所有服务 -docker compose up -d - -# 3. 初始化数据库 -docker compose exec backend python scripts/init_db.py -``` - -**本地开发**: -```bash -# 后端 -cd backend -python -m venv venv && source venv/bin/activate -pip install -r requirements.txt -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - -# 前端 -cd frontend -npm install && npm run dev - -# Celery Worker -cd backend -celery -A celery_worker.celery_app worker --loglevel=info - -# Celery Beat -cd backend -celery -A celery_worker.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule -``` - -### 数据库迁移 - -```bash -# 创建迁移 -alembic revision --autogenerate -m "描述" - -# 执行迁移 -alembic upgrade head - -# 回滚 -alembic downgrade -1 -``` - -### 添加新功能 - -1. **后端 API**: - - 在 `app/api/v1/` 创建路由文件 - - 在 `app/services/` 创建服务文件 - - 在 `app/schemas/` 定义请求/响应模式 - - 在 `app/models/` 定义数据模型(如需要) - -2. **前端页面**: - - 在 `views/` 创建页面组件 - - 在 `api/` 添加 API 调用 - - 在 `router/` 添加路由配置 - - 在 `stores/` 添加状态管理(如需要) - ---- - -## 常见问题 - -### SSH 连接失败 -- 检查网络连通性 -- 验证 SSH 凭证 -- 检查防火墙设置 -- 查看日志:`docker-compose logs backend` - -### 数据库连接失败 -- 检查 `DATABASE_URL` 配置 -- 验证数据库服务状态 -- 检查网络权限 - -### Casdoor 登录失败 -- 检查 Casdoor 服务状态 -- 验证 `CASDOOR_*` 配置 -- 检查回调地址配置 - ---- - -## 参考文档 - -- [FastAPI 文档](https://fastapi.tiangolo.com/) -- [Vue 3 文档](https://vuejs.org/) -- [Element Plus 文档](https://element-plus.org/) -- [Casdoor 文档](https://casdoor.org/) -- [现场ONU故障排查指南](./docs/现场ONU故障排查指南.md) - - -## 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. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..78bc1ab --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.10.0 diff --git a/backend/VERSION b/backend/VERSION new file mode 100644 index 0000000..78bc1ab --- /dev/null +++ b/backend/VERSION @@ -0,0 +1 @@ +0.10.0 diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index f27868a..784a8af 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -668,10 +668,9 @@ 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() - events = ssh.get_onu_events(device.port_id) + with SSHService(olt.ip_address, olt.username, olt.password) as ssh: + events = ssh.get_onu_events(device.port_id) return { "interface": f"Onu{device.port_id}", "olt_location": olt.location, @@ -679,8 +678,6 @@ def get_onu_events( } except Exception as e: raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") - finally: - ssh.close() @router.get("/{device_id}/optical-power-history") diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py index 1a2fc5d..21956b0 100644 --- a/backend/app/api/v1/olt.py +++ b/backend/app/api/v1/olt.py @@ -263,14 +263,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() - ssh.clear_onu_port(body.port_id) + 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() # 从 ports 列表移除已清除的端口 remaining = [p for p in record.ports if p["port_id"] != body.port_id] @@ -451,10 +448,9 @@ 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() - detection = ssh.detect_loopback() + with SSHService(olt.ip_address, olt.username, olt.password) as ssh: + detection = ssh.detect_loopback() except Exception as e: return { "olt_id": olt.id, @@ -464,8 +460,6 @@ def loopback_detection( "has_loop": False, "loop_interfaces": [], } - finally: - ssh.close() loop_interfaces = [] for iface in detection.get("interfaces", []): @@ -488,6 +482,7 @@ def loopback_detection( "has_loop": detection["has_loop"], "loop_interfaces": loop_interfaces, "error": None, + "raw": detection.get("raw", ""), } results_map = {} @@ -522,10 +517,9 @@ 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() - ssh.sync_ntp(body.old_server, body.new_server) + with SSHService(olt.ip_address, olt.username, olt.password) as ssh: + ssh.sync_ntp(body.old_server, body.new_server) return { "olt_ip": olt.ip_address, "olt_location": olt.location or olt.ip_address, @@ -539,8 +533,6 @@ def sync_ntp( "success": False, "error": str(e), } - finally: - ssh.close() results_map = {} with ThreadPoolExecutor(max_workers=len(olts) or 1) as executor: @@ -574,15 +566,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() - ports = ssh.get_olt_ports() + 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() @router.post("/devices/{olt_id}/ports/toggle") @@ -594,13 +583,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() - ssh.toggle_olt_port(port_name, body.action) + 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() diff --git a/backend/app/main.py b/backend/app/main.py index 441712e..370d548 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -81,9 +81,19 @@ async def startup(): asyncio.create_task(ws._redis_listener()) +def _read_version() -> str: + """读取项目版本号""" + version_paths = ["/app/VERSION", os.path.join(os.path.dirname(__file__), "../../VERSION")] + for p in version_paths: + if os.path.exists(p): + with open(p) as f: + return f.read().strip() + return "0.0.0" + + @app.get("/health") def health_check(): - status = {"status": "ok", "db": "ok", "redis": "ok"} + status = {"status": "ok", "db": "ok", "redis": "ok", "version": _read_version()} try: import redis import psycopg2 diff --git a/backend/app/middleware/permission_middleware.py b/backend/app/middleware/permission_middleware.py index 8bbb7bc..6ea9f95 100644 --- a/backend/app/middleware/permission_middleware.py +++ b/backend/app/middleware/permission_middleware.py @@ -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): token前20字符=%.20s...", permission, token[:20]) 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) diff --git a/backend/app/services/ssh_service.py b/backend/app/services/ssh_service.py index 0709792..ac144e6 100644 --- a/backend/app/services/ssh_service.py +++ b/backend/app/services/ssh_service.py @@ -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: @@ -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,14 +75,28 @@ class SSHService: if "---- More ----" in chunk: self.shell.send(" ") time.sleep(0.3) - elif ">" in chunk: - # 命令提示符出现,说明输出完毕 + elif re.search(r'^<[^>]+>\s*$', chunk, re.MULTILINE): + # 匹配整行为 的提示符行(不匹配回显中的 ">") break else: time.sleep(0.2) return output + def _send_and_wait(self, cmd: str, expect: str, timeout: int = 10) -> str: + """发送命令并等待期望字符串出现,超时返回已收集的输出""" + self.shell.send(cmd + "\n") + buf = "" + deadline = time.time() + timeout + while time.time() < deadline: + if self.shell.recv_ready(): + buf += self.shell.recv(4096).decode('utf-8', errors='ignore') + if expect in buf: + return buf + else: + time.sleep(0.2) + return buf + def clear_onu_port(self, port_id: str) -> bool: """清除指定端口的 ONU 配置(恢复默认) 流程: system-view -> interface Onu{port_id} -> default -> Y @@ -92,26 +104,13 @@ 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 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} @@ -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: diff --git a/backend/tests/test_ssh_parser.py b/backend/tests/test_ssh_parser.py index d938da2..0cbb7aa 100644 --- a/backend/tests/test_ssh_parser.py +++ b/backend/tests/test_ssh_parser.py @@ -1,4 +1,5 @@ """SSH 输出解析测试""" +import re import pytest from app.services.ssh_service import SSHService @@ -91,3 +92,71 @@ class TestCleanOutput: 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 + +""" + has_loop, interfaces = self._parse(output) + assert has_loop + assert interfaces == ["Onu1/0/1:1"] diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 8e2ed3e..84decb4 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -74,6 +74,13 @@ @@ -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() @@ -218,6 +226,14 @@ const onKeydown = (e) => { 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) @@ -225,6 +241,7 @@ onMounted(async () => { if (authStore.token && !authStore.user) { await authStore.fetchProfile() } + fetchVersion() }) onUnmounted(() => { clearInterval(timer); document.removeEventListener('keydown', onKeydown) }) @@ -351,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') @@ -510,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; diff --git a/frontend/src/stores/theme.js b/frontend/src/stores/theme.js index 4699520..5ea2bbe 100644 --- a/frontend/src/stores/theme.js +++ b/frontend/src/stores/theme.js @@ -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' diff --git a/frontend/src/utils/sunset.js b/frontend/src/utils/sunset.js new file mode 100644 index 0000000..e268c70 --- /dev/null +++ b/frontend/src/utils/sunset.js @@ -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 +} diff --git a/frontend/src/views/OltManage.vue b/frontend/src/views/OltManage.vue index d58fee0..4c62fe1 100644 --- a/frontend/src/views/OltManage.vue +++ b/frontend/src/views/OltManage.vue @@ -951,7 +951,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) { -- 2.52.0 From 0d3fa8dd51ef255241a083402f6569455bc0b22b Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 11:15:01 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20/api/health=204?= =?UTF-8?q?04=20=E5=92=8C=E6=89=AB=E6=8F=8F/=E5=8F=91=E7=8E=B0=20500=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. /api/health 404: 前端 fetchVersion 调用 /api/health,但后端只有 /health。 OpenResty 保留完整路径,需要在后端添加 /api/health 端点。 2. 扫描/发现 500: check_service.py 的连接池 _get_cached_ssh 缓存了 SSH 连接,但调用方在 finally 中 ssh.close() 关闭连接。第二次调用 从缓存拿到已关闭的连接导致执行失败。改为异常时清除缓存、正常时 保留连接(5分钟TTL自动管理生命周期)。 Co-Authored-By: Claude --- backend/app/main.py | 6 ++++++ backend/app/services/check_service.py | 16 ++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 370d548..db524b0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,3 +112,9 @@ def health_check(): status["db"] = "error" status["status"] = "degraded" return status + + +@app.get("/api/health") +def api_health_check(): + """API 路径下的健康检查(用于前端通过 /api/ 代理访问)""" + return health_check() diff --git a/backend/app/services/check_service.py b/backend/app/services/check_service.py index a3bcd56..280c57d 100644 --- a/backend/app/services/check_service.py +++ b/backend/app/services/check_service.py @@ -103,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 设备的当前状态和距离。 @@ -251,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,返回发现的设备列表(不写入数据库)""" @@ -300,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(): -- 2.52.0 From a9551d87f54a3faf42ba13c8ca7758c5009f7ee4 Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 11:24:23 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E5=BF=AB=E9=80=9F=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=B6=85=E6=97=B6=2030s=E2=86=92180s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 快速扫描需要 SSH 连接 30+ 台 OLT 并解析入库,后端实测 61 秒, Axios 默认 30 秒超时导致请求在前端被中断。 Co-Authored-By: Claude --- frontend/src/views/OltManage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/OltManage.vue b/frontend/src/views/OltManage.vue index 4c62fe1..590f1d2 100644 --- a/frontend/src/views/OltManage.vue +++ b/frontend/src/views/OltManage.vue @@ -938,7 +938,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) { -- 2.52.0 From 8dc66d7f886c83f6e9d845c185ba9735c5d94f07 Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 11:33:17 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20el-pagination=20=E5=BC=83=E7=94=A8?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=20small=20=E2=86=92=20size=3D"small"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除 Element Plus 3.0 deprecated 控制台警告。 Co-Authored-By: Claude --- frontend/src/views/AuditLog.vue | 2 +- frontend/src/views/RecordsLog.vue | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/AuditLog.vue b/frontend/src/views/AuditLog.vue index 751cbd6..7a0fec1 100644 --- a/frontend/src/views/AuditLog.vue +++ b/frontend/src/views/AuditLog.vue @@ -83,7 +83,7 @@ :total="total" :page-sizes="[20, 50, 100]" layout="total, sizes, prev, pager, next" - small + size="small" @change="fetchLogs" /> diff --git a/frontend/src/views/RecordsLog.vue b/frontend/src/views/RecordsLog.vue index 08259a9..c2fb6ce 100644 --- a/frontend/src/views/RecordsLog.vue +++ b/frontend/src/views/RecordsLog.vue @@ -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" /> -- 2.52.0 From e87e3ebe4ba461dc9c408077d5dc902e3068a5e4 Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 11:41:58 +0800 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=E5=90=88=E5=B9=B6=20celery-bea?= =?UTF-8?q?t=20=E5=88=B0=20celery-worker=20(4=E2=86=923=E5=AE=B9=E5=99=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit celery-worker 加 -B 参数启动嵌入式 Beat,省去独立 celery-beat 容器。 定时任务由代码中 beat_schedule 驱动,schedule 文件保持持久化。 Co-Authored-By: Claude --- docker-compose.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f7353c6..dfd5cc8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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,16 +27,6 @@ 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: -- 2.52.0 From 4488e0ef4286fc6dfd1c44bcee054af9e5c19d83 Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 11:49:53 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E8=B7=AF=E7=94=B1=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=20422=20+=20=E5=BD=BB=E5=BA=95=E6=B6=88?= =?UTF-8?q?=E9=99=A4=20Header(...)=20=E9=9A=90=E6=82=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. devices.py: /tags 和 /export/csv 移到 /{device_id}(int) 之前, 避免 FastAPI 将 'tags'/'export' 当作 device_id 解析失败返回 422 2. audit.py: /logs/export/csv 移到 /logs/{log_id}(int) 之前,同上 3. auth.py: Header(...) → Header(None),统一为手动 401 返回, 消除全局最后一个 Header(...) 导致的 422 隐患 Co-Authored-By: Claude --- backend/app/api/v1/audit.py | 78 ++++++++++++++-------------- backend/app/api/v1/auth.py | 8 +-- backend/app/api/v1/devices.py | 98 +++++++++++++++++------------------ 3 files changed, 92 insertions(+), 92 deletions(-) diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py index d3f578c..ed1c3b4 100644 --- a/backend/app/api/v1/audit.py +++ b/backend/app/api/v1/audit.py @@ -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, diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 3d72568..7ba59e3 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -84,12 +84,12 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)): @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) @@ -102,11 +102,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) diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index 784a8af..5bb9ff3 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -338,6 +338,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, @@ -701,52 +750,3 @@ def get_optical_power_history( "recorded_at": r.recorded_at.isoformat() if r.recorded_at else None} for r in rows ] - - -@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"} - ) -- 2.52.0 From ec1d9860ff3fd5a9ea6dd1ff0f5ece871df77c6c Mon Sep 17 00:00:00 2001 From: v6ole Date: Fri, 12 Jun 2026 14:36:03 +0800 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=89=8D=E7=AB=AF=E5=AE=B9=E5=99=A8=EF=BC=8C=E4=BB=85?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BF=9C=E7=A8=8B=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地前端因 Casdoor OAuth 回调固定指向 onu.dhdx.fun,无法完成 登录流程,没有实际用途。转为仅远程服务器部署。 本地服务器现仅需 2 容器:backend + celery-worker(含beat) Co-Authored-By: Claude --- docker-compose.yml | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index dfd5cc8..1b9e99b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,16 +27,5 @@ services: backend: condition: service_healthy - frontend: - build: ./frontend - ports: - - "18002:80" - restart: unless-stopped - depends_on: - backend: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:80/health"] - interval: 15s - timeout: 10s - retries: 5 + # frontend 仅在远程服务器部署,本地通过 docker-compose 不再启动 + # 部署命令见 .claude/rules/07-remote-operations.md -- 2.52.0