物业 SaaS 笔记:子账号 Policy 按通道隔离,At_ 管控面 / St_ 数据面治理 accessToken

周四凌晨,老周的物业 SaaS 炸了。A 小区值班员图省事,把后台接口返回的 accessToken 塞进了小程序 localStorage。第二天,B 小区一位业主用同一套前端,把地下车库一路点开了------镜头没坏,坏的是「谁有权看哪路」。

12 个小区、一台开发者应用、一个管理员 token。列表接口一拉,全量设备都出来。越权不是播放器的事,是租户边界没落在平台侧。

下面按现行账户对接接口,把多小区拆成:一个子账号、一套 Policy、两层 token。代码可直接跑,踩坑写进逻辑里。


一、为什么「设备在线」还会串小区

1.1 业务真相:多小区买的不是账号,是爆炸半径

物业中台、连锁工地、多园区安防,真正要守的不是「能不能播」,而是三条边界:

  1. 租户隔离:A 小区的值班员,默认不该枚举到 B 小区任何一路。
  2. 能力最小化 :单元门只给 Real,不要顺手给 Ptz / Talk / Format
  3. 凭证分层 :管理员 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 分钟)

  1. 打开 open.imou.com 注册并创建应用,在「我的应用 → 应用信息」拿到 appId / appSecret
  2. 把各小区摄像机绑到该应用资产下(乐橙 App 添加,或走设备托管)。全量台账用 listDeviceDetailsByPage(管理员 token),不要把这个列表直接回给某个小区前端
  3. 为每个小区准备一个手机号或邮箱,作为子账号 account
  4. 现行网关: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 };

样例里管理员 expireTime259176 秒(接近 3 天),子账号约 143999 秒。两套 TTL 不要混用同一个 Redis key。

2.3 开租户:一个小区一个 openid

createSubAccountaccount 只接受手机号或邮箱。已经开过的号不要硬创建,改走 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 单次 statementresource 不能超过 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 时,返回设备级 permissionchannelIdPermissions[]。上线前用它做对账:你以为只开了 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管理员 accessTokenkitToken 有效约 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,参数是 streamId0 主码流 / 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 小时

appSecretAt_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:0Real,后来又给 dev:NVR 开了 DevControl,通道闸门会被设备级吞掉。对账时两头都看:queryDevicePermission 的设备级字段,和通道数组。

listSubAccountDevice 样例里权限字符串会出现 GET / UPDATE / REAL 这种大写形态,和 addPolicy 写入的 Real 并不完全一个写法。闸门里不要做精确字符串相等,按集合包含判断,并单独放行 DevControl

3.4 和「设备托管 / 分享」别接成同一条管道

子账号管的是开发者名下设备再授权给租户 。业主 App 里那台摄像机授权给开发者,走的是设备托管 / 分享,接口是另一组(authorizedDeviceList / shareDeviceList)。多小区 SaaS 常见组合是:

  1. 物业用官方 App 把点位托管进开发者应用;
  2. 开发者再用子账号把托管进来的点位,按小区切给租户;
  3. 业主能不能看,仍要你自己的房号白名单裁决。

平台 Policy 解决「这个子账号能不能碰这路」;「这个业主是不是这户的人」不在 OpenAPI 里。

3.5 并发刷新和撤权延迟

token-store.js 里的 inflight 是为了防止 12 个小区同时 TK1002accessToken 打爆。撤权后,已发出的 kitToken 在剩余有效期内仍可能能播------所以业务侧要维护一份「已禁用的 deviceId」,预览接口先查黑名单,再查 Policy。


四、收束:多租户不是多买几个账号

这套链路拆开就四句话:

  1. 一个租户一个 openid ,用手机号 / 邮箱开号,映射进自己的 community_tenant
  2. 授权写到 cam: 通道 ,单次最多 10 路,忌 dev: 整机、忌 DevControl
  3. At_ 管划权与签发,St_ 管本租户台账 ,前端只拿 kitToken
  4. 撤权分三档:通道 / 策略 / 账号,并同步踢缓存。

联调清单可以按这个打勾:

text 复制代码
[ ] sign-selftest.js 通过
[ ] accessToken 返回 At_ 且已缓存
[ ] 小区 A / B 各有独立 openid
[ ] addPolicy 后 queryDevicePermission 与预期一致
[ ] subAccountDeviceList(St_A) 看不到 NVR_B
[ ] /api/preview-token 跨小区返回 403
[ ] 本小区 type=1 能拿到 kitToken
[ ] revoke 后闸门立即拒绝

延伸阅读可以顺着现行文档往下翻:子账号功能说明账户对接说明accessTokengetKitToken开发规范。设备托管、轻应用嵌入、告警回调是另一条线,和本文的「租户边界」正交,不要指望一个 token 通吃。

若你正在把多小区、多工地或多园区接到同一套后台,可以先在 乐橙开放平台 建一个应用,按上面的 createSubAccountaddPolicysubAccountDeviceList 跑通最小闭环。平台以视频与安全能力为主,开放低代码组件,适合把设备权限和播放凭证先落到可验证的接口上,再谈业务包装。

相关推荐
站大爷IP1 小时前
Python的is和==把我坑惨了,原来对象比较的水这么深
后端
fatcoder1 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
雨落倾城夏未凉1 小时前
halcon核心-颜色识别/颜色控件转换(十)
后端
SomeB1oody1 小时前
【RustyML入门】5.2. 分类指标
开发语言·后端·机器学习·rust·教程
悟天特斯2 小时前
智慧楼宇自控系统重构:从传统BA到AI自适应控制的架构演进
人工智能·物联网·重构·架构
明月_清风3 小时前
Pi Agent 深度解析:开源极简终端 AI 编码代理的终极指南
前端·后端·ai编程
程序员cxuan3 小时前
DeepSeek Harness 必装的插件公布了!
人工智能·后端·程序员
fatcoder3 小时前
玩转Nginx 03 — location 匹配规则:让不同的路径各回各家
前端·后端·nginx
pillowss3 小时前
如何去除视频背景:5 种可靠的方法
人工智能·音视频