凌晨三点报警全乱了:一个 callback 地址,如何把设备托管消息拆成多客户流水线?

上周三凌晨 3:17,我被电话叫醒。

甲方运维说:「你们中台把 A 商场的动检,推到了 B 连锁店的值班群。」同一分钟里,设备离线事件也串了------B 店的值班员跑去重启了一台根本不属于他们的摄像头。不是推送通道挂了,更糟:通道「很健康」,只是消息进错了房间。

那晚我们才真正承认一个事实:开放平台通常只给你一个消息回调入口;callbackFlag 管的是「收哪些大类消息」,不是「发给哪个客户」。 多客户报警运营要成立,必须自己做「设备托管 → 租户映射 → 分流转发」这一层。

下面这篇,就是我们把「报警运营平台 MVP」从事故现场拉回可交付状态的完整复盘:可运行代码、真实接口字段、以及踩坑记录。


一、为什么这个问题值得认真做

1.1 业务真相:你卖的不是「看视频」,是「报警不串、可追责」

报警运营(安防值班、连锁店巡检、物业中控)的核心 SLA 通常不是码流延迟,而是:

  • 谁的设备报警,必须进谁的工单/群/CRM;
  • 托管关系变更(授权、改权、取消)要实时反映到路由表;
  • 同一台设备的 alarmdeviceStatus 要能分策略处理(例如离线只发运维,动检才进值班)。

如果还停留在「绑一个 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 目标(两周可交付)

  1. 开通托管后,拿到控制台给出的托管 H5 链接,自行在链接末尾拼接 &state=xxxxxx 为你的业务票据),再把拼好的链接发给指定客户门店;
  2. 收到 warrantInit(消息体中会携带你拼进去的 state)后,把 deviceId → tenantId 写入路由表;
  3. 订阅 callbackFlag=alarm,deviceStatus
  4. 报警/上下线进入中台后,按设备映射转发到租户回调,并保证快速返回 HTTP 200。

二、架构、接口与可运行代码

2.1 架构与主流程

flowchart LR U[门店终端用户] -->|访问托管H5末尾拼&state=xxx| H[开放平台托管页] H -->|warrantInit| CB[/中台 /openapi/callback/] CB -->|写库| R[(device_route)] D[设备事件] -->|alarm/deviceStatus| CB CB -->|查路由| R CB -->|异步转发| T1[租户A webhook] CB --> T2[租户B webhook] API[OpenAPI 网关] -->|accessToken/setMessageCallback/authorizedDeviceList| Svc[中台服务]

分流只看三样东西:

  1. 消息身份 :是授权事件还是设备事件(看 msgType / 字段形态);
  2. 设备主键 :报警用 did,授权用 deviceList[].deviceId
  3. 租户出口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 原样返回"
}

同步还要处理:deviceAuthCancelwarrantModifyauthorityRemove------否则路由表会「幽灵设备」。

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 本地联调清单(我们真实跑通的顺序)

  1. 本地签名自检通过;
  2. accessToken 成功并缓存;
  3. 公网隧道(如 frp/ngrok)暴露 /openapi/callback
  4. setMessageCallback 订阅 alarm,deviceStatus
  5. 在托管 H5 末尾手动拼接测试租户的 &state=xxx,用该链接走一遍真实托管;
  6. 确认收到 warrantInit 且路由表写入;
  7. 触发动检 / 拔网线,看是否只打到该租户 webhook;
  8. 取消托管,确认路由删除且不再转发。

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

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 我们踩过、建议你直接躲开的坑

  1. 串单根因 :没有 device_route,只靠「最近一次托管的客户」猜归属。
  2. 改权未更新 :只处理 warrantInit,忽略 warrantModify,导致你以为有 Alarm 权限,实际用户已取消。
  3. 回调同步重活:在 handler 里同步调客户 CRM,偶发超时 → 平台停推 → 「报警全站静默」。
  4. nonce 复用 :重试框架复用同一请求体,5 分钟内撞 SN1005
  5. basePush 设成 1 却不理解含义:把开发者侧关联 App 设备噪音一并灌进运营中台,值班被刷屏。

四、总结与延伸

报警运营 MVP 的最小闭环其实只有四句话:

  1. 用设备托管拿到合法设备权限与事件;
  2. 在托管 H5 末尾手动拼接 &state=xxx,借授权消息回传的 state 完成「设备 ↔ 租户」首次绑定;
  3. callbackFlag 管好「收哪些大类」,不要幻想它能替你做多租户;
  4. 在唯一回调之上自建分流层:快速 200、异步转发、授权变更同步、定期对账。

延伸阅读建议(对照你所用平台的现行文档目录即可,避开已标注「旧版/不再维护」的协议栏目):

  • 开发规范(签名 / 请求体)
  • 事件消息推送流程
  • 事件消息格式定义 / 类型定义
  • 设备托管说明 & 授权消息推送
  • setMessageCallback / getMessageCallback
  • authorizedDeviceList / deviceListByAuthStatus

附:本文涉及的关键接口速查

方法名 用途
accessToken 获取管理员 token(约 3 天)
setMessageCallback 设置唯一回调 URL + callbackFlag
getMessageCallback 查看当前回调配置
authorizedDeviceList 分页获取已托管设备详情
deviceListByAuthStatus 按托管状态分页查询(init/accept/refuse)

关键 callbackFlag 取值:alarmdeviceStatusiotnumberstatfaceAnalysis

关键授权 msgTypewarrantInitdeviceAuthCancelwarrantModifyauthorityRemove

相关推荐
国科安芯2 小时前
还是4位?ASC8T245S 与 ASC4T245S 的双向电平转换选型实战
单片机·嵌入式硬件·物联网·fpga开发·机器人·云计算
柱子jason3 小时前
使用IOT-Tree的用户和角色控制监控画面指令下达授权
物联网·安全·自动化·iiot·iot-tree·监控画面
桐盛科技3 小时前
景区智慧公厕:解决“找厕难、排队长”的技术实践
物联网·智慧城市·风景
TDengine (老段)4 小时前
已有TSDB?一条配置,免费解锁AI数据管理平台
大数据·数据库·物联网·ai·时序数据库·tdengine·涛思数据
蝎蟹居4 小时前
GBT 4706.1-2024逐句解读系列(31) 第7.21.1条款:说明书怎么写符合标准要求
人工智能·单片机·嵌入式硬件·物联网·安全
Inhand陈工4 小时前
路由器专题二:有线为主,蜂窝备份——映翰通路由器链路备份功能详解
运维·网络·物联网·智能路由器·系统安全
数字新视界18 小时前
动环监控可视化在数据中心管理中的应用与实践解析
数据库·物联网·解决方案·动环监控·大榕树
老孙讲技术20 小时前
业主半夜想看楼道监控,物业却说「去机房」?我用设备托管+轻应用,把小区摄像头嵌进了社区小程序
后端·物联网
lcj092466620 小时前
基于云IoT搭建企业固定资产全生命周期管理系统落地实践
物联网