零售后端笔记:AI 能力接不上?先分清 numberstat / alarm / faceAnalysis 三条管

一、标书写了客流,日志里只有挥手

连锁便利店改造项目开标那天,甲方 Demo 页上画着三块板:进店客流、货架热区、店员区人形滞留

我们后台只订了 alarm,联调日志里刷的全是 videoMotion------挥手有、进店计数没有、热度图没有。甲方问「AI 呢?」------不是设备不会算,是你订错了消息大类

零售场景要把客流 / 热度 / 人形拆进不同 callbackFlag,再按 msgType 分流落库。下面是可抄的接入笔记。


二、为什么零售 AI 必须「分频道订阅」

2.1 产品页承诺 vs 工程现实

零售老板要的不是「再弹一次有人」,而是三条可结算指标:

业务诉求 工程含义 推送大类 callbackFlag 常见 msgType
进店 / 离店人数 客流统计 numberstat numberstatmanyStay
哪里站得久 热度分析 alarm(类型表归在报警类) heatmap
店内有人活动 人形检测 alarm human(及 videoMotion
人脸 / 拌线等行业智能 智能分析 faceAnalysis aiFaceDetectaiPerLine

只订 alarm,客流管永远不会响;只订 numberstat,人形和热度进不来。一张看板,往往要订多个大类。

2.2 推送优先,轮询补洞

方式 延迟 配额 零售适用性
只看原厂 App 与你无关 进不了自建看板
定时拉告警列表 分钟级 烧调用量 客流峰值易漏
HTTP 回调分流 秒级 按事件 正路

实时链路靠 setMessageCallback;漏推时再用告警查询类接口补洞。列表资产仍用 listDeviceDetailsByPage 验收能力集------别用已停维护的列表方法名。

2.3 架构:一条 HTTPS,三个处理器

text 复制代码
┌─ 门店 IPC(客流/热度/人形能力视机型)─┐
└────────────────┬─────────────────────┘
                 ▼
          厂商视频云 OpenAPI
                 │
     setMessageCallback
     callbackFlag =
       numberstat,alarm,faceAnalysis
                 │
                 ▼
         你的桥接 HTTPS(先 200)
                 │
     ┌───────────┼───────────┐
     ▼           ▼           ▼
  客流聚合    热度落库     人形/智能
  (进/出/滞留) (heatData)   (human / ai*)
     │           │           │
     └───────────┴───────────┘
                 ▼
         零售看板 / BI / 企微推送
能力 谁负责 说明
设备会不会报客流 机型 + 设备端策略 能力集无客流字段则永远无 numberstat
订哪个频道 callbackFlag 逗号拼接多个大类
怎么分流 你的桥接 msgType 路由,勿一把梭写库
图片 URL 部分 face 消息 云端图通常短时有效,尽快转存

边界 :本文讲 消息推送接入,不替代收银 POS、会员 CRM。客流数字要和收银订单对齐,是下一层数仓的事。


三、从能力验收到三分流落库

Step 0 · 调用壳

javascript 复制代码
// 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;
}
bash 复制代码
# .env.example
APP_ID=your_app_id
APP_SECRET=your_secret
OPENAPI_BASE=https://YOUR_REGION_GATEWAY/openapi
CALLBACK_URL=https://retail.example.com/hooks/events
# 按店实际能力裁剪;不要默认全开
CALLBACK_FLAGS=numberstat,alarm,faceAnalysis

Step 1 · 先验收:这台机「会报什么」

零售项目最常见翻车:PPT 写了客流,机型能力集里没有 。用分页接口拉 deviceAbility / channelAbility 做准入。

javascript 复制代码
// scripts/inspect-retail-ability.js
import 'dotenv/config';
import { platformCall, adminToken } from '../lib/platform-call.js';

const HINTS = {
  // 能力集字符串因机型而异,用模糊匹配做「是否值得订频道」的预检
  footfall: /NumberStat|Passenger|PeopleCount|Stay|客流/i,
  heat: /Heat|Heatmap|热度/i,
  human: /AiHuman|AiHumanCar|Human/i,
  face: /Face|FaceDetect|FaceCompa/i,
};

function scoreAbility(ability = '') {
  return {
    raw: ability,
    maybeFootfall: HINTS.footfall.test(ability),
    maybeHeat: HINTS.heat.test(ability),
    maybeHuman: HINTS.human.test(ability),
    maybeFace: HINTS.face.test(ability),
  };
}

const token = await adminToken();
let page = 1;
const pageSize = 50;
const rows = [];

for (;;) {
  const data = await platformCall('listDeviceDetailsByPage', {
    token,
    page,
    pageSize,
    source: 'bindAndShare',
  });
  const list = data.deviceList ?? [];
  if (!list.length) break;

  for (const d of list) {
    const ch0 = d.channelList?.[0] || {};
    const ability = [d.deviceAbility, ch0.channelAbility].filter(Boolean).join(',');
    rows.push({
      deviceId: d.deviceId,
      name: d.deviceName,
      status: d.deviceStatus,
      ...scoreAbility(ability),
    });
  }
  if (list.length < pageSize) break;
  page += 1;
}

console.table(rows.map((r) => ({
  deviceId: r.deviceId,
  status: r.status,
  footfall: r.maybeFootfall,
  heat: r.maybeHeat,
  human: r.maybeHuman,
  face: r.maybeFace,
})));

踩坑 A :能力集预检只是「值得试」,最终以 能否收到对应 msgType 为准------设备端还要打开客流/热度/人形开关。

踩坑 B:设备在原厂 App 可见,但不在开发者资产池 → 回调永远静音。先绑定进池,再订推送。


Step 2 · 登记回调:按零售需求拼 callbackFlag

javascript 复制代码
// scripts/enable-retail-callback.js
import 'dotenv/config';
import { platformCall, adminToken } from '../lib/platform-call.js';

const token = await adminToken();
const flags = (process.env.CALLBACK_FLAGS || 'numberstat,alarm').trim();

await platformCall('setMessageCallback', {
  token,
  status: 'on',
  callbackUrl: process.env.CALLBACK_URL,
  callbackFlag: flags,
  basePush: '2',
});

const cfg = await platformCall('getMessageCallback', { token });
console.log(cfg);
// 期望:status=on,callbackFlag 含你声明的大类

推荐组合(按店裁剪,别无脑全开):

text 复制代码
基础零售看板:numberstat,alarm
  └─ 客流 numberstat / manyStay
  └─ 人形 human、热度 heatmap、动检 videoMotion

要做人脸/拌线:再加 faceAnalysis
  └─ aiFaceDetect / aiAFaceCompa / aiSFaceCompa / aiPerLine ...

踩坑 C :以为「订了 alarm 就有客流」→ 客流必须含 numberstat

踩坑 D :订了 faceAnalysis 却永远无消息 → 机型无对应智能能力,或设备端未开策略;先从 alarmhuman 做最小闭环。


Step 3 · 认清三种报文(先贴样例,再写分流)

客流(callbackFlag=numberstat):

json 复制代码
{
  "msgType": "numberstat",
  "appId": "lcdxxxxxxxxx",
  "deviceId": "DEVICE_SERIAL",
  "detail": [
    {
      "channelId": "0",
      "action": "0",
      "beginTime": "20190523111256",
      "beginUtcTime": "20190523111256Z",
      "endTime": "20190523111256",
      "endUtcTime": "20190523111256Z",
      "number": 12
    }
  ]
}

action0 进 · 1 出 · 2 停留。滞留类还可能看到 manyStay

热度(msgType=heatmap,类型定义挂在报警大类下,需订 alarm):

json 复制代码
{
  "msgType": "heatmap",
  "channelId": "0",
  "localBeginTime": "20031215T144307",
  "localEndTime": "20031215T144307",
  "utcBeginTime": "20031215T144307Z",
  "utcEndTime": "20031215T144307Z",
  "desc": {
    "width": "200",
    "height": "100",
    "heatData": "XXXXXXXX",
    "min": "1",
    "max": "188",
    "average": "156"
  }
}

人形(同属 alarm):

json 复制代码
{
  "id": 2447736561,
  "did": "DEVICE_SERIAL",
  "cid": 0,
  "msgType": "human",
  "time": 1475052555,
  "cname": "通道名",
  "token": "optional-cloud-record-token"
}

注意字段漂移:客流/人脸常用 deviceId,普通报警常用 did------桥接里要统一抽取。


Step 4 · 桥接:先 200,再按 msgType 路由

文档要求:多次不返回 200 会停推。零售峰值时段尤其不能在应答前写库或画热图。

javascript 复制代码
// services/msg-id.js
export function pickDeviceId(msg) {
  return msg.deviceId || msg.did || msg.msgDeviceId || null;
}

export function pickChannelId(msg) {
  if (msg.channelId !== undefined && msg.channelId !== '') return String(msg.channelId);
  if (msg.cid !== undefined && msg.cid !== null) return String(msg.cid);
  return '0';
}
javascript 复制代码
// services/retail-handlers.js
import fs from 'node:fs/promises';
import path from 'node:path';
import { pickDeviceId, pickChannelId } from './msg-id.js';

const store = {
  footfall: [], // Demo 内存;生产换时序库
  heat: [],
  presence: [],
};

export function getRetailStore() {
  return store;
}

/** action: 0 进 / 1 出 / 2 停留 */
export async function handleNumberstat(msg) {
  const deviceId = pickDeviceId(msg);
  for (const row of msg.detail || []) {
    const rec = {
      deviceId,
      channelId: String(row.channelId ?? '0'),
      action: String(row.action),
      number: Number(row.number || 0),
      beginUtcTime: row.beginUtcTime,
      endUtcTime: row.endUtcTime,
      receivedAt: new Date().toISOString(),
    };
    store.footfall.push(rec);
    console.log('FOOTFALL', JSON.stringify(rec));
  }
}

export async function handleHeatmap(msg) {
  const deviceId = pickDeviceId(msg) || 'unknown';
  const desc = msg.desc || {};
  const rec = {
    deviceId,
    channelId: String(msg.channelId ?? '0'),
    utcBeginTime: msg.utcBeginTime,
    utcEndTime: msg.utcEndTime,
    width: Number(desc.width || 0),
    height: Number(desc.height || 0),
    min: desc.min,
    max: desc.max,
    average: desc.average,
    heatDataBytes: String(desc.heatData || '').length,
    receivedAt: new Date().toISOString(),
  };
  store.heat.push(rec);

  if (desc.heatData) {
    const dir = path.join(process.cwd(), 'data', 'heatmap');
    await fs.mkdir(dir, { recursive: true });
    const file = path.join(dir, `${deviceId}_${msg.utcBeginTime || Date.now()}.json`);
    await fs.writeFile(file, JSON.stringify({ ...rec, heatData: desc.heatData }));
  }
  console.log('HEAT', JSON.stringify(rec));
}

export async function handleHuman(msg) {
  const rec = {
    deviceId: pickDeviceId(msg),
    channelId: pickChannelId(msg),
    msgType: msg.msgType,
    time: msg.time,
    alarmId: msg.id,
    receivedAt: new Date().toISOString(),
  };
  store.presence.push(rec);
  console.log('HUMAN', JSON.stringify(rec));
}

export async function handleFaceAnalysis(msg) {
  const pics = msg.picUrlArray || [];
  const rec = {
    deviceId: pickDeviceId(msg),
    channelId: pickChannelId(msg),
    msgType: msg.msgType,
    time: msg.time || msg.localTime,
    picCount: pics.length,
    receivedAt: new Date().toISOString(),
  };
  store.presence.push(rec);
  console.log('FACE', JSON.stringify(rec));

  for (const [i, url] of pics.entries()) {
    if (!url) continue;
    try {
      const res = await fetch(url);
      if (!res.ok) continue;
      const buf = Buffer.from(await res.arrayBuffer());
      const dir = path.join(process.cwd(), 'data', 'faces');
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(
        path.join(dir, `${rec.deviceId}_${Date.now()}_${i}.jpg`),
        buf,
      );
    } catch (e) {
      console.warn('face pic save fail', e.message);
    }
  }
}

桥接入口:

javascript 复制代码
// server/retail-bridge.js
import express from 'express';
import 'dotenv/config';
import { pickDeviceId, pickChannelId } from '../services/msg-id.js';
import {
  handleNumberstat,
  handleHeatmap,
  handleHuman,
  handleFaceAnalysis,
  getRetailStore,
} from '../services/retail-handlers.js';

const app = express();
app.use(express.json({ limit: '4mb' })); // heatmap / 人脸描述可能偏大

app.post('/hooks/events', (req, res) => {
  res.status(200).send('ok'); // ★ 必须先应答
  queueMicrotask(() => route(req.body).catch(console.error));
});

async function route(msg) {
  const msgType = msg?.msgType;
  if (!msgType) return;

  if (msgType === 'numberstat' || msgType === 'manyStay') {
    return handleNumberstat(msg);
  }
  if (msgType === 'heatmap') {
    return handleHeatmap(msg);
  }
  if (msgType === 'human') {
    return handleHuman(msg);
  }
  if (
    msgType === 'aiFaceDetect' ||
    msgType === 'aiAFaceCompa' ||
    msgType === 'aiSFaceCompa' ||
    msgType === 'rtFaceDetect' ||
    msgType === 'rtFaceCompa' ||
    String(msgType).startsWith('ai')
  ) {
    return handleFaceAnalysis(msg);
  }

  // videoMotion:零售白天噪声大,默认可只记采样
  if (msgType === 'videoMotion') {
    console.log('MOTION_SAMPLE', pickDeviceId(msg), pickChannelId(msg));
  }
}

app.get('/api/v1/retail/summary', (_req, res) => {
  const s = getRetailStore();
  const inCount = s.footfall
    .filter((x) => x.action === '0')
    .reduce((a, b) => a + b.number, 0);
  const outCount = s.footfall
    .filter((x) => x.action === '1')
    .reduce((a, b) => a + b.number, 0);

  res.json({
    footfallEvents: s.footfall.length,
    enter: inCount,
    leave: outCount,
    heatFrames: s.heat.length,
    presenceEvents: s.presence.length,
    lastHeatAvg: s.heat.at(-1)?.average ?? null,
  });
});

app.listen(8080, () => console.log('retail bridge :8080'));

Step 5 · 客流聚合:给看板「今天进了多少人」

推送是片段,看板要的是日汇总。先贴聚合,再解释口径。

javascript 复制代码
// services/footfall-rollup.js
import { getRetailStore } from './retail-handlers.js';

/** 简易日汇总:按设备 + action 累加 number */
export function rollupFootfallByDay(dayPrefix /* '20190523' */) {
  const map = new Map();
  for (const row of getRetailStore().footfall) {
    const keyTime = (row.beginUtcTime || '').replace(/T.*/, '').replace(/Z$/, '');
    const day = keyTime.slice(0, 8);
    if (dayPrefix && day !== dayPrefix) continue;
    const k = `${row.deviceId}|${row.action}`;
    map.set(k, (map.get(k) || 0) + row.number);
  }
  return [...map.entries()].map(([k, number]) => {
    const [deviceId, action] = k.split('|');
    return { deviceId, action, number };
  });
}

口径注意number该时间段检测到的人数,不是「唯一访客 ID」。和会员系统去重是你的 BI 层工作,别在回调里假装算 UV。


Step 6 · 验收清单(按业务验收,不只按 HTTP 200)

text 复制代码
① inspect-retail-ability.js:目标机 online,能力预检与标书对齐
② enable-retail-callback.js:getMessageCallback 含 numberstat 与/或 alarm
③ 客流:过人线/进门 → 日志 FOOTFALL,action 0/1 合理
④ 热度:策略开启后出现 HEAT,width/height/heatDataBytes > 0
⑤ 人形:店内走动 → HUMAN(白天可降噪,勿与防盗电话混用)
⑥ faceAnalysis(可选):aiFaceDetect 进 FACE,图片已转存本地
⑦ 故意延迟写库仍先 200;断桥接后恢复,不丢后续推送

踩坑 E :热度报文很大,express.json 默认 100kb 直接 413 → 限流调到数 MB,或网关放宽。

踩坑 F :人脸 picUrlArray 隔天失效 → 收到就转存。

踩坑 G :把白天 videoMotion 和客流一起推值班群 → 噪声淹没真正客流异常;动检只采样或营业外启用。


四、边界、性能与合规

4.1 能力边界:别在合同里写设备做不到的 AI

text 复制代码
□ 客流:依赖 numberstat 能力 + 设备端客流规则(方向线/区域)
□ 热度:heatmap 归 alarm 订阅;heatData 需自建可视化
□ 人形:AiHuman 类能力 + alarm;不等于人脸识别
□ 人脸/拌线:faceAnalysis;涉及人像须合规告知与留存策略

4.2 性能与配额

text 复制代码
□ 回调只做「校验 + 入队」,聚合/画图丢给 Worker
□ 热度帧按分钟落对象存储,看板读摘要字段 min/max/average
□ 客流高峰别同步打第三方 BI;用队列削峰
□ 关注开放平台月调用与推送失败监控(你侧 5xx 会连坐停推)

4.3 隐私与安全

text 复制代码
□ 人脸图默认私有桶,带过期 URL
□ 回调 URL 带 path token;内网服务不裸奔
□ 日志打印 number/action,避免默认打印完整 heatData / 人脸特征
□ 加盟商数据按 storeCode 隔离

4.4 和防盗 / 巡店怎么拼

text 复制代码
同一套桥接
  ├─ numberstat / heatmap → 零售经营看板(本文)
  ├─ human(营业外)     → 防盗电话提醒(另文)
  └─ deviceStatus        → 离线 SLA(另文)

白天客流与夜间防盗共用 human 时,必须加营业时段开关,否则店员走动会打爆值班电话。


五、先订对频道,再谈智能零售

零售 AI 接入的最小正确姿势:

text 复制代码
listDeviceDetailsByPage 验收能力
  → setMessageCallback 按需订 numberstat + alarm(+ faceAnalysis)
  → 桥接先 200,再按 msgType 分流
  → 客流聚合 / 热度落库 / 人形或人脸转存

只会订 alarm ,看板永远像安防;订对 numberstat 却不做聚合 ,甲方仍然看不到「今日进店」;热度不落盘 ,第二天 heatData 就只剩回忆。

如果你卡在「App 里有客流数字、自建服务没有」,先查三件事:资产池是否有设备、callbackFlag 是否含 numberstat、桥接是否在业务逻辑前返回了 200------多数「AI 接不上」的项目,缺的不是模型,是 把事件订进自己的管道

相关推荐
维基框架2 小时前
维基框架(Wiki-Framework) 1.2.0:Mybatis 升级为主从读写分离
java·后端·架构
Larcher2 小时前
MCP协议实战:让AI Agent跨进程调用工具的正确姿势
人工智能·后端
星恒讯工业路由器2 小时前
三大工业以太网协议选型对比
网络·物联网·ethercat·profinet·ethernet/ip·工业以太网协议
Reart3 小时前
Leetcode 121. 买卖股票的最佳时机(717)
后端·算法
Oflycomm3 小时前
物理AI时代的“连接底座”:Wi-Fi 7模组如何撑起具身智能的神经网络
人工智能·深度学习·神经网络·物联网·具身智能·wifi7
Reart3 小时前
Leetcode 337.打家劫舍3(717)
后端·算法
Hotchip_MEMS4 小时前
智能手表与语音交互:MP381A-AB08E MEMS麦克风的低功耗方案
人工智能·物联网·制造
站大爷IP4 小时前
Python的浅拷贝把我坑惨了,原来copy()和deepcopy()的区别这么大
后端
卷无止境5 小时前
Apache Ossie:打破数据孤岛的语义互操作标准
后端·数据分析