feat(alerts): 添加微信告警、WebSocket实时推送和地图功能
- 新增微信告警服务(wechat_service)和告警任务(alert_tasks) - 新增 WebSocket 实时推送端点 - 新增监控管理模块(monitor) - 增强统计仪表板:趋势图、区域分布、光功率历史 - 设备管理:添加坐标信息、标签系统、复合索引优化 - 前端:重构Dashboard/Charts页面,新增业务组件 - 新增5个数据库迁移(坐标、复合索引、标签、光功率历史、显示名) - 更新部署配置和脚本 - 新增测试框架基础结构
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
@@ -98,3 +98,29 @@ def update_about(
|
||||
db.add(SystemSetting(key='about_content', value=content, description='关于页面内容(Markdown)'))
|
||||
db.commit()
|
||||
return {"key": "about_content", "value": content}
|
||||
|
||||
|
||||
@router.put("/webhook")
|
||||
def update_webhook(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('*')),
|
||||
):
|
||||
"""更新企业微信告警配置(仅管理员)"""
|
||||
configs = [
|
||||
("wechat_corpid", body.get("corpid", ""), "企业微信 CorpID"),
|
||||
("wechat_corpsecret", body.get("corpsecret", ""), "企业微信 CorpSecret"),
|
||||
("wechat_agentid", body.get("agentid", ""), "企业微信 AgentID"),
|
||||
]
|
||||
for key, value, desc in configs:
|
||||
setting = db.query(SystemSetting).filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.add(SystemSetting(key=key, value=value, description=desc))
|
||||
|
||||
db.commit()
|
||||
return {"message": "企业微信配置已保存"}
|
||||
|
||||
|
||||
|
||||
|
||||
+177
-7
@@ -179,16 +179,14 @@ def get_trend(
|
||||
)
|
||||
snapshot_map = {s.snapshot_date: s for s in snapshots}
|
||||
|
||||
# 今天实时聚合
|
||||
# 今天实时聚合 — 取每个设备最新状态(不限日期),反映真实当前状况
|
||||
today_str = today.strftime('%Y-%m-%d')
|
||||
start_of_today = datetime.combine(today, datetime.min.time())
|
||||
|
||||
daily_latest_subq = (
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"),
|
||||
)
|
||||
.filter(DeviceStatusHistory.checked_at >= start_of_today)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
@@ -198,9 +196,9 @@ def get_trend(
|
||||
func.sum(case((DeviceStatusHistory.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.join(
|
||||
daily_latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == daily_latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == daily_latest_subq.c.max_checked_at)
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.one()
|
||||
)
|
||||
@@ -246,3 +244,175 @@ def get_trend(
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/olt-stats")
|
||||
def get_olt_stats(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取每台 OLT 下的设备在线率统计"""
|
||||
from app.models.device import OLTDevice
|
||||
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||
.join(latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at))
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
OLTDevice.id,
|
||||
OLTDevice.ip_address,
|
||||
OLTDevice.location,
|
||||
OLTDevice.region,
|
||||
func.count(ONUDevice.id).label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.outerjoin(ONUDevice, ONUDevice.olt_id == OLTDevice.id)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.group_by(OLTDevice.id)
|
||||
.order_by(OLTDevice.region, OLTDevice.location)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"olt_id": r.id,
|
||||
"name": r.location or r.ip_address,
|
||||
"ip": r.ip_address,
|
||||
"region": r.region or "未知",
|
||||
"total": int(r.total or 0),
|
||||
"online": int(r.online or 0),
|
||||
"offline": int(r.offline or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/offline-schools")
|
||||
def get_offline_schools(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取全部离线的学校列表"""
|
||||
_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at"))
|
||||
.group_by(DeviceStatusHistory.onu_device_id).subquery()
|
||||
)
|
||||
_status_subq = (
|
||||
db.query(DeviceStatusHistory.onu_device_id, DeviceStatusHistory.status)
|
||||
.join(_subq,
|
||||
(DeviceStatusHistory.onu_device_id == _subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == _subq.c.max_checked_at)).subquery()
|
||||
)
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.school_name, ONUDevice.region,
|
||||
func.count().label("total"),
|
||||
func.sum(case((_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
)
|
||||
.outerjoin(_status_subq, ONUDevice.id == _status_subq.c.onu_device_id)
|
||||
.group_by(ONUDevice.school_name, ONUDevice.region)
|
||||
.having(func.sum(case((_status_subq.c.status == 'online', 1), else_=0)) == 0)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{"school_name": r.school_name or "未知", "region": r.region or "未知", "total": int(r.total or 0)}
|
||||
for r in rows if int(r.total or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
@router.get("/model-distribution")
|
||||
def get_model_distribution(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""统计 ONU 设备型号分布"""
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.model,
|
||||
func.count(ONUDevice.id).label("count"),
|
||||
)
|
||||
.filter(ONUDevice.model.isnot(None), ONUDevice.model != '')
|
||||
.group_by(ONUDevice.model)
|
||||
.order_by(func.count(ONUDevice.id).desc())
|
||||
.all()
|
||||
)
|
||||
unknown = db.query(func.count(ONUDevice.id)).filter(
|
||||
(ONUDevice.model.is_(None)) | (ONUDevice.model == '')
|
||||
).scalar() or 0
|
||||
|
||||
result = [{"model": r.model or "未知", "count": r.count} for r in rows]
|
||||
if unknown > 0:
|
||||
result.append({"model": "未知型号", "count": unknown})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/school-locations")
|
||||
def get_school_locations(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(require_permission('device.view')),
|
||||
):
|
||||
"""获取各学校的聚合位置数据(用于地图展示)"""
|
||||
latest_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
func.max(DeviceStatusHistory.checked_at).label("max_checked_at")
|
||||
)
|
||||
.group_by(DeviceStatusHistory.onu_device_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_status_subq = (
|
||||
db.query(
|
||||
DeviceStatusHistory.onu_device_id,
|
||||
DeviceStatusHistory.status
|
||||
)
|
||||
.join(
|
||||
latest_subq,
|
||||
(DeviceStatusHistory.onu_device_id == latest_subq.c.onu_device_id) &
|
||||
(DeviceStatusHistory.checked_at == latest_subq.c.max_checked_at)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
ONUDevice.school_name,
|
||||
ONUDevice.region,
|
||||
ONUDevice.latitude,
|
||||
ONUDevice.longitude,
|
||||
func.count().label("total"),
|
||||
func.sum(case((latest_status_subq.c.status == 'online', 1), else_=0)).label("online"),
|
||||
func.sum(case((latest_status_subq.c.status == 'offline', 1), else_=0)).label("offline"),
|
||||
)
|
||||
.outerjoin(latest_status_subq, ONUDevice.id == latest_status_subq.c.onu_device_id)
|
||||
.filter(ONUDevice.latitude.isnot(None))
|
||||
.filter(ONUDevice.longitude.isnot(None))
|
||||
.group_by(ONUDevice.school_name, ONUDevice.region, ONUDevice.latitude, ONUDevice.longitude)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"school_name": row.school_name or "未知",
|
||||
"region": row.region or "未知",
|
||||
"latitude": row.latitude,
|
||||
"longitude": row.longitude,
|
||||
"total": int(row.total or 0),
|
||||
"online": int(row.online or 0),
|
||||
"offline": int(row.offline or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def get_users(
|
||||
query = query.filter(User.role == role)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
User.username.contains(keyword) | User.email.contains(keyword)
|
||||
User.username.contains(keyword) | User.display_name.contains(keyword) | User.email.contains(keyword)
|
||||
)
|
||||
query = query.order_by(asc(User.created_at))
|
||||
total = query.count()
|
||||
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user