一、九宫格还没亮,办公室网先炸了
连锁运营总监甩过来一句话:「下周总部要远程巡 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 |
查询已有直播地址 | 控制台/运维核对 |
写进方案附件的官方语义:
streamId:0 = 高清主码流;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) → 点播辅码流巡检(按需升主码流)
先台账、后取流;先辅码流、后高清;先点播、后拼墙------带宽、配额、督导体验会一起变好。
如果你卡在「系统少店」还是「一点播就卡」,先拆两件事:分页台账是否拉全、是否在用主码流硬刚九宫格------多数巡店事故,不是播放器选错,是 顺序反了。