乐橙分页台账同步 + deviceStatus 驱动离线 SLA**

运维笔记:乐橙分页台账同步 + deviceStatus 驱动离线 SLA

现行 OpenAPI · listDeviceDetailsByPage · setMessageCallbackdeviceStatus)· 勿用旧版本协议(不再维护) · 勿用停维护的 deviceList 方法


周五下午,区域经理在群里问:「东区有几家店摄像机掉线?谁负责?」

运维同学切了十几次乐橙 App,又在 Excel 里对 SN——半小时后才凑出一张「疑似离线」清单。真正的问题不是不会看监控,是 多店设备状态没有进你们自己的台账和待办

后来我们在 乐橙开放平台listDeviceDetailsByPage 做全量同步,再用回调里的 deviceStatus(online/offline) 驱动 SLA 工单。下面是可落地的「同步 + 离线待办」笔记。


为什么「人肉翻 App」撑不住多店运维

做法延迟可审计性多店扩展
督导口头报修小时~天
人工打开原厂 App 逐店看分钟~小时
定时全量分页同步分钟级
分页同步 + 状态推送秒~分钟

运维 SLA 要写进合同,至少需要三个可计算字段:

deviceId / 门店码
当前状态 online | offline
状态变更时间 → 超时未恢复则升级工单

乐橙侧提供两块积木:

  1. listDeviceDetailsByPagedeviceStatus 快照,适合对账、补洞、冷启动
  2. setMessageCallbackcallbackFlagdeviceStatusmsgTypeonline / 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.jsdeviceStatus 回调,再把 SLA_OFFLINE_MINUTES 写成你和业务方都认的数字。

乐橙开放平台以视频技术与安全为核心,开放 OpenAPI、轻应用、OpenSDK 等低代码开发组件,一站式帮助第三方厂商与个人开发者快速、低成本落地视频场景应用——设备状态进你们自己的台账与待办,巡店和防盗才能建在同一地基上。

注册入口:https://open.imou.com/?article_id=cjqptKjME1Yl9GJM
今晚先交付一张「离线门店清单 API」,比再开一次对线会更有用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值