业主半夜想看楼道监控,物业却说「去机房」?我用设备托管+轻应用,把小区摄像头嵌进了社区小程序

一、半夜想看监控,却被告知「去机房」

上周三凌晨两点,某社区业主群炸了:楼道进了陌生人,业主想看一眼监控,物业回复------「明天上班去机房调」。

不是摄像头坏了,是画面出不了机房

社区小程序能缴费、能报修,偏偏播不了自家门口那一路。要 live-player 资质、要设备归属、还要防止 A 栋业主点开 B 栋摄像头。

三天之后,同一条监控嵌进了社区小程序「我的监控」页:物业托管授权 → 业务侧按房号映射 → 轻应用 web-view 出流。业主扫码即看,越权直接被后端拦下。

下面把这条链路拆开写清楚。


二、为什么「能播」不等于「能上线」

2.1 真正的问题不是播放器,是权限闭环

社区监控对业主开放,技术上至少要同时解决四件事:

  1. 设备归属:摄像头在物业 / 物管厂商官方 APP 账号下,开发者不能要求每户再买一台。
  2. 授权边界:业主只能看本楼栋 / 本单元约定点位,不能看全小区。
  3. 小程序资质 :主体小程序未必有微信 live-player 资质,硬接会卡审批。
  4. 体验一致:预览、回放、(可选)对讲,最好一套组件搞定,别每个端重写。

视频开放平台对应的解法是两条腿走路:

  • 设备侧:用「设备托管」把厂商官方 APP 已绑定设备的指定权限(预览、回放、对讲等)授权给开发者,终端用户仍保留设备所有权与 APP 体验。
  • 展示侧 :用「轻应用 JS 组件」做 H5 播放,再经小程序 web-view 嵌入;或直接用官方小程序插件 / 半屏小程序,免主体资质。

查阅所用平台现行文档时,优先对照以下主题(避开「旧版本协议」栏目):

  • 设备托管说明
  • 轻应用组件
  • 小程序插件
  • 半屏小程序

2.2 整体架构(物业 → 开发者 → 业主)

text 复制代码
┌─────────────────────────────────────────────────────────────┐
│ 物业侧:厂商官方 APP 绑定小区摄像头                            │
│   └─ 打开托管链接(可带 state=小区ID/楼栋ID)完成设备托管       │
└───────────────────────────┬─────────────────────────────────┘
                            │ warrantInit / warrantModify 回调
                            ▼
┌─────────────────────────────────────────────────────────────┐
│ 开发者业务后端                                                │
│  1. accessToken(3 天生命周期,服务端缓存)                     │
│  2. authorizedDeviceList 拉取已托管设备                       │
│  3. 业主身份 ↔ 可看 deviceId/channelId 白名单(自建表)         │
│  4. getKitToken / createWeChatMiniProgramToken(短时凭证)     │
└───────────────────────────┬─────────────────────────────────┘
                            │ 仅返回短时 token + 设备列表
                            ▼
┌─────────────────────────────────────────────────────────────┐
│ 社区小程序「我的监控」                                         │
│  方案 A:web-view 打开轻应用 H5(KitPlayer + kitToken)        │
│  方案 B:原生页嵌入 KitPlayer 插件(miniToken)                │
│  方案 C:半屏唤起平台半屏小程序(openEmbeddedMiniProgram)     │
└─────────────────────────────────────────────────────────────┘

关键认知 :开放平台负责「设备能不能被你播」;「这个业主能不能播这一路」必须由你自己的业务库裁决。漏了这一层,就是合规事故,不是 bug。

2.3 为什么优先「托管 + 轻应用嵌入」

对比项 自建 RTMP + live-player 托管 + 轻应用 web-view 托管 + 小程序插件
微信资质 需主体具备 live-player 不依赖主体资质 不依赖主体资质
对接成本 低(1~7 天量级)
权限模型 自建 平台托管 + 业务映射 同左
适合 已有资质、深度定制 社区 H5 快速嵌入 原生小程序页体验

社区项目往往「先要上线、再谈炫技」,所以交付层以 设备托管 + 轻应用嵌入小程序 为主线,插件与半屏作为进阶备选。


三、从托管到业主能看的完整实操

3.1 Step 0:注册应用,拿到 appId / appSecret

  1. 在所用平台开发者控制台注册并创建应用。
  2. 在控制台「我的应用 → 应用信息」拿到 appIdappSecret
  3. 开通设备托管能力,获取托管 H5 链接(形如 https://{托管H5域名}/h5/company/{托管标识})。

联调阶段先确认控制台剩余接入路数与媒体带宽,再排正式小区容量。

3.2 Step 1:签名与 accessToken(所有 API 的地基)

开放平台 HTTP 统一走:

POST ${OPENAPI_BASE}/{method}

其中 OPENAPI_BASE 取自所用平台文档的 OpenAPI 域名(形如文档中的 OpenAPI 根路径,不含具体厂商名)。

签名规则(UTF-8,MD5 32 位小写):

text 复制代码
原始串 = time:{秒级时间戳},nonce:{随机串},appSecret:{你的密钥}
sign   = md5(原始串)

官方校验用例(务必本地跑通再写业务):

text 复制代码
time:1706511734,nonce:f5a1ae2d-c09c-4d39-a744-83a5c2c653c2,appSecret:test123456789test123456789
→ sign = fd37b62889e4757c58b8f3bf05fb9976

Node.js 可运行示例:

javascript 复制代码
// openapi-sign.js ------ 本地 node openapi-sign.js 验证签名算法
const crypto = require("crypto");
const { v4: uuidv4 } = require("uuid"); // npm i uuid

// OPENAPI_BASE 取自所用平台文档的 OpenAPI 域名
const OPENAPI_BASE = process.env.OPENAPI_BASE; // 例:平台文档给出的 OpenAPI 根路径

function md5(s) {
  return crypto.createHash("md5").update(s, "utf8").digest("hex");
}

function buildSign(appSecret, time, nonce) {
  const raw = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
  return md5(raw);
}

// 官方标准案例自检
const demoSign = buildSign(
  "test123456789test123456789",
  1706511734,
  "f5a1ae2d-c09c-4d39-a744-83a5c2c653c2"
);
console.log("demoSign", demoSign); // 期望 fd37b62889e4757c58b8f3bf05fb9976
console.assert(demoSign === "fd37b62889e4757c58b8f3bf05fb9976", "签名算法错误");

async function accessToken(appId, appSecret) {
  const time = Math.floor(Date.now() / 1000);
  // nonce 建议 ≥32 位且 5 分钟内不重复,否则可能 SN1005
  const nonce = (uuidv4() + uuidv4()).replace(/-/g, "").slice(0, 32);
  const sign = buildSign(appSecret, time, nonce);
  const body = {
    system: { ver: "1.0", appId, sign, time, nonce },
    id: uuidv4(),
    params: {},
  };
  const res = await fetch(`${OPENAPI_BASE}/accessToken`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return res.json();
}

// 用法:把下面换成你的真实凭证后再调用
// accessToken("your_app_id", "your_app_secret").then(console.log);

module.exports = { buildSign, accessToken, OPENAPI_BASE };

accessToken 有效期约 3 天 。超过约 2 天再调会拿到新 token,新旧可并存;生产环境务必服务端缓存,别每个业主请求都刷一次(对照所用平台文档中的 accessToken 方法说明)。

3.3 Step 2:物业托管设备(带业务 state)

把托管链接发给物业,并拼接业务参数,便于回调时绑定「哪一个小区 / 哪一栋」:

text 复制代码
https://{托管H5域名}/h5/company/{托管标识}?state=community_10086_building_A

物业在厂商官方 APP / 托管页勾选权限。社区业主预览场景,至少勾选:

  • 视频预览(Real)
  • 视频回放(RecordReplay)(若要回放)
  • 一般不要一上来就给 Config / Upgrade 等运维权限

托管成功后,开放平台会推送 msgType: warrantInit(需在控制台配置消息推送地址):

json 复制代码
{
  "warrantId": "146746972164984832",
  "msgType": "warrantInit",
  "phone": "158***404",
  "appId": "your_app_id",
  "deviceList": [
    {
      "authority": "Real,RecordReplay,Talk",
      "channelName": "A栋大门",
      "deviceId": "TESTQWERXXXX",
      "deviceName": "A栋大门枪机",
      "channelId": "0"
    }
  ],
  "remark": "",
  "state": "community_10086_building_A"
}

业务侧落库建议:

sql 复制代码
-- 已托管设备(来自开放平台)
CREATE TABLE community_device (
  id BIGINT PRIMARY KEY,
  community_id VARCHAR(32) NOT NULL,
  building_code VARCHAR(32),
  device_id VARCHAR(64) NOT NULL,
  channel_id VARCHAR(8) NOT NULL DEFAULT '0',
  share_functions VARCHAR(256),
  online_status VARCHAR(16),
  UNIQUE KEY uk_dev_ch (device_id, channel_id)
);

-- 业主可看点位(业务授权,核心)
CREATE TABLE owner_camera_acl (
  id BIGINT PRIMARY KEY,
  owner_openid VARCHAR(64) NOT NULL,
  community_id VARCHAR(32) NOT NULL,
  device_id VARCHAR(64) NOT NULL,
  channel_id VARCHAR(8) NOT NULL,
  expire_at DATETIME NULL,
  UNIQUE KEY uk_owner_cam (owner_openid, device_id, channel_id)
);

取消托管、改权限、解绑会分别推送 deviceAuthCancel / warrantModify / authorityRemove,务必同步 ACL,避免「设备已撤权,小程序还能点开」。

3.4 Step 3:拉取已托管设备列表

javascript 复制代码
// authorizedDeviceList.js
const crypto = require("crypto");
const { v4: uuidv4 } = require("uuid");
const { buildSign, OPENAPI_BASE } = require("./openapi-sign");

async function authorizedDeviceList({ appId, appSecret, accessToken, page = 1, pageSize = 20 }) {
  const time = Math.floor(Date.now() / 1000);
  const nonce = (uuidv4() + uuidv4()).replace(/-/g, "").slice(0, 32);
  const sign = buildSign(appSecret, time, nonce);
  const body = {
    system: { ver: "1.0", appId, sign, time, nonce },
    id: uuidv4(),
    params: { token: accessToken, page, pageSize },
  };
  const res = await fetch(`${OPENAPI_BASE}/authorizedDeviceList`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return res.json();
}

// 返回里重点看 channels[].shareFunctions,例如:
// "Real,Talk,RecordReplay,..." ------ 没有 Real 就别给业主发预览入口

对照所用平台文档中的 authorizedDeviceList 方法说明。

补充:若设备是「终端用户 APP 分享给开发者」而非托管流程,可用 shareDeviceListqueryRange 形如 "1-10")拉取分享列表。社区 B 端批量授权更推荐托管。

3.5 Step 4:业主点「看监控」------服务端发 kitToken

业主进入小程序 → 后端校验 owner_camera_acl → 仅对白名单内设备调用 getKitToken

javascript 复制代码
// getKitToken.js
async function getKitToken({
  appId,
  appSecret,
  accessToken,
  deviceId,
  channelId = "0",
  type = "1", // 1=实时预览;2=录像回放;0=全部;6=云台
}) {
  const time = Math.floor(Date.now() / 1000);
  const nonce = (require("uuid").v4() + require("uuid").v4()).replace(/-/g, "").slice(0, 32);
  const { buildSign, OPENAPI_BASE } = require("./openapi-sign");
  const sign = buildSign(appSecret, time, nonce);
  const body = {
    system: { ver: "1.0", appId, sign, time, nonce },
    id: require("uuid").v4(),
    params: {
      token: accessToken,
      deviceId,
      channelId: String(channelId),
      type: String(type),
    },
  };
  const res = await fetch(`${OPENAPI_BASE}/getKitToken`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return res.json();
}

Express 风格接口示例(代码先于解释):

javascript 复制代码
// routes/ownerMonitor.js
const express = require("express");
const router = express.Router();

router.get("/api/owner/cameras", async (req, res) => {
  const openid = req.headers["x-wx-openid"]; // 你的登录态
  const list = await db.query(
    `SELECT d.device_id, d.channel_id, d.name, d.online_status
     FROM owner_camera_acl a
     JOIN community_device d
       ON a.device_id=d.device_id AND a.channel_id=d.channel_id
     WHERE a.owner_openid=? AND (a.expire_at IS NULL OR a.expire_at > NOW())`,
    [openid]
  );
  res.json({ code: 0, data: list });
});

router.post("/api/owner/kit-token", async (req, res) => {
  const openid = req.headers["x-wx-openid"];
  const { deviceId, channelId = "0", type = "1" } = req.body;

  const allowed = await db.get(
    `SELECT 1 FROM owner_camera_acl
     WHERE owner_openid=? AND device_id=? AND channel_id=?`,
    [openid, deviceId, channelId]
  );
  if (!allowed) return res.status(403).json({ code: 403, msg: "无权查看该摄像头" });

  const tokenPack = await getKitToken({
    appId: process.env.OPENAPI_APP_ID,
    appSecret: process.env.OPENAPI_APP_SECRET,
    accessToken: await getCachedAccessToken(),
    deviceId,
    channelId,
    type,
  });
  // kitToken 有效约 2 小时;建议业务侧缓存 ≤ 1 小时
  res.json(tokenPack);
});

module.exports = router;

踩坑记录 1 :第一次联调把 type 写成数字 1 没转字符串,个别网关严格按文档「String」校验会失败------统一 String(type)

踩坑记录 2kitToken 有效期约 2 小时,文档建议业务缓存约 1 小时,不要每次滑动列表都打开放平台。

3.6 Step 5:轻应用 H5 播放页(可嵌入小程序 web-view)

从开放平台「资源下载 → 轻应用直播套件」拿到 player.js / player.css / WasmLib(实际包名/组件名以所用平台轻应用或小程序插件文档为准),按文档把 WasmLib 放到 public(2024-12-31 后版本务必做这一步)。

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    />
    <title>小区监控预览</title>
    <link href="./player.css" rel="stylesheet" />
    <script src="./player.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script>
      // URL: player.html?deviceId=xxx&channelId=0&kitToken=Kt_xxx
      // 实际包名/组件名以所用平台轻应用文档为准;下文 KitPlayer 为中性示例名
      const q = new URLSearchParams(location.search);
      const deviceId = q.get("deviceId");
      const channelId = q.get("channelId") || "0";
      const token = q.get("kitToken");

      const player = new KitPlayer({
        id: "root",
        width: window.innerWidth,
        height: Math.floor(window.innerWidth * 0.56),
        deviceId,
        channelId,
        token,
        type: 1, // 直播
        streamId: 1, // 业主端建议默认标清,省带宽
        templateMode: "mobile",
        WasmLibPath: "/", // 按你的 public 路径调整
        // 设备开启自定义加密时必填;默认加密常用设备序列号
        // code: deviceId,
        controls: true,
        controlsConfig: ["play", "volume", "resolution", "fullScreen", "capture"],
        // 社区业主端一般关掉对讲/云台,避免误操作公共设备
        handleError(err) {
          console.error("play error", err);
          // 1001 解密失败 → 引导核对 code
        },
        handleCallBack(e) {
          if (e.type === "playStart") console.log("playing");
        },
      });

      document.addEventListener("visibilitychange", () => {
        if (!player) return;
        if (document.hidden) player.pause();
        else player.start();
      });
    </script>
  </body>
</html>

多线程解码需要响应头(否则 SharedArrayBuffer 不可用,会退回单线程):

nginx 复制代码
add_header Cross-Origin-Opener-Policy "same-origin";
add_header Cross-Origin-Embedder-Policy "require-corp";

3.7 Step 6:社区小程序用 web-view 嵌入

javascript 复制代码
// pages/monitor/list.js
Page({
  data: { cameras: [] },
  async onShow() {
    const res = await wx.request({ url: "https://your.api/api/owner/cameras" });
    this.setData({ cameras: res.data.data });
  },
  async openPlayer(e) {
    const { deviceId, channelId } = e.currentTarget.dataset;
    const tokenRes = await wx.request({
      url: "https://your.api/api/owner/kit-token",
      method: "POST",
      data: { deviceId, channelId, type: "1" },
    });
    const kitToken = tokenRes.data.result.data.kitToken; // 以实际返回字段为准
    const url = encodeURIComponent(
      `https://monitor.your-domain.com/player.html?deviceId=${deviceId}&channelId=${channelId}&kitToken=${kitToken}`
    );
    wx.navigateTo({ url: `/pages/webview/index?src=${url}` });
  },
});
xml 复制代码
<!-- pages/webview/index.wxml -->
<web-view src="{{src}}"></web-view>

业务域名需在小程序后台配置为合法业务域名;H5 需 HTTPS。

踩坑记录 3 :iOS / Android 微信小程序 web-view、Android 微信内置浏览器不支持截图与屏幕录制 ,控件不会展示------业主端别把「截图存证」当成必达功能,或改走原生插件。

踩坑记录 4 :WasmLib 路径配错会出现 Unexpected token '<'(其实返回了 HTML 404 页),用 WasmLibPath 指到正确 public 目录即可。

3.8 可选增强:原生小程序插件(免 web-view)

若希望完全在小程序原生页播放,集成官方插件(provider 填平台文档给出的插件 appId):

json 复制代码
// app.json
{
  "plugins": {
    "myPlugin": {
      "version": "1.0.0",
      "provider": "平台文档给出的插件appId"
    }
  }
}

服务端改调 createWeChatMiniProgramToken

javascript 复制代码
async function createWeChatMiniProgramToken({
  appId, appSecret, accessToken, deviceId, channelId = "0", productId,
}) {
  const time = Math.floor(Date.now() / 1000);
  const nonce = (require("uuid").v4() + require("uuid").v4()).replace(/-/g, "").slice(0, 32);
  const { buildSign, OPENAPI_BASE } = require("./openapi-sign");
  const sign = buildSign(appSecret, time, nonce);
  const params = { token: accessToken, deviceId, channelId: String(channelId) };
  if (productId) params.productId = productId; // IoT 物模型设备必填
  const body = {
    system: { ver: "1.0", appId, sign, time, nonce },
    id: require("uuid").v4(),
    params,
  };
  const res = await fetch(
    `${OPENAPI_BASE}/createWeChatMiniProgramToken`,
    { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
  );
  return res.json();
  // data.miniToken 最长约 24 小时有效;>12h 再请求会发新钥,新旧可并存
}
xml 复制代码
<!-- 实际组件名以所用平台小程序插件文档为准;下文 kit-player 为中性示例名 -->
<kit-player
  wx:if="{{show}}"
  miniToken="{{miniToken}}"
  deviceId="{{deviceId}}"
  channelId="0"
  liveType="real"
  functionConfig="snapShot"
  playConfig='{"resolution":"SD","voice":"on","fullScreen":"off"}'
  width="{{width}}"
  height="210"
  bind:handleEvent="handleEvent"
  objectFit="contain"
/>

注意:插件实时预览要求设备为 H264 编码;H265 设备优先用轻应用路径。


四、边界、性能与生产注意

4.1 权限边界:平台托管 ≠ 业主 ACL

text 复制代码
平台 shareFunctions 有 Real ──┐
                              ├── 两者都满足,才发 kitToken / miniToken
业务 owner_camera_acl 命中 ───┘

建议强制:

  • appSecret、管理员 accessToken 永不下发到小程序前端。
  • 半屏小程序官方示例 path 里带了 appSecret,仅适合内测;生产请优先 插件 + 服务端 miniToken轻应用 + kitToken
  • 业主端默认标清(streamId: 1 / resolution: SD),高峰再允许切高清。

4.2 带宽与并发粗算

假设辅码流约 512 kbps,晚高峰 200 业主同时看:

text 复制代码
200 × 0.512 Mbps ≈ 102 Mbps

开放平台按媒体带宽计费/限额,上线前在控制台核对剩余资源;小区项目务必做「同时在线路数」熔断------超出排队或降清晰度,而不是让页面狂转圈。

4.3 Token 与流地址生命周期

凭证 大约有效期 建议缓存
accessToken 3 天 服务端缓存,TK1002 时刷新
kitToken 2 小时 业务缓存 ≤ 1 小时
miniToken 最长 24 小时 剩余 < 12 小时可续

文档提醒:流地址用时再取,提前批量拉流容易超时 404、串流。

4.4 加密设备

设备开启音视频加密时,轻应用初始化必须传 code:自定义秘钥 / 设备密码 / 默认序列号。业主端若解密失败(errCode 1001),优先排查托管后密钥是否与物业 APP 侧一致,而不是重装播放器。

4.5 消息推送与「幽灵权限」

生产环境务必处理:

  • deviceAuthCancel / authorityRemove → 立刻禁用 ACL + 踢掉播放中会话
  • warrantModify → 同步 shareFunctions,去掉 Real 后隐藏入口

漏了回调,会出现「物业已取消托管,业主缓存页还能播一会儿」的尴尬窗口。

4.6 轻应用多路与移动端

同页多播放器会吃 Canvas + 解密 CPU,社区「监控墙」建议列表缩略图 + 点进单路全屏,而不是一次起 9 路。移动端监听 visibilitychange 暂停,避免切后台仍占带宽。


五、技术收束与文档对照

社区「业主看授权监控」这条链路,拆开其实就三句话:

  1. 设备进来 :物业用设备托管把预览/回放权限交给开发者(可带 state 绑定楼栋)。
  2. 权限收口:业务库做业主 ↔ 摄像头 ACL,开放平台不管房号。
  3. 画面出去 :服务端发短时 kitToken / miniToken,轻应用嵌 web-view,或插件原生播------主体小程序甚至不必申请 live-player 资质。

对照所用平台现行文档时,建议按方法名检索(避开「旧版本协议」栏目):

  • accessToken --- 获取服务端访问凭证
  • 开发规范 / 签名算法(MD5:time,nonce,appSecret
  • 设备托管(托管 H5、warrantInit / warrantModify 等回调)
  • 授权消息推送(deviceAuthCancel / authorityRemove
  • authorizedDeviceList / shareDeviceList
  • getKitToken / createWeChatMiniProgramToken
  • 轻应用组件(H5 播放套件)
  • 小程序插件 / 平台半屏小程序(openEmbeddedMiniProgram

落地顺序建议:先跑通「托管一台设备 → getKitToken → 轻应用出画」最小闭环,再叠业主 ACL 与带宽熔断。


文末 checklist(上线前自测)

  • 签名标准案例算出 fd37b62889e4757c58b8f3bf05fb9976
  • authorizedDeviceList 能看到托管设备且含 Real
  • 无 ACL 的 openid 调 kit-token 返回 403
  • web-view 业务域名与 COOP/COEP 头配置完成
  • 取消托管后业主端入口立即消失
  • 默认标清,高峰带宽可估算可熔断
相关推荐
Conan在掘金2 小时前
ArkTS 进阶之道(13):ForEach 循环渲染边界——为啥 build 里不能写 for 循环
后端
妙码生花2 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(四十一):增加管理员账号管理接口
后端·go·gin
用户0678260743272 小时前
APP版本管理全链路(后端设计)
后端
suconnect2 小时前
Spring Boot接入企业RAG:文档切分、向量检索、权限过滤和答案溯源
java·spring boot·后端
神奇小汤圆3 小时前
别再用 nohup java -jar 了:Spring Boot 生产环境该怎么守护?
后端
JavaGuide3 小时前
GitHub 9.8 万 Star!把整个代码仓库变成知识图谱,这个 AI Coding 工具太适合 Claude Code / Codex 了
前端·后端·ai编程
延凡科技3 小时前
延凡科技电力数字化平台技术解析:从电力交易到虚拟电厂的全链路架构实践
人工智能·科技·物联网·架构·虚拟电厂·电力平台
旺仔学长 哈哈3 小时前
Spring Boot 智能停车场管理系统---附源码+数据库文档
数据库·spring boot·后端·智能停车场
用户8356290780513 小时前
使用 Python 在 PDF 中绘制线条、矩形和自定义图形
后端·python
进击的丸子3 小时前
虹软人脸服务器SDK-C++语言Demo实操指南
后端·算法