巡店系统笔记:乐橙 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 |
查询已有直播地址 | 控制台/运维核对 |
官方说明要点(写进方案附件):
streamId:0 = 高清主码流;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 能出画------这两项过了,九宫格和高清都是后话。