周二下午三点,物业办公室的对讲机响了。
「3 栋地下室电瓶车又乱停,业主吵着要处理。」保安小刘跑去机房,对着一台嗡嗡作响的 NVR 点回放------十六路画面里翻了二十多分钟,才截到一张模糊截图,再手工填进物业 App 的「报事报修」。等工单创建出来,当事人早走了,业主群里已经开始骂「装监控有什么用」。
那天我站在机房门口才想清楚一件事:老旧小区加装监控,如果事件只落在硬盘里,对物业来说几乎等于没装。 真正缺的不是再多一路枪机,而是「镜头看见的事」能自动变成「有人、有时限、有证据」的工单。
下面这篇,记录一次把乐橙设备告警推进物业工单系统的完整做法:从回调登记、msgType 分级、证据富化,到幂等与风暴抑制,含可运行 Node.js 代码和踩坑清单。
一、为什么「只存 NVR」在老小区一定会翻车
老旧小区加装改造常见三件套:枪机/球机 + 本地 NVR + 物业值班室电视墙。验收时画面清晰、回放能翻,项目就结了。但运营三个月后,你会稳定看到三种尴尬:
- 发现靠人喊,不是靠系统:乱停、高空抛物线索、楼道堆积物,仍靠业主微信群截图;
- 处置靠翻录像,效率极低:保安不会用检索,只会拖进度条;
- 复盘没有工单号:出了纠纷只能说「当时有监控」,却拿不出「何时派谁、何时关闭」的闭环记录。
本质矛盾是:
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 却用同一种逻辑处理 human 和 videoMotion,群会炸、工单会灌水,保安会直接关掉通知。
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 准备清单
- 在 乐橙开放平台 注册并创建应用,拿到
appId/appSecret; - 将小区摄像机绑定到开发者应用资产池(列表用
listDeviceDetailsByPage); - 准备公网 HTTPS 回调地址(联调可用内网穿透,生产用正式证书);
- 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 → 入队 /
setImmediate→res.status(200); getAlarmMessageById、转存图片、调物业工单 API,全部异步;- 物业工单接口慢时,用队列(Bull / RabbitMQ)隔离,避免拖垮回调。
4.2 动检风暴是老小区头号坑
楼道窗帘、树影、飞蛾,会让 videoMotion 一夜上千条。处理策略:
- 优先订/用
human(能力允许时),动检降为观察级; - 同一
deviceId:channelId对videoMotion做 5~15 分钟合并窗; - 夜间周界用
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 能回放」,而应能回答三句话:
- 事发后多少秒,班组手机上出现工单?
- 工单上有没有点位、级别、证据图?
- 超时未关闭的单,能不能被统计出来?
技术路径已经足够清晰:用现行 OpenAPI 完成 accessToken → listDeviceDetailsByPage → setMessageCallback → 回调先 200 → getAlarmMessageById / setDeviceSnapEnhanced 富化 → 按 msgType 写入物业工单。本地硬盘继续留长录像,云端推送负责把「看见」变成「派人」。
延伸阅读(官方文档)
如果你也在做老旧小区、园区或城中村的「加装后如何运营」,可以在 乐橙开放平台 open.imou.com 注册开发者应用,把设备纳入资产池后,按本文脚本打开回调,再把 createPropertyWorkOrder 换成你们现有的报事报修接口。乐橙开放平台以视频技术与安全为核心,开放低代码开发组件与 OpenAPI,便于第三方厂商和个人开发者低成本把视频事件接到真实业务系统------对物业场景来说,最有价值的往往不是多一路画面,而是少一次「人肉翻 NVR」。