验收那天,街道信息中心的领导只问了一句:「东门那 16 路老 NVR 还在吗?西侧新加的球机,能不能和东门同一张墙上看?」
你点头,心里却发凉——东门是三年前按 GB/T 28181 挂到区平台的设备,西侧是这两周刚上的消费级/行业私有协议摄像机。两套账号、两套预览页、两套「设备是否在线」的口径。拆东门?预算没有;并行运维?值班室已经在骂「点哪个 App」。
那天晚上我们把目标改成一句话:老设备走国标 SIP 利旧进来,新点位走平台私有协议绑定进来,业务侧只认一套 deviceId + channelId,只调一套现行 OpenAPI。
下面这篇是街道 / 物业视频网关「混合接入」的完整落地笔记:可运行 Node.js、真实配置项、以及把踩坑写进正文的联调过程。
一、「利旧」不是口号,是验收口径
项目合同里写着「利旧」,甲方理解成「别让我们再买一批枪机」;技术标书里写着「国标兼容」,实施理解成「再搭一套 SIP 平台」。两边都没错,但拼在一起就会变成:
- 老 NVR 还能出图,却进不了你的 SaaS 设备列表;
- 新摄像头绑进了开发者账号,值班大屏却要切两个标签页;
- 同一条路「在线」,国标侧看 SIP 注册,私有协议侧看心跳,运维对账永远对不齐。
真正要命的不是协议多,而是资产模型分裂。混合接入要解决的,是把分裂收敛掉。
二、为什么街道/物业场景必须认真对待混合接入
2.1 场景真相:存量是国标,增量是私有协议
街道、社区、物业中控的典型资产结构是:
| 类型 | 常见形态 | 协议现实 | 业务诉求 |
|---|---|---|---|
| 存量 | 大华 / 海康 / 宇视等 IPC、NVR | GB/T 28181-2011/2016 | 不拆不换、少改 Web 配置 |
| 增量 | 互联网化摄像机、球机 | 厂商私有协议上云 | 配网快、App 可管、云能力齐 |
| 业务 | 值班预览墙、事件回放、简单云台 | 只要「一个列表、一套接口」 | 运维口径统一、可对账 |
国标解决的是「多厂商存量如何联网」;私有协议解决的是「新点位如何低成本上云」。街道项目往往两者同时存在——所以网关层必须是双轨汇聚,单轨消费。
2.2 平台侧能力边界(先划清,再设计)
主流视频开放平台对国标接入的产品定位,可以概括成四句话(对照现行「国标 GB28181 设备接入 / 产品介绍」):
- 利旧:存量 IPC/NVR 按 GB28181 配置 SIP,无需为上云改固件业务逻辑;
- 统一管理:国标设备与私有协议设备进入同一开发者资产池;
- 协议复用:国标通道上线后,无需为国标再写一套业务 API——预览、直播、回放、云台等走现有设备 OpenAPI / 云直播 OpenAPI;
- 传输偏好: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 可预览 | 「能看」≠「已进开放平台账号」 |
| 3 | bindDevice 或控制台/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 同屏出流:国标通道与私有协议通道走同一套直播接口
现行设备直播模块(不要翻旧版协议)常用路径:
| 能力 | 方法 | 产物 |
|---|---|---|
| 创建 HLS | bindDeviceLive | 指定码流 HLS |
| 查询全量 HLS | getLiveStreamInfo | 主/辅 × HTTP/HTTPS |
| 创建 FLV | createDeviceFlvLive | flv / 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 结果短缓存,避免每次点击都创建 |
| 国标注册抖动 | 对「短时离线」做观察窗,避免预览墙疯狂闪红 |
| 批量利旧 | 用控制台批量绑定 + 导出清单做设备侧施工单 |
五、总结与延伸
街道 / 物业视频网关的混合接入,本质不是「再写一套国标栈」,而是:
- 国标轨把存量 IPC/NVR 通过 SIP 注册利旧进云;
- 私有协议轨把新增点位绑定进同一开发者资产池;
- 消费轨只用一套现行 OpenAPI(
accessToken→ 资产列表 →bindDeviceLive/createDeviceFlvLive…)驱动预览墙与业务。
如果你正处在「东门不能拆、西侧必须加、领导还要同一张墙」的项目里,建议按本文顺序先跑通:
签名自测 → token → 一路国标 HLS → 一路私有协议 HLS → 归一化资产表 → BFF 出流。
三周内做出可验收的同屏预览,通常比先争论「要不要自建 GB 平台」现实得多。
延伸阅读(在你使用的视频开放平台文档树中检索现行章节即可):
- 国标 GB28181:产品介绍 / 总体流程 / 详细流程 / 各品牌 IPC·NVR 本地配置
- 开发规范:域名、请求体、
sign标准案例 - 设备管理:
accessToken、bindDevice、listDeviceDetailsByPage - 设备直播:
bindDeviceLive、getLiveStreamInfo、createDeviceFlvLive - 轻应用 / 云直播:预览墙要降延迟或要浏览器组件时再往下挖
这类以视频技术与安全能力为核心的开放平台,通常还会提供低代码播放组件与事件回调,方便第三方厂商和个人开发者把「设备进得来、列表收得拢、画面出得去」做成可交付的街道与物业应用。注册开发者账号、创建应用并开通国标接入与设备接入额度后,把本文的双轨清单跑成你现场的第一块混合预览墙——老国标不必下场,新点位也不必另起炉灶。