周界枪响了、电梯脸却进了物业群:一个 callback,怎么把 alarm 和 faceAnalysis 拆成两条值班线?

周六晚上 9:40,物业中控的微信群突然炸了。

先是一条「东门周界有人徘徊」------值班员老陈刚穿上鞋;紧接着连续刷进十几条「电梯人脸比对成功」「陌生人抓拍」「人脸检测」------有人在群里回:「又是业主回家?还是要出警?」老陈站在电梯口对着手机发愣:到底该守周界,还是该看电梯?

那晚我才把问题钉死:不是回调没通,是订阅大类和业务通道被揉成了一锅粥。

周界要的是 alarm(动检、人形、徘徊、拌线),电梯要的是 faceAnalysis(人脸检测、熟人/陌生人比对)。平台往往只给你一个 callbackUrlcallbackFlag 又允许你写成 alarm,faceAnalysis------消息全进同一个 webhook 之后,若你不按大类与 msgType 分流,值班群就会同时被「枪机动检」和「电梯人脸」淹没。

下面这篇,是我们把社区「周界值守」和「电梯通行」拆成两条业务线的完整做法:含对照表、架构图、可运行 Node.js 代码,以及真实踩坑。


一、为什么「能收到」还远远不够

1.1 业务真相:两类告警,两套 SLA

社区智慧安防里,至少有两条互不替代的业务线:

业务线 典型点位 关注的事件 SLA 直觉
周界值守 围墙枪机、出入口、绿化带 动检、人形、徘徊、拌线 分钟级出警,误报要压
电梯通行 轿厢/厅门人脸设备 人脸检测、熟人比对、陌生人比对 秒级放行/拦截,需比对结果

老板买的不是「多一条红点」,而是:

  1. 周界该响的响:深夜徘徊不能丢;
  2. 电梯该认的认:熟人比对进通行日志,陌生人进安检队列;
  3. 两条线互不污染:电梯高峰期的人脸风暴,不能冲垮周界值班群。

很多团队第一周就把 setMessageCallback 调通了------群里开始刷消息。第二周开始甩锅:「明明推了,怎么没人去周界?」「电梯比对成功也当入侵报了。」根因通常不是推送挂了,而是:

callbackFlag 的「订阅开关」当成了「业务路由」,却从未按大类 + msgType 拆通道。

1.2 技术背景:两层类型,千万别混成一层

主流视频开放平台的消息模型,建议先在脑子里画成两层:

text 复制代码
callbackFlag(订阅大类,setMessageCallback 里配置)
   ├─ alarm          → 设备告警类(周界主力)
   ├─ deviceStatus   → 上下线/状态类
   ├─ iot            → 物模型事件
   ├─ numberstat     → 客流类
   └─ faceAnalysis   → 人脸/智能分析类(电梯主力)
          │
          ▼
     msgType(具体事件,消息体里带)
   videoMotion / human / hoveringAlarm / crossLineDetection
   aiFaceDetect / aiAFaceCompa / aiSFaceCompa / ...

关键事实:

  • callbackFlag:决定「这一大类收不收」;
  • msgType:决定「这条具体是什么事、进哪条业务线」;
  • 一个开发者账号通常只挂一个 callbackUrl ------同时订 alarmfaceAnalysis 时,两类消息都会 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=oncallbackFlag 必填。只写 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 实操清单(按顺序打勾)

  1. 控制台创建应用,拿到 appId / appSecret
  2. 周界枪机、电梯人脸设备绑定到该开发者账号并确认在线;
  3. 部署公网 HTTPS webhook,确认能 curl 通且返回 200;
  4. sign-test.jssetup-callback.js
  5. getMessageCallback 确认 callbackFlagalarm,faceAnalysis
  6. 用真实设备各触发一次:周界走动 / 电梯刷脸;
  7. 核对归一化后的 deviceId、分流 lane、通知群是否正确;
  8. 人脸图落自有存储;周界高优先级事件写工单(负责人、SLA、证据 token)。

三、边界、性能与生产注意事项

3.1 边界:哪些不该进值守群

现象 原因 建议
傍晚电梯 aiFaceDetect 刷屏 纯检测量远大于比对 默认 observe,仅统计;比对结果才通知
树影/videoMotion 风暴 动检阈值高、夜间灵敏度高 周界优先 human/hoveringAlarm;动检降噪或时段策略
陌生人比对误伤业主 阈值阈值、侧脸、口罩 similarity 设业务阈值 + 人工复核队列
设备离线进保安群 deviceStatus 与入侵混推 运维单独通道,或勿把 offline 映射为出警

3.2 性能:先回 200,重活异步

  • Webhook 线程只做:解析、幂等、入队;落库、拉图、调 IM、写工单全部异步。
  • 幂等键:alarm 优先用 idfaceAnalysisdeviceId+channelId+msgType+time 组合。
  • 高峰期(下班电梯)用队列(Redis Stream / MQ)削峰,避免进程内 setImmediate 堆积导致 OOM。
  • 对同一 deviceIdvideoMotion 做滑动窗口合并(例如 60 秒内只建一张观察单)。

3.3 生产注意

  1. 回调停推:多次非 200 / 超时,平台可能停推------监控 webhook 成功率与「最后一条消息时间」。
  2. token 过期 :管理员 accessToken 约 3 天有效,遇 TK1002 再刷;不要每个业务请求都重新取。
  3. 字段双轨 :永远先归一化 did|deviceIdcid|channelId,再查点位表(楼栋/单元/值班组)。
  4. 隐私合规:人脸图、比对人 ID 属于敏感数据,日志脱敏,访问审计,保留周期按物业制度落地。
  5. 能力开关 :设备侧动检/人形等能力需在设备能力集开启;只订回调、设备侧未开检测,永远收不到对应 msgType
  6. 勿抄旧协议:对接时以现行 OpenAPI 与「事件消息类型 / 格式定义」为准,避免引用已不维护的旧版协议栏目。

3.4 我们真实踩过的三个坑(可当验收用例)

  1. 只订了 alarm,电梯比对永远不来 ------以为人脸也算「告警」。人脸智能在 faceAnalysis 大类。
  2. body.did 取电梯设备号为空 ------人脸消息用的是 deviceId,点位路由全失效。
  3. 配置了 callback 但联调内网地址------控制台显示成功,设备侧事件产生,业务侧零消息;换成公网 HTTPS 后一次性通。

四、小结与延伸

社区场景里,真正难的不是「会不会调 setMessageCallback」,而是承认两件事:

  1. 周界与电梯是两条产品,只是碰巧共用同一套视频开放能力;
  2. 平台给你的是订阅与推送管道,值班群、工单、闸机放行,必须在你自己的分流层完成。

落地最小闭环可以压成四步:

text 复制代码
订 alarm + faceAnalysis → 公网 webhook 必 200
→ 字段归一化 → 按 msgType 进「值守 / 通行 / 运维」三车道

延伸阅读(对照你所用平台的现行文档目录即可):

  • 设置 / 查看消息回调(setMessageCallback / getMessageCallback
  • 事件消息推送流程(务必 200 的约定)
  • 事件消息类型定义(alarmfaceAnalysis 各自的 msgType 表)
  • 事件消息格式定义(普通告警 vs 人脸检测/比对字段差异)
  • 开发规范(签名、accessToken、错误码如 SN1005 / TK1002

如果你正在做物业中控、社区报警运营或电梯通行联动,可以把本文的 normalize + route 直接嵌进现有后端:先让周界「该响的响」,再让电梯「该认的认」,最后才谈大屏与漂亮报表。

主流视频开放平台通常提供设备接入、消息推送、云直播与低代码播放组件,适合第三方团队用较低成本把「能看视频」升级成「能值班、能通行、能闭环」。从注册开发者、创建应用、绑定第一台设备开始,把 callbackFlag 设对、把分流写清,比堆更多摄像头更能立刻减少物业群里的无效噪音。


本文完整示例依赖: Node.js 18+(原生 fetch)、expressdotenv。先跑 sign-test.js,再跑 setup-callback.js,最后用两段 curl 验证分流是否进了不同业务车道。

相关推荐
大勇前进1 小时前
千万级大表 SQL Server 查询慢?一套可落地的性能调优实战路径
后端
前端开发张小七1 小时前
Java 学习笔记 · 第三课:多线程与并发编程(线程、同步、死锁、Lock、乐观锁与悲观锁)
java·后端·程序员
站大爷IP1 小时前
Python 的 defaultdict 把我坑惨了,原来缺失键会自动创建,但 `__missing__` 的副作用让我调试到崩溃
后端
用户名不能为空被占用1 小时前
记一次数据权限改造,看 NestJS 装饰器与守卫的组合应用
后端·nestjs
摇滚侠1 小时前
SpringBoot 官网 阅读笔记 启用生产就绪功能 端点
spring boot·笔记·后端
我命由我123452 小时前
匈牙利命名法
java·服务器·后端·学习·java-ee·kotlin·学习方法
苏三说技术2 小时前
一线大厂的Git规范
后端
神奇小汤圆3 小时前
阿里面试官问我:“Redis 的 String 底层是怎么设计的?”,我画完 SDS,他点了点头……
后端
云边云科技_云网融合3 小时前
连锁门店 POS、监控、IoT 设备网络不稳定怎么解决?
网络·物联网