feat(alerts): 添加微信告警、WebSocket实时推送和地图功能
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks) - 新增 WebSocket 实时推送端点 - 新增监控管理模块(monitor) - 增强统计仪表板:趋势图、区域分布、光功率历史 - 设备管理:添加坐标信息、标签系统、复合索引优化 - 前端:重构Dashboard/Charts页面,新增业务组件 - 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名) - 更新部署配置和脚本 - 新增测试框架基础结构
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user