明厨亮灶笔记:乐橙轻应用 H5 + 小程序插件,一套 BFF 出两张播放凭证

明厨亮灶笔记:乐橙轻应用 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 → 插件实时预览失败(售前用实机验)。

踩坑 Jwx: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 创建应用,把厨房摄像机绑进开发者资产池;按本文拆开 kitTokenminiToken,先跑通「店长大屏 + 监管小程序」同看一路厨房,再叠加回放与告警。

乐橙开放平台以视频技术与安全 为核心,开放 OpenAPI、轻应用、小程序插件、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频场景应用------监管端与运营端共用一套画面,明厨亮灶才算真正「可验收」。

注册入口:https://open.imou.com

今晚先让同一 deviceId 在 Web 与小程序各出一帧,比再写一套自建播放器更有用。

相关推荐
何时梦醒8 小时前
⚛️ React 19 + TypeScript 深度学习笔记 —— 从组件化思维到 WebGPU 端侧 AI 落地
前端·javascript·人工智能
码农学院8 小时前
Neo4j知识图谱赋能跨境电商GEO:LLM实体识别与AI搜索引擎结构化数据输出实战
人工智能·知识图谱·neo4j
东风破_8 小时前
大模型流式输出是怎么实现的?从 ReadableStream、Uint8Array 到 SSE
人工智能
ZZZMMM.zip8 小时前
断舍离清单 —— 鸿蒙AI智能助手开发全流程解析
人工智能·华为·harmonyos·鸿蒙·鸿蒙系统
用户938515635078 小时前
从 Vite 脚手架到 WebGPU 推理:手写一个 DeepSeek-R1 浏览器端大模型 Demo
javascript·人工智能·全栈
不如语冰8 小时前
AI大模型入门-参数的传递
数据结构·人工智能·pytorch·python
Jerry_Chenug8 小时前
MCP 入门到实战:把文档、接口和工具接入 Cursor
人工智能
Revolution618 小时前
一堆 if 把 Agent Loop 写乱了:Hooks 到底解决了什么?
人工智能
m沐沐8 小时前
【深度学习】深入理解长短期记忆网络 LSTM
人工智能·pytorch·深度学习·神经网络·lstm