明厨亮灶笔记:乐橙轻应用 H5 + 小程序插件,一套 BFF 出两张播放凭证
食安验收前夜,市场监管同事用手机小程序打开「明厨亮灶」,画面卡在转圈;店长那边 PC 大屏却已经在播后厨。
两边对接人互相甩锅:一个说 token 过期,一个说设备离线------其实设备好好的,是 Web 端和小程序端拿了两种不同的播放凭证,却当成同一种在用。
后来我们在 乐橙开放平台 把厨房机绑进同一开发者资产池,用 getKitToken 喂 Web 轻应用、用 createWeChatMiniProgramToken 喂小程序插件。下面是「一套台账、两张凭证」的双端同看笔记。
业务角色不同,终端不同
| 角色 | 终端 | 诉求 |
|---|---|---|
| 食安监管 / 公众 | 微信小程序 | 打开即看、少操作、免 live-player 资质更省事 |
| 店长 / 连锁运营 | PC / Pad 浏览器 | 多路巡检、回放、抓图、偶发对讲 |
| 你的中台 | BFF | 统一授权、审计「谁看过哪路厨房」 |
两端都是「看同一台乐橙厨房摄像机」,但 播放组件与鉴权字段不同 (见 轻应用 / 小程序插件):
| 端 | 组件形态 | 鉴权接口 | 凭证字段 |
|---|---|---|---|
| Web 轻应用 | ImouPlayer + Wasm | getKitToken |
kitToken(约 2h,建议缓存 1h) |
| 微信小程序插件 | imou-player |
createWeChatMiniProgramToken |
miniToken(最长约 24h) |
text
错:小程序里塞 kitToken;或 Web 播放器塞 accessToken / miniToken
对:BFF 按 client=web|mp 分别签发;设备列表两端共用
为什么明厨优先插件,而不是自建 live-player
相对「自建 live-player + FLV/RTMP」路径,乐橙小程序插件 常见卖点:
text
□ 主体小程序往往无需再单独申请微信 live-player 资质(以插件文档说明为准)
□ 预览 / 对讲 / 回放 / 云台 / 抓图可按需开
□ 要求设备编码为 H264(插件实时预览前提,售前必验)
自建 live-player 仍可用 createDeviceFlvLive 等接口,但资质与联调成本更高------明厨亮灶首期更建议 插件 + 轻应用双端同看。
架构:一套台账,两张凭证
text
┌─ 乐橙厨房 IPC(绑定进开发者资产池)─┐
└────────────────┬──────────────────┘
▼
listDeviceDetailsByPage
│
▼
你的 BFF
┌────────┴────────┐
▼ ▼
getKitToken createWeChatMiniProgramToken
kitToken miniToken
▼ ▼
ImouPlayer(Web) imou-player(小程序插件)
(店长大屏/后台) (监管/公众端)
| 能力 | 谁负责 | 文档 |
|---|---|---|
| 设备在线与通道 | 绑定 + 列表验收 | 接入指南 · listDeviceDetailsByPage |
| 门店/厨房 ACL | 你的账号体系 | --- |
| Web 出画 | getKitToken + ImouPlayer |
轻应用 |
| 小程序出画 | createWeChatMiniProgramToken + 插件 |
小程序插件 |
| 编码是否 H264 | 售前验机 | 插件文档前提 |
边界 :轻应用 不做配网 ;小程序插件 不等于 半屏跳转方案(半屏是另一条低代码路径)。本文聚焦「插件 + 轻应用」双端同看。
Step 0 · 调用壳与环境
在 乐橙开放平台 创建应用后,控制台可拿到 appId / appSecret。厨房摄像机需按接入指南进入开发者资产池。
javascript
// 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,
};
// 国内常见:https://openapi.lechange.cn/openapi
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;
}
bash
# .env.example
APP_ID=your_app_id
APP_SECRET=your_secret
OPENAPI_BASE=https://openapi.lechange.cn/openapi
# 厨房设备白名单,逗号分隔
KITCHEN_DEVICE_IDS=SN_KITCHEN_01,SN_KITCHEN_02
Step 1 · 厨房台账:两端共用同一列表
javascript
// config/kitchens.js
export const kitchens = {
// shopId → 允许观看的 deviceId 列表
SHOP_A: ['SN_KITCHEN_01'],
SHOP_B: ['SN_KITCHEN_02'],
};
export function canView(shopId, deviceId) {
return (kitchens[shopId] || []).includes(deviceId);
}
javascript
// services/list-kitchen-devices.js
import { platformCall, adminToken } from '../lib/platform-call.js';
export async function listKitchenDevices({ pageSize = 50 } = {}) {
const token = await adminToken();
const allow = new Set(
(process.env.KITCHEN_DEVICE_IDS || '').split(',').filter(Boolean),
);
const out = [];
let page = 1;
for (;;) {
// 文档:https://open.imou.com/document/pages/683248/
const data = await platformCall('listDeviceDetailsByPage', {
token,
page,
pageSize,
source: 'bindAndShare',
});
const list = data.deviceList ?? [];
if (!list.length) break;
for (const d of list) {
if (allow.size && !allow.has(d.deviceId)) continue;
const ch0 = d.channelList?.[0] || {};
out.push({
deviceId: d.deviceId,
name: d.deviceName,
status: d.deviceStatus,
channelId: String(ch0.channelId ?? 0),
productId: d.productId || null,
// 明厨插件实时预览要求 H264:能力集仅作提示,最终以实机为准
ability: d.deviceAbility || ch0.channelAbility || '',
});
}
if (list.length < pageSize) break;
page += 1;
}
return out;
}
踩坑 A :监管小程序与店长后台各维护一份 SN ------ 验收必穿帮。必须同一 BFF 列表。
踩坑 B :乐橙 App 能看、列表为空 ------ 未绑进开发者资产池(见接入指南)。
Step 2 · 双凭证服务:kitToken / miniToken 分开缓存
javascript
// services/play-tokens.js
import { platformCall, adminToken } from '../lib/platform-call.js';
const kitCache = new Map();
const miniCache = new Map();
/** Web 轻应用:kitToken ≈ 2h,缓存 1h · https://open.imou.com/document/pages/92c72c/ */
export async function getKitTokenCached(deviceId, channelId = '0', type = '1') {
const key = `${deviceId}:${channelId}:${type}`;
const hit = kitCache.get(key);
if (hit && Date.now() < hit.exp) return hit.token;
const data = await platformCall('getKitToken', {
token: await adminToken(),
deviceId,
channelId: String(channelId),
type: String(type), // 0 全部;1 预览;2 回放;6 云台
});
kitCache.set(key, { token: data.kitToken, exp: Date.now() + 3600 * 1000 });
return data.kitToken;
}
/**
* 小程序插件:miniToken · https://open.imou.com/document/pages/0b44e4/
* expireTime 为剩余秒数;最长约 24h;使用超 12h 再请求会发新钥,新旧短期内均可
* IoT 设备若 productId 有值需带上
*/
export async function getMiniTokenCached(deviceId, channelId = '0', productId) {
const key = `${deviceId}:${channelId}:${productId || ''}`;
const hit = miniCache.get(key);
if (hit && Date.now() < hit.exp) return hit.token;
const params = {
token: await adminToken(),
deviceId,
channelId: String(channelId),
};
if (productId) params.productId = productId;
const data = await platformCall('createWeChatMiniProgramToken', params);
const ttlSec = Math.max(60, Number(data.expireTime || 7200) - 300);
miniCache.set(key, {
token: data.miniToken,
exp: Date.now() + ttlSec * 1000,
});
return data.miniToken;
}
javascript
// server/kitchen-bff.js
import express from 'express';
import 'dotenv/config';
import { listKitchenDevices } from '../services/list-kitchen-devices.js';
import { getKitTokenCached, getMiniTokenCached } from '../services/play-tokens.js';
import { canView } from '../config/kitchens.js';
const app = express();
/** Demo:Header 带门店;生产接 SSO */
function shopId(req) {
return req.header('x-shop-id') || 'SHOP_A';
}
app.get('/api/v1/kitchen/devices', async (req, res) => {
const sid = shopId(req);
const all = await listKitchenDevices();
res.json({
shopId: sid,
list: all.filter((d) => canView(sid, d.deviceId)),
});
});
app.get('/api/v1/kitchen/play-token', async (req, res) => {
const sid = shopId(req);
const { deviceId, channelId = '0', client } = req.query;
if (!deviceId) return res.status(400).json({ msg: 'deviceId required' });
if (!canView(sid, deviceId)) return res.status(403).json({ msg: '无权查看该厨房' });
const devices = await listKitchenDevices();
const meta = devices.find((d) => d.deviceId === deviceId);
if (!meta) return res.status(404).json({ msg: '设备不在台账' });
if (meta.status !== 'online') {
return res.status(409).json({ msg: '设备离线' });
}
if (client === 'web') {
const kitToken = await getKitTokenCached(deviceId, channelId, '1');
return res.json({ client: 'web', kitToken, deviceId, channelId });
}
if (client === 'mp') {
const miniToken = await getMiniTokenCached(
deviceId,
channelId,
meta.productId || undefined,
);
return res.json({ client: 'mp', miniToken, deviceId, channelId });
}
return res.status(400).json({ msg: 'client 必须是 web 或 mp' });
});
app.listen(8793, () => console.log('kitchen dual-end bff :8793'));
踩坑 C :把 kitToken 传给小程序插件 → 校验失败。
踩坑 D :把 accessToken 传给 ImouPlayer → 黑屏。
踩坑 E :页面加载时批量预拉所有厨房流地址 → WebSocket 404 / 流源超时。原则:点开再取凭证。
Step 3 · Web 端:ImouPlayer 出画(店长大屏)
从 乐橙开放平台资源中心 下载 Web 轻应用套件,放入 player.js / player.css / WasmLib 。构造器名以套件为准(常见为 ImouPlayer)。
多线程解码常见响应头(见轻应用文档):
javascript
// middleware/coop-coep.js
export function lightAppHeaders(_req, res, next) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
}
html
<!-- public/kitchen-web.html -->
<!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; }
#list { display:flex; gap:8px; padding:12px; flex-wrap:wrap; }
button { background:#ff6600; color:#fff; border:0; padding:8px 12px; border-radius:6px; }
#player-root { width:min(960px,100%); margin:0 auto; aspect-ratio:16/9; background:#000; }
</style>
</head>
<body>
<div id="list"></div>
<div id="player-root"></div>
<script>
let player;
async function loadList() {
const { list } = await fetch('/api/v1/kitchen/devices', {
headers: { 'x-shop-id': 'SHOP_A' },
}).then((r) => r.json());
const box = document.getElementById('list');
box.innerHTML = '';
list.forEach((d) => {
const b = document.createElement('button');
b.textContent = `${d.name || d.deviceId} (${d.status})`;
b.onclick = () => play(d);
box.appendChild(b);
});
}
async function play(cam) {
if (cam.status !== 'online') return alert('离线');
const { kitToken, deviceId, channelId } = await fetch(
`/api/v1/kitchen/play-token?client=web&deviceId=${encodeURIComponent(cam.deviceId)}&channelId=${cam.channelId}`,
{ headers: { 'x-shop-id': 'SHOP_A' } },
).then((r) => r.json());
try { player?.destroy?.(); } catch (_) {}
const el = document.getElementById('player-root');
const w = el.clientWidth || 960;
const h = Math.round((w * 9) / 16);
// 构造器名以你下载的轻应用套件为准
player = new ImouPlayer({
id: 'player-root',
width: w,
height: h,
deviceId,
channelId: Number(channelId),
token: kitToken,
type: 1,
streamId: 1, // 明厨公网多并发优先标清
WasmLibPath: '/WasmLib/',
code: deviceId,
templateMode: 'pc',
controls: true,
controlsConfig: ['play', 'volume', 'capture', 'resolution', 'fullScreen'],
});
}
document.addEventListener('visibilitychange', () => {
if (!player) return;
if (document.hidden) player.pause();
else player.start();
});
loadList();
</script>
</body>
</html>
踩坑 F :Wasm 路径错 → Unexpected token '<'。用 WasmLibPath 指到 public。
踩坑 G:多路同开卡顿------明厨大屏建议同时 ≤2~4 路。
Step 4 · 小程序端:乐橙插件 + miniToken(监管/公众)
完整说明见 小程序插件对接开发。
4.1 申请并声明插件
微信公众平台 → 设置 → 第三方设置 → 插件管理 → 添加插件。
provider AppID 以文档为准(当前文档常见为 wx26dd070c090dfbdf,若控制台有变更以最新为准)。
json
// app.json
{
"plugins": {
"myPlugin": {
"version": "1.0.0",
"provider": "wx26dd070c090dfbdf"
}
}
}
json
// pages/kitchen/kitchen.json
{
"usingComponents": {
"imou-player": "plugin://myPlugin/imou-player"
}
}
4.2 页面:点开再取 miniToken
xml
<!-- pages/kitchen/kitchen.wxml -->
<view class="list">
<block wx:for="{{devices}}" wx:key="deviceId">
<button data-id="{{item.deviceId}}" data-ch="{{item.channelId}}" bindtap="onPlay">
{{item.name}} · {{item.status}}
</button>
</block>
</view>
<imou-player
wx:if="{{show}}"
miniToken="{{miniToken}}"
deviceId="{{deviceId}}"
channelId="{{channelId}}"
liveType="real"
functionConfig="snapShot"
playConfig="{{playConfig}}"
width="{{width}}"
height="210"
objectFit="contain"
bind:handleEvent="onPlayerEvent"
/>
javascript
// pages/kitchen/kitchen.js
const { windowWidth } = wx.getSystemInfoSync();
Page({
data: {
devices: [],
show: false,
miniToken: '',
deviceId: '',
channelId: '0',
width: Math.max(260, windowWidth),
playConfig: { resolution: 'SD', voice: 'on', fullScreen: 'off' },
},
onShow() {
this.loadDevices();
},
async loadDevices() {
const res = await new Promise((resolve, reject) => {
wx.request({
url: 'https://api.example.com/api/v1/kitchen/devices',
header: { 'x-shop-id': 'SHOP_A' },
success: resolve,
fail: reject,
});
});
this.setData({ devices: res.data.list || [] });
},
async onPlay(e) {
const deviceId = e.currentTarget.dataset.id;
const channelId = String(e.currentTarget.dataset.ch || '0');
// 先销毁再创建,避免多实例抢流
this.setData({ show: false });
const tok = await new Promise((resolve, reject) => {
wx.request({
url: 'https://api.example.com/api/v1/kitchen/play-token',
data: { client: 'mp', deviceId, channelId },
header: { 'x-shop-id': 'SHOP_A' },
success: resolve,
fail: reject,
});
});
if (tok.statusCode !== 200) {
wx.showToast({ title: '取证失败', icon: 'none' });
return;
}
this.setData({
show: true,
miniToken: tok.data.miniToken,
deviceId,
channelId,
});
},
onPlayerEvent(e) {
console.log('player event', e.detail);
// 3003 播放地址获取失败;2998 无录像 等 ------ 对照插件文档状态码
},
onHide() {
this.setData({ show: false });
},
});
插件关键参数(与文档对齐):
| 参数 | 说明 |
|---|---|
miniToken |
createWeChatMiniProgramToken 返回 |
liveType |
real / localRecord / cloudRecord |
functionConfig |
如 talk,snapShot,PTZ,timeLine 逗号拼接 |
playConfig |
resolution HD/SD,voice on/off 等 |
| 尺寸 | 宽最小 260px,高最小 210px |
踩坑 H :未添加插件或 provider 填错 → 组件无法渲染。
踩坑 I :设备非 H264 → 插件实时预览失败(售前用实机验)。
踩坑 J :wx:if 不销毁就换 SN ------ 旧实例残留;先 show=false 再赋新 token。
Step 5 · 明厨验收清单(监管视角)
text
① listKitchenDevices:厨房在线,门店白名单正确
② client=web → 返回 kitToken;ImouPlayer 出画
③ client=mp → 返回 miniToken;imou-player 出画
④ 交叉误用凭证应失败(反例验收)
⑤ 切后台:Web pause;小程序 onHide 销毁组件
⑥ 回放(可选):web type=2;mp liveType=cloudRecord/localRecord + 时间窗
⑦ 审计日志:shopId + deviceId + client + 时间
javascript
// scripts/assert-dual-token.js
import 'dotenv/config';
import { getKitTokenCached, getMiniTokenCached } from '../services/play-tokens.js';
const deviceId = process.env.KITCHEN_DEVICE_IDS.split(',')[0];
const kit = await getKitTokenCached(deviceId, '0', '1');
const mini = await getMiniTokenCached(deviceId, '0');
console.log({
kitPrefix: String(kit).slice(0, 8),
miniPrefix: String(mini).slice(0, 8),
same: kit === mini, // 期望 false
});
if (kit === mini) throw new Error('两种凭证不应相同');
能力边界
text
□ Web 轻应用:浏览器 Wasm 解码;多路吃 CPU;适合店长后台
□ 小程序插件:免主体 live-player 资质(以文档为准);要求 H264
□ 自建 live-player:另需资质 + FLV/RTMP 接口,首期可不碰
□ 半屏跳转:另一条低代码路径,凭证模型不同,别和插件混用
性能
text
□ 点开再取 token;禁止列表页预拉全部厨房流
□ kitToken 缓存 ~1h;miniToken 按 expireTime 提前 5 分钟刷新
□ 公网明厨默认 SD;投诉卡顿再开 HD
□ 多路 Web 播放器同时存在易卡顿------控制并发
安全与合规(明厨特有)
text
□ 公众端只读预览;对讲/云台默认关闭 functionConfig
□ 监管账号按区县/门店授权;日志可审计
□ appSecret 永不进小程序包
□ 画面涉食品加工场所:注意留存周期与隐私告知
联调夜真实踩坑(可直接当验收反例)
text
坑 1:小程序把 Web 接口返回的 kitToken 原样塞进插件
→ 现象:组件能挂载,事件报取流失败;大屏正常
→ 修法:URL 强制带 client=mp,响应字段校验 miniToken
坑 2:列表页 onLoad 并发预取 20 路厨房凭证
→ 现象:偶发流源超时、后台限流;验收时「有的店亮有的店不亮」
→ 修法:点开再取;会话内 LRU 缓存最多 N 路
坑 3:IoT 厨房机有 productId,create 时漏传
→ 现象:部分门店取 miniToken 失败,普通 IPC 正常
→ 修法:列表带回 productId,有值必传
坑 4:公众端开了 talk
→ 现象:监管验收质疑「为何能对后厨喊话」
→ 修法:公众端 functionConfig 只留 snapShot 或空;对讲留给店长 Web
联调建议固定「双端对照表」:同一 deviceId,先 Web 出画,再小程序出画,再故意交叉凭证------交叉必须失败,才算鉴权模型拆干净。
本文结论
明厨亮灶双端同看的最小正确姿势:
text
listDeviceDetailsByPage 统一厨房台账
→ BFF 按门店 ACL
→ Web:getKitToken → ImouPlayer
→ 小程序:createWeChatMiniProgramToken → imou-player
→ 点开再取流,分端缓存,分端销毁
一套画面、两种凭证 ------搞混就会在验收夜「一边亮一边黑」。先把 client=web|mp 分成两条 API,再谈多门店与 AI 巡检。
注册与下一步
如果你正在做明厨亮灶 / 食安监管 SaaS / 连锁后厨可视化,先在 乐橙开放平台 open.imou.com 创建应用,把厨房摄像机绑进开发者资产池;按本文拆开 kitToken 与 miniToken,先跑通「店长大屏 + 监管小程序」同看一路厨房,再叠加回放与告警。
乐橙开放平台以视频技术与安全 为核心,开放 OpenAPI、轻应用、小程序插件、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频场景应用------监管端与运营端共用一套画面,明厨亮灶才算真正「可验收」。
今晚先让同一 deviceId 在 Web 与小程序各出一帧,比再写一套自建播放器更有用。