工程后台笔记:乐橙多工地集中看,国标/乐橙设备混合 + 轻应用统一嵌入

工程后台笔记:乐橙多工地集中看,国标/乐橙设备混合 + 轻应用统一嵌入


周五下午,安全总监在群里甩一句:「东区塔吊下现在有没有人?截图发我。」

现场同学打开 A 品牌国标 NVR 的客户端,又切到 B 工地的乐橙云摄像机 App,再翻 Excel 对 SN——三分钟过去,群里还在「稍等」。真正的问题不是不会看监控,是 多工地、多协议设备没有进你们自己的项目看板

后来我们在 乐橙开放平台 把国标 GB28181 设备与乐橙云摄像机都收进同一开发者资产池,用 getKitToken + Web 轻应用 ImouPlayer 嵌进项目管理页。下面是可落地的「混合设备 + 统一嵌入」笔记。


工地现场的真实设备图谱

一个在建项目很少只有一种摄像头:

来源典型形态进云方式播放入口
既有安防海康 / 大华 / 宇视等国标 IPC、NVRGB28181 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~50count 是本页条数。

// 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

接口协议见轻应用文档中的 getKitTokentype0 全部 / 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:播放器 tokenaccessToken → 黑屏。轻应用只要 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
今晚先让东区塔吊那一路在项目页出画,比再开一次「要不要统一平台」的会更有用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值