运维笔记:乐橙分页台账同步 + deviceStatus 驱动离线 SLA
现行 OpenAPI ·
listDeviceDetailsByPage·setMessageCallback(deviceStatus)· 勿用旧版本协议(不再维护) · 勿用停维护的deviceList方法
周五下午,区域经理在群里问:「东区有几家店摄像机掉线?谁负责?」
运维同学切了十几次乐橙 App,又在 Excel 里对 SN——半小时后才凑出一张「疑似离线」清单。真正的问题不是不会看监控,是 多店设备状态没有进你们自己的台账和待办。
后来我们在 乐橙开放平台 用 listDeviceDetailsByPage 做全量同步,再用回调里的 deviceStatus(online/offline) 驱动 SLA 工单。下面是可落地的「同步 + 离线待办」笔记。
为什么「人肉翻 App」撑不住多店运维
| 做法 | 延迟 | 可审计性 | 多店扩展 |
|---|---|---|---|
| 督导口头报修 | 小时~天 | 差 | 差 |
| 人工打开原厂 App 逐店看 | 分钟~小时 | 差 | 差 |
| 定时全量分页同步 | 分钟级 | 好 | 好 |
| 分页同步 + 状态推送 | 秒~分钟 | 好 | 好 |
运维 SLA 要写进合同,至少需要三个可计算字段:
deviceId / 门店码
当前状态 online | offline
状态变更时间 → 超时未恢复则升级工单
乐橙侧提供两块积木:
listDeviceDetailsByPage:deviceStatus快照,适合对账、补洞、冷启动setMessageCallback且callbackFlag含deviceStatus:msgType为online/offline,适合实时驱动待办
callbackFlag=deviceStatus → msgType: online | offline
callbackFlag=alarm → msgType: videoMotion | human(防盗另文)
架构:快照同步 × 事件驱动
┌─ 多店 IPC ─┐
└─────┬──────┘
▼
乐橙云 OpenAPI
│
├─ 定时/手动 ── listDeviceDetailsByPage ──▶ 本地台账 DB
│ (全量对账)
│
└─ 推送 ── setMessageCallback ──────────▶ 桥接 HTTPS
(deviceStatus) │
├─ 更新台账状态
└─ 生成/关闭离线待办
│
▼
运维看板 / SLA 告警
| 能力 | 谁负责 | 说明 |
|---|---|---|
| 设备是否在资产池 | 绑定流程 | App 能看 ≠ 开发者池有设备 |
| 状态快照 | 分页接口 | 以云端返回为准 |
| 状态变更 | 回调 | 先 HTTP 200,再异步写库 |
| SLA 计时 | 你的系统 | 云不替你算「超时多久该升级」 |
边界:本文解决 在线/离线运维,不替代巡店预览(见辅码流文)与动检防盗(见电话提醒文)。三件事可以共用同一套台账表。
Step 0 · 调用壳与环境
// 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,
};
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;
}
APP_ID=lc0xxxxxxxx
APP_SECRET=your_secret
OPENAPI_BASE=https://openapi.lechange.cn/openapi
CALLBACK_URL=https://ops.example.com/hooks/imou
# SLA:离线超过多少分钟仍未恢复 → 升级
SLA_OFFLINE_MINUTES=15
SYNC_CRON="*/10 * * * *"
签名见 开发规范;accessToken 建议缓存。
Step 1 · 台账表设计(本地真相源)
生产用 Postgres/MySQL 均可。示例用内存 Map 方便跑通,结构按表来:
// db/schema.js —— 逻辑模型
export const DeviceColumns = {
deviceId: 'PK',
storeCode: 'string', // S01 / 东区-01
name: 'string',
status: 'online|offline|unknown',
lastSeenAt: 'datetime', // 最近一次确认在线
lastOfflineAt: 'datetime',
syncAt: 'datetime', // 最近一次分页同步写入
todoId: 'nullable', // 关联离线待办
};
export const TodoColumns = {
id: 'PK',
deviceId: 'string',
storeCode: 'string',
openedAt: 'datetime',
severity: 'P2|P1', // 超时升级
status: 'open|resolved',
resolvedAt: 'nullable',
};
门店映射(生产放 DB;演示用配置):
// config/store-map.js
export const storeMap = {
// deviceId: storeCode
// '7H0B18XXXXXXXX': 'S01',
};
export function resolveStore(deviceId, deviceName) {
if (storeMap[deviceId]) return storeMap[deviceId];
const m = String(deviceName || '').match(/^(S\d{2,})/i);
return m ? m[1].toUpperCase() : 'UNMAPPED';
}
Step 2 · 全量/增量同步:listDeviceDetailsByPage
// services/sync-devices.js
import { platformCall, adminToken } from '../lib/platform-call.js';
import { resolveStore } from '../config/store-map.js';
import { upsertDevice, listDevices } from '../db/devices.js';
export async function syncAllDevices({ pageSize = 50 } = {}) {
const token = await adminToken();
const seen = new Set();
let page = 1;
let pulled = 0;
for (;;) {
const data = await platformCall('listDeviceDetailsByPage', {
token,
page,
pageSize,
source: 'bindAndShare',
});
const list = data.deviceList ?? [];
for (const d of list) {
seen.add(d.deviceId);
const status = d.deviceStatus === 'online' ? 'online' : 'offline';
await upsertDevice({
deviceId: d.deviceId,
name: d.deviceName,
storeCode: resolveStore(d.deviceId, d.deviceName),
status,
lastSeenAt: status === 'online' ? new Date() : undefined,
lastOfflineAt: status === 'offline' ? new Date() : undefined,
syncAt: new Date(),
});
pulled += 1;
}
const total = Number(data.totalCount ?? data.count ?? pulled);
if (list.length < pageSize || pulled >= total) break;
page += 1;
await new Promise((r) => setTimeout(r, 200));
}
// 可选:本次未见的设备标 unknown,避免误删历史
const all = await listDevices();
const missing = all.filter((x) => !seen.has(x.deviceId));
return { pulled, pages: page, missingCount: missing.length, missing };
}
极简内存仓储(可换成 SQL):
// db/devices.js
const devices = new Map();
export async function upsertDevice(row) {
const prev = devices.get(row.deviceId) || {};
const next = {
...prev,
...row,
lastSeenAt: row.lastSeenAt ?? prev.lastSeenAt ?? null,
lastOfflineAt: row.lastOfflineAt ?? prev.lastOfflineAt ?? null,
todoId: row.todoId !== undefined ? row.todoId : prev.todoId ?? null,
};
// 同步时若状态变化,交给 SLA 模块处理
if (prev.status && prev.status !== next.status) {
next._statusChanged = { from: prev.status, to: next.status };
}
devices.set(row.deviceId, next);
return next;
}
export async function getDevice(deviceId) {
return devices.get(deviceId) || null;
}
export async function listDevices() {
return [...devices.values()];
}
export async function listOffline() {
return [...devices.values()].filter((d) => d.status === 'offline');
}
CLI 验收:
// scripts/run-sync.js
import 'dotenv/config';
import { syncAllDevices } from '../services/sync-devices.js';
import { listOffline } from '../db/devices.js';
const r = await syncAllDevices();
const offline = await listOffline();
console.table(r);
console.log('offline now', offline.map((d) => `${d.storeCode}:${d.deviceId}`));
踩坑 A:只拉 page=1 → 多店必漏。
踩坑 B:设备未进开发者池 → 同步永远少店(接入绑定)。
踩坑 C:把同步频率开到每 10 秒全量扫 → 配额被烧光。建议 10~15 分钟对账一次,实时靠回调。
Step 3 · 打开 deviceStatus 回调
// scripts/enable-status-callback.js
import 'dotenv/config';
import { platformCall, adminToken } from '../lib/platform-call.js';
const token = await adminToken();
await platformCall('setMessageCallback', {
token,
status: 'on',
callbackUrl: process.env.CALLBACK_URL,
// 运维 SLA 至少要 deviceStatus;可与 alarm 并存
callbackFlag: 'deviceStatus,alarm',
basePush: '2',
});
console.log(await platformCall('getMessageCallback', { token }));
| 参数 | 运维相关说明 |
|---|---|
callbackUrl | 公网 HTTPS |
callbackFlag | 必须含 deviceStatus 才有 online/offline |
getMessageCallback | 读回验收 |
踩坑 D:只订了 alarm → 永远收不到掉线事件,只能靠定时同步「碰巧」发现。
Step 4 · 桥接:先 200,再写待办 / 关单
// server/status-bridge.js
import express from 'express';
import 'dotenv/config';
import { upsertDevice, getDevice } from '../db/devices.js';
import { openOfflineTodo, resolveOfflineTodo } from '../services/sla-todos.js';
import { resolveStore } from '../config/store-map.js';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.post('/hooks/imou', (req, res) => {
res.status(200).send('ok'); // ★ 必须先应答
queueMicrotask(() => handle(req.body).catch(console.error));
});
async function handle(msg) {
const msgType = msg.msgType;
const deviceId = msg.did;
if (!deviceId) return;
if (msgType !== 'online' && msgType !== 'offline') {
// alarm 等事件交给别的处理器
return;
}
const prev = await getDevice(deviceId);
const storeCode = prev?.storeCode || resolveStore(deviceId, prev?.name);
const row = await upsertDevice({
deviceId,
storeCode,
name: prev?.name,
status: msgType, // online | offline
lastSeenAt: msgType === 'online' ? new Date() : prev?.lastSeenAt,
lastOfflineAt: msgType === 'offline' ? new Date() : prev?.lastOfflineAt,
syncAt: new Date(),
});
console.log(JSON.stringify({
event: msgType,
deviceId,
storeCode,
prev: prev?.status,
}));
if (msgType === 'offline') {
await openOfflineTodo(row);
} else if (msgType === 'online') {
await resolveOfflineTodo(deviceId);
}
}
app.listen(8080, () => console.log('status bridge :8080'));
SLA 待办服务:
// services/sla-todos.js
import { getDevice, upsertDevice } from '../db/devices.js';
const todos = new Map(); // id -> todo
let seq = 1;
export async function openOfflineTodo(device) {
if (device.todoId && todos.get(device.todoId)?.status === 'open') {
return todos.get(device.todoId); // 幂等:已有未关闭待办
}
const id = `TODO-${seq++}`;
const todo = {
id,
deviceId: device.deviceId,
storeCode: device.storeCode,
openedAt: new Date(),
severity: 'P2',
status: 'open',
resolvedAt: null,
};
todos.set(id, todo);
await upsertDevice({ deviceId: device.deviceId, todoId: id });
console.log('OPEN', id, device.storeCode, device.deviceId);
// TODO: 推企业微信 / 短信值班号
return todo;
}
export async function resolveOfflineTodo(deviceId) {
const device = await getDevice(deviceId);
if (!device?.todoId) return null;
const todo = todos.get(device.todoId);
if (!todo || todo.status !== 'open') return null;
todo.status = 'resolved';
todo.resolvedAt = new Date();
await upsertDevice({ deviceId, todoId: null });
console.log('RESOLVE', todo.id, `${todo.openedAt.toISOString()} → now`);
return todo;
}
export async function escalateOverdue(minutes = Number(process.env.SLA_OFFLINE_MINUTES || 15)) {
const now = Date.now();
const hit = [];
for (const t of todos.values()) {
if (t.status !== 'open') continue;
const ageMin = (now - t.openedAt.getTime()) / 60000;
if (ageMin >= minutes && t.severity === 'P2') {
t.severity = 'P1';
hit.push(t);
console.warn('ESCALATE P1', t.id, t.storeCode, `${Math.floor(ageMin)}m`);
// TODO: 电话升级 / 值班经理
}
}
return hit;
}
export function listOpenTodos() {
return [...todos.values()].filter((t) => t.status === 'open');
}
定时器:同步对账 + SLA 升级:
// server/workers.js
import cron from 'node-cron'; // 或 setInterval
import { syncAllDevices } from '../services/sync-devices.js';
import { escalateOverdue } from '../services/sla-todos.js';
import { listDevices } from '../db/devices.js';
import { openOfflineTodo, resolveOfflineTodo } from '../services/sla-todos.js';
// 每 10 分钟全量对账,补回调漏推
cron.schedule(process.env.SYNC_CRON || '*/10 * * * *', async () => {
const { pulled } = await syncAllDevices();
console.log('sync ok', pulled);
// 同步后根据快照修正待办(防漏)
for (const d of await listDevices()) {
if (d.status === 'offline') await openOfflineTodo(d);
if (d.status === 'online') await resolveOfflineTodo(d.deviceId);
}
});
// 每分钟扫 SLA
setInterval(() => {
escalateOverdue().catch(console.error);
}, 60 * 1000);
运维看板 API:
// routes/ops.js
import express from 'express';
import { listDevices, listOffline } from '../db/devices.js';
import { listOpenTodos } from '../services/sla-todos.js';
const router = express.Router();
router.get('/api/v1/ops/summary', async (req, res) => {
const all = await listDevices();
const offline = await listOffline();
const todos = listOpenTodos();
res.json({
total: all.length,
online: all.length - offline.length,
offline: offline.length,
openTodos: todos.length,
p1: todos.filter((t) => t.severity === 'P1').length,
storesOffline: [...new Set(offline.map((d) => d.storeCode))],
});
});
router.get('/api/v1/ops/todos', (req, res) => {
res.json({ todos: listOpenTodos() });
});
export default router;
Step 5 · 验收:像运维真实排障一样走一遍
① run-sync.js:pulled 与真实设备数一致;UNMAPPED 清单清零
② enable-status-callback.js:读回含 deviceStatus
③ 拔网线/断电源模拟离线 → 桥接日志 msgType=offline → OPEN TODO
④ 15 分钟内未恢复 → ESCALATE P1(可把 SLA_OFFLINE_MINUTES 临时改成 1 测)
⑤ 恢复网络 → msgType=online → RESOLVE TODO
⑥ 故意停桥接 20 分钟,靠 10 分钟同步仍能 open/resolve 待办(补洞)
踩坑 E:先调企微/打电话再 res.status(200) → 超时停推,整片区「假在线」。
踩坑 F:offline 抖动(弱网秒断秒连)→ 待办刷屏。应用 去抖:离线持续 N 秒再 open,或 online 后短冷却。
踩坑 G:多实例桥接重复 open → 用 deviceId 唯一未关闭待办做幂等(上文已示范)。
去抖示例:
// services/debounce-offline.js
const pending = new Map(); // deviceId -> timer
export function debounceOffline(deviceId, ms, fn) {
clearTimeout(pending.get(deviceId));
const t = setTimeout(() => {
pending.delete(deviceId);
fn();
}, ms);
pending.set(deviceId, t);
}
export function cancelDebounce(deviceId) {
clearTimeout(pending.get(deviceId));
pending.delete(deviceId);
}
// offline 事件:debounceOffline(id, 60_000, () => openOfflineTodo(...))
// online 事件:cancelDebounce(id); resolveOfflineTodo(id)
1. SLA 怎么写才不被业务方怼
| 指标 | 建议定义 | 数据来源 |
|---|---|---|
| 发现时长 | 掉线 → 待办创建 | 回调优先,同步兜底 |
| 响应时长 | 待办创建 → 值班确认 | 你的工单系统 |
| 恢复时长 | offline → online | 回调/同步 |
| 可用性 | 门店维度 online 占比 | 台账快照 |
合同别写「平台保证永不掉线」——应写「掉线后 T 分钟内生成待办并通知值班」。
2. 配额与性能
□ 全量同步 10~15 分钟一次,足够对账
□ 回调承担实时;同步承担补洞
□ pageSize 50~100,页间 sleep 200ms
□ 关注开放平台「我的资源」月调用量
3. 安全与权限
□ 回调 URL 带 path token 或验签
□ 运维看板按区域授权,加盟商互不可见
□ 日志里 SN 可保留,门店地址需脱敏
□ appSecret 仅 BFF
4. 与巡店/防盗的拼装
同一张设备台账
├─ deviceStatus → 离线 SLA(本文)
├─ bindDeviceLive 辅码流 → 远程巡店
└─ alarm 回调 → 动检/电话提醒
状态离线时,巡店入口应直接标红并禁用取流,避免督导点开黑屏还浪费配额。
本文结论
多店运维最小闭环:
listDeviceDetailsByPage 定时同步台账
+ setMessageCallback(deviceStatus) 实时 online/offline
→ 离线开待办 / 上线关待办
→ 超时升级 P1(你的 SLA)
人肉翻 App 解决不了可审计;只有分页没有回调 发现太慢;只有回调没有同步 怕漏推。两块积木缺一不可。
如果你正在管十几家、上百家门店的摄像机运维,先在 乐橙开放平台 open.imou.com 创建应用,把设备绑进开发者资产池,跑通本文的 run-sync.js 与 deviceStatus 回调,再把 SLA_OFFLINE_MINUTES 写成你和业务方都认的数字。
乐橙开放平台以视频技术与安全为核心,开放 OpenAPI、轻应用、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频场景应用——设备状态进你们自己的台账与待办,巡店和防盗才能建在同一地基上。
注册入口:https://open.imou.com/?article_id=cjqptKjME1Yl9GJM
今晚先交付一张「离线门店清单 API」,比再开一次对线会更有用。

297

被折叠的 条评论
为什么被折叠?



