老旧小区监控事件驱动派单:基于 setMessageCallback 的 msgType 分级与工单闭环实现

周二下午三点,物业办公室的对讲机响了。

「3 栋地下室电瓶车又乱停,业主吵着要处理。」保安小刘跑去机房,对着一台嗡嗡作响的 NVR 点回放------十六路画面里翻了二十多分钟,才截到一张模糊截图,再手工填进物业 App 的「报事报修」。等工单创建出来,当事人早走了,业主群里已经开始骂「装监控有什么用」。

那天我站在机房门口才想清楚一件事:老旧小区加装监控,如果事件只落在硬盘里,对物业来说几乎等于没装。 真正缺的不是再多一路枪机,而是「镜头看见的事」能自动变成「有人、有时限、有证据」的工单。

下面这篇,记录一次把乐橙设备告警推进物业工单系统的完整做法:从回调登记、msgType 分级、证据富化,到幂等与风暴抑制,含可运行 Node.js 代码和踩坑清单。


一、为什么「只存 NVR」在老小区一定会翻车

老旧小区加装改造常见三件套:枪机/球机 + 本地 NVR + 物业值班室电视墙。验收时画面清晰、回放能翻,项目就结了。但运营三个月后,你会稳定看到三种尴尬:

  1. 发现靠人喊,不是靠系统:乱停、高空抛物线索、楼道堆积物,仍靠业主微信群截图;
  2. 处置靠翻录像,效率极低:保安不会用检索,只会拖进度条;
  3. 复盘没有工单号:出了纠纷只能说「当时有监控」,却拿不出「何时派谁、何时关闭」的闭环记录。

本质矛盾是:

text 复制代码
设备侧:已经产生了 videoMotion / human / offline ...
业务侧:物业工单系统对此一无所知
中间层:缺一条「事件 → 工单」的桥

乐橙开放平台提供的正是这条桥:设备告警经云端 setMessageCallback 主动 POST 到你的服务,你再映射成物业工单。流程见事件消息推送流程


二、技术背景与解决思路

2.1 先分清两层类型,别把「订阅」当成「分级」

消息模型建议记成两层(类型全集见事件消息类型定义):

text 复制代码
callbackFlag(你订不订这一大类)
  ├─ alarm         → 设备告警
  ├─ deviceStatus  → 上下线
  ├─ iot / numberstat / faceAnalysis ...
          │
          ▼
     msgType(这条具体是什么事)
  human / videoMotion / smokeAlarm / offline / videoBlind ...

老旧小区物业场景,第一版通常只需:

msgType 含义 建议工单级别 典型点位
human 人形检测 P1 观察/派巡 单元门、车库入口
videoMotion 动检 P2 合并观察 楼道、周界(易风暴)
hoveringAlarm 徘徊 P0 立即派保安 围墙、死角
videoBlind 遮挡 P0 立即检修 任意关键点位
offline 设备离线 P0 运维工单 全小区
storageAbnormal / storageEmpty 存储异常 P1 运维 NVR/IPC 本地卡

分级的钥匙在 msgType,不在 callbackFlag 订了 alarm 却用同一种逻辑处理 humanvideoMotion,群会炸、工单会灌水,保安会直接关掉通知。

2.2 一句话架构

text 复制代码
设备告警 → 乐橙云推送 → 桥接服务(先 200) → 幂等/分级
        → getAlarmMessageById 补图 → 点位映射 → 创建物业工单

物业工单系统可以是你们现有的报事报修、也可以是钉钉/飞书审批流------先把字段设计对,再谈换系统

2.3 和「只存 NVR」的本质差别

维度 只存 NVR 事件进工单
发现方式 事后翻录像 秒级推送
责任人 不清楚 工单指定班组
证据 存在硬盘里 截图 URL 挂工单
SLA 可统计超时率
复盘 口头 工单号可追溯

三、从回调到物业工单的可运行链路

3.1 总流程图

text 复制代码
┌──────────────┐  动检/人形/遮挡   ┌─────────────────┐
│ 小区 IPC/NVR │ ───────────────► │ 乐橙开放平台云端  │
│ (已上云绑定) │                  │ setMessageCallback│
└──────────────┘                  └────────┬────────┘
                                           │ HTTP POST
                                           ▼
                              ┌────────────────────────┐
                              │ 桥接服务 /openapi/callback│
                              │ ① 立刻返回 200           │
                              │ ② 入队异步处理           │
                              └────────────┬───────────┘
                                           │
              ┌────────────────────────────┼────────────────────────────┐
              ▼                            ▼                            ▼
        Redis 幂等                   msgType 分级              getAlarmMessageById
        (alarmId)                    P0/P1/P2                  补 picurlArray
              │                            │                            │
              └────────────────────────────┼────────────────────────────┘
                                           ▼
                              ┌────────────────────────┐
                              │ deviceId → 楼栋/点位映射 │
                              │ 组装 PropertyWorkOrder  │
                              └────────────┬───────────┘
                                           ▼
                              ┌────────────────────────┐
                              │ 物业工单 API / 企微通知  │
                              │ 可选:setDeviceSnapEnhanced│
                              └────────────────────────┘

3.2 准备清单

  1. 乐橙开放平台 注册并创建应用,拿到 appId / appSecret
  2. 将小区摄像机绑定到开发者应用资产池(列表用 listDeviceDetailsByPage);
  3. 准备公网 HTTPS 回调地址(联调可用内网穿透,生产用正式证书);
  4. OpenAPI 基址:https://openapi.lechange.cn/openapi/{method},请求体含 system + params + id,签名规范见开发规范

3.3 签名与请求封装(先贴代码)

javascript 复制代码
// openapi-client.js
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');

const OPENAPI_BASE = 'https://openapi.lechange.cn/openapi';

function calcSign(time, nonce, appSecret) {
  // 文档约定:time:{t},nonce:{n},appSecret:{s} → MD5 小写 32 位
  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 = uuidv4();
  const body = {
    system: {
      ver: '1.0',
      appId,
      time,
      nonce,
      sign: calcSign(time, nonce, appSecret),
    },
    id: uuidv4(),
    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 复制代码
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 五分钟内勿重复,否则可能返回 SN1005

3.4 获取 accessToken

javascript 复制代码
// get-token.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');

(async () => {
  const data = await callOpenApi(
    'accessToken',
    process.env.OPENAPI_APP_ID,
    process.env.OPENAPI_APP_SECRET,
    {}
  );
  console.log('accessToken:', data.accessToken);
  console.log('expireTime(s):', data.expireTime);
})();

管理员 token 有效期约 3 天,见 accessToken。遇到 TK1002 再刷新,不必每个业务请求都重取。

3.5 验收设备资产池(别用停维护的列表接口)

javascript 复制代码
// list-devices.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');

(async () => {
  const token = (
    await callOpenApi('accessToken', process.env.OPENAPI_APP_ID, process.env.OPENAPI_APP_SECRET, {})
  ).accessToken;

  const data = await callOpenApi(
    'listDeviceDetailsByPage',
    process.env.OPENAPI_APP_ID,
    process.env.OPENAPI_APP_SECRET,
    { token, page: 1, pageSize: 50, source: 'bindAndShare' }
  );

  for (const d of data.deviceList || []) {
    const ch = (d.channelList || [])[0] || {};
    console.log({
      deviceId: d.deviceId,
      name: d.deviceName,
      status: d.deviceStatus,
      ability: d.deviceAbility,
      channelAbility: ch.channelAbility,
    });
  }
})();

接口说明:listDeviceDetailsByPage。老小区点位建议在 deviceName / channelName 写成「3栋-单元门」「车库入口-东」,后面工单标题会好看很多。

3.6 打开设备侧能力(按能力集,失败是正常的)

javascript 复制代码
// enable-community-alarms.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');

async function tryEnable(appId, appSecret, token, deviceId, channelId, enableType) {
  try {
    await callOpenApi('setDeviceCameraStatus', appId, appSecret, {
      token,
      deviceId,
      channelId,
      enableType, // 首字母小写,如 motionDetect / aiHuman
      enable: true,
    });
    console.log('OK', enableType);
  } catch (e) {
    console.warn('SKIP', enableType, e.message);
  }
}

(async () => {
  const appId = process.env.OPENAPI_APP_ID;
  const appSecret = process.env.OPENAPI_APP_SECRET;
  const token = (await callOpenApi('accessToken', appId, appSecret, {})).accessToken;
  const deviceId = process.env.DEVICE_ID;
  const channelId = process.env.CHANNEL_ID || '0';

  await tryEnable(appId, appSecret, token, deviceId, channelId, 'motionDetect');
  await tryEnable(appId, appSecret, token, deviceId, channelId, 'aiHuman');
  await tryEnable(appId, appSecret, token, deviceId, channelId, 'hoveringAlarm');
})();

家用/社区 IPC 多数有动检;人形看能力集是否含 AiHuman。能力没有就跳过,不要在验收清单里写死「必须开徘徊」。

3.7 登记回调 setMessageCallback

javascript 复制代码
// set-callback.js
require('dotenv').config();
const { callOpenApi } = require('./openapi-client');

(async () => {
  const token = (
    await callOpenApi('accessToken', process.env.OPENAPI_APP_ID, process.env.OPENAPI_APP_SECRET, {})
  ).accessToken;

  await callOpenApi('setMessageCallback', process.env.OPENAPI_APP_ID, process.env.OPENAPI_APP_SECRET, {
    token,
    status: 'on',
    callbackUrl: process.env.CALLBACK_URL, // https://property-bridge.example.com/openapi/callback
    callbackFlag: 'alarm,deviceStatus',
    basePush: '2', // 联调时减少与消费端 App 交叉干扰
  });

  const current = await callOpenApi(
    'getMessageCallback',
    process.env.OPENAPI_APP_ID,
    process.env.OPENAPI_APP_SECRET,
    { token }
  );
  console.log('当前回调配置:', current);
})();

参数说明见 setMessageCallback

参数 建议值 说明
status on 开启订阅
callbackUrl 公网 HTTPS 外网可访问,localhost 无效
callbackFlag alarm,deviceStatus 大类,逗号分隔
basePush "2" 不向关联消费端 App 推送(联调更干净)

3.8 点位映射表:deviceId → 楼栋/班组

物业工单能不能「看懂」,取决于这张表,而不是回调本身:

javascript 复制代码
// site-map.js  ------ 可落库,这里用静态表示意
module.exports = {
  // deviceId:channelId
  'TESTQWERXXXX:0': {
    community: '阳光花园(老改)',
    building: '3栋',
    spot: '单元门东侧',
    team: '保安一班',
    slaMinutes: 15,
  },
  'TESTNVR0001:3': {
    community: '阳光花园(老改)',
    building: '地下车库',
    spot: '充电区通道',
    team: '秩序维护',
    slaMinutes: 10,
  },
};

3.9 msgType 分级与工单字段

javascript 复制代码
// severity.js
const RULES = {
  hoveringAlarm: { priority: 'P0', category: '安防巡查', title: '周界徘徊' },
  videoBlind:    { priority: 'P0', category: '设备运维', title: '镜头遮挡' },
  offline:       { priority: 'P0', category: '设备运维', title: '设备离线' },
  human:         { priority: 'P1', category: '秩序巡查', title: '人形出现' },
  smokeAlarm:    { priority: 'P0', category: '消防应急', title: '烟感告警' },
  videoMotion:   { priority: 'P2', category: '秩序观察', title: '动检触发' },
  storageEmpty:  { priority: 'P1', category: '设备运维', title: '无存储介质' },
};

function classify(msgType) {
  return RULES[msgType] || { priority: 'P3', category: '其他', title: msgType || 'unknown' };
}

module.exports = { classify };

工单最小字段集(对接你们物业系统时按这个对齐):

text 复制代码
workOrderId        业务侧生成
sourceAlarmId      乐橙回调 id(幂等键)
deviceId / channelId
msgType / priority
community / building / spot / team
occurredAt
evidenceUrls[]     告警图 / 抓图
slaMinutes
status             open / assigned / done

3.10 回调接收:先 200,再异步建单

普通告警体(事件消息格式定义):

json 复制代码
{
  "id": 2447736561,
  "appId": "lcdxxxxxxxxx",
  "did": "TESTQWERXXXX",
  "cid": 0,
  "msgType": "human",
  "time": 1719900000,
  "cname": "3栋-单元门东侧",
  "remark": "",
  "token": "可选云录像token"
}

上下线体:

json 复制代码
{
  "id": -1,
  "did": "TESTQWERXXXX",
  "cid": -1,
  "msgType": "offline",
  "time": 1719900100,
  "cname": "3栋-单元门东侧"
}

完整桥接服务:

javascript 复制代码
// server.js
require('dotenv').config();
const express = require('express');
const { callOpenApi } = require('./openapi-client');
const { classify } = require('./severity');
const siteMap = require('./site-map');

const app = express();
app.use(express.json({ limit: '1mb' }));

// 简易内存幂等;生产请换 Redis SETNX + TTL
const seen = new Map();

function remember(key, ttlMs = 10 * 60 * 1000) {
  const now = Date.now();
  for (const [k, exp] of seen) if (exp < now) seen.delete(k);
  if (seen.has(key)) return false;
  seen.set(key, now + ttlMs);
  return true;
}

async function adminToken() {
  const data = await callOpenApi(
    'accessToken',
    process.env.OPENAPI_APP_ID,
    process.env.OPENAPI_APP_SECRET,
    {}
  );
  return data.accessToken;
}

async function enrichAlarm(msg) {
  // 上下线没有有效 alarmId,跳过详情
  if (msg.id == null || msg.id === -1) return { pics: [], aiTag: [], aiCopyWriting: '' };

  try {
    const token = await adminToken();
    const detail = await callOpenApi(
      'getAlarmMessageById',
      process.env.OPENAPI_APP_ID,
      process.env.OPENAPI_APP_SECRET,
      {
        token,
        deviceId: msg.did,
        channelId: String(msg.cid ?? 0),
        alarmId: String(msg.id),
        msgType: msg.msgType,
      }
    );
    return {
      pics: detail.picurlArray || [],
      thumbUrl: detail.thumbUrl,
      aiTag: detail.aiTag || [],
      aiCopyWriting: detail.aiCopyWriting || '',
      cloudRecToken: detail.token,
    };
  } catch (e) {
    console.warn('getAlarmMessageById failed:', e.message);
    return { pics: [], aiTag: [], aiCopyWriting: '' };
  }
}

async function maybeSnap(deviceId, channelId) {
  try {
    const token = await adminToken();
    const data = await callOpenApi(
      'setDeviceSnapEnhanced',
      process.env.OPENAPI_APP_ID,
      process.env.OPENAPI_APP_SECRET,
      { token, deviceId, channelId: String(channelId) }
    );
    // url 约 2 小时有效,需尽快转存到物业对象存储
    return data.url ? [data.url] : [];
  } catch (e) {
    console.warn('snap failed:', e.message);
    return [];
  }
}

/** 替换成你们真实的物业工单 API */
async function createPropertyWorkOrder(order) {
  // 示例:POST 到物业中台
  // await fetch(process.env.PROPERTY_WO_URL, { method:'POST', headers:{...}, body: JSON.stringify(order) });
  console.log('[WO]', JSON.stringify(order, null, 2));
  return { ok: true, woId: `WO-${order.sourceAlarmId}` };
}

async function handleMessage(msg) {
  const deviceId = msg.did || msg.deviceId;
  const channelId = msg.cid != null ? msg.cid : msg.channelId;
  const msgType = msg.msgType;
  const alarmKey = `${deviceId}:${channelId}:${msgType}:${msg.id ?? msg.time}`;

  if (!remember(alarmKey)) {
    console.log('duplicate skip', alarmKey);
    return;
  }

  // 动检风暴:同一点位 5 分钟内只建一张 P2 观察单
  if (msgType === 'videoMotion') {
    const stormKey = `storm:${deviceId}:${channelId}:videoMotion`;
    if (!remember(stormKey, 5 * 60 * 1000)) {
      console.log('motion storm suppressed', stormKey);
      return;
    }
  }

  const rule = classify(msgType);
  const site = siteMap[`${deviceId}:${channelId}`] || {
    community: '未映射小区',
    building: '未映射楼栋',
    spot: msg.cname || deviceId,
    team: '值班班长',
    slaMinutes: 30,
  };

  const enriched = await enrichAlarm(msg);
  let evidence = [...(enriched.pics || [])];
  if (evidence.length === 0 && ['P0', 'P1'].includes(rule.priority)) {
    evidence = await maybeSnap(deviceId, channelId ?? 0);
  }

  const order = {
    sourceAlarmId: String(msg.id ?? `${msgType}-${msg.time}`),
    deviceId,
    channelId,
    msgType,
    priority: rule.priority,
    category: rule.category,
    title: `【${rule.priority}】${site.building}-${site.spot}-${rule.title}`,
    community: site.community,
    building: site.building,
    spot: site.spot,
    team: site.team,
    slaMinutes: site.slaMinutes,
    occurredAt: msg.time,
    evidenceUrls: evidence,
    aiCopyWriting: enriched.aiCopyWriting,
    aiTag: enriched.aiTag,
    status: 'open',
  };

  // P3 不建单,只打日志(避免噪声)
  if (rule.priority === 'P3') {
    console.log('log only', order.title);
    return;
  }

  await createPropertyWorkOrder(order);
}

app.post('/openapi/callback', (req, res) => {
  // 关键:尽快 200,否则多次无响应平台可能停推
  res.status(200).json({ code: '0', msg: 'ok' });

  const payload = req.body;
  // 平台可能推单条对象或数组,按实际抓包兼容
  const list = Array.isArray(payload) ? payload : [payload];
  setImmediate(() => {
    Promise.all(list.map((m) => handleMessage(m).catch((e) => console.error(e))))
      .catch(() => {});
  });
});

app.get('/health', (_req, res) => res.send('ok'));

app.listen(process.env.PORT || 3000, () => {
  console.log('property bridge listening');
});

getAlarmMessageById 文档:根据 id 查询详细报警消息setDeviceSnapEnhanced 文档:设备抓图升级版,返回 URL 约 2 小时有效,P0/P1 建议立刻转存到物业自己的对象存储,不要只把临时链挂在工单里过夜。

3.11 三分钟验收清单

步骤 操作 期望
1 node 跑签名自测 打印 sign ok
2 listDeviceDetailsByPage 能看到小区设备且 online
3 setMessageCallback + getMessageCallback URL、flag 读回一致
4 在镜头前走动触发人形/动检 桥接日志出现 POST
5 回调 handler 先 200,再异步打印 [WO]
6 故意让回调超时 恢复后确认推送是否中断(文档警告多次无响应会停推)

四、边界、性能与生产注意

4.1 回调必须「轻」

文档写得很清楚:收到推送务必返回 200;多次不返回,平台可能不再向该地址推送。因此:

  • 同步路径只做:解析 JSON → 入队 / setImmediateres.status(200)
  • getAlarmMessageById、转存图片、调物业工单 API,全部异步;
  • 物业工单接口慢时,用队列(Bull / RabbitMQ)隔离,避免拖垮回调。

4.2 动检风暴是老小区头号坑

楼道窗帘、树影、飞蛾,会让 videoMotion 一夜上千条。处理策略:

  1. 优先订/用 human(能力允许时),动检降为观察级;
  2. 同一 deviceId:channelIdvideoMotion 做 5~15 分钟合并窗;
  3. 夜间周界用 hoveringAlarm(机型支持时),比纯动检更接近「可疑徘徊」。

4.3 图片与隐私

  • 告警图在平台侧保存时长有限(人脸类文档提示最长约一天),收到后尽快落物业侧存储;
  • 工单附件按物业内网权限控制,避免把签名 OSS 链接直接甩进业主微信群;
  • 抓图 URL 2 小时过期,转存失败要有重试与告警。

4.4 设备范围边界

setMessageCallback 推的是开发者应用资产池 内设备事件。老改项目若设备还在业主个人 App 名下、未托管/未绑定到开发者账号,列表为空、回调永远不来------这是权限问题,不是代码 bug。先用 listDeviceDetailsByPage 验收,再谈工单。

4.5 与 NVR 的关系:不是二选一

本地 NVR 继续负责「事后长回放」;云回调负责「事中派单」。两者叠加才是老小区可运营的形态:

text 复制代码
NVR  = 存证底座
回调 = 处置触发器
工单 = 责任与 SLA

4.6 我们真实踩过的坑(可直接当排查手册)

现象 根因 处理
回调登记成功但收不到 callbackUrl 非公网 / 证书异常 用外网 curl 自测,再 getMessageCallback
能收到但工单重复 未按 alarmId 幂等 Redis SETNX,TTL 覆盖重推窗口
群被刷爆 全部 videoMotion 建单 分级 + 风暴窗 + 优先人形
工单无图 只信回调体、未调详情 getAlarmMessageById;仍空则抓图
列表接口报错/空 用了停维护的 deviceList 改用 listDeviceDetailsByPage
sign 偶发失败 机器时间漂移 NTP 校准,误差 < 5 分钟

五、小结与延伸

老旧小区加装监控,验收标准不该停在「NVR 能回放」,而应能回答三句话:

  1. 事发后多少秒,班组手机上出现工单?
  2. 工单上有没有点位、级别、证据图?
  3. 超时未关闭的单,能不能被统计出来?

技术路径已经足够清晰:用现行 OpenAPI 完成 accessTokenlistDeviceDetailsByPagesetMessageCallback → 回调先 200 → getAlarmMessageById / setDeviceSnapEnhanced 富化 → 按 msgType 写入物业工单。本地硬盘继续留长录像,云端推送负责把「看见」变成「派人」。

延伸阅读(官方文档)

如果你也在做老旧小区、园区或城中村的「加装后如何运营」,可以在 乐橙开放平台 open.imou.com 注册开发者应用,把设备纳入资产池后,按本文脚本打开回调,再把 createPropertyWorkOrder 换成你们现有的报事报修接口。乐橙开放平台以视频技术与安全为核心,开放低代码开发组件与 OpenAPI,便于第三方厂商和个人开发者低成本把视频事件接到真实业务系统------对物业场景来说,最有价值的往往不是多一路画面,而是少一次「人肉翻 NVR」

相关推荐
猿长大人2 小时前
C# | MediatR 入门指南:后端架构解耦
分布式·后端·架构·c#·.net
lailai04102 小时前
视频离线观看工具的多维度比较分析
音视频
人间凡尔赛2 小时前
Kubernetes十周年:从Cloud Native到AI Native的架构范式跃迁
后端·云原生·架构
JNX_SEMI2 小时前
Hi5000Q/H 6.5~75V宽输入——与H5227A完全对等,无缝适配现有电源方案-聚能芯-智芯官方授权一级代理
驱动开发·单片机·嵌入式硬件·物联网·硬件工程
John jj2 小时前
拆解 Telegram 群组频道收录市场:三类方案,一个可运行的评分模型,目前TG中文人工评分加模型评分机制——LetsTG收录“快速”、“无门槛”
大数据·后端·python·深度学习·搜索引擎·django·全文检索
sunywz2 小时前
【从零搭建物联网智能充电桩系统】6、用户服务与钱包:微信登录、JWT 鉴权与乐观锁并发控制
物联网
Boop_wu2 小时前
SpringBoot 集成阿里云短信认证服务(个人)
spring boot·后端·阿里云
Gopher_HBo2 小时前
Engine 核心(gin.go)
后端
用户125758524363 小时前
为什么队列长度归零,不代表后台异步任务真的跑完了
redis·后端·go