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
+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
)