工程后台笔记:乐橙多工地集中看,国标/乐橙设备混合 + 轻应用统一嵌入
周五下午,安全总监在群里甩一句:「东区塔吊下现在有没有人?截图发我。」
现场同学打开 A 品牌国标 NVR 的客户端,又切到 B 工地的乐橙云摄像机 App,再翻 Excel 对 SN——三分钟过去,群里还在「稍等」。真正的问题不是不会看监控,是 多工地、多协议设备没有进你们自己的项目看板。
后来我们在 乐橙开放平台 把国标 GB28181 设备与乐橙云摄像机都收进同一开发者资产池,用 getKitToken + Web 轻应用 ImouPlayer 嵌进项目管理页。下面是可落地的「混合设备 + 统一嵌入」笔记。
工地现场的真实设备图谱
一个在建项目很少只有一种摄像头:
| 来源 | 典型形态 | 进云方式 | 播放入口 |
|---|---|---|---|
| 既有安防 | 海康 / 大华 / 宇视等国标 IPC、NVR | GB28181 SIP 注册 | 控制台在线后,走乐橙 OpenAPI |
| 临时点位 | 乐橙云摄像机 | 开放平台绑定 | 同一套列表 + 播放接口 |
| 甲方要求 | 「一个网页看完」 | — | 轻应用嵌入,别再发五个 App |
业务方要的不是「再买一套平台」,而是:
按工地过滤设备 → 点开即出画 → 权限按项目隔离
两条进池路径,一套播放出口
┌─ 国标 IPC/NVR ─┐ ┌─ 乐橙云摄像机 ─┐
│ 设备 Web 配 SIP │ │ App/开放平台绑定 │
└────────┬───────┘ └──────┬──────┘
▼ ▼
乐橙控制台国标项目 乐橙开发者资产池
└──────────┬─────────┘
▼
listDeviceDetailsByPage(统一列表)
│
▼
你的 BFF(按工地授权)
│
▼
getKitToken(缓存约 1h)
│
▼
乐橙轻应用 ImouPlayer(项目看板 iframe/组件)
| 能力 | 谁负责 | 说明 |
|---|---|---|
| 国标能否上线 | 控制台国标项目 + 设备端 SIP | 约 60s 内应出现注册状态 |
| 云机能否上线 | 绑定进开发者池 | App 能看 ≠ 开发者池有设备 |
| 统一列表 | listDeviceDetailsByPage | 勿用停维护的列表方法名 |
| 统一出画 | getKitToken + 轻应用 | kitToken ≠ accessToken |
| 工地权限 | 你的系统 | 云不替你做「项目维度 ACL」 |
边界:轻应用解决 Web 预览/回放/对讲嵌入;配网、国标 SIP 参数、企业认证开通国标项目,仍在控制台与设备 Web 完成。本文不替代安监平台的 AI 行为分析——先把「看得见」做成一条链路。
为什么选轻应用而不是每人装客户端
| 方案 | 多工地成本 | 混合设备 | 嵌入项目系统 |
|---|---|---|---|
| 各品牌原厂 App | 高 | 差 | 差 |
| 自研全量播放器 | 极高 | 中 | 好 |
| 开放平台列表 + 轻应用 | 低 | 好 | 好 |
文档约定:kitToken 有效期约 2 小时,建议在自有服务缓存约 1 小时;多线程解码需配置跨域嵌入响应头。这些会直接变成踩坑素材。
Step 0 · 调用壳与环境
// lib/platform-call.js
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';
export function calcSign(time, nonce, appSecret) {
return crypto
.createHash('md5')
.update(`time:${time},nonce:${nonce},appSecret:${appSecret}`, 'utf8')
.digest('hex');
}
export async function platformCall(method, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = randomUUID();
const body = {
system: {
ver: '1.0',
appId: process.env.APP_ID,
sign: calcSign(time, nonce, process.env.APP_SECRET),
time,
nonce,
},
id: randomUUID(),
params,
};
const res = await fetch(`${process.env.OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.result?.code !== '0') {
throw new Error(`[${json.result?.code}] ${json.result?.msg}`);
}
return json.result.data;
}
let cached = { token: null, exp: 0 };
export async function adminToken() {
if (Date.now() < cached.exp) return cached.token;
const { accessToken, expireTime } = await platformCall('accessToken', {});
cached = { token: accessToken, exp: Date.now() + (expireTime - 300) * 1000 };
return accessToken;
}
# .env.example
APP_ID=your_app_id
APP_SECRET=your_secret
OPENAPI_BASE=https://openapi.lechange.cn/openapi
# 并发预览上限(轻应用多路很吃 CPU/带宽)
MAX_LIVE_TILES=4
KIT_CACHE_SECONDS=3600
在 乐橙开放平台 创建应用后,控制台可拿到 appId / appSecret。国标需企业认证;乐橙设备需绑定进入开发者资产池。
Step 1 · 工地台账:先建「项目维度」,再挂设备
云侧列表是扁平的;工地码必须在你库里。
// config/sites.js
export const sites = {
SITE_EAST: { name: '东区主体', managers: ['u_zhang', 'u_li'] },
SITE_WEST: { name: '西区基坑', managers: ['u_wang'] },
SITE_TEMP: { name: '临建板房', managers: ['u_zhang'] },
};
/** deviceId → siteCode;国标机与云机同一张映射表 */
export const deviceSiteMap = {
// 'GB_OR_CLOUD_SN_001': 'SITE_EAST',
};
export function resolveSite(deviceId, deviceName) {
if (deviceSiteMap[deviceId]) return deviceSiteMap[deviceId];
const m = String(deviceName || '').match(/^(SITE_[A-Z0-9]+)/i);
return m ? m[1].toUpperCase() : 'UNMAPPED';
}
export function canViewSite(userId, siteCode) {
const site = sites[siteCode];
if (!site) return false;
return site.managers.includes(userId);
}
踩坑 A:只按「谁绑定的账号」授权 → 分包商互相看见别的工地。必须做 siteCode ACL。
Step 2 · 国标侧:控制台进池(工程步骤,先清单后代码)
完整步骤与名词表见乐橙文档 GB28181 详细流程。
国标设备不走乐橙 App 扫码绑定,而是按 GB28181 详细流程 SIP 注册进乐橙云。控制台侧最小清单:
① 企业认证通过 → 创建国标项目
② 绑定国标 IPC / NVR(可自动生成或自定义国标 ID)
③ 记下:SIP 服务 ID、SIP 域、SIP 服务器地址/端口、设备国标 ID、设备密码
④ 在设备 Web(海康/大华/宇视等)填入上述 SIP 参数并启用国标
⑤ 约 60 秒内在「国标设备管理」看到在线;可用控制台直播页先验 HLS
⑥ 确认计入接入数/带宽(国标通道与普通通道统一计量)
设备端配置时,SIP 域名/IP、端口一律以控制台当前项目为准(例如文档示例注册域 gbaccess-online-hz.imou.com,以控制台当前项目为准;不支持域名的设备改填控制台 IP)。多目国标机按文档按 NVR 方式接入。产品介绍见 GB28181 设备接入。
踩坑 B:设备 Web 配错 SIP 域 → 永远离线。先控制台验在线,再写业务代码。
踩坑 C:国标未企业认证就催开发 → 项目建不出来,联调空转。
Step 3 · 统一同步:listDeviceDetailsByPage
乐橙云摄像机绑定进池后,与国标在线设备一样,用分页列表拉进工地台账。pageSize 1~50;count 是本页条数。
// services/sync-site-devices.js
import { platformCall, adminToken } from '../lib/platform-call.js';
import { resolveSite } from '../config/sites.js';
/** 粗分来源:便于看板打标;精确以控制台国标列表为准 */
export function classifySource(device) {
const accessType = String(device.accessType || '');
const catalog = String(device.catalog || '');
// 国标接入后常见仍以 IPC/NVR 出现;用映射表 + 命名约定双保险
const site = resolveSite(device.deviceId, device.deviceName);
const taggedGb = /^GB[_-]/i.test(device.deviceName || '') || site.startsWith('SITE_');
return {
accessType,
catalog,
// 业务标签:gb | cloud | unknown —— 以你们录入为准
sourceTag: deviceSiteMapHint(device.deviceId) || (taggedGb ? 'mixed' : 'cloud'),
};
}
function deviceSiteMapHint(deviceId) {
// 可换成读 DB:devices.source_tag
return null;
}
export async function syncAllDevices({ pageSize = 50 } = {}) {
if (pageSize < 1 || pageSize > 50) throw new Error('pageSize 1..50');
const token = await adminToken();
const devices = [];
let page = 1;
for (;;) {
const data = await platformCall('listDeviceDetailsByPage', {
token,
page,
pageSize,
source: 'bindAndShare',
});
const list = data.deviceList ?? [];
if (!list.length) break;
for (const d of list) {
const ch0 = d.channelList?.[0] || {};
const siteCode = resolveSite(d.deviceId, d.deviceName);
const cls = classifySource(d);
devices.push({
deviceId: d.deviceId,
name: d.deviceName,
status: d.deviceStatus,
siteCode,
channelId: String(ch0.channelId ?? 0),
channelName: ch0.channelName || d.deviceName,
accessType: cls.accessType,
catalog: cls.catalog,
sourceTag: cls.sourceTag,
});
}
if (list.length < pageSize) break;
page += 1;
await new Promise((r) => setTimeout(r, 200));
}
return devices;
}
// scripts/run-sync-sites.js
import 'dotenv/config';
import { syncAllDevices } from '../services/sync-site-devices.js';
const list = await syncAllDevices();
console.table(
list.map((d) => ({
site: d.siteCode,
id: d.deviceId,
status: d.status,
ch: d.channelId,
accessType: d.accessType,
})),
);
console.log('UNMAPPED', list.filter((d) => d.siteCode === 'UNMAPPED').map((d) => d.deviceId));
踩坑 D:只拉 page=1 → 多工地必漏。
踩坑 E:国标在控制台在线,列表却没有 → 核对是否同一开发者账号/区域网关;未进资产视图前不要写播放。
Step 4 · BFF:按工地授权发 kitToken
接口协议见轻应用文档中的 getKitToken:type 为 0 全部 / 1 实时预览 / 2 回放 / 6 云台;kitToken 约 2 小时有效,建议自建缓存 1 小时。
// services/kit-token.js
import { platformCall, adminToken } from '../lib/platform-call.js';
const cache = new Map();
const TTL = Number(process.env.KIT_CACHE_SECONDS || 3600) * 1000;
export async function getKitTokenCached(deviceId, channelId = '0', type = '1') {
const key = `${deviceId}:${channelId}:${type}`;
const hit = cache.get(key);
if (hit && Date.now() < hit.exp) return hit.token;
const data = await platformCall('getKitToken', {
token: await adminToken(),
deviceId,
channelId: String(channelId),
// 0 全部;1 实时预览;2 回放;6 云台
type: String(type),
});
cache.set(key, { token: data.kitToken, exp: Date.now() + TTL });
return data.kitToken;
}
// server/site-bff.js
import express from 'express';
import 'dotenv/config';
import { syncAllDevices } from '../services/sync-site-devices.js';
import { getKitTokenCached } from '../services/kit-token.js';
import { canViewSite, sites } from '../config/sites.js';
const app = express();
app.use(express.json());
/** Demo:从 Header 取用户;生产接 SSO */
function currentUser(req) {
return req.header('x-user-id') || 'u_zhang';
}
let snapshot = [];
async function refresh() {
snapshot = await syncAllDevices();
}
await refresh();
setInterval(() => refresh().catch(console.error), 10 * 60 * 1000);
app.get('/api/v1/sites', (req, res) => {
const uid = currentUser(req);
const list = Object.entries(sites)
.filter(([code]) => canViewSite(uid, code))
.map(([code, s]) => ({ siteCode: code, name: s.name }));
res.json({ list });
});
app.get('/api/v1/sites/:siteCode/devices', (req, res) => {
const uid = currentUser(req);
const { siteCode } = req.params;
if (!canViewSite(uid, siteCode)) {
return res.status(403).json({ msg: '无权查看该工地' });
}
const list = snapshot.filter((d) => d.siteCode === siteCode);
res.json({
siteCode,
total: list.length,
online: list.filter((d) => d.status === 'online').length,
devices: list,
});
});
app.get('/api/v1/kit-token', async (req, res) => {
const uid = currentUser(req);
const { deviceId, channelId = '0', type = '1' } = req.query;
if (!deviceId) return res.status(400).json({ msg: 'deviceId required' });
const device = snapshot.find((d) => d.deviceId === deviceId);
if (!device) return res.status(404).json({ msg: '设备不在台账,请先同步' });
if (!canViewSite(uid, device.siteCode)) {
return res.status(403).json({ msg: '无权预览该设备' });
}
if (device.status !== 'online') {
return res.status(409).json({ msg: '设备离线,拒绝发播放凭证' });
}
const kitToken = await getKitTokenCached(deviceId, channelId, type);
res.json({
kitToken,
deviceId,
channelId: String(channelId),
siteCode: device.siteCode,
// 提示前端:勿缓存超过 1h;勿把 accessToken 当 token
});
});
app.listen(8790, () => console.log('site bff :8790'));
踩坑 F:播放器 token 填 accessToken → 黑屏。轻应用只要 getKitToken 返回的 kitToken。
踩坑 G:九宫格一进来批量预拉 9 个 kitToken → 配额与解码同时爆炸。应 点开/进入视口再取。
Step 5 · 前端:项目看板嵌入轻应用(多路受控)
从 乐橙开放平台 资源中心下载 Web 轻应用套件,将播放器 JS/CSS 与 Wasm 解码目录 放到 public/。构造器为 imouPlayer,参数见轻应用文档。下文示例使用 imouPlayer。
多线程解码常见需响应头(按套件文档启用):
// server/static-headers.js(挂在静态资源中间件)
export function lightAppHeaders(_req, res, next) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
}
<!-- public/site-wall.html —— 单工地宫格(受 MAX_LIVE_TILES 约束) -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>工地集中预览</title>
<link href="/player/player.css" rel="stylesheet" />
<script src="/player/player.js"></script>
<style>
body { margin:0; font-family: system-ui, sans-serif; background:#111; color:#eee; }
header { padding:12px 16px; display:flex; gap:12px; align-items:center; }
#grid { display:grid; grid-template-columns: repeat(2, 1fr); gap:8px; padding:8px; }
.tile { background:#1a1a1a; border-radius:8px; overflow:hidden; }
.tile h3 { margin:0; padding:8px 10px; font-size:13px; font-weight:600; }
.player { width:100%; aspect-ratio:16/9; background:#000; }
button { background:#ff6600; color:#fff; border:0; padding:8px 12px; border-radius:6px; }
</style>
</head>
<body>
<header>
<strong id="title">工地预览</strong>
<select id="site"></select>
<button id="refresh" type="button">刷新列表</button>
<span id="hint" style="color:#aaa;font-size:12px;"></span>
</header>
<div id="grid"></div>
<script>
const MAX = 4; // 与 BFF MAX_LIVE_TILES 对齐;九宫格请分页/轮播
const players = [];
async function loadSites() {
const { list } = await fetch('/api/v1/sites', {
headers: { 'x-user-id': 'u_zhang' },
}).then((r) => r.json());
const sel = document.getElementById('site');
sel.innerHTML = list.map((s) =>
`<option value="${s.siteCode}">${s.name}</option>`
).join('');
}
async function openSite() {
players.splice(0).forEach((p) => { try { p.destroy?.(); } catch (_) {} });
const siteCode = document.getElementById('site').value;
const data = await fetch(`/api/v1/sites/${siteCode}/devices`, {
headers: { 'x-user-id': 'u_zhang' },
}).then((r) => r.json());
document.getElementById('title').textContent =
`${siteCode} · 在线 ${data.online}/${data.total}`;
const online = data.devices.filter((d) => d.status === 'online').slice(0, MAX);
document.getElementById('hint').textContent =
data.devices.length > MAX
? `仅同时预览 ${MAX} 路,其余请点选切换`
: '';
const grid = document.getElementById('grid');
grid.innerHTML = '';
for (const cam of online) {
const tile = document.createElement('div');
tile.className = 'tile';
const pid = `p_${cam.deviceId.replace(/\W/g, '_')}`;
tile.innerHTML = `<h3>${cam.name || cam.deviceId}</h3><div class="player" id="${pid}"></div>`;
grid.appendChild(tile);
const { kitToken } = await fetch(
`/api/v1/kit-token?deviceId=${encodeURIComponent(cam.deviceId)}&channelId=${cam.channelId}`,
{ headers: { 'x-user-id': 'u_zhang' } },
).then((r) => r.json());
const el = document.getElementById(pid);
const w = el.clientWidth || 480;
const h = Math.round((w * 9) / 16);
const player = new imouPlayer({
id: pid,
width: w,
height: h,
deviceId: cam.deviceId,
channelId: Number(cam.channelId),
token: kitToken,
type: 1,
streamId: 1, // 工地 4G/弱网优先标清
WasmLibPath: '/WasmLib/',
code: cam.deviceId,
templateMode: 'mobile',
controls: true,
controlsConfig: ['play', 'volume', 'capture', 'fullScreen'],
handleError(err) {
tile.querySelector('h3').textContent +=
` · ${err?.errMsg || '失败'}`;
},
});
players.push(player);
}
}
document.getElementById('refresh').onclick = openSite;
document.getElementById('site').onchange = openSite;
loadSites().then(openSite);
</script>
</body>
</html>
可选:把上述页以 iframe 嵌进现有工程管理系统(进度/安全模块),只传 siteCode 与登录态——播放凭证仍只从你们 BFF 取,前端永远拿不到 appSecret。
Step 6 · 验收清单(按安全总监场景走)
① 国标:控制台设备在线 + 直播页能出画
② 云机:绑定进开发者池,listDeviceDetailsByPage 可见
③ run-sync-sites.js:UNMAPPED 清零;工地过滤正确
④ 用户 A 只能看 SITE_EAST;跨工地 kit-token 返回 403
⑤ 轻应用:kitToken 出画;误传 accessToken 应黑屏(反例)
⑥ 同时预览 ≤ MAX_LIVE_TILES;超出走切换而非全开
⑦ Wasm 路径与 COOP/COEP 头按套件要求配齐
踩坑 H:Wasm 目录没进 public → 解码失败。
踩坑 I:iframe 嵌套时跨域头冲突 → 先同域托管播放页,再嵌主系统。
踩坑 J:离线设备仍发 kitToken → 用户以为「系统坏了」;BFF 对离线直接 409。
资源计量(写进项目预算)
□ 国标通道与普通云通道统一计「接入数」
□ 拉流带宽按实际统计;多工地九宫格是成本放大器
□ 可为国标配置日流量/推流路数预警(控制台能力)
□ 标清 streamId=1 作为工地默认;高清仅单路放大时开
性能
□ kitToken 缓存 ~1h(官方建议;有效期约 2h)
□ 禁止页面加载时批量预取全部工地 token
□ 宫格上限 4 路起(桌面可调到 6,移动端 1~2)
□ 切后台 pause;离开工地 destroy 播放器
□ 同步列表 10~15 分钟一次即可,别秒级扫
安全与合规
□ 工地画面属敏感现场:按项目 RBAC,审计谁点开过哪路
□ 日志打 deviceId/siteCode,不打 kitToken 全文
□ appSecret 仅 BFF;播放页可公开域名但接口要鉴权
□ 分包账号到期立刻从 managers 移除
能力边界
□ 轻应用:预览 / 回放 / 对讲 / 抓图(视套件与 type)
□ 国标:先 SIP 在线,再谈 OpenAPI;别指望未注册机能 getKitToken
□ AI 安全帽/反光衣:另接智能分析,不在本文最小闭环
□ 原生 App 极致低延迟:可评估 OpenSDK;Web 集中看优先轻应用
本文结论
多工地集中看的最小正确姿势:
国标 SIP 进池 + 乐橙云摄像机绑定进池
→ listDeviceDetailsByPage 统一台账(按 siteCode)
→ BFF 鉴权后 getKitToken(缓存 1h)
→ Web 轻应用 ImouPlayer 嵌入项目看板(控制并发路数)
五个 App 来回切,安全总监的问题永远慢半拍;只有列表没有播放器,Excel 对完 SN 仍出不了画;有播放器却不做工地 ACL,分包商会看见不该看的基坑。
注册与下一步
如果你正在给总包/安监做「多工地一个网页看完」,先在 乐橙开放平台 open.imou.com 完成企业认证并创建国标项目,把国标机 SIP 拉进乐橙云,同时把临时点位的乐橙摄像机绑进同一开发者资产池;再用本文的 BFF + getKitToken + ImouPlayer 嵌进现有工程管理系统。
乐橙开放平台以视频技术与安全为核心,开放 OpenAPI、轻应用、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频场景应用——国标与乐橙设备可以共用一套列表和播放出口,工地集中看不必再装五个客户端。
注册入口:https://open.imou.com
今晚先让东区塔吊那一路在项目页出画,比再开一次「要不要统一平台」的会更有用。

295

被折叠的 条评论
为什么被折叠?



