周四凌晨,老周的物业 SaaS 炸了。A 小区值班员图省事,把后台接口返回的 accessToken 塞进了小程序 localStorage。第二天,B 小区一位业主用同一套前端,把地下车库一路点开了------镜头没坏,坏的是「谁有权看哪路」。
12 个小区、一台开发者应用、一个管理员 token。列表接口一拉,全量设备都出来。越权不是播放器的事,是租户边界没落在平台侧。
下面按现行账户对接接口,把多小区拆成:一个子账号、一套 Policy、两层 token。代码可直接跑,踩坑写进逻辑里。
一、为什么「设备在线」还会串小区
1.1 业务真相:多小区买的不是账号,是爆炸半径
物业中台、连锁工地、多园区安防,真正要守的不是「能不能播」,而是三条边界:
- 租户隔离:A 小区的值班员,默认不该枚举到 B 小区任何一路。
- 能力最小化 :单元门只给
Real,不要顺手给Ptz/Talk/Format。 - 凭证分层 :管理员
At_只活在管控面;租户运行时用St_;前端只拿短时kitToken。
很多团队第一周用 listDeviceDetailsByPage 把台账拉通了------后台有了,12 个小区也在同一页。第二周把管理员 token 缓到 Redis,结果前端一泄露,全量设备一起穿仓。根因通常不是设备不会授权,而是:
把「开发者怎么管设备」和「某个小区能看哪几路」揉成了同一枚 accessToken。
1.2 官方能力边界:子账号是长期权限控制,不是第二个登录页
现行文档把这件事写得很直白(见子账号功能说明):子账号要解决的核心问题,是在不暴露开发者 AccessToken 的情况下,安全地授权别人访问设备。管理员 token 一旦漏出,对方可以操作该开发者名下全部设备资源。
账户对接模块的接口清单在账户对接说明,本文只用现行协议,不碰旧版本栏目:
| 动作 | 接口 | 用哪枚 token | 干什么 |
|---|---|---|---|
| 拿管理员凭证 | accessToken |
无(appId + appSecret) |
管控面入口,有效期 3 天 |
| 开租户 | createSubAccount / getOpenIdByAccount |
At_ |
手机号或邮箱 → openid |
| 划权 | addPolicy |
At_ |
按 dev: / cam: 写 Statement,单次最多 10 路 |
| 核权 | queryDevicePermission / listSubAccountDevice |
At_ |
上线前对账、预览前闸门 |
| 租户拉台账 | subAccountDeviceList |
仅 St_ |
运行时只看见本租户已授权设备 |
| 撤权 | deleteDevicePermission / clearPolicy / deleteSubAccount |
At_ |
通道级 / 策略级 / 账号级 |
Policy 不是「分享一张二维码」,而是一组 Statement:permission × resource。请求进来后逐条匹配,任一匹配成功即放行;一条都匹配不上,直接禁。
text
开发者应用(一个 appId)
└─ 管理员 accessToken At_* 只活在管控面,3 天
├─ 子账号 小区A openid_A
│ ├─ addPolicy cam:NVR001:0 Real,RecordReplay
│ └─ subAccountToken St_* 只拉本小区台账
├─ 子账号 小区B openid_B
│ └─ addPolicy cam:NVR002:3 Real
└─ 签发 kitToken(仍走 At_,但必须先过本租户闸门)
└─ 前端只拿 kitToken(约 2 小时),永不拿 At_ / St_
1.3 选型一句话
- 一个开发者应用管多个小区 / 项目:一个租户一个子账号 ,不要共用
At_做数据面。 - 单元级隔离:授权写
cam:序列号:通道号,不要图省事写dev:NVR序列号。 - 预览接口文档写的是管理员 token:可以调,但必须先用 Policy 闸门,再签发短时凭证。
二、从开租户到本小区只能看见本小区
2.1 准备(5 分钟)
- 打开 open.imou.com 注册并创建应用,在「我的应用 → 应用信息」拿到
appId/appSecret。 - 把各小区摄像机绑到该应用资产下(乐橙 App 添加,或走设备托管)。全量台账用
listDeviceDetailsByPage(管理员 token),不要把这个列表直接回给某个小区前端。 - 为每个小区准备一个手机号或邮箱,作为子账号
account。 - 现行网关:
https://openapi.lechange.cn/openapi/{method},请求壳为system+params+id(开发规范)。
bash
mkdir imou-tenant-acl && cd imou-tenant-acl
npm init -y
npm i express dotenv uuid
# Node 18+ 自带 fetch
bash
# .env
IMOU_APP_ID=lcdxxxxxxxxx
IMOU_APP_SECRET=your_secret
TENANT_A_ACCOUNT=13800001111
TENANT_B_ACCOUNT=13800002222
NVR_A=你的A小区设备序列号
NVR_B=你的B小区设备序列号
CHANNEL_A=0
CHANNEL_B=0
业务侧先落一张映射表。平台认 openid,你的 SaaS 认 tenant_id,中间这张表断了,后面全是玄学。
sql
CREATE TABLE community_tenant (
tenant_id VARCHAR(32) PRIMARY KEY, -- 你的小区 / 项目 ID
account VARCHAR(64) NOT NULL, -- 手机号或邮箱
openid VARCHAR(64) NOT NULL, -- createSubAccount 返回
created_at DATETIME NOT NULL
);
CREATE TABLE tenant_device_acl (
tenant_id VARCHAR(32) NOT NULL,
device_id VARCHAR(64) NOT NULL,
channel_id VARCHAR(8) NOT NULL,
permission VARCHAR(128) NOT NULL, -- 与 Policy 对齐,如 Real,RecordReplay
PRIMARY KEY (tenant_id, device_id, channel_id)
);
2.2 签名与 OpenAPI 最小封装
先把请求壳搭起来。后面所有接口都走这一层,避免每个文件重写一遍 MD5。
javascript
// imou-client.js ------ 现行 OpenAPI 请求壳 + MD5 签名
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
const OPENAPI_BASE = 'https://openapi.lechange.cn/openapi';
function calcSign(time, nonce, appSecret) {
// 官方:time:{time},nonce:{nonce},appSecret:{appSecret} → MD5 小写 32 位
const official = `time:${time},nonce:${nonce},appSecret:${appSecret}`;
return crypto.createHash('md5').update(official, 'utf8').digest('hex');
}
async function callOpenApi(method, appId, appSecret, params = {}) {
const time = Math.floor(Date.now() / 1000);
const nonce = uuidv4();
const body = {
system: {
ver: '1.0',
appId,
time,
nonce,
sign: calcSign(time, nonce, appSecret),
},
id: uuidv4(),
params,
};
const res = await fetch(`${OPENAPI_BASE}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.result || json.result.code !== '0') {
const msg = json.result ? `${json.result.code} ${json.result.msg}` : JSON.stringify(json);
const err = new Error(`OpenAPI ${method} failed: ${msg}`);
err.code = json.result && json.result.code;
throw err;
}
return json.result.data;
}
module.exports = { callOpenApi, calcSign };
javascript
// sign-selftest.js ------ 与文档标准案例对齐
const assert = require('assert');
const { calcSign } = require('./imou-client');
assert.strictEqual(
calcSign(1706511734, 'f5a1ae2d-c09c-4d39-a744-83a5c2c653c2', 'test123456789test123456789'),
'fd37b62889e4757c58b8f3bf05fb9976'
);
console.log('sign ok');
time 与服务器误差不能超过 5 分钟;nonce 5 分钟内不可重复,否则撞 SN1005。管理员 token 有效约 3 天,遇到 TK1002 再刷新,不要每个业务请求都重新拿(accessToken)。
文档还有一句容易被忽略的话:超过 2 天但未满 3 天再调 accessToken,会返回新 token,新旧生命周期相互独立,且都可以用。 所以缓存策略是「快过期再换」,不是「每天零点对刷」。
javascript
// token-store.js ------ At_ / St_ 两层缓存,TK1002 单飞刷新
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const appId = process.env.IMOU_APP_ID;
const appSecret = process.env.IMOU_APP_SECRET;
const admin = { token: null, expireAt: 0, inflight: null };
const subMap = new Map(); // openid -> { token, expireAt, inflight }
function stillValid(entry, skewSec = 600) {
return entry.token && Date.now() / 1000 < entry.expireAt - skewSec;
}
async function getAdminToken() {
if (stillValid(admin)) return admin.token;
if (admin.inflight) return admin.inflight;
admin.inflight = callOpenApi('accessToken', appId, appSecret, {})
.then((data) => {
admin.token = data.accessToken; // 形如 At_...
admin.expireAt = Date.now() / 1000 + Number(data.expireTime);
return admin.token;
})
.finally(() => { admin.inflight = null; });
return admin.inflight;
}
async function getSubToken(openid) {
let entry = subMap.get(openid);
if (!entry) {
entry = { token: null, expireAt: 0, inflight: null };
subMap.set(openid, entry);
}
if (stillValid(entry)) return entry.token;
if (entry.inflight) return entry.inflight;
entry.inflight = (async () => {
const adminToken = await getAdminToken();
const data = await callOpenApi('subAccountToken', appId, appSecret, {
token: adminToken,
openid,
});
entry.token = data.accessToken; // 形如 St_...
entry.expireAt = Date.now() / 1000 + Number(data.expireTime);
return entry.token;
})().finally(() => { entry.inflight = null; });
return entry.inflight;
}
async function withTokenRetry(fn) {
try {
return await fn();
} catch (e) {
if (e.code === 'TK1002') {
admin.expireAt = 0;
for (const v of subMap.values()) v.expireAt = 0;
return fn();
}
throw e;
}
}
module.exports = { getAdminToken, getSubToken, withTokenRetry, appId, appSecret };
样例里管理员 expireTime 约 259176 秒(接近 3 天),子账号约 143999 秒。两套 TTL 不要混用同一个 Redis key。
2.3 开租户:一个小区一个 openid
createSubAccount 的 account 只接受手机号或邮箱。已经开过的号不要硬创建,改走 getOpenIdByAccount。
javascript
// create-tenant.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const { getAdminToken, appId, appSecret } = require('./token-store');
async function ensureSubAccount(account) {
const token = await getAdminToken();
try {
const data = await callOpenApi('createSubAccount', appId, appSecret, { token, account });
return data.openid;
} catch (e) {
// 已存在时回查。不要把「号已经在了」当成创建失败就停。
const data = await callOpenApi('getOpenIdByAccount', appId, appSecret, { token, account });
return data.openid;
}
}
(async () => {
const openidA = await ensureSubAccount(process.env.TENANT_A_ACCOUNT);
const openidB = await ensureSubAccount(process.env.TENANT_B_ACCOUNT);
console.log('小区A openid:', openidA);
console.log('小区B openid:', openidB);
const page = await callOpenApi('listSubAccount', appId, appSecret, {
token: await getAdminToken(),
pageNo: 1,
pageSize: 10, // 默认 5,最大 10
});
console.log('子账号总数:', page.total, page.accounts);
})();
listSubAccount 按创建时间升序,pageSize 最大 10。小区一多,记得翻页,别以为第一页就是全集。
2.4 划权:Policy 按通道写,不要把整台 NVR 扔出去
权限枚举以子账号功能说明为准。社区值班常见组合:
| permission | 适用 | 什么时候给 |
|---|---|---|
Real |
dev / cam | 实时预览,小区值班最低配 |
RecordReplay |
dev / cam | 回放(云录像 + 本地录像) |
Alarm |
dev / cam | 查告警消息 |
Capture |
dev / cam | 抓图 |
Ptz / Talk |
dev / cam | 云台 / 对讲,单元门慎开 |
Config / Upgrade / Format |
视资源 | 运维号才给 |
DevControl |
dev / cam | 完全控制,含以后新增的权限。租户号不要用。 |
资源写法只有两种:dev:序列号、cam:序列号:通道号。cam 隶属于 dev :给 dev:NVR001 开了 Real,该 NVR 下所有通道默认都有 Real。多小区共用一台 NVR 时,这是最容易串楼的坑。
addPolicy 单次 statement 或 resource 不能超过 10。超过就切块。
javascript
// grant-devices.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const { getAdminToken, appId, appSecret } = require('./token-store');
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
async function grantChannels(openid, channels, permission = 'Real,RecordReplay') {
const token = await getAdminToken();
for (const group of chunk(channels, 10)) {
await callOpenApi('addPolicy', appId, appSecret, {
token,
openid,
policy: {
statement: [
{
permission,
resource: group.map((c) => `cam:${c.deviceId}:${c.channelId}`),
},
],
},
});
}
}
(async () => {
const openidA = process.env.OPENID_A; // 上一步打印的值
const openidB = process.env.OPENID_B;
await grantChannels(openidA, [
{ deviceId: process.env.NVR_A, channelId: process.env.CHANNEL_A || '0' },
]);
await grantChannels(openidB, [
{ deviceId: process.env.NVR_B, channelId: process.env.CHANNEL_B || '0' },
]);
const checkA = await callOpenApi('queryDevicePermission', appId, appSecret, {
token: await getAdminToken(),
openid: openidA,
deviceId: process.env.NVR_A,
});
console.log('小区A在 NVR_A 上的权限:', JSON.stringify(checkA, null, 2));
})();
queryDevicePermission 不传 channelId 时,返回设备级 permission 和 channelIdPermissions[]。上线前用它做对账:你以为只开了 0 通道,结果设备级已经是 GET,UPDATE,REAL,后面全是隐患。
2.5 运行时台账:必须用 St_ 调 subAccountDeviceList
管理员视角的 listDeviceDetailsByPage 会返回绑定 / 分享 / 托管的全量 设备。租户运行时不要走它。现行文档里,仅限子账号 token 的列表是 subAccountDeviceList(分页获取子账号下授权设备的详细信息)。
对照一下两套列表,避免接错:
| 接口 | token | 谁在看 | 看见什么 |
|---|---|---|---|
listDeviceDetailsByPage |
At_ |
平台管理员 | 应用下全部设备 |
listSubAccountDevice |
At_ + openid |
运维对账 | 该子账号的授权清单(pageSize 最大 50) |
subAccountDeviceList |
St_ |
某小区后台 | 只含已授权设备,pageSize 1~20 |
javascript
// list-tenant-devices.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const { getSubToken, withTokenRetry, appId, appSecret } = require('./token-store');
async function listTenantDevices(openid, page = 1, pageSize = 20) {
return withTokenRetry(async () => {
const st = await getSubToken(openid);
return callOpenApi('subAccountDeviceList', appId, appSecret, {
token: st,
page,
pageSize,
});
});
}
(async () => {
const openidA = process.env.OPENID_A;
const data = await listTenantDevices(openidA, 1, 20);
console.log('本小区设备数:', data.count);
for (const d of data.deviceList || []) {
const ch = (d.channels || [])
.map((c) => `${c.channelId}:${c.permission || d.permission || '-'}`)
.join(', ');
console.log(d.deviceId, d.status, d.catalog, ch);
}
})();
只授权了通道级时,设备级 permission 可能是空串,通道对象上的 permission 才是准的。样例里一台 NVR 的某通道会返回类似 Alarm,Talk,Ptz,Config,Upgrade------如果你只想给预览,回头检查 addPolicy 是不是写成了 DevControl。
运维对账可以再用管理员接口拉一遍授权清单,和业务表 tenant_device_acl 做 diff:
javascript
// audit-policy.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const { getAdminToken, appId, appSecret } = require('./token-store');
(async () => {
const token = await getAdminToken();
const data = await callOpenApi('listSubAccountDevice', appId, appSecret, {
token,
openid: process.env.OPENID_A,
pageNo: 1,
pageSize: 50,
});
console.log('对账 total:', data.total);
console.log(JSON.stringify(data.policy, null, 2));
})();
2.6 预览闸门:At_ 可以签发 kitToken,但不能跳过 Policy
getKitToken 文档写明:token 是管理员 accessToken ,kitToken 有效约 2 小时 ,建议服务端缓存 1 小时。type 用字符串:0 所有权限、1 实时预览、2 录像回放、6 云台。
这是多租户里最容易「看起来对、实际穿仓」的点:平台允许你用 At_ 给任意已绑定设备签发播放凭证。隔离不在 getKitToken 内部,在你调它之前。
javascript
// preview-gate.js
require('dotenv').config();
const express = require('express');
const { callOpenApi } = require('./imou-client');
const { getAdminToken, withTokenRetry, appId, appSecret } = require('./token-store');
const kitCache = new Map(); // `${openid}:${deviceId}:${channelId}:${type}` -> { kitToken, expireAt }
function hasPerm(csv, need) {
const set = new Set(String(csv || '').split(',').map((s) => s.trim()).filter(Boolean));
if (set.has('DevControl')) return true;
const alias = { Real: ['Real', 'REAL'], RecordReplay: ['RecordReplay', 'RECORDREPLAY'] };
return (alias[need] || [need]).some((n) => set.has(n));
}
async function assertTenantCan(openid, deviceId, channelId, need) {
const token = await getAdminToken();
const data = await callOpenApi('queryDevicePermission', appId, appSecret, {
token, openid, deviceId, channelId: String(channelId),
});
const ch = (data.channelIdPermissions || []).find((c) => String(c.channelId) === String(channelId));
const perm = (ch && ch.permission) || data.permission || '';
if (!hasPerm(perm, need)) {
const err = new Error(`tenant ${openid} has no ${need} on ${deviceId}:${channelId}`);
err.status = 403;
throw err;
}
}
async function issueKitToken(openid, deviceId, channelId, type) {
const key = `${openid}:${deviceId}:${channelId}:${type}`;
const hit = kitCache.get(key);
if (hit && Date.now() / 1000 < hit.expireAt - 600) return hit.kitToken;
const need = type === '2' ? 'RecordReplay' : type === '6' ? 'Ptz' : 'Real';
await assertTenantCan(openid, deviceId, channelId, need);
const data = await withTokenRetry(async () => {
const token = await getAdminToken();
return callOpenApi('getKitToken', appId, appSecret, {
token,
deviceId,
channelId: String(channelId),
type: String(type),
});
});
kitCache.set(key, { kitToken: data.kitToken, expireAt: Date.now() / 1000 + 3600 });
return data.kitToken;
}
const app = express();
app.use(express.json());
// 假设上游网关已经把 JWT 解成 tenant.openid,这里不再演示登录
app.post('/api/preview-token', async (req, res) => {
try {
const { openid, deviceId, channelId, type = '1' } = req.body;
if (!openid || !deviceId) return res.status(400).json({ error: 'missing openid/deviceId' });
if (!['1', '2', '6'].includes(String(type))) {
return res.status(400).json({ error: 'type must be 1/2/6, do not use 0 in tenant API' });
}
const kitToken = await issueKitToken(openid, deviceId, channelId || '0', String(type));
res.json({ kitToken, expiresIn: 3600 });
} catch (e) {
res.status(e.status || 500).json({ error: e.message });
}
});
app.listen(process.env.PORT || 3000, () => console.log('preview-gate on :3000'));
联调时用 curl 打两枪,比看文档更有感觉:
bash
# 本小区:应返回 kitToken
curl -s -X POST http://127.0.0.1:3000/api/preview-token \
-H "Content-Type: application/json" \
-d "{\"openid\":\"$OPENID_A\",\"deviceId\":\"$NVR_A\",\"channelId\":\"0\",\"type\":\"1\"}"
# 跨小区:应 403
curl -s -X POST http://127.0.0.1:3000/api/preview-token \
-H "Content-Type: application/json" \
-d "{\"openid\":\"$OPENID_A\",\"deviceId\":\"$NVR_B\",\"channelId\":\"0\",\"type\":\"1\"}"
如果业务要的是 HLS 而不是轻应用,bindDeviceLive 同样声明管理员 token,参数是 streamId(0 主码流 / 1 辅码流),不是 streamType。闸门逻辑可以复用,只换签发接口。
javascript
// live-gate.js 片段 ------ 多路墙默认辅码流
const data = await callOpenApi('bindDeviceLive', appId, appSecret, {
token: await getAdminToken(),
deviceId,
channelId: String(channelId),
streamId: 1,
});
// data.streams[].hls 不要把 At_ 一起回给前端
2.7 撤权:通道、策略、账号是三把不同的扫帚
退租、换物业、某路摄像头改挂别的项目,不要只会删子账号。
javascript
// revoke.js
require('dotenv').config();
const { callOpenApi } = require('./imou-client');
const { getAdminToken, appId, appSecret } = require('./token-store');
async function revokeChannel(openid, deviceId, channelId) {
await callOpenApi('deleteDevicePermission', appId, appSecret, {
token: await getAdminToken(),
openid,
deviceId,
channelId: String(channelId), // 不传则删整机权限
});
}
async function revokeAllPolicies(openid) {
await callOpenApi('clearPolicy', appId, appSecret, {
token: await getAdminToken(),
openid,
});
}
async function dropTenant(openid) {
await callOpenApi('deleteSubAccount', appId, appSecret, {
token: await getAdminToken(),
openid,
});
}
(async () => {
const cmd = process.argv[2];
const openid = process.env.OPENID_A;
if (cmd === 'channel') {
await revokeChannel(openid, process.env.NVR_A, process.env.CHANNEL_A || '0');
console.log('已删通道权限,子账号还在');
} else if (cmd === 'policy') {
await revokeAllPolicies(openid);
console.log('已清空策略,子账号还在');
} else if (cmd === 'account') {
await dropTenant(openid);
console.log('子账号和权限一起没了');
} else {
console.log('usage: node revoke.js channel|policy|account');
}
})();
deleteDevicePermission 默认设备级;带 channelId 只撕这一路。clearPolicy 清策略但留号。deleteSubAccount 连号带权一起删。退租当天如果只清了业务表、没调这三枪之一,小程序还能靠缓存的 kitToken 再播一会儿------所以闸门缓存也要一起踢。
三、生产环境里,这套模型还会在哪里裂开
3.1 三层凭证,三套过期策略
| 凭证 | 前缀 / 形态 | 典型寿命 | 谁能看见 | 过期怎么处理 |
|---|---|---|---|---|
管理员 accessToken |
At_ |
3 天;满 2 天再申请会发新的,旧的仍可用 | 仅管控面进程 | TK1002 刷新,禁止定时对刷 |
子账号 accessToken |
St_ |
样例约 143999 秒 |
仅该租户的数据面 | 按 openid 分 key 缓存 |
kitToken |
短时播放凭证 | 约 2 小时 | 前端 / 小程序 | 服务端缓存约 1 小时 |
appSecret、At_、St_ 都不下发。前端能拿到的只有 kitToken 和本租户设备列表。小程序里出现 At_,就是事故,不是「先联通再说」。
3.2 分页和批量上限,抄错会直接 4xx
| 接口 | 默认 | 上限 | 踩过的坑 |
|---|---|---|---|
listSubAccount |
pageSize=5 |
10 | 写成 20,第一页就失败 |
listSubAccountDevice |
10 | 50 | 对账漏页,以为没授权 |
subAccountDeviceList |
10 | 20 | page 从 1 开始;文档「大于 1」与默认值 1 不一致,按默认 1 联调 |
addPolicy |
--- | 10 路 / 次 | 一个小区 16 路门禁,一次塞进去会失败 |
3.3 权限语义:匹配是「有一条过就算过」
Statement 是或关系。你先给了 cam:NVR:0 的 Real,后来又给 dev:NVR 开了 DevControl,通道闸门会被设备级吞掉。对账时两头都看:queryDevicePermission 的设备级字段,和通道数组。
listSubAccountDevice 样例里权限字符串会出现 GET / UPDATE / REAL 这种大写形态,和 addPolicy 写入的 Real 并不完全一个写法。闸门里不要做精确字符串相等,按集合包含判断,并单独放行 DevControl。
3.4 和「设备托管 / 分享」别接成同一条管道
子账号管的是开发者名下设备再授权给租户 。业主 App 里那台摄像机授权给开发者,走的是设备托管 / 分享,接口是另一组(authorizedDeviceList / shareDeviceList)。多小区 SaaS 常见组合是:
- 物业用官方 App 把点位托管进开发者应用;
- 开发者再用子账号把托管进来的点位,按小区切给租户;
- 业主能不能看,仍要你自己的房号白名单裁决。
平台 Policy 解决「这个子账号能不能碰这路」;「这个业主是不是这户的人」不在 OpenAPI 里。
3.5 并发刷新和撤权延迟
token-store.js 里的 inflight 是为了防止 12 个小区同时 TK1002 把 accessToken 打爆。撤权后,已发出的 kitToken 在剩余有效期内仍可能能播------所以业务侧要维护一份「已禁用的 deviceId」,预览接口先查黑名单,再查 Policy。
四、收束:多租户不是多买几个账号
这套链路拆开就四句话:
- 一个租户一个
openid,用手机号 / 邮箱开号,映射进自己的community_tenant。 - 授权写到
cam:通道 ,单次最多 10 路,忌dev:整机、忌DevControl。 At_管划权与签发,St_管本租户台账 ,前端只拿kitToken。- 撤权分三档:通道 / 策略 / 账号,并同步踢缓存。
联调清单可以按这个打勾:
text
[ ] sign-selftest.js 通过
[ ] accessToken 返回 At_ 且已缓存
[ ] 小区 A / B 各有独立 openid
[ ] addPolicy 后 queryDevicePermission 与预期一致
[ ] subAccountDeviceList(St_A) 看不到 NVR_B
[ ] /api/preview-token 跨小区返回 403
[ ] 本小区 type=1 能拿到 kitToken
[ ] revoke 后闸门立即拒绝
延伸阅读可以顺着现行文档往下翻:子账号功能说明、账户对接说明、accessToken、getKitToken、开发规范。设备托管、轻应用嵌入、告警回调是另一条线,和本文的「租户边界」正交,不要指望一个 token 通吃。
若你正在把多小区、多工地或多园区接到同一套后台,可以先在 乐橙开放平台 建一个应用,按上面的 createSubAccount → addPolicy → subAccountDeviceList 跑通最小闭环。平台以视频与安全能力为主,开放低代码组件,适合把设备权限和播放凭证先落到可验证的接口上,再谈业务包装。