AI 告警 WebSocket 弹窗:从「能弹出来」到「弹对人 + 能录像截图」

园区安防平台有一类需求:AI 摄像头检测到人员越界,前端要能在右下角弹一个带实时视频的告警卡片。旧版代码里这套弹窗是「半幅骨架」——后端发送链路还在,但前端订阅早就下架了。这两天分两轮把它补成了完整闭环:第一轮让它「能弹出来、能播视频」,第二轮加上「只弹给指定的人」和「用户能在卡片里录一段、截一张」。这篇记录链路设计、几个关键决策,还有联调时踩的坑。

背景

任务来自一个 AI 视频分析的基本功能闭环,里面有三个联动:平台弹窗、信息屏发布、语音播报。这篇只讲平台弹窗

平台是 Spring Cloud Alibaba 微服务,告警在 warning 服务,安防业务在 security 服务,AI 算法上游是 Vplat 平台。前端是 Vue 2 + Element UI 的管理后台。

初步核对现状就发现两个反直觉的点:

  • 后端发送链路其实是好的warning 暴露了 STOMP 端点 web-terminal,告警触发且处理器开关打开时,会往 /topic/1024/alarmPopup 推一条 AlarmingPopupVO
  • 前端订阅代码只剩在废弃副本里。现役 src/layout 没有任何 SockJS/STOMP,订阅代码只存在于 layout_copy_1/index.vue,而且那个副本里的 initWebSocket() 还被注释掉了。

所以这不是「迁移旧代码」,而是「在还能用的后端发送链路上,重写前端订阅 + 补齐 VO 字段」。

后端:只补字段,service 一行不改

第一轮后端改动只有一个文件:AlarmingPopupVO.java,在 workOrderId 后面追加两个 String 字段:

1
2
3
4
5
@ApiModelProperty("设备编号")
private String deviceCode;

@ApiModelProperty("告警图片")
private String alarmImg;

第二轮再补一个:

1
2
@ApiModelProperty("关联摄像头编号(多路,CSV)")
private String relatedCameraCodes;

parkId 故意没加——AI 链路上游恒为 null,前端也不依赖它,加了只是给 VO 多一个永远不被读取的字段。

为什么 service 一行都不用改

pushAlarmPopupAlarmingEvenSinkServiceImpl.java:265)用的是框架里的 BeanConvertUtils.convert,底层是 cglib BeanCopier。只要 VO 和源 DTO 同名同类型,就自动拷贝。

constructionParameters 早就把 deviceCodealarmImgrelatedCameraCodes 填进了 AlarmingRecordDTO,只是 VO 没有对应字段,序列化时被丢掉了。所以 VO 补上字段,整条链路自然透传,不需要动 service。

这是一个值得记住的偷懒姿势:当你看到一个 DTO 字段在 HTTP 响应里消失,先确认它是不是只是 VO 没有同名字段,而不是急着去 service 里加 setXxx

多摄像头的数据源头在 security 侧,MonitoringResultPictureServiceImpl.java:95

1
2
alarmingEventData.setRelatedCameraCodes(monitoring.getRelationCamera());
// monitoring.getRelationCamera() 是监控任务表里存的关联摄像头串

监控任务本来就记着「我管哪几个摄像头」,这一行把它原样带进告警事件数据,warning 透传到 VO,前端拆数组做多摄像头切换。不带这行,弹窗永远只有一台摄像头可看。

WebSocket 推送链路

整条链路从 AI 盒子到用户眼前一共五层,每一层都可能「吞掉」告警:

1
2
3
4
5
6
7
flowchart TD
A["[0] AI 盒子<br/>strategyType = 越界"] --> B["[1] integrate-iot 定时拉取<br/>本地5分钟 / 线上15分钟延迟"]
B --> C["[2] security 监控任务<br/>白名单过滤 + 盖章<br/>strategyType → bizType + alarmType"]
C --> D["[3] warning 按章查处理器<br/>生成告警记录 + 工单<br/>读弹窗开关 + 接收人"]
D --> E["[4] WebSocket /topic/1024/alarmPopup<br/>广播给所有在线用户"]
E --> F["[5] 前端 canReceiveAlarm<br/>按 userId 过滤"]
F --> G["右下角弹窗 + 播视频"]

联调时按层排查比反复问「为什么没弹窗」要高效得多——AI 盒子没检出、拉取延迟未到、白名单过滤掉了、处理器配置没启用、接收人名单不含当前用户,任何一环断了现象都一样。

STOMP 端点与凭据

warningWebSocketConfig 暴露 STOMP 端点 web-terminal(SockJS,allowedOriginPatterns("*")),broker 前缀 /topic/1024(硬编码命名空间,不是 parkId)。

这里有个隐藏坑:common-websocket 框架的 AuthChannelInterceptor 会按 icfg.websocket.clients 校验 CONNECT 帧的 appId / appSecret。这个类在框架 jar 里,在 warning 源码里 grep 不到,本地没配就会报 appId or appSecret is incorrect

线上 Nacos 里 icfg.auth.public-api-patterns 包含了 /**/web-terminal/**,浏览器 SockJS 握手免 JWT;icfg.websocket.clients 也和前端 env 对齐。所以握手这块前端零改动。本地联调时要在本地 Nacos 补同样的两条。

接收人定向:为什么把过滤放在前端

第二天的核心需求是「弹窗不再全员弹出,可指定名单」。

广播与定向接收
图 1:左——广播给所有在线用户;右——按名单定向到指定人,过滤职责放在前端。

第一反应是服务端按 user 定向推送。但 STOMP 的 convertAndSend广播,一个 topic 没法在服务端按 user 定向——除非用 user queue,但那条路要改框架的订阅地址和认证方式,成本不值。

所以方案是:WS 广播给所有在线用户,前端拿到 VO 后自己判断要不要弹

规则很简单,放在 alarmPopupQueue.js

1
2
3
4
// 简化逻辑,仅示意过滤规则
if (alarm.bizType !== 'VIDEO_SECURITY') return false
const recipients = (alarm.popupRecipientUserIds || '').split(',')
return recipients.length === 0 || recipients.includes(String(userId))

两个分支:空名单 = 全员弹;非空 = includes(userId) 才弹。后端推送逻辑零分支,过滤职责单点在前端。

一个差点让功能「看起来没生效」的坑

注意 String(userId) 的强制转换。后端返的是字符串 CSV("id1,id2"),前端 store 里的 userId 可能是数字,不转就是 "123" === 123 永远 false

更隐蔽的是 userId 兜底。src/store/modules/user.jsgetInfo 里,后端响应体没有顶层 userId 字段,必须从 login_u(loginUserInfo)里兜底取:

1
const userId = res.userId || (loginUserInfo && loginUserInfo.userId)

不加这行,canReceiveAlarmincludes(userId) 永远拿 undefined 比对,即使接收人名单里确实有这个用户也不会弹窗。功能上线后看起来是「接收人过滤不生效」,实际是 userId 根本没拿到。

配置挂处理器,不挂任务

这是一个关键的架构决策。弹窗配置不在「监控任务」(security_monitoring 表) 上,而在「告警处理器」(alarming_processor 表) 行上:

维度 监控任务 monitoring 告警处理器 alarming_processor
粒度 摄像头级(编号 + 星期 + 时段) 告警种类级
决定 管哪些摄像头、什么时段 弹不弹、弹给谁、推不推屏
关系 两者无外键 bizType + alarmType 字符串相等汇合

好处:假设有 10 个越界监控任务(不同摄像头、不同时段),共用 1 行处理器配置即可;改一次接收人名单,10 个任务的告警全部跟着变。

汇合点是 security 给事件盖的「类型章」——一张硬编码翻译表:

strategyType(算法侧) bizType + alarmType(平台侧)
越界 VIDEO_SECURITY + PERSONNEL_TRANSGRESSION
人脸 + 黑名单 PERSONNEL_MONITOR
其他 直接丢弃

加新告警类型只需在 security 翻译表加一行分支 + 告警配置页加一行处理器配置,warning 和前端都是通用的,读 bizType + alarmType 匹配,不用改。这是松耦合带来的红利。

本地录像截图:MediaRecorder + canvas

第二天还有个需求:用户要在弹窗卡片里录一段视频、截一张图,只本地保存,不入库

浏览器内截图与录像
图 2:视频画面分两路——canvas 截 jpg、MediaRecorder 录 webm,全在浏览器内完成,不碰后端。

用户诉求原话是「只复制样式、本地保存」。对比之前做过的线上录像存储——那条路是后端拉流切片写 MinIO、入库、关联工单;本方案是用户在弹窗里手动截/录、立刻下载、不留痕迹。两条路解决不同需求,不冲突。

截图canvas.drawImage(videoEl)toBlob('image/jpeg') → 触发下载 .jpg。canvas 的妙处是它不关心视频源是 flv 流还是本地 mp4——只要能渲染到 <video> 标签上,drawImage 就能截。

录像MediaRecorder(videoEl.captureStream(), { mimeType: 'video/webm' }),点开始 → 录 → 点停止 → 下载 .webm

三个边界处理:

  • 切换摄像头和关闭弹窗时调 stopRecording(),自动结束并下载当前片段——否则用户切了摄像头,正在录的那段就丢了。
  • 单段最长 5 分钟,setTimeout 兜底停止,防用户开了录像忘了关,生成超大 webm。
  • 纯本地,不调后端、不入库。

jsdom 环境没有原生 MediaRecorder,单测里必须手动注入 stub,否则 14 个用例启动就崩。

弹窗队列

纯内存队列,不写 localStorage(不回放陈旧告警):

1
const { incoming, closed } = createAlarmPopupQueue(3)
  • incoming(alarm):返回 show / queue / dup,按告警 id 对「正在弹 + 排队中」去重。
  • closed(alarm):释放槽位并补位,策略是后进先出——最新告警优先弹出。

同屏最多 3 条,多余排队。这是从「告警风暴时用户不会被铺满屏幕」折衷出来的。

踩坑记录

假 BUILD SUCCESS

mvn ... | tail 看结果时,shell 的退出码是 tail 的,不是 mvn 的。真相是旧 warning 进程在 Windows 上锁着 jar,spring-boot:repackage 重命名失败,被 -q 静默吞掉。于是第一次推送跑的还是旧 jar,VO 新字段自然没有。

教训:构建验证要看 ${PIPESTATUS[0]};Windows 重打包前先杀掉占用进程。BUILD SUCCESS 这个词本身不值得信任。

迁移文件入库 ≠ 已执行

广播迁移 V20260801.22.45 提交后没在任何库里执行过,新的 Mapper XML 一查 main_idsub_idaudio_url 就炸 Unknown column 'main_id'

本地已补跑,测试/线上部署前必须先执行这条迁移。Flyway 迁移文件进了仓库不等于跑过。

alarmTime 只认 ISO 格式

alarmTime 只接受 yyyy-MM-dd'T'HH:mm:ss.SSSX,例如 2026-08-03T16:10:00.000Z。传 "2026-08-03 16:10:00" 会被直接拒成「请求参数不正确」。

这不是后端新增限制,而是 DTO 上日期反序列化一直沿用 ISO。联调脚本里统一用 toISOString() 生成即可。

curl 中文乱码

Git Bash 里 curl -d 直接带中文,默认按 GBK 编码发出去,服务端 Jackson 报 Invalid UTF-8 middle byte。把 JSON 写进文件,用 curl --data-binary @file 发送,避开终端编码干扰。

列权限挡住了配置入口

告警配置页「级别配置」按钮绑了 v-has('updateTrigger'),它检查页面资源 sys_resource 下的 function 子节点。页面资源下没有 updateTrigger function 子节点,按钮被权限指令直接从 DOM 移除——整个弹窗配置入口不可用。

修复:手工 INSERT 一行 sys_resource,再 grant 给角色。上线后需确认线上库是否有这个 function 资源行,否则所有非 admin 角色都看不到按钮。

两条独立的管道

一个容易混淆的点:告警 WebSocket(SockJS)只传告警 JSON,不传视频画面。视频是弹窗弹出后单独拉的,和告警 sock 是两条独立管道。这个混淆会导致「为什么告警 sock 连上了但没画面」这类疑问——它们压根不是一条路。

告警推送 视频画面
通道 SockJS / STOMP over WS mpegts.js 拉 flv 流
内容 告警 JSON 视频帧
时机 告警触发时广播 弹窗弹出后按需拉
本地演示 原生 <video> 播静态 mp4

本地演示里两个演示摄像头编号共用同一个 mp4,所以切换摄像头时画面相同,但索引/名称/计数器/loading 在变——演示的是切换交互,不是视频内容。

本地演示视频做截图/录像要开 CORS(python -m http.server 默认无),否则 canvas 被 “tainted” 无法 toBlob。线上同源代理视频不受影响。

小结

两天两轮改动,第一轮让弹窗「能弹出来」,第二轮让它「弹对人 + 能存画面」。几个值得带走的点:

  • VO 字段消失先查 BeanCopier 同名约定,别急着改 service。
  • 广播无法服务端定向时,把过滤职责单点放前端,后端零分支。
  • 配置挂告警种类粒度的处理器,不挂摄像头粒度的任务,靠类型章松耦合汇合。
  • 本地录像截图MediaRecorder + canvas,纯浏览器,不碰后端。
  • 联调时的坑大多不在业务逻辑,而在构建验证、迁移执行、权限资源、编码格式这些环境细节上。

后端 VO 两轮共补 3 个字段,前端新增弹窗组件 + 队列 + 接收人选择器,外加 14 个单测。弹窗完整链路按层排查,每一层都有明确的「吞掉告警」条件,比笼统排查高效得多。