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 |
|
第二轮再补一个:
1 |
|
parkId 故意没加——AI 链路上游恒为 null,前端也不依赖它,加了只是给 VO 多一个永远不被读取的字段。
为什么 service 一行都不用改
pushAlarmPopup(AlarmingEvenSinkServiceImpl.java:265)用的是框架里的 BeanConvertUtils.convert,底层是 cglib BeanCopier。只要 VO 和源 DTO 同名同类型,就自动拷贝。
而 constructionParameters 早就把 deviceCode、alarmImg、relatedCameraCodes 填进了 AlarmingRecordDTO,只是 VO 没有对应字段,序列化时被丢掉了。所以 VO 补上字段,整条链路自然透传,不需要动 service。
这是一个值得记住的偷懒姿势:当你看到一个 DTO 字段在 HTTP 响应里消失,先确认它是不是只是 VO 没有同名字段,而不是急着去 service 里加
setXxx。
多摄像头的数据源头在 security 侧,MonitoringResultPictureServiceImpl.java:95:
1 | alarmingEventData.setRelatedCameraCodes(monitoring.getRelationCamera()); |
监控任务本来就记着「我管哪几个摄像头」,这一行把它原样带进告警事件数据,warning 透传到 VO,前端拆数组做多摄像头切换。不带这行,弹窗永远只有一台摄像头可看。
WebSocket 推送链路
整条链路从 AI 盒子到用户眼前一共五层,每一层都可能「吞掉」告警:
1 | flowchart TD |
联调时按层排查比反复问「为什么没弹窗」要高效得多——AI 盒子没检出、拉取延迟未到、白名单过滤掉了、处理器配置没启用、接收人名单不含当前用户,任何一环断了现象都一样。
STOMP 端点与凭据
warning 的 WebSocketConfig 暴露 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 | // 简化逻辑,仅示意过滤规则 |
两个分支:空名单 = 全员弹;非空 = includes(userId) 才弹。后端推送逻辑零分支,过滤职责单点在前端。
一个差点让功能「看起来没生效」的坑
注意 String(userId) 的强制转换。后端返的是字符串 CSV("id1,id2"),前端 store 里的 userId 可能是数字,不转就是 "123" === 123 永远 false。
更隐蔽的是 userId 兜底。src/store/modules/user.js 的 getInfo 里,后端响应体没有顶层 userId 字段,必须从 login_u(loginUserInfo)里兜底取:
1 | const userId = res.userId || (loginUserInfo && loginUserInfo.userId) |
不加这行,canReceiveAlarm 的 includes(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_id、sub_id、audio_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 个单测。弹窗完整链路按层排查,每一层都有明确的「吞掉告警」条件,比笼统排查高效得多。