一、标书写了客流,日志里只有挥手
连锁便利店改造项目开标那天,甲方 Demo 页上画着三块板:进店客流、货架热区、店员区人形滞留。
我们后台只订了 alarm,联调日志里刷的全是 videoMotion------挥手有、进店计数没有、热度图没有。甲方问「AI 呢?」------不是设备不会算,是你订错了消息大类。
零售场景要把客流 / 热度 / 人形拆进不同 callbackFlag,再按 msgType 分流落库。下面是可抄的接入笔记。
二、为什么零售 AI 必须「分频道订阅」
2.1 产品页承诺 vs 工程现实
零售老板要的不是「再弹一次有人」,而是三条可结算指标:
| 业务诉求 | 工程含义 | 推送大类 callbackFlag |
常见 msgType |
|---|---|---|---|
| 进店 / 离店人数 | 客流统计 | numberstat |
numberstat、manyStay |
| 哪里站得久 | 热度分析 | alarm(类型表归在报警类) |
heatmap |
| 店内有人活动 | 人形检测 | alarm |
human(及 videoMotion) |
| 人脸 / 拌线等行业智能 | 智能分析 | faceAnalysis |
aiFaceDetect、aiPerLine 等 |
只订 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 却永远无消息 → 机型无对应智能能力,或设备端未开策略;先从 alarm 的 human 做最小闭环。
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
}
]
}
action:0 进 · 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 接不上」的项目,缺的不是模型,是 把事件订进自己的管道。