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

一、九宫格还没亮,办公室网先炸了

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

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

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


二、连锁巡店真正验收什么

2.1 四个角色,四件不同的事

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

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

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

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

写进方案附件的官方语义:

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

和轻应用的边界 :要浏览器控件/对讲,可上 Web 轻应用播放器 + 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=your_app_id
APP_SECRET=your_secret
OPENAPI_BASE=https://YOUR_REGION_GATEWAY/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, total: 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 店只看见前 N 台,运营投诉「系统少店」。

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

踩坑 3′nonce 复用触发 SN1005 ------ 每次请求新 UUID。

台账验收脚本:

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();

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);
  if (hit && Date.now() < hit.exp) return hit;

  const data = await platformCall('bindDeviceLive', {
    token: await adminToken(),
    deviceId,
    channelId: String(channelId),
    streamId,
  });

  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;
}

并发路数闸门:

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

const router = express.Router();
const activeSessions = new Set();

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 · 总部巡店页(最小前端)

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; }
    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);

    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 不入库长期保存
□ (可选)离线门店在台账标红,督导先催店长通电/网络

四、带宽、安全、升级边界

4.1 带宽与配额

策略 效果
默认辅码流 单路码率显著低于主码流
并发硬顶 4~6 防止「全开 30 路」
台账缓存 1~5 分钟 降低分页列表频率
取流失败再刷新地址 避免无意义重试打满配额

4.2 安全

text 复制代码
□ 直播地址等同于「谁有链接谁能看」------必须登录态 + 门店权限校验
□ 日志打印 HLS 时做截断
□ 督导账号按区域授权
□ 总部入口走 HTTPS;内网大屏也走同一 BFF
□ 区域:App 区域、API 网关、账号注册地不一致时,常见「凭证对、列表空」

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

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

4.4 合同话术

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

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

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


五、顺序不能反

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

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

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

如果你卡在「系统少店」还是「一点播就卡」,先拆两件事:分页台账是否拉全、是否在用主码流硬刚九宫格------多数巡店事故,不是播放器选错,是 顺序反了

相关推荐
Dong先生2 小时前
Harness vibecoding:从 PRD 到可交付代码的结构化工法
后端
雪隐3 小时前
用Flutter做背单词APP-03为了给单词库塞满数据,我让AI给我打了上万份工
前端·人工智能·后端
苏三说技术3 小时前
为什么越来越多的人使用PostgreSQL?
后端
Csvn3 小时前
📊 SQL 入门 Day 1:SELECT & WHERE — 数据查询的基本功
后端·sql
不一样的少年_4 小时前
不用框架,手搓 AI Agent:(一) 先让它跑起来
前端·后端·agent
用户526835677904 小时前
《拒绝当背锅侠!写了个 WebHook 中间件,让局域网硬件在服务挂掉时“喊”出来》
后端
云技纵横4 小时前
线上报 Deadlock,怎么从日志里找出是哪两条 SQL 打架?
后端
卷无止境4 小时前
Python数据进阶专题:用声明式验证让数据管道告别"惊喜"
后端·python·数据分析
glartchen4 小时前
win11下使用 WSL2 部署kali 虚拟机(不踩坑版)
后端