一、半夜想看监控,却被告知「去机房」
上周三凌晨两点,某社区业主群炸了:楼道进了陌生人,业主想看一眼监控,物业回复------「明天上班去机房调」。
不是摄像头坏了,是画面出不了机房。
社区小程序能缴费、能报修,偏偏播不了自家门口那一路。要 live-player 资质、要设备归属、还要防止 A 栋业主点开 B 栋摄像头。
三天之后,同一条监控嵌进了社区小程序「我的监控」页:物业托管授权 → 业务侧按房号映射 → 轻应用 web-view 出流。业主扫码即看,越权直接被后端拦下。
下面把这条链路拆开写清楚。
二、为什么「能播」不等于「能上线」
2.1 真正的问题不是播放器,是权限闭环
社区监控对业主开放,技术上至少要同时解决四件事:
- 设备归属:摄像头在物业 / 物管厂商官方 APP 账号下,开发者不能要求每户再买一台。
- 授权边界:业主只能看本楼栋 / 本单元约定点位,不能看全小区。
- 小程序资质 :主体小程序未必有微信
live-player资质,硬接会卡审批。 - 体验一致:预览、回放、(可选)对讲,最好一套组件搞定,别每个端重写。
视频开放平台对应的解法是两条腿走路:
- 设备侧:用「设备托管」把厂商官方 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
- 在所用平台开发者控制台注册并创建应用。
- 在控制台「我的应用 → 应用信息」拿到
appId、appSecret。 - 开通设备托管能力,获取托管 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 分享给开发者」而非托管流程,可用
shareDeviceList(queryRange形如"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)。
踩坑记录 2 :kitToken 有效期约 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 暂停,避免切后台仍占带宽。
五、技术收束与文档对照
社区「业主看授权监控」这条链路,拆开其实就三句话:
- 设备进来 :物业用设备托管把预览/回放权限交给开发者(可带
state绑定楼栋)。 - 权限收口:业务库做业主 ↔ 摄像头 ACL,开放平台不管房号。
- 画面出去 :服务端发短时
kitToken/miniToken,轻应用嵌 web-view,或插件原生播------主体小程序甚至不必申请 live-player 资质。
对照所用平台现行文档时,建议按方法名检索(避开「旧版本协议」栏目):
accessToken--- 获取服务端访问凭证- 开发规范 / 签名算法(MD5:
time,nonce,appSecret) - 设备托管(托管 H5、
warrantInit/warrantModify等回调) - 授权消息推送(
deviceAuthCancel/authorityRemove) authorizedDeviceList/shareDeviceListgetKitToken/createWeChatMiniProgramToken- 轻应用组件(H5 播放套件)
- 小程序插件 / 平台半屏小程序(
openEmbeddedMiniProgram)
落地顺序建议:先跑通「托管一台设备 → getKitToken → 轻应用出画」最小闭环,再叠业主 ACL 与带宽熔断。
文末 checklist(上线前自测)
- 签名标准案例算出
fd37b62889e4757c58b8f3bf05fb9976 -
authorizedDeviceList能看到托管设备且含Real - 无 ACL 的 openid 调 kit-token 返回 403
- web-view 业务域名与 COOP/COEP 头配置完成
- 取消托管后业主端入口立即消失
- 默认标清,高峰带宽可估算可熔断