周六晚上 9:40,物业中控的微信群突然炸了。
先是一条「东门周界有人徘徊」------值班员老陈刚穿上鞋;紧接着连续刷进十几条「电梯人脸比对成功」「陌生人抓拍」「人脸检测」------有人在群里回:「又是业主回家?还是要出警?」老陈站在电梯口对着手机发愣:到底该守周界,还是该看电梯?
那晚我才把问题钉死:不是回调没通,是订阅大类和业务通道被揉成了一锅粥。
周界要的是 alarm(动检、人形、徘徊、拌线),电梯要的是 faceAnalysis(人脸检测、熟人/陌生人比对)。平台往往只给你一个 callbackUrl,callbackFlag 又允许你写成 alarm,faceAnalysis------消息全进同一个 webhook 之后,若你不按大类与 msgType 分流,值班群就会同时被「枪机动检」和「电梯人脸」淹没。
下面这篇,是我们把社区「周界值守」和「电梯通行」拆成两条业务线的完整做法:含对照表、架构图、可运行 Node.js 代码,以及真实踩坑。
一、为什么「能收到」还远远不够
1.1 业务真相:两类告警,两套 SLA
社区智慧安防里,至少有两条互不替代的业务线:
| 业务线 | 典型点位 | 关注的事件 | SLA 直觉 |
|---|---|---|---|
| 周界值守 | 围墙枪机、出入口、绿化带 | 动检、人形、徘徊、拌线 | 分钟级出警,误报要压 |
| 电梯通行 | 轿厢/厅门人脸设备 | 人脸检测、熟人比对、陌生人比对 | 秒级放行/拦截,需比对结果 |
老板买的不是「多一条红点」,而是:
- 周界该响的响:深夜徘徊不能丢;
- 电梯该认的认:熟人比对进通行日志,陌生人进安检队列;
- 两条线互不污染:电梯高峰期的人脸风暴,不能冲垮周界值班群。
很多团队第一周就把 setMessageCallback 调通了------群里开始刷消息。第二周开始甩锅:「明明推了,怎么没人去周界?」「电梯比对成功也当入侵报了。」根因通常不是推送挂了,而是:
把
callbackFlag的「订阅开关」当成了「业务路由」,却从未按大类 +msgType拆通道。
1.2 技术背景:两层类型,千万别混成一层
主流视频开放平台的消息模型,建议先在脑子里画成两层:
text
callbackFlag(订阅大类,setMessageCallback 里配置)
├─ alarm → 设备告警类(周界主力)
├─ deviceStatus → 上下线/状态类
├─ iot → 物模型事件
├─ numberstat → 客流类
└─ faceAnalysis → 人脸/智能分析类(电梯主力)
│
▼
msgType(具体事件,消息体里带)
videoMotion / human / hoveringAlarm / crossLineDetection
aiFaceDetect / aiAFaceCompa / aiSFaceCompa / ...
关键事实:
callbackFlag:决定「这一大类收不收」;msgType:决定「这条具体是什么事、进哪条业务线」;- 一个开发者账号通常只挂一个
callbackUrl------同时订alarm和faceAnalysis时,两类消息都会 POST 到同一地址,分流必须自己做。
1.3 还有一个更阴险的坑:字段名不一致
alarm 普通告警与 faceAnalysis 智能消息,字段命名并不统一:
text
alarm 普通告警 faceAnalysis 人脸类
───────────────── ─────────────────────
did / cid deviceId / channelId
id(告警 id) 常无统一 alarmId(靠组合键去重)
time(Unix 秒) time / localTime(字符串时间)
token(云录像) token + picUrlArray(图仅保留约 1 天)
若你写死 const deviceId = body.did,电梯人脸消息会「设备号为空」------路由表查不到楼栋,消息进黑洞或进默认群,表现为「推送成功但业务没人接」。
一句话解决思路:
text
订阅 alarm + faceAnalysis
→ 同一 webhook 收消息
→ 立刻 HTTP 200
→ 归一化字段
→ 按大类/msgType 分流
→ 周界工单 / 电梯通行日志
二、从订阅到双通道分流(含代码)
2.1 先画清楚:社区双通道流水线
text
┌──────────────┐ 周界事件(alarm) ┌─────────────────────┐
│ 围墙/出入口枪机 │ ─────────────────► │ │
└──────────────┘ │ 开放平台消息推送 │
┌──────────────┐ 电梯事件(faceAnalysis)│ callbackUrl 唯一入口 │
│ 电梯人脸设备 │ ─────────────────► │ │
└──────────────┘ └──────────┬──────────┘
│ HTTP POST
▼
┌──────────────────────┐
│ /webhook/events │
│ ① 立刻返回 200 │
│ ② 归一化 + 入队 │
└──────────┬───────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ 周界通道 (alarm) │ │ 电梯通道 (faceAnalysis)│
│ videoMotion/human │ │ aiFaceDetect │
│ hoveringAlarm/拌线 │ │ aiAFaceCompa 熟人 │
│ → 物业值守工单 │ │ aiSFaceCompa 陌生人 │
└─────────────────────┘ │ → 通行/安检队列 │
└─────────────────────┘
2.2 订阅对照:社区场景该订什么
| callbackFlag | 社区用途 | 常见 msgType | 建议接收方 |
|---|---|---|---|
alarm |
周界/出入口入侵感知 | videoMotion 动检、human 人形、hoveringAlarm 徘徊、crossLineDetection 拌线 |
物业值守 / 保安对讲 |
faceAnalysis |
电梯/门禁人脸智能 | aiFaceDetect 人脸检测、aiAFaceCompa 熟人比对、aiSFaceCompa 陌生人比对 |
通行闸机逻辑 / 安检复核 |
deviceStatus(可选) |
设备掉线影响值守 | online / offline |
运维群,不宜进保安出警群 |
不建议一上来把所有 flag 全开。 客流 numberstat、无关 IoT 事件会显著抬高噪声比。社区 MVP 优先:alarm,faceAnalysis,稳定后再加 deviceStatus。
2.3 OpenAPI 请求壳(签名 + 调用)
先贴可运行壳。签名规则:time:{time},nonce:{nonce},appSecret:{appSecret} → UTF-8 MD5 → 32 位小写。网关形如 https://openapi.lechange.cn/openapi/{method}(以你所用平台现行文档为准)。
javascript
// openapi-client.js
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const OPENAPI_BASE = process.env.OPENAPI_BASE || 'https://openapi.lechange.cn/openapi';
function calcSign(time, nonce, appSecret) {
const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
return crypto.createHash('md5').update(raw, 'utf8').digest('hex');
}
async function callOpenApi(method, appId, appSecret, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = randomUUID();
const body = {
system: {
ver: '1.0',
appId,
time,
nonce,
sign: calcSign(time, nonce, appSecret),
},
id: randomUUID(),
params,
};
const res = await fetch(`${OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.result || json.result.code !== '0') {
const msg = json.result ? `${json.result.code} ${json.result.msg}` : JSON.stringify(json);
throw new Error(`OpenAPI ${method} failed: ${msg}`);
}
return json.result.data;
}
module.exports = { callOpenApi, calcSign };
签名自测(与文档标准案例一致):
javascript
// sign-test.js
const assert = require('assert');
const { calcSign } = require('./openapi-client');
assert.strictEqual(
calcSign(
1706511734,
'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2',
'test123456789test123456789'
),
'fd37b62889e4757c58b8f3bf05fb9976'
);
console.log('sign ok');
time 与服务器误差不宜超过 5 分钟;nonce 在 5 分钟内勿重复,否则可能返回 SN1005。
2.4 取 token,打开双类订阅
javascript
// setup-callback.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');
async function main() {
const appId = process.env.OPENAPI_APP_ID;
const appSecret = process.env.OPENAPI_APP_SECRET;
const callbackUrl = process.env.CALLBACK_URL; // 必须公网 HTTPS
const { accessToken, expireTime } = await callOpenApi(
'accessToken',
appId,
appSecret,
{}
);
console.log('token ok, expireTime(s)=', expireTime);
// 社区双通道:周界 alarm + 电梯 faceAnalysis
await callOpenApi('setMessageCallback', appId, appSecret, {
token: accessToken,
status: 'on',
callbackUrl,
callbackFlag: 'alarm,faceAnalysis',
basePush: '2', // 一般不把消费端 App 设备消息再推一份,按业务选择
});
const cfg = await callOpenApi('getMessageCallback', appId, appSecret, {
token: accessToken,
});
console.log('current callback:', cfg);
// 期望:status=on, callbackFlag 含 alarm 与 faceAnalysis
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
.env 示例:
bash
OPENAPI_APP_ID=lcdxxxxxxxxx
OPENAPI_APP_SECRET=your_app_secret
CALLBACK_URL=https://your-domain.example.com/webhook/events
# 可选:OPENAPI_BASE=https://openapi.lechange.cn/openapi
踩坑 1: callbackUrl 必须外网可达。localhost、仅内网 IP、未穿透的联调地址会导致「配置成功但永远收不到」。联调可用穿透工具,生产必须正式证书域名。
踩坑 2: status=on 时 callbackFlag 必填。只写 URL 不写 flag,接口可能成功,但你期望的大类根本没订上------用 getMessageCallback 回读校验。
2.5 两类消息体长什么样(对照真实字段)
周界侧(alarm 普通告警,字段以 did/cid 为主):
json
{
"id": 2447736561,
"appId": "lcdxxxxxxxxx",
"did": "PERIMETER_CAM_01",
"cid": 0,
"msgType": "hoveringAlarm",
"time": 1722670800,
"cname": "东门周界",
"remark": "",
"token": "f2dc8c09eeae4b5bad6abf522c93d825",
"desc": {}
}
电梯侧------人脸检测(faceAnalysis):
json
{
"appId": "lcdxxxxxxxxx",
"msgType": "aiFaceDetect",
"deviceId": "ELEVATOR_FACE_03",
"channelId": "0",
"localTime": "20260803194012",
"time": "20260803114012Z",
"token": "f2dc8c09eeae4b5bad6abf522c93d825",
"picUrlArray": ["https://example.com/big.jpg", "https://example.com/face.jpg"],
"desc": [
{
"sex": "Man",
"age": "34",
"mask": 1,
"feature": ["Neutral"]
}
]
}
电梯侧------熟人比对(aiAFaceCompa):
json
{
"appId": "lcdxxxxxxxxx",
"msgType": "aiAFaceCompa",
"deviceId": "ELEVATOR_FACE_03",
"channelId": "0",
"localTime": "20260803194100",
"time": "20260803114100Z",
"picUrlArray": ["https://example.com/big.jpg", "https://example.com/face.jpg"],
"desc": {
"snapFace": { "sex": "Man", "age": "34", "mask": 1 },
"candidates": [
{ "groupId": "owner_lib", "personId": "U10086", "similarity": "92" }
]
}
}
陌生人比对(aiSFaceCompa)结构类似,但 desc 侧重点在路人信息;业务上通常进「安检复核」而不是「直接放行」。
踩坑 3: 人脸类 picUrlArray 平台侧保存时长通常很短(文档常见表述约一天)。收到后应尽快落自有对象存储,否则复盘时只剩一条没图的比对记录。
2.6 归一化 + 分流(交付核心)
javascript
// normalize.js
function normalizeEvent(raw) {
const msgType = raw.msgType || '';
const faceTypes = new Set([
'aiFaceDetect',
'aiAFaceCompa',
'aiSFaceCompa',
'rtFaceDetect',
'rtFaceCompa',
'aiVehDetect',
'aiNonVehDetect',
'aiPerLine',
'aiVehLine',
'aiNonVehLine',
'aiUnknownLine',
'aiPerArea',
'aiVehArea',
'aiNonVehArea',
'aiUnknownArea',
]);
const channel =
faceTypes.has(msgType) ? 'elevator' // faceAnalysis 族 → 电梯/智能分析通道
: ['online', 'offline', 'close', 'changeDevName'].includes(msgType) ? 'ops'
: 'perimeter'; // 默认按周界 alarm 处理
const deviceId = String(raw.deviceId || raw.did || raw.msgDeviceId || '');
const channelId = String(
raw.channelId != null ? raw.channelId
: raw.cid != null ? raw.cid
: '0'
);
const eventId = String(
raw.id != null ? raw.id
: raw.alarmId != null ? raw.alarmId
: `${deviceId}:${channelId}:${msgType}:${raw.time || raw.localTime || ''}`
);
return {
channel, // perimeter | elevator | ops
msgType,
deviceId,
channelId,
eventId,
time: raw.time,
localTime: raw.localTime,
token: raw.token,
picUrlArray: raw.picUrlArray || [],
desc: raw.desc,
raw,
};
}
module.exports = { normalizeEvent };
javascript
// router.js
const PERIMETER_TYPES = new Set([
'videoMotion',
'human',
'hoveringAlarm',
'crossLineDetection',
'alarmPIR',
'mobileDetect',
]);
const ELEVATOR_PRIORITY = {
aiSFaceCompa: 'review', // 陌生人 → 安检复核
aiAFaceCompa: 'pass', // 熟人 → 通行日志
aiFaceDetect: 'observe', // 纯检测 → 观察/统计,默认不进值守群
};
function routeEvent(evt) {
if (evt.channel === 'elevator') {
const action = ELEVATOR_PRIORITY[evt.msgType] || 'observe';
return { lane: 'elevator', action, notifyGroup: action === 'review' ? 'security-review' : 'access-log' };
}
if (evt.channel === 'perimeter') {
if (!PERIMETER_TYPES.has(evt.msgType)) {
return { lane: 'perimeter', action: 'drop', notifyGroup: null }; // 无关 alarm 先丢弃/入库
}
// 徘徊/拌线优先于普通动检
const action =
evt.msgType === 'hoveringAlarm' || evt.msgType === 'crossLineDetection'
? 'dispatch'
: evt.msgType === 'human'
? 'dispatch'
: 'observe';
return {
lane: 'perimeter',
action,
notifyGroup: action === 'dispatch' ? 'property-duty' : 'perimeter-silent-log',
};
}
return { lane: 'ops', action: 'ops', notifyGroup: 'device-ops' };
}
module.exports = { routeEvent };
2.7 Webhook:先 200,再异步处理
平台明确要求:回调服务收到推送后务必返回 HTTP 200;多次无响应,可能停止向该地址推送。
javascript
// server.js
require('dotenv').config();
const express = require('express');
const { normalizeEvent } = require('./normalize');
const { routeEvent } = require('./router');
const app = express();
app.use(express.json({ limit: '2mb' }));
// 简易幂等:生产请换 Redis SETNX,TTL 建议 24h
const seen = new Map();
function once(eventId) {
if (seen.has(eventId)) return false;
seen.set(eventId, Date.now());
if (seen.size > 20000) {
const oldest = seen.keys().next().value;
seen.delete(oldest);
}
return true;
}
async function handleAsync(evt, decision) {
// 人脸图尽快落库(示意)
if (evt.picUrlArray?.length) {
console.log('[persist-pics]', evt.eventId, evt.picUrlArray.length);
}
if (decision.action === 'drop') {
console.log('[drop]', evt.msgType, evt.deviceId);
return;
}
// 这里接:企微/钉钉/自建工单/闸机放行 API
console.log('[dispatch]', {
lane: decision.lane,
action: decision.action,
group: decision.notifyGroup,
msgType: evt.msgType,
deviceId: evt.deviceId,
channelId: evt.channelId,
similarity: evt.desc?.candidates?.[0]?.similarity,
personId: evt.desc?.candidates?.[0]?.personId,
});
}
app.post('/webhook/events', (req, res) => {
// ① 立刻 200,避免平台判定超时停推
res.status(200).json({ code: '0', msg: 'ok' });
const evt = normalizeEvent(req.body || {});
if (!evt.msgType || !evt.deviceId) {
console.warn('[bad-payload]', JSON.stringify(req.body).slice(0, 500));
return;
}
if (!once(evt.eventId)) {
console.log('[dup]', evt.eventId);
return;
}
const decision = routeEvent(evt);
setImmediate(() => {
handleAsync(evt, decision).catch((err) => console.error('[async-fail]', err));
});
});
// 本地联调:模拟周界 / 电梯两条消息
app.post('/_debug/simulate', (req, res) => {
req.url = '/webhook/events';
app.handle(req, res);
});
app.listen(process.env.PORT || 8080, () => {
console.log('webhook listening');
});
联调模拟(周界徘徊):
bash
curl -s -X POST http://127.0.0.1:8080/webhook/events \
-H "Content-Type: application/json" \
-d "{\"id\":1001,\"did\":\"PERIMETER_CAM_01\",\"cid\":0,\"msgType\":\"hoveringAlarm\",\"time\":1722670800,\"cname\":\"东门周界\"}"
联调模拟(电梯熟人比对):
bash
curl -s -X POST http://127.0.0.1:8080/webhook/events \
-H "Content-Type: application/json" \
-d "{\"msgType\":\"aiAFaceCompa\",\"deviceId\":\"ELEVATOR_FACE_03\",\"channelId\":\"0\",\"time\":\"20260803114100Z\",\"desc\":{\"candidates\":[{\"personId\":\"U10086\",\"similarity\":\"92\"}]}}"
期望日志:前者进 property-duty,后者进 access-log------同一 webhook,两条值班线。
2.8 实操清单(按顺序打勾)
- 控制台创建应用,拿到
appId/appSecret; - 周界枪机、电梯人脸设备绑定到该开发者账号并确认在线;
- 部署公网 HTTPS webhook,确认能
curl通且返回 200; - 跑
sign-test.js→setup-callback.js; getMessageCallback确认callbackFlag含alarm,faceAnalysis;- 用真实设备各触发一次:周界走动 / 电梯刷脸;
- 核对归一化后的
deviceId、分流lane、通知群是否正确; - 人脸图落自有存储;周界高优先级事件写工单(负责人、SLA、证据 token)。
三、边界、性能与生产注意事项
3.1 边界:哪些不该进值守群
| 现象 | 原因 | 建议 |
|---|---|---|
傍晚电梯 aiFaceDetect 刷屏 |
纯检测量远大于比对 | 默认 observe,仅统计;比对结果才通知 |
树影/videoMotion 风暴 |
动检阈值高、夜间灵敏度高 | 周界优先 human/hoveringAlarm;动检降噪或时段策略 |
| 陌生人比对误伤业主 | 阈值阈值、侧脸、口罩 | similarity 设业务阈值 + 人工复核队列 |
| 设备离线进保安群 | deviceStatus 与入侵混推 |
运维单独通道,或勿把 offline 映射为出警 |
3.2 性能:先回 200,重活异步
- Webhook 线程只做:解析、幂等、入队;落库、拉图、调 IM、写工单全部异步。
- 幂等键:
alarm优先用id;faceAnalysis用deviceId+channelId+msgType+time组合。 - 高峰期(下班电梯)用队列(Redis Stream / MQ)削峰,避免进程内
setImmediate堆积导致 OOM。 - 对同一
deviceId的videoMotion做滑动窗口合并(例如 60 秒内只建一张观察单)。
3.3 生产注意
- 回调停推:多次非 200 / 超时,平台可能停推------监控 webhook 成功率与「最后一条消息时间」。
- token 过期 :管理员
accessToken约 3 天有效,遇TK1002再刷;不要每个业务请求都重新取。 - 字段双轨 :永远先归一化
did|deviceId、cid|channelId,再查点位表(楼栋/单元/值班组)。 - 隐私合规:人脸图、比对人 ID 属于敏感数据,日志脱敏,访问审计,保留周期按物业制度落地。
- 能力开关 :设备侧动检/人形等能力需在设备能力集开启;只订回调、设备侧未开检测,永远收不到对应
msgType。 - 勿抄旧协议:对接时以现行 OpenAPI 与「事件消息类型 / 格式定义」为准,避免引用已不维护的旧版协议栏目。
3.4 我们真实踩过的三个坑(可当验收用例)
- 只订了
alarm,电梯比对永远不来 ------以为人脸也算「告警」。人脸智能在faceAnalysis大类。 body.did取电梯设备号为空 ------人脸消息用的是deviceId,点位路由全失效。- 配置了 callback 但联调内网地址------控制台显示成功,设备侧事件产生,业务侧零消息;换成公网 HTTPS 后一次性通。
四、小结与延伸
社区场景里,真正难的不是「会不会调 setMessageCallback」,而是承认两件事:
- 周界与电梯是两条产品,只是碰巧共用同一套视频开放能力;
- 平台给你的是订阅与推送管道,值班群、工单、闸机放行,必须在你自己的分流层完成。
落地最小闭环可以压成四步:
text
订 alarm + faceAnalysis → 公网 webhook 必 200
→ 字段归一化 → 按 msgType 进「值守 / 通行 / 运维」三车道
延伸阅读(对照你所用平台的现行文档目录即可):
- 设置 / 查看消息回调(
setMessageCallback/getMessageCallback) - 事件消息推送流程(务必 200 的约定)
- 事件消息类型定义(
alarm与faceAnalysis各自的msgType表) - 事件消息格式定义(普通告警 vs 人脸检测/比对字段差异)
- 开发规范(签名、
accessToken、错误码如SN1005/TK1002)
如果你正在做物业中控、社区报警运营或电梯通行联动,可以把本文的 normalize + route 直接嵌进现有后端:先让周界「该响的响」,再让电梯「该认的认」,最后才谈大屏与漂亮报表。
主流视频开放平台通常提供设备接入、消息推送、云直播与低代码播放组件,适合第三方团队用较低成本把「能看视频」升级成「能值班、能通行、能闭环」。从注册开发者、创建应用、绑定第一台设备开始,把 callbackFlag 设对、把分流写清,比堆更多摄像头更能立刻减少物业群里的无效噪音。
本文完整示例依赖: Node.js 18+(原生 fetch)、express、dotenv。先跑 sign-test.js,再跑 setup-callback.js,最后用两段 curl 验证分流是否进了不同业务车道。