ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

setMessageCallback实战:林区烟火告警推送企业微信群

setMessageCallback实战:林区烟火告警推送企业微信群 上个月接手了一个林区防火监控的联动需求核心就一件事让 AI 摄像头识别出来的烟火告警自动出现在防火值班群里。项目里几十个监控点位分布在几十公里长的林区沿线值班室只有两三个人盯着大屏真要等他们自己发现异常火苗早就蹿起来了。所以告警链路必须做到设备侧识别、平台侧订阅、群内秒级触达而整条链路的入口就是 setMessageCallback 这个回调注册方法订阅 smokeAlarm 和 deviceStatus 两类核心事件。这篇文章我会从实际项目出发把从零到一的完整过程拎出来讲一遍。内容包括为什么用回调而不是轮询、smokeAlarm 与 deviceStatus 事件模型怎么设计、setMessageCallback 怎么接入、企业微信群机器人怎么把告警推进值班群、前端 Vue 大屏如何做联动以及我在项目里踩过的坑和处理方案。适合正在做林区/园区/景区监控开发的读者参考尤其是刚接触监控平台消息订阅和告警推送的朋友可以直接照着抄。1. 先想清楚为什么要把烟火告警塞进值班群1.1 林区防火监控场景的痛点林区防火和普通的视频监控不太一样最大的区别在于无人值守和响应时效。普通园区监控人就在中控室坐着看到异常能马上处理林区不一样设备都在山头、路边、制高点上值班室和大屏往往只是集中展示真正确认现场情况还得靠手机或者对讲机远程指挥。再一个痛点是告警量大、误报率高。我调试过程中发现太阳光反射、晨雾、水汽、树叶晃动都可能触发烟火算法的误判。如果这些告警只出现在监控平台上值班人员打开平台的频次又低那误报和真实火情全都会淹没在系统里起不到任何预警作用。唯一的出路就是把告警推到人最常看的地方——日常工作中大家最离不开的群聊。1.2 回调机制在告警链路里的位置从设备采集到值班人员看到消息完整的链路是边缘摄像头或智能盒子运行烟火识别算法侦测到烟雾/火焰后生成 smokeAlarm 事件事件上报给监控管理平台业务后端通过 setMessageCallback 注册回调订阅 smokeAlarm 和 deviceStatus平台产生对应事件后主动调用回调函数把事件数据推给业务后端业务后端把结构化事件渲染成群消息调用企业微信机器人 webhook 推到值班群前端 Vue 大屏通过 WebSocket 订阅同一份事件流实现地图闪烁、告警列表滚动、设备状态刷新。setMessageCallback 在这里相当于整个链路的水龙头开关。你把它拧开了事件才能流到你的业务系统里你不注册事件就只停留在监控平台内部跟业务系统完全隔离。2. 技术选型与整体方案设计2.1 为什么是 setMessageCallback而不是轮询数据库我先说结论实时告警类的业务能走回调就不要用轮询。一开始团队有人提过一个简单粗暴的方案——业务后端每隔 5 秒查询一次告警表发现新数据就推送。这个方案实现确实简单但问题也很明显实时性差5 秒已经是比较极限的间隔了真要改成 1 秒轮询数据库压力又上来了监控点位数一旦过百每秒轮询的请求量会让业务库表变成热点轮询查到的数据还要自己做增量标记增删改查全是手工管理很容易出乱子。回调机制刚好相反。平台内部产生事件之后主动推送给你事件到达的时机就是处理时机中间没有间隔轮询也不存在空转请求。监控平台在设计时就把事件治理好了业务方只管对接回调函数精力可以集中在收到事件之后怎么处理上而不是怎么轮询才不漏事件。2.2 事件消息的主题划分与数据结构设计在正式编码前必须先约定好事件主题和数据结构。我们现阶段只需要关注两个主题smokeAlarm烟火识别的告警事件也就是核心报警deviceStatus摄像头、智能盒子等设备的在线/离线/故障状态变化。这两个主题分开订阅非常必要。原因是它们的消费逻辑差异太大烟火告警要第一时间推防火值班群还要附带现场截图和高危提醒设备状态掉线却不需要轰炸值班人员推给运维群就够了。混在一个主题里接收方还得自己判断类型不仅耦合重还容易漏处理。smokeAlarm 事件字段我自定义了一份结构参考了大多数监控平台事件上报的通用格式{ eventId: 140001-20240521103022-8847-001, eventType: smokeAlarm, deviceId: 140001_05_03, deviceName: 北坡3号监控点, channelId: 1, timestamp: 1716265822000, alarmConfidence: 0.93, alarmType: smoke, imageUrls: [ http://storage.internal/forest/2024/05/21/10/30/alarm_8847.jpg ], ptz: { pan: 120.5, tilt: 35.2, zoom: 3 }, location: { lng: 116.403, lat: 39.924, name: 云岭林场-北坡 } }deviceStatus 事件字段相对简单但同样需要保留上下文信息{ eventId: 140001-20240521103022-8848-001, eventType: deviceStatus, deviceId: 140001_05_03, status: offline, reason: network_timeout, lastOnlineTime: 1716265800000, timestamp: 1716265820000 }两个事件共同的地方是都有 eventId 和 deviceId区别在核心负载上。eventId 是整个方案里最关键的字段后续做幂等去重全指望它。3. 核心实现setMessageCallback 订阅与处理链路3.1 监控 SDK 的初始化与回调注册项目后端我用的 Node.js监控平台提供了一套 Node 版 SDK。初始化时最需要注意的三个配置是 endpoint、appKey/appSecret 和自动重连参数。// services/forest-monitor.js const { MonitorClient } require(forest-monitor/sdk); const monitor new MonitorClient({ endpoint: process.env.MONITOR_ENDPOINT, appKey: process.env.MONITOR_APP_KEY, appSecret: process.env.MONITOR_APP_SECRET, autoReconnect: true, reconnectInterval: 5000, maxReconnectAttempts: 0 });这里我详细说下 endpoint 和自动重连的取舍。监控平台一般提供公网或者专网接入地址林区项目里很多摄像头都在运营商专网内所以 endpoint 通常配置的是专网 IP 加端口而不是公网域名。另外要把 autoReconnect 打开林区网络受天气、供电影响很大长连接断掉是常态SDK 自带的重连机制能保证恢复后重新订阅否则服务重启一次、断网一次回调就彻底失联了。SDK 初始化完成之后在服务启动阶段就要注册回调函数绝不能等到有告警了再注册。// app.js const { monitor, handleSmokeAlarm, handleDeviceStatus } require(./services/forest-monitor); async function bootstrap() { // 服务启动阶段注册 monitor.setMessageCallback(smokeAlarm, handleSmokeAlarm); monitor.setMessageCallback(deviceStatus, handleDeviceStatus); await monitor.connect(); console.log([forest-monitor] message callback registered and connected); } bootstrap();回调函数必须保持幂等。因为监控平台的回调是至少一次投递机制网络问题、平台重试都可能导致同一个 eventId 被回调多次。回调内部不做去重值班群就会被重复消息刷屏。3.2 smokeAlarm 烟火告警的完整处理链路烟火告警是我这个项目的核心处理链路是最长的一条回调到达、幂等校验、告警落库、推送值班群、更新前端缓存。// services/forest-monitor.js const { saveAlarm, deduplicate } require(../repositories/alarm-repo); const { WECOM_WEBHOOK_URL } require(../config); const { WecomRobot } require(../utils/wecom-robot); async function handleSmokeAlarm(event) { // 1. 幂等校验同一 eventId 只处理一次 const isDuplicate await deduplicate.check(smokeAlarm: event.eventId); if (isDuplicate) { console.log([smokeAlarm] duplicate ignored:, event.eventId); return; } // 2. 告警落库方便事后统计和溯源 await saveAlarm({ eventId: event.eventId, deviceId: event.deviceId, deviceName: event.deviceName, alarmType: event.alarmType, confidence: event.alarmConfidence, imageUrl: event.imageUrls?.[0] || , locationName: event.location?.name || , lng: event.location?.lng || null, lat: event.location?.lat || null, occurredAt: event.timestamp }); // 3. 推送到防火值班群 const robot new WecomRobot({ webhookUrl: WECOM_WEBHOOK_URL }); await robot.sendAlarmMessage({ alarmType: event.alarmType, deviceName: event.deviceName, locationName: event.location?.name || , confidence: event.alarmConfidence, timestamp: event.timestamp, imageUrl: await proxyImageToPublic(event.imageUrls?.[0]) }); }这一段里有两个关键细节值得展开。第一个是幂等。我用 Redis 的 SETNX 命令实现键名是 smokeAlarm:{eventId}过期时间设置 10 分钟。为什么是 10 分钟因为平台的重试窗口通常在几分钟内超过 10 分钟还重复投递的概率极低键设置太长又会积累大量僵尸 key白白浪费内存。第二个是图片公网访问问题。监控设备截图通常存在内网存储里值班群机器人如果拿不到公网 URL图片就显示不了。我的处理方式是把内网图片转发到对象存储用签名 URL 返回群消息。这个后面推送章节再细讲。3.3 deviceStatus 设备状态监控与值班通知设备状态回调的处理逻辑和 smokeAlarm 不太一样烟雾告警讲究快但设备状态讲究稳。如果每个设备掉线都往防火值班群里推群就变成了故障播报台真正重要的烟火消息反而会被挤掉。我的策略是把设备状态分两级影响防火监控核心能力的掉线才推运维群恢复上线推一条简短通知中间状态只更新前端缓存不推送。async function handleDeviceStatus(event) { // 更新前端缓存状态 await deviceCache.updateStatus(event.deviceId, event.status); // 离线/故障推送运维群 if (event.status offline || event.status error) { const opsRobot new WecomRobot({ webhookUrl: OPS_WECOM_WEBHOOK_URL }); await opsRobot.sendText( 【设备状态】设备 ${event.deviceId} 已离线原因${event.reason || 未知}最后在线时间${new Date(event.lastOnlineTime).toLocaleString()} ); } // 恢复上线推送 if (event.status online) { await opsRobot.sendText(【设备恢复】设备 ${event.deviceId} 已上线); } }需要注意的是设备状态事件往往比烟火告警更频繁。一台设备网络闪断会连续上报多条 offline同样会产生重复推送问题。所以设备状态处理也要加幂等只不过去重窗口更短一些比如 2 分钟即可因为一分钟后同一台设备上报的 offline 可能对应新一次故障。4. 关键环节把告警推进值班群4.1 企业微信群机器人 webhook 对接值班群推送到企业微信我采用群自定义机器人 webhook。这种方式的好处是零审批成本、零额外开发只需要在群里添加一个机器人拿到 webhook 地址就能调用。最简单的封装如下// utils/wecom-robot.js const axios require(axios); class WecomRobot { constructor({ webhookUrl }) { this.webhookUrl webhookUrl; } async sendMarkdown(content) { const response await axios.post( this.webhookUrl, { msgtype: markdown, markdown: { content } }, { timeout: 5000 } ); if (response.data.errcode ! 0) { throw new Error(wecom webhook error: ${response.data.errmsg}); } return response.data; } } module.exports { WecomRobot };这里提醒一句webhook 地址等于群消息的写权限泄露之后别人可以往你群里灌垃圾消息。所以 webhook 地址一定要放在环境变量或配置中心里不要硬编码到代码仓库。4.2 告警消息模板的设计与图片转发推送到值班群的消息模板我用了 Markdown 格式重点是把值班人员最关心的信息放在最前面图片放在第二屏。模板实际效果如下### 林区烟火告警 等级font colorwarning高/font 位置云岭林场-北坡3号监控点 时间2024-05-21 10:30:22 置信度93% 图片[查看现场截图](http://public.storage/xxx.jpg) [打开监控大屏](http://console.xxx.com/veiw/index) 请值班人员立即核实处置为什么必须带一个大屏链接因为群消息里能展示的信息有限值班人员看到告警后需要快速进入监控平台查看实时视频、回放录像一个链接就省去了翻菜单找平台的步骤能把响应时间缩短不少。图片转发逻辑我再强调一点。企业微信机器人消息里如果直接放内网图片 URL群成员点击时是访问不通的。更合理的方式是后端接收到告警事件后把内网截图下载下来上传到对象存储拿到公网 URL 再放进群消息。当然这一步会增加延迟我实测单张图片从内网拉取几秒钟就可以完成对于告警场景来说是可以接受的。4.3 告警聚合同一事件窗口的多次触发林区场景里还有一种常见情况一个火点被相邻两个摄像头同时拍到或者同一摄像头连续 5 秒侦测到烟火就会在短时间内产生多条告警。这时候如果全部推到群里值班人员会看到一连串几乎一模一样的消息体验极差。我的解决方案是拉长聚合窗口。在回调处理逻辑里增加一个同设备同类型告警 60 秒聚合窗口的逻辑窗口内的多条事件合并成一条推送窗口内第一条事件立即推送标题标注发生烟火告警后续 60 秒内同设备再次上报的告警只更新累计次数不单独推送窗口结束时如果有新增累计次数补推一条该点位 60 秒内累计告警 N 次的提醒。这个方案避免刷屏同时又不会漏报真实火情。不过在项目早期我建议先保留全量推送确认算法误报率不高之后再开聚合这样能更早摸清实际情况。5. 前端 Vue 大屏联动与实时展示5.1 消息中心模块与 Vue 项目的订阅设计后端订阅事件用于推送群消息但值班室的大屏也不能闲着。大屏上需要实时闪烁告警点、滚动告警列表、更新设备状态仪表盘这些实时数据不能靠刷新页面去拉而是通过 WebSocket 建立一条从后端到前端的长连接通道。我在 Vue3 项目里封装了一个消息中心模块核心还是 setMessageCallback只不过这一次它运行在浏览器侧订阅的是 WebSocket 推送下来的事件。// src/services/message-center.ts import { ref } from vue; import { io } from socket.io-client; const socket io(import.meta.env.VITE_WS_URL, { reconnection: true, reconnectionDelay: 3000 }); const callbackMap new Mapstring, Array(payload: any) void(); export function setMessageCallback(topic: string, callback: (payload: any) void) { if (!callbackMap.has(topic)) { callbackMap.set(topic, []); socket.on(topic, (payload: any) { const cbs callbackMap.get(topic); cbs?.forEach((cb) cb(payload)); }); } callbackMap.get(topic)?.push(callback); } export function removeMessageCallback(topic: string, callback: (payload: any) void) { const cbs callbackMap.get(topic); if (!cbs) return; const idx cbs.indexOf(callback); if (idx ! -1) cbs.splice(idx, 1); }这里有个容易被忽视的点Vue 组件卸载时如果不移除回调组件重新打开时会重复订阅同一个事件触发多次处理逻辑。我在大屏地图组件里就踩过这个坑后面排查篇里详细说。5.2 地图告警闪烁与设备状态刷新大屏页面用的是 Vue3 OpenLayers 的地图组件。订阅到 smokeAlarm 事件后地图上对应监控点位会生成一个闪烁圆环同时右侧告警面板插入一条新记录。核心代码片段如下// src/views/forest-map.vue import { setMessageCallback, removeMessageCallback } from /services/message-center; import { useAlarmStore } from /stores/alarm; import { useDeviceStore } from /stores/device; const alarmStore useAlarmStore(); const deviceStore useDeviceStore(); const handleSmokeAlarm (event: any) { alarmStore.addAlarm({ id: event.eventId, deviceName: event.deviceName, locationName: event.location?.name || , confidence: event.alarmConfidence, occurredAt: event.timestamp }); flashMarker(event.deviceId, event.location?.lng, event.location?.lat); }; const handleDeviceStatus (event: any) { deviceStore.updateDevice(event.deviceId, event.status); }; onMounted(() { setMessageCallback(smokeAlarm, handleSmokeAlarm); setMessageCallback(deviceStatus, handleDeviceStatus); }); onUnmounted(() { removeMessageCallback(smokeAlarm, handleSmokeAlarm); removeMessageCallback(deviceStatus, handleDeviceStatus); });前端大屏和后端推送共用同一套事件协议好处是开发时只需要定义一次事件结构前后端对照着实现不容易出现字段对不上的问题。5.3 Vue 项目的编译与部署配置Vue 大屏项目我用的 Vite 构建部署的是纯静态资源。这里有一个和普通前端项目不太一样的地方WebSocket 地址和地图 API 地址不能写死在代码里而是用环境变量区分开发/测试/生产环境。# .env.production VITE_WS_URLwss://monitor.xxx.com/ws VITE_MAP_SERVERhttps://map.xxx.com/构建时执行npm run build编译产物会生成在 dist 目录我配合 Nginx 部署同时配置了/ws路径的反代把 WebSocket 请求转发到后端 Node 服务。这个环节说明白了其实就是构建、配 Nginx、发布三步但很多新手会漏掉一个细节Vite 默认打包生成的资源路径是绝对路径/assets/...如果部署在子路径下必须配置base选项否则页面白屏。我项目里就因为这个踩过坑后来在 vite.config.ts 里加了一句export default defineConfig({ base: process.env.VITE_PUBLIC_PATH || /, // ... });6. 常见问题与排查实录6.1 回调不触发或回调延迟大的排查思路项目里遇到最多的一个问题就是明明设备上报了告警但群里就是没消息。排查顺序我整理成了一套思路先在监控平台的操作日志里确认设备事件确实上报了事件 ID、时间都能查到检查后端服务日志确认 setMessageCallback 是否注册成功是否打印了 connected 日志检查回调是否注册在 connect 之前如果先 connect 再注册平台建立的 session 里可能没有这个订阅检查 WebSocket/MQTT 连接是否反复断开如果重连频率很高事件会在断线窗口内丢失最后再用平台自带的测试工具直接推送一条模拟事件验证回调链路是通的。林区网络环境差时回调延迟往往表现为过了两三分钟才收到。这种情况通常不是代码问题而是底层通信链路质量差SDK 自动重连后需要时间恢复订阅。我在重连逻辑里加了事件补拉机制连上之后主动查询最近 5 分钟的事件列表做补偿能覆盖大部分断线窗口。6.2 消息重复推送的处理重复推送的根因我在 3.2 节提过至少一次投递是大多数消息中间件的默认语义。加上 SDK 重连、网络超时重试重复率在我实测环境里大概有 2% 左右。去重方案我最终选的是 Redis SETNX 加过期时间const SET_IF_NOT_EXIST SET; const result await redisClient.sendCommand([ SET, key, 1, SET_IF_NOT_EXIST, PX, 600000 // 10分钟 ]); if (result OK) { // 第一次处理 await processEvent(); }这个方案的关键在于命令本身是原子操作所以在高并发场景下也不会出现两个并发回调同时拿到未处理的状态。改用先查后写的非原子方案在重复回调并发来时去重就失效了值班群会被连环刷屏。6.3 群机器人 webhook 触达频率限制企业微信自定义机器人有频率限制默认是每分钟最多 20 条。如果同一时刻十几个点位同时告警很容易直接把 webhook 打满触发限流后消息排队延迟就很明显了。我的解决方案有两个层面。第一层是业务聚合把同一时间窗口的告警合并推送减少推送条数第二层是代码熔断给机器人发送封装加一个简单滑动窗口计数器如果一分钟内已经消耗了 15 条配额后续消息先走日志下一分钟再补推。6.4 时区与时间格式的兼容问题监控平台上报的事件时间一般是 Unix 毫秒时间戳但在值班群展示时我发现不同值班人员手机上的显示有时差问题。排查后确认不是代码 bug而是部分值班手机系统设置为了非中国时区导致 toLocaleString 输出跟北京时间不一致。解决办法是明确告知消息模板使用中国时区格式化不能用服务端本地时区function formatBeijingTime(timestamp) { return new Date(timestamp).toLocaleString(zh-CN, { timeZone: Asia/Shanghai, hour12: false }); }这个问题说大不大但对值班人员来说非常影响体验夜里值班本来就困看到时间对不上还要心里换算一下确实不专业。7. 实操心得与后续扩展方向项目上线跑了两周整体效果符合预期烟火告警从产生到值班群收到消息平均耗时稳定在 3 秒左右值班人员基本都能在 1 分钟内核实响应。最让我满意的是设备离线通知和烟火告警分群推送的设计运维群和值班群各司其职没有出现群消息互相污染的情况。有几个经验我觉得值得单独拎出来说说。第一回调机制是整个链路的地基但地基不只是把 setMessageCallback 调起来就完事了。它的可靠性靠的是平台侧的重试、业务侧的去重、断线后的补偿三者共同保障任何一环缺失最终表现都会是丢消息或者重消息而且这种问题往往在上线后才暴露。第二告警推送要克制。每次都把消息推全当然简单但群里的告警消息一旦多了大家就会麻木。把真正需要关注的烟火告警、和只需要后台处理的设备状态分开是监控类项目非常重要的一步设计。第三前端和大屏的联动不要做成锦上添花的装饰。这次我直接让前端事件源和后端推送共用同一份事件协议前端才真正成了值班场景的一部分而不只是看板展示。后续我还计划在告警聚合基础上加一个告警确认循环群消息上附一个我已收到并前往核实的按钮链接点进去回写确认状态到监控平台这样值班主管能随时看到每个告警的处理进展。如果你也在做类似的监控告警推送项目建议也从消息可靠性和告警分级入手先把这两点想清楚比纠结具体某个组件选什么靠谱得多。
返回列表