街道办要你「老国标别拆、新点位也要上」:视频网关如何同时吞下 GB28181 与私有协议设备?

0 阅读16分钟

验收那天,街道信息中心的领导只问了一句:「东门那 16 路老 NVR 还在吗?西侧新加的球机,能不能和东门同一张墙上看?」

你点头,心里却发凉——东门是三年前按 GB/T 28181 挂到区平台的设备,西侧是这两周刚上的消费级/行业私有协议摄像机。两套账号、两套预览页、两套「设备是否在线」的口径。拆东门?预算没有;并行运维?值班室已经在骂「点哪个 App」。

那天晚上我们把目标改成一句话:老设备走国标 SIP 利旧进来,新点位走平台私有协议绑定进来,业务侧只认一套 deviceId + channelId,只调一套现行 OpenAPI。

下面这篇是街道 / 物业视频网关「混合接入」的完整落地笔记:可运行 Node.js、真实配置项、以及把踩坑写进正文的联调过程。


一、「利旧」不是口号,是验收口径

项目合同里写着「利旧」,甲方理解成「别让我们再买一批枪机」;技术标书里写着「国标兼容」,实施理解成「再搭一套 SIP 平台」。两边都没错,但拼在一起就会变成:

  • 老 NVR 还能出图,却进不了你的 SaaS 设备列表;
  • 新摄像头绑进了开发者账号,值班大屏却要切两个标签页;
  • 同一条路「在线」,国标侧看 SIP 注册,私有协议侧看心跳,运维对账永远对不齐。

真正要命的不是协议多,而是资产模型分裂。混合接入要解决的,是把分裂收敛掉。


二、为什么街道/物业场景必须认真对待混合接入

2.1 场景真相:存量是国标,增量是私有协议

街道、社区、物业中控的典型资产结构是:

类型常见形态协议现实业务诉求
存量大华 / 海康 / 宇视等 IPC、NVRGB/T 28181-2011/2016不拆不换、少改 Web 配置
增量互联网化摄像机、球机厂商私有协议上云配网快、App 可管、云能力齐
业务值班预览墙、事件回放、简单云台只要「一个列表、一套接口」运维口径统一、可对账

国标解决的是「多厂商存量如何联网」;私有协议解决的是「新点位如何低成本上云」。街道项目往往两者同时存在——所以网关层必须是双轨汇聚,单轨消费

2.2 平台侧能力边界(先划清,再设计)

主流视频开放平台对国标接入的产品定位,可以概括成四句话(对照现行「国标 GB28181 设备接入 / 产品介绍」):

  1. 利旧:存量 IPC/NVR 按 GB28181 配置 SIP,无需为上云改固件业务逻辑;
  2. 统一管理:国标设备与私有协议设备进入同一开发者资产池;
  3. 协议复用:国标通道上线后,无需为国标再写一套业务 API——预览、直播、回放、云台等走现有设备 OpenAPI / 云直播 OpenAPI;
  4. 传输偏好:SIP 侧支持 TCP / UDP / TLS,生产建议优先 TCP(穿透与丢包表现更稳)。

对应到你要设计的视频网关:

                    ┌─ GB28181 轨 ─ SIP 注册 ─┐
存量 NVR/IPC ──────┤                         ├──► 开放平台资产池
新增私有协议摄像机 ─┤─ 私有协议轨 ─ 绑定上云 ─┘         │
                                                      ▼
                                            现行 OpenAPI(同一套)
                                   accessToken / listDeviceDetailsByPage
                                   bindDeviceLive / getLiveStreamInfo
                                   createDeviceFlvLive / controlPTZ …
                                                      ▼
                                            街道/物业视频网关 BFF
                                            (统一 deviceId+channelId)

2.3 解决思路:把「协议差异」关在接入层

业务同学不该知道某路是国标还是私有协议——他们只该知道:

  • 这路有没有在线;
  • 怎么拿到可播地址(HLS / FLV);
  • 云台、回放、告警是否可用。

因此网关的分层建议是:

职责不该做的事
接入层国标项目申请、SIP 下发、设备 Web 配置;私有协议绑定把 SIP 细节泄漏到前端
资产层listDeviceDetailsByPage 同步 + 本地 protocolTag用两套设备主键
媒体层bindDeviceLive / createDeviceFlvLive 签发 URL在浏览器端藏 appSecret
业务层预览墙、值班轮询、简单联动为国标再复制一套 Controller

接下来进入:从国标项目创建,到混合列表,再到同屏出流。


三、架构、配置、可运行代码

3.1国标轨:控制台侧要拿到的配置项

创建国标项目并绑定设备后,控制台会给出一组给设备 Web 页填写的参数。名词务必对齐(对照现行「详细流程」文档):

名词含义填到哪里
SIP 服务 ID国标项目唯一 ID设备国标配置页
SIP 服务域一般为 SIP 服务 ID 的前十位设备国标配置页
SIP 服务器域名 / IP设备向云平台注册的地址优先域名;设备不支持域名再填 IP(以控制台当前值为准,联调前在安装地 ping 一下)
SIP 服务器端口注册端口与控制台一致
设备国标 ID该设备在平台侧的唯一国标身份与控制台「设备国标 ID」一致
设备密码SIP 注册鉴权密码与控制台一致

绑定策略小技巧(利旧关键):

  • 若原区级/街道平台已有在用的国标设备 ID,优先选自定义国标 ID 复用,减少设备 Web 改项;
  • 若是全新利旧,可用平台自动生成 ID,再按导出清单批量改设备;
  • NVR 多通道:控制台通道国标 ID 顺序,必须与 NVR Web 里「通道号 ↔ 视频通道编码 ID」严格同序,乱序会导致平台通道画面串位(真实踩坑,见后文)。

设备侧配置完成后,通常约 60 秒内可见注册状态;在线后,可先在控制台直播能力里用 HLS 验一下出流,再进入 API 对接。

多目国标摄像机:按现行文档,应按 NVR 方式接入,而不是当单通道 IPC 硬套。

3.2 私有协议轨:绑定进同一开发者账号

新点位不走 SIP,走「配网上线 → 绑定到开发者应用」:

步骤做什么验收标准
1创建应用,拿到 appId / appSecret仅存服务端环境变量
2设备配网,终端 App 可预览「能看」≠「已进开放平台账号」
3bindDevice 或控制台/App 完成绑定listDeviceDetailsByPage 出现该 deviceId
4确认 deviceStatus === "online"再去创建直播

code 传法(现行 bindDevice):

  • 未改密:标签/二维码上的 8 位安全码;
  • 已改密:改后的设备密码;
  • 未改密且无 8 位安全码:code 可传空。

3.3 统一请求壳:签名 + OpenAPI 调用(先贴代码)

现行开发规范约定:

  • URL:{OPENAPI_BASE}/{method}OPENAPI_BASE 以你所在平台「开发规范 → 接口域名」为准,不要抄「旧版本协议」栏目);
  • Body:system + params + id
  • sign = MD5("time:{time},nonce:{nonce},appSecret:{appSecret}"),UTF-8,32 位小写。

官方标准案例(用来自测签名实现是否正确):

time:1706511734,nonce:f5a1ae2d-c09c-4d39-a744-83a5c2c653c2,appSecret:test123456789test123456789
→ sign = fd37b62889e4757c58b8f3bf05fb9976
// openapi-client.js
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');

// 从平台「开发规范」复制当前 OpenAPI 根路径,勿硬编码过期域名
const OPENAPI_BASE = process.env.OPENAPI_BASE;

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 = 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?.code !== '0') {
    const err = new Error(`${method} failed: ${json?.result?.code} ${json?.result?.msg}`);
    err.payload = json;
    throw err;
  }
  return json.result.data;
}

module.exports = { calcSign, callOpenApi };
// sign-selftest.js —— 联调前先跑通
const { calcSign } = require('./openapi-client');
const sign = calcSign(
  1706511734,
  'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2',
  'test123456789test123456789'
);
console.assert(sign === 'fd37b62889e4757c58b8f3bf05fb9976', sign);
console.log('sign ok', sign);

3.4 拿 token:accessToken

// 01-access-token.js
const { callOpenApi } = require('./openapi-client');

async function main() {
  const appId = process.env.APP_ID;
  const appSecret = process.env.APP_SECRET;
  const data = await callOpenApi('accessToken', appId, appSecret, {});
  // data.accessToken / data.expireTime(秒)
  console.log(JSON.stringify(data, null, 2));
}

main().catch((e) => {
  console.error(e.message, e.payload || '');
  process.exit(1);
});

注意(现行文档口径):

  • 管理员 token 有效期约 3 天;报 TK1002 或临近过期再换;
  • 每个 token 有独立生命周期,不要高频刷 accessToken 浪费调用次数;
  • 超过约 2 天未满 3 天再请求,可能返回新 token,新旧可并存——网关侧用「到期前刷新 + 本地缓存」即可。

3.5 私有协议绑定(可选 API 路径)

// 02-bind-private.js
const { callOpenApi } = require('./openapi-client');

async function main() {
  const appId = process.env.APP_ID;
  const appSecret = process.env.APP_SECRET;
  const token = process.env.ACCESS_TOKEN; // 上一步拿到
  const deviceId = process.env.DEVICE_ID;
  const code = process.env.DEVICE_CODE || '';

  const data = await callOpenApi('bindDevice', appId, appSecret, {
    token,
    deviceId,
    code,
  });
  console.log('bind ok', data);
}

main().catch((e) => {
  console.error(e.message, e.payload || '');
  process.exit(1);
});

部分新机型可能无法仅靠 HTTP bindDevice 完成绑定,需结合官方客户端 SDK / 控制台流程。网关设计时把「绑定」做成可插拔步骤,不要写死唯一路径。

3.6 混合资产同步:一页一页拉齐国标 + 私有协议

国标设备在平台侧注册成功后,与私有协议设备一样,进入开发者账号的设备详情列表。业务主键统一为 deviceId + channelId

// 03-sync-assets.js
const { callOpenApi } = require('./openapi-client');

async function listAllDevices(appId, appSecret, token) {
  const pageSize = 50; // 合法范围 1~50
  let page = 1;
  const all = [];

  for (;;) {
    const data = await callOpenApi('listDeviceDetailsByPage', appId, appSecret, {
      token,
      pageSize,
      page,
      source: 'bindAndShare', // bind | share | bindAndShare
    });
    const chunk = data.deviceList || [];
    all.push(...chunk);
    if (chunk.length < pageSize) break;
    page += 1;
  }
  return all;
}

/**
 * 网关本地资产归一:协议差异只留 tag,不留第二套主键
 * protocolTag 建议由你在录入时写入(gb28181 / private),
 * 列表接口未必直接给出「是否国标」枚举,别假设字段一定存在。
 */
function normalizeAsset(device, channel, protocolTag) {
  return {
    deviceId: device.deviceId,
    channelId: String(channel.channelId ?? channel.channelId === 0 ? 0 : channel.channelId),
    name: channel.channelName || device.deviceName || device.deviceId,
    online: String(device.deviceStatus).toLowerCase() === 'online',
    protocolTag, // 'gb28181' | 'private'
    raw: { device, channel },
  };
}

async function main() {
  const appId = process.env.APP_ID;
  const appSecret = process.env.APP_SECRET;
  const token = process.env.ACCESS_TOKEN;

  const devices = await listAllDevices(appId, appSecret, token);
  const assets = [];

  for (const d of devices) {
    const channels = d.channels || d.channelList || [];
    // 若平台把通道挂在不同字段,按你账号实测字段名微调
    if (!channels.length) {
      assets.push(
        normalizeAsset(d, { channelId: '0', channelName: d.deviceName }, process.env.DEFAULT_TAG || 'private')
      );
      continue;
    }
    for (const ch of channels) {
      assets.push(normalizeAsset(d, ch, process.env.DEFAULT_TAG || 'unknown'));
    }
  }

  console.log(JSON.stringify({ total: assets.length, sample: assets.slice(0, 3) }, null, 2));
}

main().catch((e) => {
  console.error(e.message, e.payload || '');
  process.exit(1);
});

街道预览墙的数据源,就应该是这份归一化后的 assets,而不是「国标列表页 + 私有协议列表页」拼 HTML。

3.7 同屏出流:国标通道与私有协议通道走同一套直播接口

现行设备直播模块(不要翻旧版协议)常用路径:

能力方法产物
创建 HLSbindDeviceLive指定码流 HLS
查询全量 HLSgetLiveStreamInfo主/辅 × HTTP/HTTPS
创建 FLVcreateDeviceFlvLiveflv / flvHD(实时或回放)
// 04-live-unified.js
const { callOpenApi } = require('./openapi-client');

async function ensureHls(appId, appSecret, token, deviceId, channelId, streamId = 1) {
  // streamId: 0 高清主码流;1 标清辅码流(大屏墙建议先用 1 省带宽)
  const created = await callOpenApi('bindDeviceLive', appId, appSecret, {
    token,
    deviceId,
    channelId: String(channelId),
    streamId,
    liveMode: 'proxy',
  });

  const info = await callOpenApi('getLiveStreamInfo', appId, appSecret, {
    token,
    deviceId,
    channelId: String(channelId),
  });

  return { created, info };
}

async function ensureFlv(appId, appSecret, token, deviceId, channelId) {
  return callOpenApi('createDeviceFlvLive', appId, appSecret, {
    token,
    deviceId,
    channelId: String(channelId),
    type: 'realTime',
  });
}

async function main() {
  const appId = process.env.APP_ID;
  const appSecret = process.env.APP_SECRET;
  const token = process.env.ACCESS_TOKEN;
  const deviceId = process.env.DEVICE_ID;
  const channelId = process.env.CHANNEL_ID || '0';

  const hls = await ensureHls(appId, appSecret, token, deviceId, channelId, 1);
  const flv = await ensureFlv(appId, appSecret, token, deviceId, channelId);

  console.log(
    JSON.stringify(
      {
        hlsStreams: hls.info?.streams?.map((s) => ({
          streamId: s.streamId,
          status: s.status,
          hls: s.hls,
        })),
        flv: flv.flv,
        flvHD: flv.flvHD,
      },
      null,
      2
    )
  );
}

main().catch((e) => {
  console.error(e.message, e.payload || '');
  process.exit(1);
});

值班墙前端最小播放示意(HLS 用原生 / hls.js;FLV 用 flv.js):

<!-- wall-player.html —— URL 必须由 BFF 下发,禁止把 appSecret 放进页面 -->
<video id="v" controls autoplay muted playsinline></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
  async function play(channelKey) {
    const { hlsUrl } = await fetch('/api/live?key=' + encodeURIComponent(channelKey)).then((r) => r.json());
    const video = document.getElementById('v');
    if (video.canPlayType('application/vnd.apple.mpegurl')) {
      video.src = hlsUrl;
    } else if (window.Hls?.isSupported()) {
      const hls = new Hls({ enableWorker: true });
      hls.loadSource(hlsUrl);
      hls.attachMedia(video);
    } else {
      alert('当前浏览器不支持 HLS 播放');
    }
  }
  play(new URLSearchParams(location.search).get('key') || 'east-gate-01');
</script>
// bff-live.js(Express 片段)—— 国标/私有协议同一入口
const express = require('express');
const { callOpenApi } = require('./openapi-client');

const app = express();
const assetIndex = new Map(); // key -> { deviceId, channelId }

app.get('/api/live', async (req, res) => {
  try {
    const asset = assetIndex.get(String(req.query.key));
    if (!asset) return res.status(404).json({ error: 'unknown channel' });

    const token = await callOpenApi('accessToken', process.env.APP_ID, process.env.APP_SECRET, {})
      .then((d) => d.accessToken);

    // 幂等:已创建过可直接 getLiveStreamInfo;首次会走 bindDeviceLive
    try {
      await callOpenApi('bindDeviceLive', process.env.APP_ID, process.env.APP_SECRET, {
        token,
        deviceId: asset.deviceId,
        channelId: String(asset.channelId),
        streamId: 1,
        liveMode: 'proxy',
      });
    } catch (e) {
      // 部分账号对「已存在直播」会返回业务码,可按实测白名单忽略后继续查询
      console.warn('bindDeviceLive warn', e.message);
    }

    const info = await callOpenApi('getLiveStreamInfo', process.env.APP_ID, process.env.APP_SECRET, {
      token,
      deviceId: asset.deviceId,
      channelId: String(asset.channelId),
    });

    const preferHttps = (info.streams || []).find(
      (s) => String(s.streamId) === '1' && String(s.hls || '').startsWith('https')
    );
    const any = preferHttps || (info.streams || [])[0];
    if (!any?.hls) return res.status(502).json({ error: 'no hls' });

    res.json({
      deviceId: asset.deviceId,
      channelId: asset.channelId,
      hlsUrl: any.hls,
      status: any.status,
    });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

app.listen(process.env.PORT || 3080);

到这里,东门国标 NVR 的某一通道西侧私有协议球机,在值班墙上已经是同一种「点开即播」体验。

3.8 联调清单(建议打印贴在实施群公告)

□ 企业认证已通过,国标项目已创建(新账号首次国标通常需要)
□ 国标设备:SIP 服务 ID / 域 / 域名或 IP / 端口 / 设备国标 ID / 密码 与控制台一致
□ 传输协议选 TCP(除非现场网络明确要求 UDP)
□ NVR 通道编码 ID 与控制台通道国标 ID 顺序一致
□ 国标设备控制台状态在线,直播菜单 HLS 可播
□ 私有协议设备:App 可预览 + 开发者账号 listDeviceDetailsByPage 可见
□ sign 自测通过;accessToken 可取
□ bindDeviceLive / getLiveStreamInfo 对国标通道、私有协议通道各跑通一路
□ BFF 不暴露 appSecret;直播 URL 按会话下发
□ 接入路数与带宽预警已在控制台配置(国标通道计入接入数)

3.9 踩坑实录(直接当素材,别当耻辱墙)

坑 1:NVR「在线」但画面是隔壁通道
原因:通道号与通道国标 ID 在设备 Web 与控制台两侧顺序不一致。
处理:导出控制台通道清单,按通道号重新对齐设备端编码 ID,改完等重新注册后再验流。

坑 2:设备填了域名却注册失败,改 IP 又好了
原因:部分老固件 DNS / SNI 能力弱,或安装地解析异常。
处理:在设备安装城市的网络里 ping 控制台给出的注册域名,确认可达 IP;设备不支持域名时改填 IP,并记录变更,避免日后控制台 IP 变更不知情。

坑 3:App 里能看,开放平台列表是 0
原因:配网成功只说明终端账号侧可见,不等于绑定进开发者应用
处理:补 bindDevice / 控制台绑定,再用 listDeviceDetailsByPage 验收。

坑 4:getLiveStreamInfo 为空
原因:未先 bindDeviceLive
处理:创建 → 再查询;网关做成「ensure」语义。

坑 5:签名偶发 SN1005
原因:nonce 5 分钟内重复,或服务器时间漂移超过约 5 分钟。
处理:每次请求新 UUID;服务器做 NTP。

坑 6:把国标当「另一套 API」开发了两周
原因:没读产品说明里的「现有开放平台协议支持国标设备」。
处理:媒体与控制统一走现行设备/直播 OpenAPI;国标差异只留在 SIP 接入与计费计量。


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

4.1 能力边界:不是所有国标能力都 1:1

按现行产品范围,开放平台对国标侧重点覆盖:实时预览、(部分厂商)语音对讲、本地/云录像回放、HLS/FLV 云直播、报警订阅、设备信息/云台等控制类 OpenAPI。实施前用你现场的具体型号做能力矩阵,不要假设「国标声明支持 = 云平台全功能可用」。

对讲、云台、回放在国标通道上的体验,可能弱于同平台私有协议机型——预览墙可以统一,高级功能要按 protocolTag 降级 UI。

4.2 资源与计量(别等到超限短信)

现行计费口径要点:

  • 接入数:国标通道与普通设备通道统一计入,每台国标通道算 1 路
  • 带宽:拉流带宽与普通通道统一统计,按实际值计;
  • 建议配置日流量上限 / 推流路数上限类预警,避免街道大屏「全开主码流」把额度打穿。

大屏墙实践:

  • 默认辅码流(streamId = 1);
  • 轮巡时销毁不可见路的播放器,避免隐形拉流;
  • HLS 适合墙上看,强互动再上 FLV / 轻应用低延迟栈。

4.3 安全与架构约束

  • appSecret、SIP 设备密码只放服务端或实施加密配置库;
  • 直播 URL 等同于「公开即可看」,值班墙外发链接必须带过期策略与权限;
  • 网关 BFF 做租户隔离:街道办、物业公司、分包运维不该共享一个未分割的设备列表视图;
  • 只使用现行 OpenAPI 网关与签名算法,明确跳过文档树里的「旧版本协议」

4.4 性能优化清单(混合接入特有)

做法
列表同步listDeviceDetailsByPage 分页缓存,变更用增量对账
Token进程内缓存 accessToken,到期前 1 小时刷新
直播liveToken / HLS 结果短缓存,避免每次点击都创建
国标注册抖动对「短时离线」做观察窗,避免预览墙疯狂闪红
批量利旧用控制台批量绑定 + 导出清单做设备侧施工单

五、总结与延伸

街道 / 物业视频网关的混合接入,本质不是「再写一套国标栈」,而是:

  1. 国标轨把存量 IPC/NVR 通过 SIP 注册利旧进云;
  2. 私有协议轨把新增点位绑定进同一开发者资产池;
  3. 消费轨只用一套现行 OpenAPI(accessToken → 资产列表 → bindDeviceLive / createDeviceFlvLive …)驱动预览墙与业务。

如果你正处在「东门不能拆、西侧必须加、领导还要同一张墙」的项目里,建议按本文顺序先跑通:
签名自测 → token → 一路国标 HLS → 一路私有协议 HLS → 归一化资产表 → BFF 出流
三周内做出可验收的同屏预览,通常比先争论「要不要自建 GB 平台」现实得多。

延伸阅读(在你使用的视频开放平台文档树中检索现行章节即可):

  • 国标 GB28181:产品介绍 / 总体流程 / 详细流程 / 各品牌 IPC·NVR 本地配置
  • 开发规范:域名、请求体、sign 标准案例
  • 设备管理:accessTokenbindDevicelistDeviceDetailsByPage
  • 设备直播:bindDeviceLivegetLiveStreamInfocreateDeviceFlvLive
  • 轻应用 / 云直播:预览墙要降延迟或要浏览器组件时再往下挖

这类以视频技术与安全能力为核心的开放平台,通常还会提供低代码播放组件与事件回调,方便第三方厂商和个人开发者把「设备进得来、列表收得拢、画面出得去」做成可交付的街道与物业应用。注册开发者账号、创建应用并开通国标接入与设备接入额度后,把本文的双轨清单跑成你现场的第一块混合预览墙——老国标不必下场,新点位也不必另起炉灶。