巡店系统笔记:乐橙 listDeviceDetailsByPage 台账 + bindDeviceLive 辅码流最小闭环

巡店系统笔记:乐橙 listDeviceDetailsByPage 台账 + bindDeviceLive 辅码流最小闭环

现行 OpenAPI · listDeviceDetailsByPage · bindDeviceLive · 勿用旧版本协议(不再维护) · 勿用 停维护的 deviceList 方法 · 巡店默认 streamId: 1(标清辅码流)


连锁运营总监甩过来一句话:「下周总部要远程巡 30 家店,大屏九宫格,别让我飞过去。」

同事第一反应是:每路都 bindDeviceLive高清主码流 。结果试了 6 路------办公室 Wi-Fi 先炸,开放平台配额也开始报警。

后来我们把顺序反过来:先用 listDeviceDetailsByPage 把门店设备台账做对,巡检只点播、默认辅码流 streamId=1。最小闭环一周能上线。下面是可抄作业的巡店笔记。


连锁巡店真正验收什么

角色 真实诉求 技术落点
运营 哪家店在线、哪家掉线 设备台账 + online 状态
督导 点开一家店能看清货架/收银台 短时直播地址
网管 别把总部出口打满 辅码流 + 同时路数上限
财务 API 别月初就用光 按需取流,禁止全量预拉

很多人一上来就纠结播放器,忽略了更致命的半步:台账不准,流地址再漂亮也是空转 。乐橙 App 能看某店画面,不代表该设备在你的 开发者资产池------分页列表查不到,巡店系统就显示不出来。

最小闭环:台账 → 点播辅码流

text 复制代码
┌─ 门店 IPC(已绑定开发者账号)─┐
└──────────────┬───────────────┘
               ▼
        乐橙云 openapi.lechange.cn
               │
     ┌─────────┴─────────┐
     │ listDeviceDetails │  ← 台账:店名/SN/在线/能力
     │ ByPage(分页)     │
     └─────────┬─────────┘
               │ 督导点击「巡检」
               ▼
     ┌───────────────────┐
     │ bindDeviceLive    │  ← streamId=1 辅码流
     │ 返回短时 HLS      │
     └─────────┬─────────┘
               ▼
        总部 H5 / 大屏播放器(用时再取流)
接口 作用 巡店用法
accessToken 鉴权 BFF 缓存,密钥不上前端
listDeviceDetailsByPage 设备详情分页 门店台账唯一真相源
bindDeviceLive 创建直播地址 streamId: 1 辅码流巡检 ;关键时再切 0
可选 getLiveStreamInfo 查询已有直播地址 控制台/运维核对

官方说明要点(写进方案附件):

  • streamId0 = 高清主码流;1 = 标清辅码流
  • 创建直播时后台会准备多类地址,本接口返回当前选择码流的 HTTP 直播信息(含 streams[].hls
  • 直播地址对外公开后他人可直接观看------必须鉴权下发、短时使用、勿写进前端仓库
  • 设备解绑会自动删除直播地址

和轻应用的边界 :要浏览器控件/对讲,可上 ImouPlayer + getKitToken;本文最小闭环用 HLS 辅码流 即可满足「总部看一眼」------先跑通,再升级。


Phase 0 · BFF 调用壳

javascript 复制代码
// server/lib/platform-call.js
import crypto from 'node:crypto';
import { randomUUID } from 'node:crypto';

function calcSign(time, nonce, secret) {
  return crypto
    .createHash('md5')
    .update(`time:${time},nonce:${nonce},appSecret:${secret}`, '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(`${method}: ${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;
}
bash 复制代码
APP_ID=lc0xxxxxxxx
APP_SECRET=your_secret
OPENAPI_BASE=https://openapi.lechange.cn/openapi
# 同时巡检路数硬顶(大屏也别贪)
MAX_CONCURRENT_LIVE=4

签名规则见 开发规范


Phase 1 · 门店台账:listDeviceDetailsByPage 拉全量

连锁场景设备一多,必须分页,并在你自己的库里挂上门店编码。

javascript 复制代码
// server/services/device-ledger.js
import { platformCall, adminToken } from '../lib/platform-call.js';

/** 拉取开发者资产池全部分页,组装巡店台账行 */
export async function fetchAllDevices({ pageSize = 50 } = {}) {
  const token = await adminToken();
  const rows = [];
  let page = 1;

  for (;;) {
    const data = await platformCall('listDeviceDetailsByPage', {
      token,
      page,
      pageSize,
      source: 'bindAndShare',
    });

    const list = data.deviceList ?? [];
    for (const d of list) {
      rows.push({
        deviceId: d.deviceId,
        name: d.deviceName,
        online: d.deviceStatus === 'online',
        channelId: String(d.channelList?.[0]?.channelId ?? 0),
        storeCode: guessStoreCode(d), // 见下:从设备名/备注映射
        ability: d.channelList?.[0]?.channelAbility ?? d.deviceAbility ?? '',
      });
    }

    const total = Number(data.totalCount ?? data.count ?? rows.length);
    if (list.length < pageSize || rows.length >= total) break;
    page += 1;
    // 轻微节流,避免扫库式打满配额
    await new Promise((r) => setTimeout(r, 200));
  }
  return rows;
}

/** 约定:设备名写成「S01-收银台」「S01-货架」便于台账分组 */
function guessStoreCode(d) {
  const m = String(d.deviceName || '').match(/^(S\d{2,})/i);
  return m ? m[1].toUpperCase() : 'UNMAPPED';
}

BFF 接口给前端「门店树」:

javascript 复制代码
// server/routes/stores.js
import express from 'express';
import { fetchAllDevices } from '../services/device-ledger.js';

const router = express.Router();

router.get('/api/v1/patrol/ledger', async (req, res) => {
  const devices = await fetchAllDevices();
  const byStore = new Map();

  for (const d of devices) {
    if (!byStore.has(d.storeCode)) {
      byStore.set(d.storeCode, { storeCode: d.storeCode, online: 0, offline: 0, cameras: [] });
    }
    const s = byStore.get(d.storeCode);
    s.total += 1;
    if (d.online) s.online += 1;
    s.cameras.push({
      deviceId: d.deviceId,
      name: d.name,
      online: d.online,
      channelId: d.channelId,
    });
  }

  res.json({
    updatedAt: new Date().toISOString(),
    stores: [...byStore.values()].sort((a, b) => a.storeCode.localeCompare(b.storeCode)),
  });
});

export default router;

踩坑 1 :设备名全是出厂默认名 → 台账无法按店分组。SOP:绑定后立刻改名 门店码-位置 ,或在你方 DB 维护 deviceId → storeCode 映射表(生产更推荐后者)。

踩坑 2 :只用第一页 page=1 → 30 店只看见前 50 台,运营投诉「系统少店」。

踩坑 3 :私人账号配网 → 列表缺店。必须进 开发者主账号 资产池。

台账验收脚本:

javascript 复制代码
// scripts/verify-ledger.js
import { fetchAllDevices } from '../server/services/device-ledger.js';

const rows = await fetchAllDevices();
const offline = rows.filter((r) => !r.online);
const unmapped = rows.filter((r) => r.storeCode === 'UNMAPPED');

console.table({
  total: rows.length,
  online: rows.length - offline.length,
  offline: offline.length,
  unmapped: unmapped.length,
});
if (unmapped.length) console.warn('未映射门店码的设备', unmapped.map((x) => x.deviceId));

Phase 2 · 点播辅码流:bindDeviceLive streamId=1

纪律:督导点「看」才取流;离开页面即丢弃地址;禁止启动时给 30 店批量 bind。

javascript 复制代码
// server/services/live.js
import { platformCall, adminToken } from '../lib/platform-call.js';

const inflight = new Map(); // deviceId -> { hls, exp, streamId }

export async function getPatrolLive({ deviceId, channelId = '0', hd = false }) {
  const streamId = hd ? 0 : 1; // 0 主码流;1 辅码流 ← 巡店默认 1
  const cacheKey = `${deviceId}:${channelId}:${streamId}`;
  const hit = inflight.get(cacheKey);
  // HLS 地址有时效,这里只做极短缓存,避免重复创建;不要按天缓存
  if (hit && Date.now() < hit.exp) return hit;

  const data = await platformCall('bindDeviceLive', {
    token: await adminToken(),
    deviceId,
    channelId: String(channelId),
    streamId,
    // liveMode 可省略或填 proxy(见官方文档)
  });

  const stream = (data.streams ?? []).find((s) => s.streamId === streamId)
    || data.streams?.[0];
  if (!stream?.hls) throw new Error('no hls in bindDeviceLive response');

  const payload = {
    deviceId: data.deviceId,
    channelId: data.channelId,
    streamId,
    liveToken: data.liveToken,
    liveStatus: data.liveStatus,
    hls: stream.hls,
    coverUrl: stream.coverUrl,
    exp: Date.now() + 60 * 1000, // 本地提示用;真正过期以平台为准,用时失败再刷新
  };
  inflight.set(cacheKey, payload);
  return payload;
}

并发路数闸门 + HTTP 接口:

javascript 复制代码
// server/routes/live.js
import express from 'express';
import { getPatrolLive } from '../services/live.js';

const router = express.Router();
const activeSessions = new Set(); // 简化:进程内计数;多实例请换 Redis

router.post('/api/v1/patrol/live', async (req, res) => {
  const { deviceId, channelId = '0', hd = false } = req.body || {};
  if (!deviceId) return res.status(400).json({ msg: 'deviceId required' });

  // TODO: 校验登录督导是否有权看该门店

  const max = Number(process.env.MAX_CONCURRENT_LIVE || 4);
  if (activeSessions.size >= max) {
    return res.status(429).json({ msg: `同时巡检不超过 ${max} 路,请先关闭一路` });
  }

  try {
    const live = await getPatrolLive({ deviceId, channelId, hd: Boolean(hd) });
    activeSessions.add(deviceId);
    res.json({
      hls: live.hls,
      coverUrl: live.coverUrl,
      streamId: live.streamId,
      tip: live.streamId === 1 ? '辅码流(巡店推荐)' : '主码流(高清,慎用)',
    });
  } catch (e) {
    res.status(502).json({ msg: e.message });
  }
});

router.post('/api/v1/patrol/live/release', (req, res) => {
  const { deviceId } = req.body || {};
  if (deviceId) activeSessions.delete(deviceId);
  res.json({ ok: true, active: activeSessions.size });
});

export default router;

官方返回里关注这些字段:

json 复制代码
{
  "liveToken": "...",
  "liveStatus": 1,
  "streams": [
    {
      "streamId": 1,
      "hls": "http://.../*.m3u8",
      "coverUrl": "http://.../*.jpg"
    }
  ]
}

Phase 3 · 总部巡店页(最小前端)

用任意 HLS 播放器即可(hls.js / 浏览器原生)。示例:

html 复制代码
<!-- public/patrol.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>连锁巡店</title>
  <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; background: #111; color: #eee; }
    .layout { display: grid; grid-template-columns: 280px 1fr; min-height: 100vh; }
    aside { border-right: 1px solid #333; overflow: auto; padding: 12px; }
    .cam { padding: 8px; border-radius: 8px; cursor: pointer; margin-bottom: 6px; background: #1a1a1a; }
    .cam.off { opacity: 0.45; }
    .cam:hover { background: #222; }
    main { padding: 16px; }
    video { width: 100%; max-width: 960px; background: #000; border-radius: 8px; }
    .bar { margin: 12px 0; display: flex; gap: 8px; align-items: center; }
    button { padding: 8px 14px; border: 0; border-radius: 6px; background: #FF6600; color: #fff; font-weight: 600; }
  </style>
</head>
<body>
  <div class="layout">
    <aside id="ledger">加载台账...</aside>
    <main>
      <div class="bar">
        <span id="title">请选择摄像机</span>
        <button id="hd" type="button" hidden>切高清</button>
        <button id="sd" type="button" hidden>回辅码流</button>
        <button id="stop" type="button" hidden>停止</button>
      </div>
      <video id="v" controls playsinline></video>
      <p id="tip" style="color:#aaa;font-size:13px;"></p>
    </main>
  </div>
  <script>
    let current = null;
    let hls = null;

    async function loadLedger() {
      const { stores } = await fetch('/api/v1/patrol/ledger').then((r) => r.json());
      const root = document.getElementById('ledger');
      root.innerHTML = '';
      for (const s of stores) {
        const h = document.createElement('div');
        h.innerHTML = `<strong>${s.storeCode}</strong> <small>${s.online}/${s.total} 在线</small>`;
        root.appendChild(h);
        for (const c of s.cameras) {
          const el = document.createElement('div');
          el.className = 'cam' + (c.online ? '' : ' off');
          el.textContent = c.name || c.deviceId;
          el.onclick = () => play(c, false);
          root.appendChild(el);
        }
      }
    }

    async function play(cam, hd) {
      await stop(false);
      current = cam;
      document.getElementById('title').textContent = cam.name || cam.deviceId;
      document.getElementById('hd').hidden = false;
      document.getElementById('sd').hidden = false;
      document.getElementById('stop').hidden = false;

      const live = await fetch('/api/v1/patrol/live', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          deviceId: cam.deviceId,
          channelId: cam.channelId,
          hd,
        }),
      }).then(async (r) => {
        const j = await r.json();
        if (!r.ok) throw new Error(j.msg || r.status);
        return j;
      });

      document.getElementById('tip').textContent = live.tip + ' · 离开请点停止以释放并发名额';
      const video = document.getElementById('v');
      if (Hls.isSupported()) {
        hls = new Hls({ enableWorker: true, lowLatencyMode: false });
        hls.loadSource(live.hls);
        hls.attachMedia(video);
      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        video.src = live.hls;
      } else {
        alert('当前浏览器不支持 HLS');
      }
    }

    async function stop(resetUi = true) {
      if (hls) { hls.destroy(); hls = null; }
      const video = document.getElementById('v');
      video.removeAttribute('src');
      video.load();
      if (current) {
        await fetch('/api/v1/patrol/live/release', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ deviceId: current.deviceId }),
        });
      }
      if (resetUi) {
        current = null;
        document.getElementById('hd').hidden = true;
        document.getElementById('sd').hidden = true;
        document.getElementById('stop').hidden = true;
        document.getElementById('tip').textContent = '';
      }
    }

    document.getElementById('hd').onclick = () => current && play(current, true);
    document.getElementById('sd').onclick = () => current && play(current, false);
    document.getElementById('stop').onclick = () => stop(true);
    window.addEventListener('beforeunload', () => { if (current) navigator.sendBeacon?.('/api/v1/patrol/live/release'); });

    loadLedger().catch((e) => {
      document.getElementById('ledger').textContent = e.message;
    });
  </script>
</body>
</html>

踩坑 4 :页面加载时给所有 online 设备 bindDeviceLive → 配额与带宽双爆。巡店是 点播 ,不是监控墙默认全开。

踩坑 5 :把 HLS URL 存进数据库当永久地址 → 过期黑屏。应 用时创建/刷新

踩坑 6 :辅码流嫌糊,全员切 streamId=0 → 又回到开头的事故。正确做法:默认 1,单路按需升 0,升之前释放其他路


Phase 4 · 巡店验收清单(可贴运营群)

text 复制代码
□ verify-ledger.js:门店数、在线数与实际一致;UNMAPPED=0
□ 点播 1 路辅码流:数秒内出画
□ 同时打开超过 MAX_CONCURRENT_LIVE:返回 429
□ 停止/关闭页:并发计数下降
□ 单路切高清成功,且提示「主码流慎用」
□ 前端无 appSecret;直播 URL 不入库长期保存
□ (可选)离线门店在台账标红,督导先催店长通电/网络

1. 带宽与配额估算(给网管看的)

策略 效果
默认辅码流 单路码率显著低于主码流,九宫格才可能存活
并发硬顶 4~6 防止「全开 30 路」的误操作
台账缓存 1~5 分钟 降低 listDeviceDetailsByPage 频率
取流失败再刷 token/地址 避免无意义重试打满配额

关注 我的资源 月度调用量;巡店高峰可把「台账刷新」与「取流」分到不同时间窗。

2. 安全

text 复制代码
□ 直播地址等同于「谁有链接谁能看」------必须登录态 + 门店权限校验
□ 日志打印 HLS 时做截断,防链接泄露进 ELK 被捞走
□ 督导账号按区域授权,禁止全国一张网随便点
□ HTTPS 部署总部入口;内网大屏也走同一 BFF

3. 何时升级,而不是继续堆 HLS

信号 方向
要低延迟对讲/巡店喊话 OpenSDK轻应用
要动检异常门店置顶 setMessageCallback 喂给巡店台账
要更精致的 Web 控件 轻应用 ImouPlayer(kitToken)
多租户加盟商隔离 子账号/托管与设备授权表

4. 合同话术(减少扯皮)

本期交付:基于乐橙 OpenAPI 的门店设备台账(分页列表)与远程巡检点播(默认标清辅码流)。

不在本期:永久公开直播地址、无限制九宫格高清、现场配网施工。

并发路数与码流策略写入运维手册,变更需网管确认。


本文结论

连锁远程巡店的最小闭环只有两步,但顺序不能反:

text 复制代码
1) listDeviceDetailsByPage → 门店设备台账(在线、分组、可点)
2) bindDeviceLive(streamId=1) → 点播辅码流巡检(按需升主码流)

先台账、后取流;先辅码流、后高清;先点播、后拼墙------带宽、配额、督导体验会一起变好。

如果你正在做连锁/加盟远程巡店,先在 乐橙开放平台 open.imou.com 创建应用,把各店设备绑进开发者资产池并统一命名或映射门店码,用本文的 verify-ledger.js 验收台账,再打开辅码流点播页。

乐橙开放平台以视频技术与安全 为核心,开放 OpenAPI、轻应用、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频场景应用------从门店台账到远程巡检,同一套云能力就能拼出可上线的最小闭环,再按需叠加告警与对讲。

注册入口:https://open.imou.com/?article_id=cjqptKjME1Yl9GJM

建好应用后,今晚只做一件事:确认分页台账门店数与真实一致,并且 默认 streamId=1 能出画------这两项过了,九宫格和高清都是后话。

相关推荐
曦尧1 小时前
开源后台编程 Agent 系统 background-agents:架构拆解与落地判断
ai·自动化
马里马里奥-1 小时前
大模型应用开发笔记04:LangChain 工具(Tool)与中间件(Middleware)详解
笔记·中间件·langchain
apihz2 小时前
全球域名 WHOIS 信息实时查询免费 API 接口教程,支持1000+后缀
android·网络·网络协议·tcp/ip·apache·域名·whois
keke.shengfengpolang2 小时前
自动化考什么证书对就业有帮助?
运维·自动化
酣大智3 小时前
OSPF 虚链路实验
网络·ospf
程序员吕洞宾3 小时前
开源多维表格SmartTable V1.6.1:工作流画布,让自动化工作流配置更直观、更方便
java·开源·自动化
鲲穹AI全能助理3 小时前
实用音频合成工具分享:灵境配音 使用场景与功能说明
ffmpeg·音视频
珠海西格电力3 小时前
西格电力零碳园区管理系统:核心功能全解析,赋能园区低碳智能化落地
大数据·运维·网络·人工智能·信息可视化·能源
ZPC82104 小时前
model bingxing
网络·人工智能·网络协议·机器人