feat(alerts): 添加微信告警、WebSocket实时推送和地图功能

- 新增微信告警服务(wechat_service)和告警任务(alert_tasks)
- 新增 WebSocket 实时推送端点
- 新增监控管理模块(monitor)
- 增强统计仪表板:趋势图、区域分布、光功率历史
- 设备管理:添加坐标信息、标签系统、复合索引优化
- 前端:重构Dashboard/Charts页面,新增业务组件
- 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名)
- 更新部署配置和脚本
- 新增测试框架基础结构
This commit is contained in:
2026-06-11 15:16:29 +08:00
parent 3453441754
commit b2c20ec43d
45 changed files with 2089 additions and 107 deletions
+6 -1
View File
@@ -53,11 +53,16 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
if not user:
user = User(
casdoor_id=casdoor_user["sub"],
username=casdoor_user.get("name") or casdoor_user.get("preferred_username", ""),
username=casdoor_user.get("preferred_username") or casdoor_user.get("name", ""),
display_name=casdoor_user.get("displayName") or casdoor_user.get("name", ""),
email=casdoor_user.get("email"),
role="user"
)
db.add(user)
else:
# 每次登录同步 Casdoor 信息(姓名、邮箱等可能更新)
user.display_name = casdoor_user.get("displayName") or casdoor_user.get("name", user.display_name or "")
user.email = casdoor_user.get("email", user.email)
user.last_login = datetime.utcnow()
db.commit()
+6 -2
View File
@@ -1,11 +1,13 @@
"""状态检查 API"""
import asyncio
import logging
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Request
from celery.result import AsyncResult
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.orm import Session
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
@@ -14,6 +16,7 @@ from app.middleware.permission_middleware import require_permission
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/check", tags=["状态检查"])
limiter = Limiter(key_func=get_remote_address)
class CheckResult(BaseModel):
@@ -31,7 +34,8 @@ class CheckError(BaseModel):
@router.post("/status")
def trigger_check(_: dict = Depends(require_permission('device.check'))):
@limiter.limit("3/minute")
def trigger_check(request: Request, _: dict = Depends(require_permission('device.check'))):
"""手动触发状态检查"""
try:
task = check_all_devices.delay()
+92
View File
@@ -1,5 +1,8 @@
"""设备管理 API"""
import csv
import io
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
@@ -20,6 +23,7 @@ def get_devices(
school_name: str = None,
keyword: str = None,
status: str = None,
tag: str = None,
db: Session = Depends(get_db),
current: dict = Depends(require_permission('device.view')),
):
@@ -68,6 +72,8 @@ def get_devices(
query = query.filter(ONUDevice.region == region)
if school_name:
query = query.filter(ONUDevice.school_name.contains(school_name))
if tag:
query = query.filter(ONUDevice.tags.contains(tag))
if keyword:
query = query.filter(
or_(
@@ -395,6 +401,7 @@ class DeviceUpdate(BaseModel):
room_number: Optional[str] = None
place_type: Optional[str] = None
notes: Optional[str] = None
tags: Optional[str] = None
class DeviceReplaceRequest(BaseModel):
@@ -430,6 +437,7 @@ def update_device(
device.room_number = body.room_number or None
device.place_type = body.place_type or None
device.notes = body.notes or None
device.tags = body.tags or None
# 若该设备 MAC 在 new_devices 待入库列表中,自动移除(已在设备列表中补全信息)
from app.models.device import NewDevice
@@ -613,6 +621,18 @@ def get_device_optical_power(
data = IMCService().get_optical_power(device.mac_address)
if data is None:
raise HTTPException(status_code=502, detail="获取光功率失败,iMC 接口无响应")
# 记录光功率历史
try:
from app.models.device import OpticalPowerHistory
db.add(OpticalPowerHistory(
onu_device_id=device_id,
power_in=data.get("powerIn"),
power_out=data.get("powerOut"),
))
db.commit()
except Exception:
pass
return OpticalPowerResponse(
power_in=data.get("powerIn"),
power_out=data.get("powerOut"),
@@ -661,3 +681,75 @@ def get_onu_events(
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
finally:
ssh.close()
@router.get("/{device_id}/optical-power-history")
def get_optical_power_history(
device_id: int,
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取设备光功率历史记录"""
from app.models.device import OpticalPowerHistory
rows = (
db.query(OpticalPowerHistory)
.filter(OpticalPowerHistory.onu_device_id == device_id)
.order_by(OpticalPowerHistory.recorded_at.desc())
.limit(limit)
.all()
)
return [
{"power_in": r.power_in, "power_out": r.power_out,
"recorded_at": r.recorded_at.isoformat() if r.recorded_at else None}
for r in rows
]
@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"}
)
+45
View File
@@ -0,0 +1,45 @@
"""Celery 任务监控 API"""
import time
import redis as redis_lib
from fastapi import APIRouter, Depends
from app.core.celery_app import celery_app
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
router = APIRouter(prefix="/api/monitor", tags=["任务监控"])
def _get_redis():
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
@router.get("/tasks")
def get_task_status(_: dict = Depends(require_permission('*'))):
"""获取 Celery 任务状态概览"""
try:
insp = celery_app.control.inspect()
active = insp.active() or {}
scheduled = insp.scheduled() or {}
reserved = insp.reserved() or {}
r = _get_redis()
last_run = r.get("check_all_devices:last_run")
is_running = bool(r.get("check_all_devices:running"))
interval_str = r.get("system:check_interval_seconds")
interval = int(interval_str) if interval_str else 1800
next_run = None
if last_run and not is_running:
next_run = float(last_run) + interval
return {
"workers": list(active.keys()),
"active_count": sum(len(v) for v in active.values()),
"scheduled_count": sum(len(v) for v in scheduled.values()),
"check_running": is_running,
"last_check": float(last_run) if last_run else None,
"next_check": next_run,
"check_interval_seconds": interval,
}
except Exception as e:
return {"error": str(e)}
+26
View File
@@ -98,3 +98,29 @@ def update_about(
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown'))
db.commit()
return {"key": "about_content", "value": content}
@router.put("/webhook")
def update_webhook(
body: dict,
db: Session = Depends(get_db),
_: dict = Depends(require_permission('*')),
):
"""更新企业微信告警配置(仅管理员)"""
configs = [
("wechat_corpid", body.get("corpid", ""), "企业微信 CorpID"),
("wechat_corpsecret", body.get("corpsecret", ""), "企业微信 CorpSecret"),
("wechat_agentid", body.get("agentid", ""), "企业微信 AgentID"),
]
for key, value, desc in configs:
setting = db.query(SystemSetting).filter_by(key=key).first()
if setting:
setting.value = value
else:
db.add(SystemSetting(key=key, value=value, description=desc))
db.commit()
return {"message": "企业微信配置已保存"}
+177 -7
View File
@@ -179,16 +179,14 @@ def get_trend(
)
snapshot_map = {s.snapshot_date: s for s in snapshots}
# 今天实时聚合
# 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况
today_str = today.strftime('%Y-%m-%d')
start_of_today = datetime.combine(today, datetime.min.time())
daily_latest_subq = (
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
)
.filter(DeviceStatusHistory.checked_at >= start_of_today)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
@@ -198,9 +196,9 @@ def get_trend(
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
)
.join(
daily_latest_subq,
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.one()
)
@@ -246,3 +244,175 @@ def get_trend(
return result
@router.get("/olt-stats")
def get_olt_stats(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取每台 OLT 下的设备在线率统计"""
from app.models.device import OLTDevice
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
.join(latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at))
.subquery()
)
rows = (
db.query(
OLTDevice.id,
OLTDevice.ip_address,
OLTDevice.location,
OLTDevice.region,
func.count(ONUDevice.id).label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
)
.outerjoin(ONUDevice, ONUDevice.olt_id == OLTDevice.id)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(OLTDevice.id)
.order_by(OLTDevice.region, OLTDevice.location)
.all()
)
return [
{
"olt_id": r.id,
"name": r.location or r.ip_address,
"ip": r.ip_address,
"region": r.region or "未知",
"total": int(r.total or 0),
"online": int(r.online or 0),
"offline": int(r.offline or 0),
}
for r in rows
]
@router.get("/offline-schools")
def get_offline_schools(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取全部离线的学校列表"""
_subq = (
db.query(DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
.group_by(DeviceStatusHistory.onu_device_id).subquery()
)
_status_subq = (
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
.join(_subq,
(DeviceStatusHistory.onu_device_id == _subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == _subq.c.max_checked_at)).subquery()
)
rows = (
db.query(
ONUDevice.school_name, ONUDevice.region,
func.count().label("total"),
func.sum(case((_status_subq.c.status == 'online', 1), else_=0)).label("online"),
)
.outerjoin(_status_subq, ONUDevice.id == _status_subq.c.onu_device_id)
.group_by(ONUDevice.school_name, ONUDevice.region)
.having(func.sum(case((_status_subq.c.status == 'online', 1), else_=0)) == 0)
.all()
)
return [
{"school_name": r.school_name or "未知", "region": r.region or "未知", "total": int(r.total or 0)}
for r in rows if int(r.total or 0) > 0
]
@router.get("/model-distribution")
def get_model_distribution(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""统计 ONU 设备型号分布"""
rows = (
db.query(
ONUDevice.model,
func.count(ONUDevice.id).label("count"),
)
.filter(ONUDevice.model.isnot(None), ONUDevice.model != '')
.group_by(ONUDevice.model)
.order_by(func.count(ONUDevice.id).desc())
.all()
)
unknown = db.query(func.count(ONUDevice.id)).filter(
(ONUDevice.model.is_(None)) | (ONUDevice.model == '')
).scalar() or 0
result = [{"model": r.model or "未知", "count": r.count} for r in rows]
if unknown > 0:
result.append({"model": "未知型号", "count": unknown})
return result
@router.get("/school-locations")
def get_school_locations(
db: Session = Depends(get_db),
_: dict = Depends(require_permission('device.view')),
):
"""获取各学校的聚合位置数据(用于地图展示)"""
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
DeviceStatusHistory.status
)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
rows = (
db.query(
ONUDevice.school_name,
ONUDevice.region,
ONUDevice.latitude,
ONUDevice.longitude,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.filter(ONUDevice.latitude.isnot(None))
.filter(ONUDevice.longitude.isnot(None))
.group_by(ONUDevice.school_name, ONUDevice.region, ONUDevice.latitude, ONUDevice.longitude)
.all()
)
return [
{
"school_name": row.school_name or "未知",
"region": row.region or "未知",
"latitude": row.latitude,
"longitude": row.longitude,
"total": int(row.total or 0),
"online": int(row.online or 0),
"offline": int(row.offline or 0),
}
for row in rows
]
+1 -1
View File
@@ -28,7 +28,7 @@ def get_users(
query = query.filter(User.role == role)
if keyword:
query = query.filter(
User.username.contains(keyword) | User.email.contains(keyword)
User.username.contains(keyword) | User.display_name.contains(keyword) | User.email.contains(keyword)
)
query = query.order_by(asc(User.created_at))
total = query.count()
+240
View File
@@ -0,0 +1,240 @@
"""企业微信回调 API(URL验证 + 消息接收)"""
import logging
from fastapi import APIRouter, Request, Response
from app.services.wechat_service import get_wechat_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/wechat", tags=["企业微信回调"])
@router.post("/menu/create")
async def create_menu():
"""创建/更新企业微信应用菜单"""
svc = get_wechat_service()
menu = {
"button": [
{
"name": "设备查询",
"sub_button": [
{"type": "click", "name": "在线统计", "key": "online"},
{"type": "click", "name": "全离线学校", "key": "offline_schools"},
{"type": "click", "name": "MAC查询", "key": "status"},
]
},
{"type": "click", "name": "帮助", "key": "help"},
]
}
ok = svc.create_menu(menu)
return {"success": ok}
@router.get("/callback")
async def wechat_callback_get(request: Request):
"""企业微信 URL 验证(GET"""
params = request.query_params
msg_signature = params.get("msg_signature", "")
timestamp = params.get("timestamp", "")
nonce = params.get("nonce", "")
echostr = params.get("echostr", "")
svc = get_wechat_service()
result = svc.verify_url(msg_signature, timestamp, nonce, echostr)
if result:
return Response(content=result, media_type="text/plain")
return Response(content="验证失败", status_code=403)
@router.post("/callback")
async def wechat_callback_post(request: Request):
"""企业微信消息接收(POST"""
params = request.query_params
msg_signature = params.get("msg_signature", "")
timestamp = params.get("timestamp", "")
nonce = params.get("nonce", "")
xml_data = await request.body()
if not xml_data:
return Response(content="", media_type="text/plain")
svc = get_wechat_service()
msg = svc.parse_message(xml_data)
if not msg:
return Response(content="", media_type="text/plain")
msg_type = msg.get("MsgType", "")
from_user = msg.get("FromUserName", "")
logger.info(f"收到企微消息: type={msg_type}, from={from_user}, content={msg.get('Content', '')}")
if msg_type == "text":
content = msg.get("Content", "").strip()
if content.lower() in ("online", "在线", "在线统计"):
_handle_online_cmd(svc, from_user)
elif content.lower() in ("全离线", "离线学校", "offline"):
_handle_offline_schools_cmd(svc, from_user)
elif content.startswith("#状态+") or content.startswith("#status+"):
_handle_status_cmd(svc, from_user, content)
elif content.lower() in ("help", "帮助", "#帮助", "#help"):
_handle_help_cmd(svc, from_user)
else:
# 尝试作为 MAC 后缀查询
_handle_status_cmd(svc, from_user, f"#状态+{content}")
elif msg_type == "event":
event = msg.get("Event", "")
event_key = msg.get("EventKey", "")
if event == "click":
if event_key == "online":
_handle_online_cmd(svc, from_user)
elif event_key == "offline_schools":
_handle_offline_schools_cmd(svc, from_user)
elif event_key == "help":
_handle_help_cmd(svc, from_user)
return Response(content="", media_type="text/plain")
# ── 命令处理 ────────────────────────────────────────────────────────────────
def _handle_online_cmd(svc, from_user: str):
"""处理在线统计命令"""
try:
from app.core.database import SessionLocal
from app.models.device import ONUDevice, DeviceStatusHistory
from sqlalchemy import func, case
db = SessionLocal()
try:
latest_subq = (
db.query(DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
.group_by(DeviceStatusHistory.onu_device_id).subquery()
)
latest_status_subq = (
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
.join(latest_subq, (DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
)
total = db.query(func.count(ONUDevice.id)).scalar() or 0
online = (
db.query(func.count(ONUDevice.id))
.join(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id, isouter=True)
.filter(latest_status_subq.c.status == 'online').scalar() or 0
)
rate = (online / total * 100) if total > 0 else 0
svc.send_text_message(
f"📊 设备在线统计\n总设备数: {total}\n在线: {online}\n离线: {total - online}\n在线率: {rate:.1f}%",
to_user=from_user
)
finally:
db.close()
except Exception as e:
logger.error(f"在线统计失败: {e}")
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
def _handle_status_cmd(svc, from_user: str, content: str):
"""处理设备状态查询命令"""
try:
mac_suffix = content.split('+')[1].strip().upper()
from app.core.database import SessionLocal
from app.models.device import ONUDevice, DeviceStatusHistory
from sqlalchemy import func
db = SessionLocal()
try:
devices = db.query(ONUDevice).filter(
ONUDevice.mac_address.ilike(f"%{mac_suffix}")
).limit(10).all()
if not devices:
svc.send_text_message("未找到匹配的设备", to_user=from_user)
return
lines = [f"🔍 找到 {len(devices)} 个设备(MAC 含 {mac_suffix}):", ""]
for d in devices[:8]:
# 查最新状态
latest = (
db.query(DeviceStatusHistory.status,
func.max(DeviceStatusHistory.checked_at))
.filter(DeviceStatusHistory.onu_device_id == d.id)
.group_by(DeviceStatusHistory.status)
.order_by(func.max(DeviceStatusHistory.checked_at).desc())
.first()
)
status_text = latest[0] if latest else "未知"
emoji = "🟢" if status_text == "online" else "🔴"
school = d.school_name or "未知"
lines.append(f"{emoji} {d.mac_address} | {school} | {d.region or ''}")
svc.send_text_message("\n".join(lines), to_user=from_user)
finally:
db.close()
except Exception as e:
logger.error(f"设备查询失败: {e}")
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
def _handle_offline_schools_cmd(svc, from_user: str):
"""查询全离线学校"""
try:
from app.core.database import SessionLocal
from app.models.device import ONUDevice, DeviceStatusHistory
from sqlalchemy import func, case
db = SessionLocal()
try:
latest_subq = (
db.query(DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
.group_by(DeviceStatusHistory.onu_device_id).subquery()
)
latest_status_subq = (
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
.join(latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)).subquery()
)
rows = (
db.query(
ONUDevice.school_name, ONUDevice.region,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(ONUDevice.school_name, ONUDevice.region)
.having(func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0)
.all()
)
if not rows:
svc.send_text_message("✅ 当前没有全离线的学校", to_user=from_user)
return
lines = [f"🔴 全离线学校 ({len(rows)} 所)", ""]
for r in rows:
school = r.school_name or "未知"
region = r.region or "未知"
total = int(r.total or 0)
if total > 0:
lines.append(f"{school}{region}: {total}台全离线")
svc.send_text_message("\n".join(lines), to_user=from_user)
finally:
db.close()
except Exception as e:
logger.error(f"全离线查询失败: {e}")
svc.send_text_message("查询失败,请稍后重试", to_user=from_user)
def _handle_help_cmd(svc, from_user: str):
"""处理帮助命令"""
svc.send_text_message(
"📋 H3C ONU 管理助手\n\n"
"🔍 设备查询:\n"
"• 发送「在线」查看设备在线统计\n"
"• 发送「全离线」查看全离线学校\n"
"• 发送 MAC 地址后四位查询设备\n\n"
"💡 发送「帮助」显示此信息\n"
"💻 完整功能: https://onu.dhdx.fun",
to_user=from_user
)
+46
View File
@@ -0,0 +1,46 @@
"""WebSocket 实时推送"""
import asyncio
import json
import logging
import redis.asyncio as aioredis
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
REDIS_CHANNEL = "h3c_onu:status_updates"
_connected: set[WebSocket] = set()
async def _redis_listener():
"""监听 Redis pub/sub 并广播给所有 WebSocket 客户端"""
try:
r = aioredis.from_url(settings.REDIS_URL)
pubsub = r.pubsub()
await pubsub.subscribe(REDIS_CHANNEL)
logger.info("WebSocket Redis 监听已启动")
async for msg in pubsub.listen():
if msg["type"] == "message":
dead: set[WebSocket] = set()
for ws in _connected:
try:
await ws.send_text(msg["data"].decode())
except Exception:
dead.add(ws)
_connected -= dead
except Exception as e:
logger.error(f"Redis 监听异常: {e}")
@router.websocket("/ws/dashboard")
async def dashboard_ws(ws: WebSocket):
await ws.accept()
_connected.add(ws)
try:
while True:
await ws.receive_text() # keep-alive, 忽略客户端消息
except WebSocketDisconnect:
pass
finally:
_connected.discard(ws)
+9
View File
@@ -29,6 +29,15 @@ class Settings(BaseSettings):
# iMC API 配置(用于 ONU 远程重启和光功率查询)
IMC_API_URL: str = ""
# 企业微信应用消息 API(用于发送告警)
WECHAT_CORPID: str = ""
WECHAT_CORPSECRET: str = ""
WECHAT_AGENTID: str = ""
WECHAT_TOKEN: str = ""
WECHAT_ENCODING_AES_KEY: str = ""
WECHAT_USE_PROXY: bool = True
WECHAT_PROXY_API_URL: str = "https://api.v6ole.top"
IMC_API_USERNAME: str = ""
IMC_API_PASSWORD: str = ""
IMC_API_VERIFY_SSL: bool = False
+69 -5
View File
@@ -1,15 +1,52 @@
"""FastAPI 主应用"""
from fastapi import FastAPI
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
from app.core.config import settings
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
from app.middleware.audit_middleware import AuditMiddleware
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
# 结构化 JSON 日志
_handler = logging.StreamHandler()
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
logging.getLogger().handlers = [_handler]
logging.getLogger().setLevel(logging.INFO)
logging.getLogger('uvicorn.access').handlers = [_handler]
# 请求体大小限制中间件
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.headers.get("content-length"):
if int(request.headers["content-length"]) > MAX_BODY_SIZE:
return JSONResponse({"detail": "请求体过大,最大 10MB"}, status_code=413)
return await call_next(request)
# CORS 白名单
ALLOWED_ORIGINS = [
"http://localhost:5173",
"http://localhost:18002",
"https://onu.dhdx.fun",
]
allowed = [o for o in ALLOWED_ORIGINS if o]
limiter = Limiter(key_func=get_remote_address, default_limits=["120/minute"])
app = FastAPI(title=settings.APP_NAME, debug=settings.DEBUG)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(RequestSizeLimitMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=allowed if allowed else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -28,8 +65,35 @@ app.include_router(roles.router)
app.include_router(inventory.router)
app.include_router(settings_api.router)
app.include_router(audit.router)
app.include_router(wechat.router)
app.include_router(ws.router)
app.include_router(monitor.router)
@app.on_event("startup")
async def startup():
import asyncio
asyncio.create_task(ws._redis_listener())
@app.get("/health")
def health_check():
return {"status": "ok"}
status = {"status": "ok", "db": "ok", "redis": "ok"}
try:
import redis
import psycopg2
r = redis.from_url(settings.REDIS_URL, socket_timeout=2)
r.ping()
except Exception:
status["redis"] = "error"
status["status"] = "degraded"
try:
from sqlalchemy import text
from app.core.database import SessionLocal
db = SessionLocal()
db.execute(text("SELECT 1"))
db.close()
except Exception:
status["db"] = "error"
status["status"] = "degraded"
return status
+15 -1
View File
@@ -1,5 +1,5 @@
"""设备数据模型"""
from sqlalchemy import Column, BigInteger, String, Integer, Text, TIMESTAMP, ForeignKey, JSON
from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTAMP, ForeignKey, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
@@ -39,6 +39,9 @@ class ONUDevice(Base):
place_type = Column(String(50)) # 场所类型
room_number = Column(String(50))
notes = Column(Text) # 备注
tags = Column(Text) # 标签(逗号分隔),如 "重点设备,考试用"
latitude = Column(Float, nullable=True) # 纬度
longitude = Column(Float, nullable=True) # 经度
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
@@ -95,6 +98,17 @@ class NewDevice(Base):
discovered_at = Column(TIMESTAMP, server_default=func.now())
class OpticalPowerHistory(Base):
"""光功率历史记录"""
__tablename__ = "optical_power_history"
id = Column(BigInteger, primary_key=True, index=True)
onu_device_id = Column(BigInteger, ForeignKey("onu_devices.id"), nullable=False, index=True)
power_in = Column(String(20)) # 接收光功率 dBm
power_out = Column(String(20)) # 发送光功率 dBm
recorded_at = Column(TIMESTAMP, nullable=False, server_default=func.now())
class DeviceReplacement(Base):
"""设备更换记录"""
__tablename__ = "device_replacements"
+1
View File
@@ -10,6 +10,7 @@ class User(Base):
id = Column(BigInteger, primary_key=True, index=True)
casdoor_id = Column(String(100), unique=True, nullable=False)
username = Column(String(100), nullable=False)
display_name = Column(String(100)) # 中文姓名
email = Column(String(255))
role = Column(String(50), default="user")
assigned_area = Column(String(100))
+1
View File
@@ -7,6 +7,7 @@ from datetime import datetime
class UserListItem(BaseModel):
id: int
username: str
display_name: Optional[str] = None
email: Optional[str] = None
role: str
assigned_area: Optional[str] = None
+24 -6
View File
@@ -1,4 +1,5 @@
"""设备状态检查服务"""
import time
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
from app.services.ssh_service import SSHService
@@ -6,6 +7,26 @@ from app.models.device import OLTDevice, ONUDevice, DeviceStatusHistory, Duplica
from datetime import datetime
import re
# SSH 连接池缓存,TTL 5 分钟
_conn_pool: Dict[int, tuple[SSHService, float]] = {}
_POOL_TTL = 300
def _get_cached_ssh(olt_ip: str, olt_user: str, olt_pass: str, olt_id: int) -> SSHService:
"""获取缓存的 SSH 连接,过期自动重连"""
entry = _conn_pool.get(olt_id)
if entry:
ssh, ts = entry
if time.time() - ts < _POOL_TTL:
return ssh
try:
ssh.close()
except Exception:
pass
ssh = SSHService(olt_ip, olt_user, olt_pass)
ssh.connect()
_conn_pool[olt_id] = (ssh, time.time())
return ssh
def parse_distance(distance_str: Optional[str]) -> Optional[int]:
"""将距离字符串转为整数,如 '<1000' -> 1000, '1234' -> 1234"""
@@ -29,9 +50,8 @@ class CheckService:
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, _ = ssh.parse_onu_info(output)
@@ -160,9 +180,8 @@ class CheckService:
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
@@ -241,9 +260,8 @@ class CheckService:
if not olt:
raise Exception(f"OLT 设备不存在: {olt_id}")
ssh = SSHService(olt.ip_address, olt.username, olt.password)
ssh = _get_cached_ssh(olt.ip_address, olt.username, olt.password, olt_id)
try:
ssh.connect()
output = ssh.execute_command(olt.slot_command)
onu_info_dict, duplicate_dict = ssh.parse_onu_info(output)
+331
View File
@@ -0,0 +1,331 @@
"""企业微信 (WeChat Work) 应用消息服务"""
import time
import hashlib
import base64
import socket
import struct
import urllib.parse
import xml.etree.ElementTree as ET
import logging
import requests
from Crypto.Cipher import AES
from app.core.config import settings
logger = logging.getLogger(__name__)
def _get_db_setting(key: str, default: str = "") -> str:
"""从数据库读取 SystemSetting,失败时返回 default"""
try:
from app.core.database import SessionLocal
from app.models.setting import SystemSetting
db = SessionLocal()
try:
row = db.query(SystemSetting).filter_by(key=key).first()
return row.value if row else default
finally:
db.close()
except Exception:
return default
class WeChatService:
"""企业微信应用消息服务"""
def __init__(self):
# 优先读环境变量,fallback 到数据库配置
self.corpid = settings.WECHAT_CORPID or _get_db_setting("wechat_corpid")
self.corpsecret = settings.WECHAT_CORPSECRET or _get_db_setting("wechat_corpsecret")
self.agentid = settings.WECHAT_AGENTID or _get_db_setting("wechat_agentid")
self.token = settings.WECHAT_TOKEN
self.encoding_aes_key = settings.WECHAT_ENCODING_AES_KEY
self.use_proxy = settings.WECHAT_USE_PROXY
self.proxy_api_url = settings.WECHAT_PROXY_API_URL
self._access_token = None
self._token_expires_at = 0
# ── access_token 管理 ───────────────────────────────────────────────────
def get_access_token(self) -> str | None:
"""获取企业微信 access_token,自动缓存和续期"""
now = time.time()
if self._access_token and now < self._token_expires_at:
return self._access_token
if not self.corpid or not self.corpsecret:
logger.warning("WECHAT_CORPID 或 WECHAT_CORPSECRET 未配置")
return None
try:
if self.use_proxy:
url = f"{self.proxy_api_url}/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
else:
url = f"https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={self.corpsecret}"
resp = requests.get(url, timeout=10)
result = resp.json()
if result.get("errcode") == 0:
self._access_token = result.get("access_token")
expires_in = result.get("expires_in", 7200) - 300 # 提前5分钟过期
self._token_expires_at = now + expires_in
logger.info(f"企业微信 access_token 获取成功,过期时间: {time.strftime('%H:%M:%S', time.localtime(self._token_expires_at))}")
return self._access_token
elif result.get("errcode") == 60020 and not self.use_proxy:
logger.info("IP受限,尝试使用代理获取 access_token")
self.use_proxy = True
return self.get_access_token()
else:
logger.error(f"获取 access_token 失败: {result}")
return None
except Exception as e:
logger.error(f"获取 access_token 异常: {e}")
return None
# ── 消息分条 ────────────────────────────────────────────────────────────
@staticmethod
def _split_long_message(content: str, max_chars: int = 1800) -> list[str]:
"""将长消息按换行边界拆分为多条,避免企微截断"""
if len(content) <= max_chars:
return [content]
chunks = []
lines = content.split('\n')
current = ''
for line in lines:
if len(current) + len(line) + 1 > max_chars and current:
chunks.append(current.strip())
current = line
else:
current += ('\n' + line) if current else line
if current.strip():
chunks.append(current.strip())
return chunks
# ── 发送消息 ────────────────────────────────────────────────────────────
def _send_markdown_single(self, content: str, to_user: str) -> bool:
"""发送单条 Markdown 消息(内部方法)"""
access_token = self.get_access_token()
if not access_token:
return False
data = {
"touser": to_user, "toparty": "", "totag": "",
"msgtype": "markdown",
"agentid": int(self.agentid),
"markdown": {"content": content}
}
if self.use_proxy:
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}"
else:
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
resp = requests.post(url, json=data, timeout=15)
result = resp.json()
errcode = result.get("errcode", -1)
if errcode == 0:
return True
elif errcode == 40014:
self._access_token = None
self._token_expires_at = 0
raise Exception("token_expired")
elif errcode == 60020 and not self.use_proxy:
self.use_proxy = True
raise Exception("ip_restricted")
else:
logger.warning(f"企业微信消息发送失败: {result.get('errmsg')} (errcode={errcode})")
return False
def send_markdown(self, content: str, to_user: str = "@all") -> bool:
"""发送 Markdown 消息,自动分条"""
if not self.corpid or not self.corpsecret or not self.agentid:
logger.warning("企业微信未配置,跳过发送")
return False
chunks = self._split_long_message(content)
success = True
for i, chunk in enumerate(chunks):
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
for attempt in range(3):
try:
ok = self._send_markdown_single(prefix + chunk, to_user)
if ok:
break
if attempt < 2:
time.sleep(1)
except Exception as e:
logger.warning(f"发送分片 {i+1}/{len(chunks)} 异常: {e}")
if attempt < 2:
time.sleep(1)
continue
success = False
if i < len(chunks) - 1:
time.sleep(0.5) # 避免频率限制
return success
# ── 发送文本消息 ────────────────────────────────────────────────────────
def send_text_message(self, content: str, to_user: str = "@all") -> bool:
"""通过应用消息 API 发送文本消息,自动分条"""
if not self.corpid or not self.corpsecret or not self.agentid:
return False
chunks = self._split_long_message(content, max_chars=1800)
success = True
for i, chunk in enumerate(chunks):
prefix = f"({i+1}/{len(chunks)})\n" if len(chunks) > 1 else ""
try:
access_token = self.get_access_token()
if not access_token:
return False
data = {
"touser": to_user, "toparty": "", "totag": "",
"msgtype": "text",
"agentid": int(self.agentid),
"text": {"content": prefix + chunk}
}
url = f"{self.proxy_api_url}/cgi-bin/message/send?access_token={access_token}" if self.use_proxy else f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
resp = requests.post(url, json=data, timeout=15)
if resp.json().get("errcode") != 0:
success = False
except Exception as e:
logger.error(f"发送文本消息分片 {i+1}/{len(chunks)} 失败: {e}")
success = False
if i < len(chunks) - 1:
time.sleep(0.5)
return success
# ── 菜单管理 ────────────────────────────────────────────────────────────
def create_menu(self, menu_data: dict) -> bool:
"""创建/更新企业微信应用菜单"""
try:
access_token = self.get_access_token()
if not access_token:
return False
if self.use_proxy:
url = f"{self.proxy_api_url}/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
else:
url = f"https://qyapi.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}&agentid={self.agentid}"
resp = requests.post(url, json=menu_data, timeout=15)
result = resp.json()
if result.get("errcode") == 0:
logger.info("企业微信菜单创建成功")
return True
logger.warning(f"创建菜单失败: {result}")
return False
except Exception as e:
logger.error(f"创建菜单异常: {e}")
return False
# ── 获取用户信息 ─────────────────────────────────────────────────────────
def get_user_info(self, userid: str) -> dict:
"""获取企业微信用户信息"""
try:
access_token = self.get_access_token()
if not access_token:
return {"errcode": -1, "errmsg": "无 access_token"}
if self.use_proxy:
url = f"{self.proxy_api_url}/cgi-bin/user/get?access_token={access_token}&userid={userid}"
else:
url = f"https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token={access_token}&userid={userid}"
resp = requests.get(url, timeout=10)
return resp.json()
except Exception as e:
logger.error(f"获取用户信息失败: {e}")
return {"errcode": -1, "errmsg": str(e)}
# ── URL 验证(GET 回调)──────────────────────────────────────────────────
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str | None:
"""验证企业微信回调 URL"""
try:
echostr = urllib.parse.unquote(echostr)
temp_list = [self.token, timestamp, nonce, echostr]
temp_list.sort()
temp_str = ''.join(temp_list)
hash_str = hashlib.sha1(temp_str.encode('utf-8')).hexdigest()
if hash_str != msg_signature:
logger.error(f"URL验证签名不匹配: expected={msg_signature}, got={hash_str}")
return None
if self.encoding_aes_key:
return self._decrypt_echostr(echostr)
return echostr
except Exception as e:
logger.error(f"URL验证异常: {e}")
return None
def _decrypt_echostr(self, echostr: str) -> str | None:
"""解密 echostr"""
try:
aes_key = base64.b64decode(self.encoding_aes_key + '=')
encrypted = base64.b64decode(echostr)
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
decrypted = cipher.decrypt(encrypted)
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
content = decrypted[16:]
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
xml_content = content[4:xml_len + 4]
received_id = content[xml_len + 4:].decode('utf-8')
if received_id != self.corpid:
logger.error(f"企业ID验证失败: {received_id} != {self.corpid}")
return None
return xml_content.decode('utf-8')
except Exception as e:
logger.error(f"解密echostr失败: {e}")
return None
# ── 消息解密(POST 回调)─────────────────────────────────────────────────
def parse_message(self, xml_data: bytes) -> dict | None:
"""解析企业微信回调的加密 XML 消息"""
try:
root = ET.fromstring(xml_data)
msg = {child.tag: child.text for child in root}
if 'Encrypt' in msg:
decrypted = self._decrypt_message(msg['Encrypt'])
decrypted_root = ET.fromstring(decrypted)
msg = {child.tag: child.text for child in decrypted_root}
return msg
except Exception as e:
logger.error(f"解析消息失败: {e}")
return None
def _decrypt_message(self, encrypted_msg: str) -> str:
"""解密企业微信推送的消息"""
aes_key = base64.b64decode(self.encoding_aes_key + '=')
encrypted = base64.b64decode(encrypted_msg)
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
decrypted = cipher.decrypt(encrypted)
decrypted = decrypted[:-decrypted[-1]] # PKCS7 unpad
content = decrypted[16:]
xml_len = socket.ntohl(struct.unpack("I", content[:4])[0])
xml_content = content[4:xml_len + 4]
received_id = content[xml_len + 4:].decode('utf-8')
if received_id != self.corpid:
raise Exception(f"企业ID验证失败: {received_id} != {self.corpid}")
return xml_content.decode('utf-8')
# 模块级单例和便捷函数
_service: WeChatService | None = None
def get_wechat_service() -> WeChatService:
global _service
if _service is None:
_service = WeChatService()
return _service
def send_wechat_markdown(content: str) -> bool:
"""便捷函数:发送企业微信 Markdown 消息"""
return get_wechat_service().send_markdown(content)
+82
View File
@@ -0,0 +1,82 @@
"""告警相关 Celery 任务"""
import traceback
from datetime import datetime
from sqlalchemy import func, case
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
@celery_app.task(queue='h3c_onu_ms', ignore_result=True)
def check_school_offline_alerts():
"""
检查是否有学校全部离线(在线率为 0%),如有则发送企业微信告警。
该任务在每次全量状态检查完成后异步调用。
"""
from app.models.device import ONUDevice, DeviceStatusHistory
from app.services.wechat_service import send_wechat_markdown
db = SessionLocal()
try:
# 每台设备最新状态的子查询
latest_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
)
.group_by(DeviceStatusHistory.onu_device_id)
.subquery()
)
latest_status_subq = (
db.query(
DeviceStatusHistory.onu_device_id,
DeviceStatusHistory.status
)
.join(
latest_subq,
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
)
.subquery()
)
# 按学校聚合在线率,只查询在线数为 0 的学校
rows = (
db.query(
ONUDevice.school_name,
ONUDevice.region,
func.count().label("total"),
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
)
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
.group_by(ONUDevice.school_name, ONUDevice.region)
.having(
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)) == 0
)
.all()
)
if not rows:
return {"alerted": False, "reason": "没有全离线的学校"}
# 构造告警消息
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = [
"## <font color=\"warning\">[告警] 学校全部离线</font>",
f"> 检查时间:{now_str}",
"> 以下学校所有设备均处于离线状态:",
"",
]
for row in rows:
school = row.school_name or "未知"
region = row.region or "未知"
total = int(row.total or 0)
if total > 0:
lines.append(f"- **{school}**{region}: {total} 台设备全离线")
content = "\n".join(lines)
send_wechat_markdown(content)
return {"alerted": True, "schools": len(rows)}
except Exception as e:
return {"alerted": False, "error": str(e), "traceback": traceback.format_exc()}
finally:
db.close()
+10
View File
@@ -97,6 +97,16 @@ def check_all_devices(self):
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
# 通知 WebSocket 客户端状态已更新
try:
import json as _json
r.publish("h3c_onu:status_updates", _json.dumps({
"type": "check_complete", "total_online": total_online,
"total_offline": total_offline, "total_olts": total
}))
except Exception:
pass
return {
'success': True,
'total_olts': total,