上周三凌晨 3:17,我被电话叫醒。
甲方运维说:「你们中台把 A 商场的动检,推到了 B 连锁店的值班群。」同一分钟里,设备离线事件也串了------B 店的值班员跑去重启了一台根本不属于他们的摄像头。不是推送通道挂了,更糟:通道「很健康」,只是消息进错了房间。
那晚我们才真正承认一个事实:开放平台通常只给你一个消息回调入口;callbackFlag 管的是「收哪些大类消息」,不是「发给哪个客户」。 多客户报警运营要成立,必须自己做「设备托管 → 租户映射 → 分流转发」这一层。
下面这篇,就是我们把「报警运营平台 MVP」从事故现场拉回可交付状态的完整复盘:可运行代码、真实接口字段、以及踩坑记录。
一、为什么这个问题值得认真做
1.1 业务真相:你卖的不是「看视频」,是「报警不串、可追责」
报警运营(安防值班、连锁店巡检、物业中控)的核心 SLA 通常不是码流延迟,而是:
- 谁的设备报警,必须进谁的工单/群/CRM;
- 托管关系变更(授权、改权、取消)要实时反映到路由表;
- 同一台设备的
alarm与deviceStatus要能分策略处理(例如离线只发运维,动检才进值班)。
如果还停留在「绑一个 webhook,console.log 一下」------客户一多,串单几乎是必然事件。
1.2 平台能力边界(务必先划清)
主流视频开放平台对开发者账号一般提供:
| 能力 | 作用 | 多客户含义 |
|---|---|---|
| 设备托管 | 终端用户把设备权限(报警查询、预览、回放、配置等)授给开发者 | 你能合法收托管设备事件,且不影响用户原 App 体验 |
setMessageCallback |
为开发者应用 配置唯一回调 URL | 所有托管设备事件先进你的中台 |
callbackFlag |
订阅消息大类:alarm / deviceStatus / iot / numberstat / faceAnalysis |
控制「收什么」,不是「分给谁」 |
托管 H5 的 state 参数 |
开发者自行在托管 H5 链接末尾拼接 &state=xxx;用户发起/完成托管后,授权消息会原样带回该 state |
多租户绑定的关键钩子(平台不代填,需手动携带) |
所以正确心智模型是:
text
终端用户 --访问「托管H5 + &state=xxx」--> 开放平台 --唯一 callback(消息含 state)--> 你的分流中台 --按租户 webhook--> 客户 A/B/C
1.3 MVP 目标(两周可交付)
- 开通托管后,拿到控制台给出的托管 H5 链接,自行在链接末尾拼接
&state=xxx(xxx为你的业务票据),再把拼好的链接发给指定客户门店; - 收到
warrantInit(消息体中会携带你拼进去的state)后,把deviceId → tenantId写入路由表; - 订阅
callbackFlag=alarm,deviceStatus; - 报警/上下线进入中台后,按设备映射转发到租户回调,并保证快速返回 HTTP 200。
二、架构、接口与可运行代码
2.1 架构与主流程
分流只看三样东西:
- 消息身份 :是授权事件还是设备事件(看
msgType/ 字段形态); - 设备主键 :报警用
did,授权用deviceList[].deviceId; - 租户出口 :
device_route.tenant_id → tenants.callback_url。
2.2 鉴权:先算对 sign,再谈一切
OpenAPI 请求体固定三段:system / id / params。签名原始串为:
text
time:{time},nonce:{nonce},appSecret:{appSecret}
UTF-8 后做 MD5,得到 32 位小写 sign。官方标准用例:
text
time:1706511734,nonce:f5a1ae2d-c09c-4d39-a744-83a5c2c653c2,appSecret:test123456789test123456789
→ sign = fd37b62889e4757c58b8f3bf05fb9976
先本地自检签名(Node.js):
js
// sign-selfcheck.js
const crypto = require('crypto');
const time = '1706511734';
const nonce = 'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2';
const appSecret = 'test123456789test123456789';
const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
const sign = crypto.createHash('md5').update(raw, 'utf8').digest('hex');
console.log(sign);
// 期望: fd37b62889e4757c58b8f3bf05fb9976
跑不通就别往下写业务------后面所有「签名异常 / SN1001」都是这里没对齐。
封装可复用客户端:
js
// openapi-client.js
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const OPENAPI_BASE = process.env.OPENAPI_BASE; // 例如 https://openapi.xxx.cn/openapi
const APP_ID = process.env.APP_ID;
const APP_SECRET = process.env.APP_SECRET;
function buildSign(time, nonce, appSecret) {
const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
return crypto.createHash('md5').update(raw, 'utf8').digest('hex');
}
async function openapiCall(method, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = randomUUID(); // 文档要求随机串,5 分钟内不可复用
const body = {
system: {
ver: '1.0',
appId: APP_ID,
sign: buildSign(time, nonce, APP_SECRET),
time,
nonce,
},
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 (String(json?.result?.code) !== '0') {
throw new Error(`${method} failed: ${JSON.stringify(json)}`);
}
return json.result.data;
}
module.exports = { openapiCall };
2.3 获取管理员 accessToken(有效期约 3 天)
js
// get-token.js
const { openapiCall } = require('./openapi-client');
async function main() {
const data = await openapiCall('accessToken', {});
// data: { accessToken, expireTime } expireTime 单位:秒
console.log(data);
}
main().catch(console.error);
踩坑 1 :别每次请求都刷 accessToken。文档明确:token 约 3 天有效;临近过期或遇到 TK1002 再换。频繁刷会浪费配额,还容易把缓存写乱。
建议内存/Redis 缓存,过期前 1 小时刷新:
js
// token-cache.js
const { openapiCall } = require('./openapi-client');
let cache = { token: null, expireAt: 0 };
async function getAdminToken() {
const now = Date.now();
if (cache.token && now < cache.expireAt - 3600_000) return cache.token;
const data = await openapiCall('accessToken', {});
cache = {
token: data.accessToken,
// expireTime 是「剩余秒数」语义(以平台返回为准),这里按剩余秒换算
expireAt: now + Number(data.expireTime) * 1000,
};
return cache.token;
}
module.exports = { getAdminToken };
2.4 订阅回调:一次设对 callbackFlag
setMessageCallback 关键参数:
| 参数 | 说明 |
|---|---|
token |
管理员 accessToken |
status |
on / off |
callbackUrl |
公网可达;status=on 时必填 |
callbackFlag |
大类,逗号分隔:alarm,deviceStatus,iot,numberstat,faceAnalysis |
basePush |
"1" 推送 / "2" 不推送(开发者账号关联 App 设备消息是否推送;MVP 建议 "2") |
js
// set-callback.js
const { openapiCall } = require('./openapi-client');
const { getAdminToken } = require('./token-cache');
async function main() {
const token = await getAdminToken();
await openapiCall('setMessageCallback', {
token,
status: 'on',
callbackUrl: process.env.CALLBACK_URL, // https://ops.your-domain.com/openapi/callback
callbackFlag: 'alarm,deviceStatus',
basePush: '2',
});
console.log('callback subscribed');
}
main().catch(console.error);
核对当前配置用 getMessageCallback:
js
// get-callback.js
const { openapiCall } = require('./openapi-client');
const { getAdminToken } = require('./token-cache');
async function main() {
const token = await getAdminToken();
const data = await openapiCall('getMessageCallback', { token });
// { status, callbackUrl, callbackFlag }
console.log(data);
}
main().catch(console.error);
踩坑 2 :很多人把 callbackFlag 理解成「租户标识」。它不是。它只决定平台是否把某类事件推到你唯一的 callbackUrl。客户分流必须在你自己的服务里做。
踩坑 3 :回调服务必须尽快返回 HTTP 200。多次无响应,平台可能停止向该地址推送------这是文档里写得很硬的规则。业务转发请异步。
2.5 设备托管:用 state 把「门店/租户」焊进授权事件
先把能力边界说清楚:平台提供的是固定的托管 H5 链接;并不会替你「生成带 state 的托管链接」。
当前支持的方式是------开发者(或运营同学)自己拿到托管 H5 后,手动在链接末尾拼接 &state=xxx ;终端用户用这条拼好的链接发起托管时,平台下发的托管/授权消息里就会原样带上 state=xxx,供你做租户绑定。
拼接形态示意:
text
{控制台给出的托管H5链接}&state={你的业务票据}
举例(示意,勿照抄域名):
text
https://example.com/h5/company/abcde12345678910&state=eyJ0aWQiOiIxMDA4NiIsImV4cCI6...
注意:这里是「在现有托管 H5 末尾追加查询参数」的业务约定写法;
state的值由你侧定义与校验,平台只负责原样回传,不做解读。
state 建议做成短时、可校验、不可伪造 的票据,而不是明文 tenantId=3。下面这段代码只是帮你在本地拼链接、验票据------不是平台接口:
js
// host-link.js
// 说明:平台不会下发「带 state 的托管链接」;
// 你需要自己对托管 H5 末尾追加 &state=xxx,再发给门店用户。
const crypto = require('crypto');
const STATE_SECRET = process.env.STATE_SECRET;
function issueState(tenantId, ttlSec = 7 * 24 * 3600) {
const payload = {
tid: String(tenantId),
exp: Math.floor(Date.now() / 1000) + ttlSec,
n: crypto.randomBytes(8).toString('hex'),
};
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', STATE_SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyState(state) {
const [body, sig] = String(state).split('.');
if (!body || !sig) throw new Error('bad state');
const expect = crypto.createHmac('sha256', STATE_SECRET).update(body).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) {
throw new Error('state signature mismatch');
}
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (payload.exp < Math.floor(Date.now() / 1000)) throw new Error('state expired');
return payload.tid;
}
/** 在托管 H5 末尾手动追加 &state=xxx(平台侧能力等价于用户自行拼接) */
function appendStateToHostingH5(baseHostingUrl, tenantId) {
const state = issueState(tenantId);
// 按当前产品约定:在托管 H5 链接末尾以 &state=xxx 形式携带
return `${baseHostingUrl}&state=${encodeURIComponent(state)}`;
}
module.exports = { issueState, verifyState, appendStateToHostingH5 };
用户通过拼好的链接发起托管后,平台会推送授权消息(字段以授权消息为准),其中 state 为你拼接进去的原值:
json
{
"warrantId": "146746972164984832",
"msgType": "warrantInit",
"phone": "158***404",
"appId": "lcdxxxxxxxxx",
"deviceList": [
{
"authority": "Alarm,Real,Talk,RecordReplay",
"channelName": "Front-Door",
"deviceId": "TESTQWERXXXX",
"deviceName": "Front-Door",
"channelId": "0"
}
],
"remark": "",
"state": "你在托管H5末尾拼接的 &state=xxx 原样返回"
}
同步还要处理:deviceAuthCancel、warrantModify、authorityRemove------否则路由表会「幽灵设备」。
2.6 路由表(MVP 最小可用)
sql
CREATE TABLE tenants (
id BIGINT PRIMARY KEY,
name VARCHAR(64) NOT NULL,
callback_url VARCHAR(512) NOT NULL,
callback_secret VARCHAR(128) NOT NULL,
status TINYINT NOT NULL DEFAULT 1
);
CREATE TABLE device_route (
device_id VARCHAR(64) PRIMARY KEY,
tenant_id BIGINT NOT NULL,
warrant_id VARCHAR(64) NULL,
authority VARCHAR(512) NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_device_route_tenant ON device_route(tenant_id);
2.7 回调入口:先 200,再分流(Express 示例)
设备普通报警消息形态:
json
{
"id": 2447736561,
"appId": "lcdxxxxxxxxx",
"did": "TESTQWERXXXX",
"cid": 0,
"msgType": "videoMotion",
"time": 1475052555,
"cname": "TESTQWERXXXX",
"remark": "",
"token": "f2dc8c09eeae4b5bad6abf522c93d825"
}
设备上下线:
json
{
"id": -1,
"did": "TESTQWERXXXX",
"cid": -1,
"msgType": "online",
"time": 1475052555,
"cname": "TESTQWERXXXX"
}
中台实现:
js
// server.js
const express = require('express');
const crypto = require('crypto');
const { verifyState } = require('./host-link');
// 伪 DAO:替换成你的 DB
const db = {
async upsertRoute(row) { /* INSERT ... ON DUPLICATE KEY UPDATE */ },
async removeRoute(deviceId) { /* DELETE */ },
async getRoute(deviceId) { /* SELECT */ return null; },
async getTenant(tenantId) { /* SELECT */ return null; },
};
const app = express();
app.use(express.json({ limit: '1mb' }));
function classify(body) {
if (body?.msgType === 'warrantInit') return 'warrant_init';
if (body?.msgType === 'deviceAuthCancel') return 'warrant_cancel';
if (body?.msgType === 'warrantModify') return 'warrant_modify';
if (body?.msgType === 'authorityRemove') return 'authority_remove';
if (body?.did && body?.msgType) return 'device_event';
return 'unknown';
}
async function handleWarrantInit(body) {
const tenantId = verifyState(body.state);
for (const d of body.deviceList || []) {
await db.upsertRoute({
device_id: d.deviceId,
tenant_id: tenantId,
warrant_id: body.warrantId,
authority: d.authority,
});
}
}
async function handleDeviceEvent(body) {
const route = await db.getRoute(body.did);
if (!route) {
console.warn('unmapped device', body.did, body.msgType);
return;
}
const tenant = await db.getTenant(route.tenant_id);
if (!tenant || tenant.status !== 1) return;
// 二级分流:同一租户内按消息大类走不同队列/URL
const flag = mapMsgToFlag(body.msgType);
const payload = {
tenantId: tenant.id,
flag,
deviceId: body.did,
channelId: body.cid,
msgType: body.msgType,
eventTime: body.time,
raw: body,
};
// 异步投递:队列优先;MVP 可用 setImmediate + 重试表
setImmediate(() => forwardWithRetry(tenant, payload).catch(console.error));
}
function mapMsgToFlag(msgType) {
if (msgType === 'online' || msgType === 'offline') return 'deviceStatus';
// 其余默认归入 alarm 大类(动检/人形等具体类型见平台「事件消息类型定义」)
return 'alarm';
}
async function forwardWithRetry(tenant, payload, attempt = 1) {
const ts = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify(payload);
const sign = crypto
.createHmac('sha256', tenant.callback_secret)
.update(ts + '.' + body)
.digest('hex');
const res = await fetch(tenant.callback_url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Timestamp': ts,
'X-Signature': sign,
},
body,
});
if (!res.ok) {
if (attempt >= 5) throw new Error(`forward fail tenant=${tenant.id}`);
await new Promise((r) => setTimeout(r, 2 ** attempt * 200));
return forwardWithRetry(tenant, payload, attempt + 1);
}
}
app.post('/openapi/callback', async (req, res) => {
// 关键:先回 200,再处理(避免平台停推)
res.status(200).json({ code: '0', msg: 'ok' });
const body = req.body || {};
try {
switch (classify(body)) {
case 'warrant_init':
await handleWarrantInit(body);
break;
case 'warrant_cancel':
for (const d of body.deviceList || []) await db.removeRoute(d.deviceId);
break;
case 'warrant_modify':
for (const d of body.deviceList || []) {
await db.upsertRoute({
device_id: d.deviceId,
tenant_id: (await db.getRoute(d.deviceId))?.tenant_id, // 改权通常不改归属
warrant_id: body.warrantId,
authority: d.authority,
});
}
break;
case 'authority_remove':
await db.removeRoute(body.deviceId);
break;
case 'device_event':
await handleDeviceEvent(body);
break;
default:
console.warn('unknown payload', body);
}
} catch (e) {
console.error('callback handle error', e);
// 已返回 200;错误进死信/告警即可
}
});
app.listen(process.env.PORT || 3000);
2.8 对账:用托管设备列表校准路由表
授权回调偶发丢失时,用 OpenAPI 兜底同步:
deviceListByAuthStatus:按init/accept/refuse分页拉托管单状态(pageSize最大 20);authorizedDeviceList:分页拉已托管设备详情(含shareFunctions权限串)。
js
// sync-authorized.js
const { openapiCall } = require('./openapi-client');
const { getAdminToken } = require('./token-cache');
async function listAllAuthorized() {
const token = await getAdminToken();
let page = 1;
const pageSize = 20;
const all = [];
while (true) {
const data = await openapiCall('authorizedDeviceList', { token, page, pageSize });
const list = data?.deviceList || [];
all.push(...list);
if (list.length < pageSize) break;
page += 1;
}
return all;
}
async function main() {
const devices = await listAllAuthorized();
// 与 device_route 做差集:平台有、本地无 → 告警人工补绑定
// 本地有、平台无 → 标记失效,停止转发
console.log('authorized count=', devices.length);
}
main().catch(console.error);
2.9 本地联调清单(我们真实跑通的顺序)
- 本地签名自检通过;
accessToken成功并缓存;- 公网隧道(如 frp/ngrok)暴露
/openapi/callback; setMessageCallback订阅alarm,deviceStatus;- 在托管 H5 末尾手动拼接测试租户的
&state=xxx,用该链接走一遍真实托管; - 确认收到
warrantInit且路由表写入; - 触发动检 / 拔网线,看是否只打到该租户 webhook;
- 取消托管,确认路由删除且不再转发。
三、边界、性能与生产注意
3.1 callbackFlag 二级分流怎么设计才不后悔
平台层:callbackFlag 决定「进不进中台」。
中台层:建议再按租户配置订阅掩码,例如:
json
{
"tenantId": "10086",
"subscribe": ["alarm"],
"ignoreMsgTypes": ["videoMotion"]
}
连锁店可能只要 alarm;物管中心可能还要 deviceStatus;客流客户才开 numberstat。
不要为每个客户去改平台唯一回调------那会互相覆盖。正确做法是:平台宽进(中台全收需要的大类),租户窄出(按配置过滤)。
3.2 性能与可靠性
- 同步路径要短:回调 handler 只做「解析 + 入队 + 200」;转发、落库、告警聚合放 worker。
- 幂等 :以
(did/deviceId, msgType, time, id)做去重键,开放平台重推时避免重复工单。 - 积压隔离 :按
tenant_id分队列,避免慢租户拖死全局。 - 图片字段:人脸类推送里的图片 URL 通常短时有效(常见约 1 天),收到即转存对象存储。
- 时钟 :
system.time与服务器时差超过约 5 分钟会签名超时(如SN1002);容器注意 NTP。
3.3 安全边界
state必须签名 + 过期;禁止明文租户 ID。- 下游 webhook 使用 HMAC(上文
X-Signature),并校验时间窗防重放。 - 权限最小化:报警运营 MVP 托管权限优先勾选「报警消息」;需要预览/回放再按客户加购能力开通。
- 日志脱敏:手机号、图片 URL、token 字段不要进明文日志。
3.4 我们踩过、建议你直接躲开的坑
- 串单根因 :没有
device_route,只靠「最近一次托管的客户」猜归属。 - 改权未更新 :只处理
warrantInit,忽略warrantModify,导致你以为有 Alarm 权限,实际用户已取消。 - 回调同步重活:在 handler 里同步调客户 CRM,偶发超时 → 平台停推 → 「报警全站静默」。
- nonce 复用 :重试框架复用同一请求体,5 分钟内撞
SN1005。 - 把
basePush设成1却不理解含义:把开发者侧关联 App 设备噪音一并灌进运营中台,值班被刷屏。
四、总结与延伸
报警运营 MVP 的最小闭环其实只有四句话:
- 用设备托管拿到合法设备权限与事件;
- 在托管 H5 末尾手动拼接
&state=xxx,借授权消息回传的state完成「设备 ↔ 租户」首次绑定; - 用
callbackFlag管好「收哪些大类」,不要幻想它能替你做多租户; - 在唯一回调之上自建分流层:快速 200、异步转发、授权变更同步、定期对账。
延伸阅读建议(对照你所用平台的现行文档目录即可,避开已标注「旧版/不再维护」的协议栏目):
- 开发规范(签名 / 请求体)
- 事件消息推送流程
- 事件消息格式定义 / 类型定义
- 设备托管说明 & 授权消息推送
setMessageCallback/getMessageCallbackauthorizedDeviceList/deviceListByAuthStatus
附:本文涉及的关键接口速查
| 方法名 | 用途 |
|---|---|
accessToken |
获取管理员 token(约 3 天) |
setMessageCallback |
设置唯一回调 URL + callbackFlag |
getMessageCallback |
查看当前回调配置 |
authorizedDeviceList |
分页获取已托管设备详情 |
deviceListByAuthStatus |
按托管状态分页查询(init/accept/refuse) |
关键 callbackFlag 取值:alarm、deviceStatus、iot、numberstat、faceAnalysis。
关键授权 msgType:warrantInit、deviceAuthCancel、warrantModify、authorityRemove。