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)