feat(device): 一期设备域 I1-I5 全量实现(APP 接口/后台管理/OTA/MQTT/直播)
模块接入:
- 新增 ruoyi-device(org.dromara.device)并注册到根/ruoyi-modules/ruoyi-admin pom
APP 侧(/app/**,需登录,仅本人绑定设备):
- 账号闭环:短信/邮箱验证码注册登录、密码重置(不跨界返回散列)
- 设备:绑定/列表/详情/重命名/解绑(越权统一"设备不存在",防 IDOR)
- 眼罩开关(/app/device/{deviceNo}/hood,低电不对称拦截)
- 定位(/app/device/{deviceNo}/location)
- 直播(/app/stream 6 端点 + /app/device/{deviceNo}/live,自建 ZLMediaKit,设备主动推流)
后台(/device/**):
- 设备 CRUD/统计/删除/导出(ExcelBuilder)
- OTA(6 端点:升级包管理 + 批量下发,total==created+skipped+failed)
MQTT(T05):
- 遥测/事件/ack 监听、指令下发+回执(防串设备)、离线扫描、低电告警
- 报文身份仅来自 Topic,反查 device_id 防跨设备伪造
数据与脚本:
- script/sql/ry_device.sql(6 表/字典/权限字/菜单)、bm_patch.sql
- script/mqtt 设备模拟器(mqtt-simulate.js)
安全:
- MQTT broker 凭据外置为环境变量 MQTT_USERNAME/MQTT_PASSWORD,不落明文
- ZLM webhook 密钥外置为环境变量 ZLM_SECRET(未配置时 /zlm/hook/** 失败关闭),不落明文
This commit is contained in:
1
script/mqtt/.gitignore
vendored
Normal file
1
script/mqtt/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
639
script/mqtt/mqtt-simulate.js
Normal file
639
script/mqtt/mqtt-simulate.js
Normal file
@@ -0,0 +1,639 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* mqtt-simulate.js —— 设备侧模拟器(联调用,无需硬件)
|
||||
*
|
||||
* 把《设备通信协议-一期.md》附录 A 的手工 mosquitto 命令固化成一个可重复执行的进程,
|
||||
* 让**下行闭环(§8.2)**与**前置拦截(§8.3)**在真机到位之前就能端到端验证。
|
||||
*
|
||||
* 它模拟的是「设备侧」行为,因此刻意做两件服务端做不到的事:
|
||||
* 1. 收到 `command/hood` 后按需回执(或不回)——没有它,「只有 ack 才算成功」这条不变量无法被验证;
|
||||
* 2. 自己维护本地 `hoodCoverOn`,ack 成功后才切换——用真机语义约束模拟器,避免模拟器比真机更宽松。
|
||||
*
|
||||
* 依赖:node >= 18 + mqtt 包(在本目录执行 `npm install`)
|
||||
*
|
||||
* 用法:
|
||||
* node mqtt-simulate.js --device-no BM202609160001
|
||||
* node mqtt-simulate.js --device-no BM202609160001 --scenario lowbat
|
||||
* node mqtt-simulate.js --device-no BM202609160001 --reply none # 用例 5:5s 超时
|
||||
* node mqtt-simulate.js --device-no BM202609160001 --reply reject # 失败分支
|
||||
* node mqtt-simulate.js --scenario register --imei 861234567890123 # §6.2 注册
|
||||
* node mqtt-simulate.js --dry-run # 只看报文,不连 broker
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const mqtt = require('mqtt');
|
||||
|
||||
/** 设备编号格式:BM + 8 位日期 + 4 位序号(与 MqttTopics.DEVICE_NO_REGEX 同一口径) */
|
||||
const DEVICE_NO_REGEX = /^BM\d{12}$/;
|
||||
|
||||
/** IMEI 格式:15 位数字(与 MqttTopics.IMEI_REGEX 同一口径) */
|
||||
const IMEI_REGEX = /^\d{15}$/;
|
||||
|
||||
// 凭据不落明文:通过环境变量注入(export MQTT_BROKER / MQTT_USERNAME / MQTT_PASSWORD)
|
||||
const DEFAULTS = {
|
||||
broker: process.env.MQTT_BROKER || 'mqtt://47.97.217.123:1883',
|
||||
username: process.env.MQTT_USERNAME || '',
|
||||
password: process.env.MQTT_PASSWORD || '',
|
||||
scenario: 'online',
|
||||
reply: 'auto',
|
||||
interval: 60000,
|
||||
count: 0,
|
||||
deviceNo: '',
|
||||
imei: '',
|
||||
hoodCover: 0,
|
||||
offlineAfter: 5000,
|
||||
otaStepMs: 2000,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
const USAGE = `
|
||||
设备侧 MQTT 模拟器
|
||||
|
||||
必填(register 场景为 --imei):
|
||||
--device-no <no> 设备编号,形如 BM202609160001
|
||||
|
||||
可选:
|
||||
--broker <url> broker 地址 默认 ${DEFAULTS.broker}
|
||||
--username <u> 用户名 默认 ${DEFAULTS.username}
|
||||
--password <p> 密码 默认 ${DEFAULTS.password}
|
||||
--scenario <name> 设备状态场景 默认 online
|
||||
online 周期遥测 + 自动回执(用例 1/4/6)
|
||||
lowbat 低电遥测,验 §8.3 低电拦截与告警
|
||||
offline 发一条 offline 事件后退出(用例 2 · 主动下线路径)
|
||||
silent 静默不再上报(用例 2 · 心跳超时路径,需等 120s)
|
||||
register 走 §6.2 注册链路(配合 --imei)
|
||||
--reply <mode> 对下行指令的回执方式 默认 auto
|
||||
auto 正常回 ack{ok:true}(OTA 含进度事件)
|
||||
none 不回执 —— 验 5s 超时(用例 5)
|
||||
reject 回 ack{ok:false,code:3} —— 验失败分支
|
||||
--interval <ms> 遥测上报周期 默认 60000(协议 Q2 默认 60s)
|
||||
--count <n> 发 n 包遥测后退出 默认 0(不限)
|
||||
--hood-cover <0|1> 本地眼罩初始状态 默认 0(打开)
|
||||
--imei <15位> register 场景必填
|
||||
--offline-after <ms> offline 场景:上线后多久发 offline 事件 默认 5000
|
||||
--ota-step-ms <ms> OTA 进度事件间隔(真机为 10s,联调默认加速)默认 2000
|
||||
--dry-run 只打印将要发送的报文,不连接 broker
|
||||
--help 显示本帮助
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 参数解析
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 解析命令行参数。
|
||||
*
|
||||
* @param {string[]} argv process.argv.slice(2)
|
||||
* @return {object} 合并默认值后的配置
|
||||
*/
|
||||
function parseArgs(argv) {
|
||||
const opts = { ...DEFAULTS };
|
||||
const map = {
|
||||
'--broker': 'broker',
|
||||
'--username': 'username',
|
||||
'--password': 'password',
|
||||
'--device-no': 'deviceNo',
|
||||
'--scenario': 'scenario',
|
||||
'--reply': 'reply',
|
||||
'--imei': 'imei',
|
||||
};
|
||||
const numMap = {
|
||||
'--interval': 'interval',
|
||||
'--count': 'count',
|
||||
'--hood-cover': 'hoodCover',
|
||||
'--offline-after': 'offlineAfter',
|
||||
'--ota-step-ms': 'otaStepMs',
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
opts.help = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--dry-run') {
|
||||
opts.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (map[arg]) {
|
||||
opts[map[arg]] = requireValue(argv, ++i, arg);
|
||||
continue;
|
||||
}
|
||||
if (numMap[arg]) {
|
||||
const raw = requireValue(argv, ++i, arg);
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num) || num < 0) {
|
||||
fail(`参数 ${arg} 需要非负数字,收到: ${raw}`);
|
||||
}
|
||||
opts[numMap[arg]] = num;
|
||||
continue;
|
||||
}
|
||||
fail(`未知参数: ${arg}(用 --help 查看用法)`);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取参数值,缺失时终止。
|
||||
*
|
||||
* @param {string[]} argv 参数数组
|
||||
* @param {number} index 值所在下标
|
||||
* @param {string} name 参数名(报错用)
|
||||
* @return {string} 参数值
|
||||
*/
|
||||
function requireValue(argv, index, name) {
|
||||
const value = argv[index];
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
fail(`参数 ${name} 缺少取值`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印错误并退出。
|
||||
*
|
||||
* @param {string} msg 错误信息
|
||||
*/
|
||||
function fail(msg) {
|
||||
console.error(`[错误] ${msg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 报文构造
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 构造上行 Topic 集合。
|
||||
*
|
||||
* @param {string} deviceNo 设备编号
|
||||
* @return {object} Topic 集合
|
||||
*/
|
||||
function topicsOf(deviceNo) {
|
||||
return {
|
||||
telemetry: `bm/${deviceNo}/telemetry`,
|
||||
event: `bm/${deviceNo}/event`,
|
||||
ack: `bm/${deviceNo}/ack`,
|
||||
commandWildcard: `bm/${deviceNo}/command/#`,
|
||||
provisionRegister: 'bm/provision/register',
|
||||
provisionAck: (imei) => `bm/provision/ack/${imei}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备本地状态与遥测生成器。
|
||||
*
|
||||
* 这里的「本地状态」是模拟器的核心:它只在**回执成功**后才切换眼罩状态,
|
||||
* 从而使服务端 `hood_cover_on` 的回写路径能被真实地验证(而不是模拟器自己抢先改口)。
|
||||
*/
|
||||
class SimulatedDevice {
|
||||
|
||||
/**
|
||||
* @param {string} scenario 场景名
|
||||
* @param {number} initialCover 眼罩初始状态
|
||||
*/
|
||||
constructor(scenario, initialCover) {
|
||||
this.scenario = scenario;
|
||||
this.hoodCoverOn = initialCover;
|
||||
this.hoodConnected = 1;
|
||||
this.signalLevel = 3;
|
||||
this.hoodBattery = scenario === 'lowbat' ? 12 : 86;
|
||||
this.ringBattery = scenario === 'lowbat' ? 15 : 74;
|
||||
this.ticks = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一包遥测(§5.1:6 个必填字段全带,整份快照)。
|
||||
*
|
||||
* @return {object} 遥测报文
|
||||
*/
|
||||
telemetry() {
|
||||
this.ticks += 1;
|
||||
// lowbat 场景保持低电,用于稳定复现 §8.3 拦截与低电告警(不被滞回恢复掉)
|
||||
if (this.scenario !== 'lowbat') {
|
||||
this.drain();
|
||||
}
|
||||
return {
|
||||
v: 1,
|
||||
ts: Date.now(),
|
||||
hoodBattery: this.hoodBattery,
|
||||
ringBattery: this.ringBattery,
|
||||
signalLevel: this.signalLevel,
|
||||
hoodConnected: this.hoodConnected,
|
||||
hoodCoverOn: this.hoodCoverOn,
|
||||
lng: 116.397128,
|
||||
lat: 39.916527,
|
||||
address: '北京市东城区东长安街',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 电量与信号缓慢衰减,让联调中的数值看起来像真实设备。
|
||||
*/
|
||||
drain() {
|
||||
if (this.ticks % 5 === 0 && this.hoodBattery > 30) {
|
||||
this.hoodBattery -= 1;
|
||||
}
|
||||
if (this.ticks % 7 === 0 && this.ringBattery > 30) {
|
||||
this.ringBattery -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造事件报文(§5.2)。
|
||||
*
|
||||
* @param {string} eventType 事件类型
|
||||
* @param {object} extra 附加字段
|
||||
* @return {object} 事件报文
|
||||
*/
|
||||
function eventPayload(eventType, extra) {
|
||||
return { v: 1, ts: Date.now(), eventType, ...extra };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造回执报文(§5.3)。
|
||||
*
|
||||
* @param {string} msgId 下行指令的 msgId,必须原样回传
|
||||
* @param {string} cmd 指令类型
|
||||
* @param {object} extra 附加字段(ok/code/msg/progress)
|
||||
* @return {object} 回执报文
|
||||
*/
|
||||
function ackPayload(msgId, cmd, extra) {
|
||||
return { v: 1, msgId, cmd, ...extra };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 日志
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 带时间戳的日志。
|
||||
*
|
||||
* @param {string} msg 日志内容
|
||||
*/
|
||||
function log(msg) {
|
||||
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
console.log(`[${ts}] ${msg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数校验:先校验、后打印横幅,避免「先输出一段正常日志再报错」的观感。
|
||||
*
|
||||
* @param {object} opts 配置
|
||||
*/
|
||||
function validate(opts) {
|
||||
if (opts.scenario === 'register') {
|
||||
if (!IMEI_REGEX.test(opts.imei)) {
|
||||
fail(`register 场景需要 --imei(15 位数字),收到: ${opts.imei || '(空)'}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!DEVICE_NO_REGEX.test(opts.deviceNo)) {
|
||||
fail(`--device-no 必须是 BM + 8 位日期 + 4 位序号(如 BM202609160001),收到: ${opts.deviceNo || '(空)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印启动横幅:告诉联调的人「这次跑的是哪条用例」。
|
||||
*
|
||||
* @param {object} opts 配置
|
||||
*/
|
||||
function banner(opts) {
|
||||
const cases = {
|
||||
online: '用例 1(遥测落库)/ 4(指令+ack 成功)/ 6(重复 msgId)',
|
||||
lowbat: '§8.3 低电拦截(遮盖被拒、打开放行)+ 低电告警',
|
||||
offline: '用例 2 · 主动下线路径(offline 事件)',
|
||||
silent: '用例 2 · 心跳超时路径(120s 后被扫描判离线)',
|
||||
register: '§6.2 注册链路(含 code=404 未建档分支)',
|
||||
};
|
||||
const identity = opts.scenario === 'register' ? `provision (imei=${opts.imei})` : `bm/${opts.deviceNo}`;
|
||||
log('='.repeat(64));
|
||||
log(`设备模拟器启动 · 场景=${opts.scenario} · 回执=${opts.reply}`);
|
||||
log(`覆盖:${cases[opts.scenario] || '(自定义)'}`);
|
||||
log(`broker=${opts.broker} · topic 前缀=${identity}`);
|
||||
log('='.repeat(64));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// register 场景(§6.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 跑一次注册流程:上行 register,等 ack 后退出。
|
||||
*
|
||||
* @param {object} opts 配置
|
||||
*/
|
||||
function runRegister(opts) {
|
||||
const imei = opts.imei;
|
||||
const ackTopic = topicsOf('').provisionAck(imei);
|
||||
const registerPayload = {
|
||||
ts: Date.now(),
|
||||
imei,
|
||||
sn: `SN${imei.slice(-8)}`,
|
||||
model: 'BM-H1',
|
||||
hwVersion: '1.0',
|
||||
fwVersion: '1.0.3',
|
||||
mac: 'AC:1F:6B:00:11:22',
|
||||
};
|
||||
if (opts.dryRun) {
|
||||
log('[dry-run] 将订阅: ' + ackTopic);
|
||||
log('[dry-run] 将上行: bm/provision/register ' + JSON.stringify(registerPayload));
|
||||
return;
|
||||
}
|
||||
const client = mqtt.connect(opts.broker, clientOptions(opts, `reg-${imei}`));
|
||||
const timer = setTimeout(() => {
|
||||
log('注册应答超时(10s),请确认服务端已订阅 bm/provision/register');
|
||||
finish(client, 1);
|
||||
}, 10000);
|
||||
client.on('error', (err) => {
|
||||
log(`MQTT 错误: ${err.message}`);
|
||||
});
|
||||
client.on('connect', () => {
|
||||
log('已连接 broker');
|
||||
client.subscribe(ackTopic, { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
log(`订阅失败: ${err.message}`);
|
||||
finish(client, 1);
|
||||
return;
|
||||
}
|
||||
log(`<< 已订阅 ${ackTopic}`);
|
||||
client.publish('bm/provision/register', JSON.stringify(registerPayload), { qos: 1 });
|
||||
log(`>> ${JSON.stringify(registerPayload)}`);
|
||||
});
|
||||
});
|
||||
client.on('message', (topic, buf) => {
|
||||
const text = buf.toString('utf8');
|
||||
log(`<< ${topic} ${text}`);
|
||||
const ack = safeParse(text);
|
||||
if (ack && ack.ok === true) {
|
||||
log(`注册成功,deviceNo=${ack.deviceNo}(设备端需持久化,重启不再注册)`);
|
||||
} else if (ack && ack.code === 404) {
|
||||
log('注册被拒:IMEI 未建档 —— 后台需先在 bm_device 录入该 IMEI(这是私拆/串货的线索)');
|
||||
}
|
||||
clearTimeout(timer);
|
||||
finish(client, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 设备场景
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 跑设备场景。
|
||||
*
|
||||
* @param {object} opts 配置
|
||||
*/
|
||||
function runDevice(opts) {
|
||||
const deviceNo = opts.deviceNo;
|
||||
const device = new SimulatedDevice(opts.scenario, opts.hoodCover);
|
||||
const t = topicsOf(deviceNo);
|
||||
|
||||
if (opts.dryRun) {
|
||||
log('[dry-run] 将订阅: ' + t.commandWildcard);
|
||||
log('[dry-run] 将上行(connect 后): ' + t.event + ' ' + JSON.stringify(eventPayload('online', {})));
|
||||
log('[dry-run] 将上行(周期): ' + t.telemetry + ' ' + JSON.stringify(device.telemetry()));
|
||||
log('[dry-run] 收到 hood 指令按 --reply=' + opts.reply + ' 处理:'
|
||||
+ JSON.stringify(ackPayload('<下行 msgId>', 'hood', { ok: true, code: 0, msg: '' })));
|
||||
log('[dry-run] 将上行(退出前): ' + t.event + ' ' + JSON.stringify(eventPayload('offline', { reason: 'normal' })));
|
||||
return;
|
||||
}
|
||||
|
||||
const client = mqtt.connect(opts.broker, clientOptions(opts, deviceNo));
|
||||
const state = { telemetryTimer: null, sentCount: 0, otaRunning: false, shuttingDown: false };
|
||||
|
||||
client.on('error', (err) => log(`MQTT 错误: ${err.message}`));
|
||||
client.on('reconnect', () => log('重连中…'));
|
||||
|
||||
client.on('connect', () => {
|
||||
log('已连接 broker');
|
||||
client.subscribe(t.commandWildcard, { qos: 1 }, (err) => {
|
||||
if (err) {
|
||||
log(`订阅失败: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
// 协议 §5.2:连接成功且完成订阅后才发 online
|
||||
publishJson(client, t.event, eventPayload('online', { reason: 'normal' }));
|
||||
log(`<< 已订阅 ${t.commandWildcard}`);
|
||||
if (opts.scenario === 'silent' || opts.scenario === 'offline') {
|
||||
// 先落一包遥测把 status 推到在线,再进入静默/下线,避免「从未有过心跳」的设备不受心跳约束
|
||||
publishJson(client, t.telemetry, device.telemetry());
|
||||
}
|
||||
if (opts.scenario === 'offline') {
|
||||
setTimeout(() => shutdown(client, opts, 'normal'), opts.offlineAfter);
|
||||
return;
|
||||
}
|
||||
if (opts.scenario === 'silent') {
|
||||
log(`进入静默:不再上报遥测。等待服务端在 offline-threshold(120s) 后判离线,勿按 Ctrl+C。`);
|
||||
return;
|
||||
}
|
||||
state.telemetryTimer = setInterval(() => {
|
||||
publishJson(client, t.telemetry, device.telemetry());
|
||||
state.sentCount += 1;
|
||||
if (opts.count > 0 && state.sentCount >= opts.count) {
|
||||
shutdown(client, opts, 'normal');
|
||||
}
|
||||
}, opts.interval);
|
||||
});
|
||||
});
|
||||
|
||||
client.on('message', (topic, buf) => {
|
||||
const text = buf.toString('utf8');
|
||||
log(`<< ${topic} ${text}`);
|
||||
const payload = safeParse(text);
|
||||
if (!payload || !payload.msgId) {
|
||||
log('下行报文无 msgId,按设备侧策略忽略');
|
||||
return;
|
||||
}
|
||||
if (topic.endsWith('/command/hood')) {
|
||||
handleHoodCommand(client, opts, device, t, payload);
|
||||
} else if (topic.endsWith('/command/ota')) {
|
||||
handleOtaCommand(client, opts, t, payload, state);
|
||||
} else {
|
||||
log('未知下行指令,按前向兼容忽略(真实设备应记日志不动作)');
|
||||
}
|
||||
});
|
||||
|
||||
const onSignal = () => {
|
||||
if (state.shuttingDown) {
|
||||
return;
|
||||
}
|
||||
state.shuttingDown = true;
|
||||
log('收到中断信号,按「正常下线」流程退出');
|
||||
shutdown(client, opts, 'normal');
|
||||
};
|
||||
process.on('SIGINT', onSignal);
|
||||
process.on('SIGTERM', onSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理眼罩指令:按 `--reply` 决定回执行为,并只在成功后切换本地状态。
|
||||
*
|
||||
* @param {object} client MQTT 客户端
|
||||
* @param {object} opts 配置
|
||||
* @param {SimulatedDevice} device 模拟设备
|
||||
* @param {object} t Topic 集合
|
||||
* @param {object} cmd 下行指令报文
|
||||
*/
|
||||
function handleHoodCommand(client, opts, device, t, cmd) {
|
||||
const target = cmd.coverOn === true;
|
||||
if (opts.reply === 'none') {
|
||||
log(`[用例 5] 收到遮盖=${target} 的指令但**故意不回执** → 服务端应 5s 超时并返回「指令下发失败」,hood_cover_on 不变`);
|
||||
return;
|
||||
}
|
||||
if (opts.reply === 'reject') {
|
||||
publishJson(client, t.ack, ackPayload(cmd.msgId, 'hood', { ok: false, code: 3, msg: 'device busy' }));
|
||||
log('已回失败回执(ok=false, code=3)→ 服务端应返回「指令下发失败」且不写 hood_cover_on');
|
||||
return;
|
||||
}
|
||||
// auto:设备真实执行后才改本地状态,再用本地状态回执——顺序反了会掩盖服务端的回写缺陷
|
||||
device.hoodCoverOn = target ? 1 : 0;
|
||||
publishJson(client, t.ack, ackPayload(cmd.msgId, 'hood', { ok: true, code: 0, msg: '' }));
|
||||
log(`眼罩已${target ? '遮盖' : '打开'},已回执 ok=true(本地 hoodCoverOn=${device.hoodCoverOn})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 OTA 指令:先回执「已受理」,再周期上报进度,最后回终态(协议 §7.2)。
|
||||
*
|
||||
* @param {object} client MQTT 客户端
|
||||
* @param {object} opts 配置
|
||||
* @param {object} t Topic 集合
|
||||
* @param {object} cmd 下行指令报文
|
||||
* @param {object} state 运行状态(含 otaRunning 幂等标志)
|
||||
*/
|
||||
function handleOtaCommand(client, opts, t, cmd, state) {
|
||||
if (opts.reply === 'none') {
|
||||
log('收到 OTA 指令但不回执 → 服务端应 5s 超时');
|
||||
return;
|
||||
}
|
||||
if (opts.reply === 'reject') {
|
||||
publishJson(client, t.ack, ackPayload(cmd.msgId, 'ota', { ok: false, code: 3, msg: 'md5 mismatch' }));
|
||||
log('已回失败回执(code=3)→ 任务应落 FAILED,result_msg 记 md5 mismatch');
|
||||
return;
|
||||
}
|
||||
if (state.otaRunning) {
|
||||
log('已有 OTA 在跑,按设备侧幂等策略忽略该次指令');
|
||||
return;
|
||||
}
|
||||
state.otaRunning = true;
|
||||
// 受理 ≠ 完成:先回 ok=true 表示「已收到」,最终结果用第二条 ack
|
||||
publishJson(client, t.ack, ackPayload(cmd.msgId, 'ota', { ok: true, code: 0, progress: 0 }));
|
||||
log(`OTA 已受理 version=${cmd.version},开始上报进度(间隔 ${opts.otaStepMs}ms,真机为 10s)`);
|
||||
let progress = 0;
|
||||
const timer = setInterval(() => {
|
||||
progress += 20;
|
||||
if (progress >= 100) {
|
||||
clearInterval(timer);
|
||||
publishJson(client, t.event, eventPayload('ota_progress', { version: cmd.version, value: 100 }));
|
||||
publishJson(client, t.ack, ackPayload(cmd.msgId, 'ota', { ok: true, code: 0, progress: 100 }));
|
||||
state.otaRunning = false;
|
||||
log('OTA 完成,已回终态 ack{ok:true, progress:100}');
|
||||
return;
|
||||
}
|
||||
publishJson(client, t.event, eventPayload('ota_progress', { version: cmd.version, value: progress }));
|
||||
}, opts.otaStepMs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 组装 MQTT 连接选项。
|
||||
*
|
||||
* @param {object} opts 配置
|
||||
* @param {string} identity 身份标识(用于 clientId,便于在 broker 侧区分连接)
|
||||
* @return {object} mqtt.connect 选项
|
||||
*/
|
||||
function clientOptions(opts, identity) {
|
||||
return {
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
clientId: `bm-sim-${identity}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
protocolVersion: 4,
|
||||
clean: true,
|
||||
keepalive: 60,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 10000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布 JSON 报文并打印。
|
||||
*
|
||||
* @param {object} client MQTT 客户端
|
||||
* @param {string} topic 目标 Topic
|
||||
* @param {object} payload 报文对象
|
||||
*/
|
||||
function publishJson(client, topic, payload) {
|
||||
const text = JSON.stringify(payload);
|
||||
client.publish(topic, text, { qos: topic.endsWith('/telemetry') ? 0 : 1 });
|
||||
log(`>> ${topic} ${text}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 正常下线:发 offline 事件 → 断开。
|
||||
*
|
||||
* @param {object} client MQTT 客户端
|
||||
* @param {object} opts 配置
|
||||
* @param {string} reason 下线原因
|
||||
*/
|
||||
function shutdown(client, opts, reason) {
|
||||
if (!opts.deviceNo) {
|
||||
finish(client, 0);
|
||||
return;
|
||||
}
|
||||
const t = topicsOf(opts.deviceNo);
|
||||
publishJson(client, t.event, eventPayload('offline', { reason }));
|
||||
log('已发 offline 事件(服务端应置 status=2 并写一条 offline 告警)');
|
||||
setTimeout(() => finish(client, 0), 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束进程。
|
||||
*
|
||||
* @param {object} client MQTT 客户端
|
||||
* @param {number} code 退出码
|
||||
*/
|
||||
function finish(client, code) {
|
||||
client.end(true, () => process.exit(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* 容错解析 JSON。
|
||||
*
|
||||
* @param {string} text 原始报文
|
||||
* @return {object|null} 解析结果;失败返回 null
|
||||
*/
|
||||
function safeParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 入口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (opts.help) {
|
||||
console.log(USAGE);
|
||||
return;
|
||||
}
|
||||
validate(opts);
|
||||
banner(opts);
|
||||
if (opts.scenario === 'register') {
|
||||
runRegister(opts);
|
||||
return;
|
||||
}
|
||||
runDevice(opts);
|
||||
}
|
||||
|
||||
main();
|
||||
542
script/mqtt/package-lock.json
generated
Normal file
542
script/mqtt/package-lock.json
generated
Normal file
@@ -0,0 +1,542 @@
|
||||
{
|
||||
"name": "bm-mqtt-simulate",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bm-mqtt-simulate",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"mqtt": "^5.10.1"
|
||||
},
|
||||
"bin": {
|
||||
"mqtt-simulate": "mqtt-simulate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
|
||||
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz",
|
||||
"integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/broker-factory": {
|
||||
"version": "3.1.15",
|
||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz",
|
||||
"integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"fast-unique-numbers": "^9.0.27",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-factory": "^7.0.50"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commist": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
||||
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-unique-numbers": {
|
||||
"version": "9.0.27",
|
||||
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz",
|
||||
"integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt": {
|
||||
"version": "5.15.2",
|
||||
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz",
|
||||
"integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.21",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commist": "^3.2.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"debug": "^4.4.1",
|
||||
"help-me": "^5.0.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"minimist": "^1.2.8",
|
||||
"mqtt-packet": "^9.0.2",
|
||||
"number-allocator": "^1.0.14",
|
||||
"readable-stream": "^4.7.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"socks": "^2.8.6",
|
||||
"split2": "^4.2.0",
|
||||
"worker-timers": "^8.0.23",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"bin": {
|
||||
"mqtt": "build/bin/mqtt.js",
|
||||
"mqtt_pub": "build/bin/pub.js",
|
||||
"mqtt_sub": "build/bin/sub.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
|
||||
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^6.0.8",
|
||||
"debug": "^4.3.4",
|
||||
"process-nextick-args": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/number-allocator": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
||||
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.1",
|
||||
"js-sdsl": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.10",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
|
||||
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/worker-factory": {
|
||||
"version": "7.0.50",
|
||||
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz",
|
||||
"integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"fast-unique-numbers": "^9.0.27",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers": {
|
||||
"version": "8.0.34",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz",
|
||||
"integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-timers-broker": "^8.0.18",
|
||||
"worker-timers-worker": "^9.0.15"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-broker": {
|
||||
"version": "8.0.18",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz",
|
||||
"integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"broker-factory": "^3.1.15",
|
||||
"fast-unique-numbers": "^9.0.27",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-timers-worker": "^9.0.15"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-worker": {
|
||||
"version": "9.0.15",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz",
|
||||
"integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-factory": "^7.0.50"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
script/mqtt/package.json
Normal file
18
script/mqtt/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "bm-mqtt-simulate",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "智能飞禽物联平台 · 设备侧 MQTT 模拟器(无需硬件的联调工具)",
|
||||
"bin": {
|
||||
"mqtt-simulate": "mqtt-simulate.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node mqtt-simulate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"mqtt": "^5.10.1"
|
||||
}
|
||||
}
|
||||
142
script/sql/bm_patch.sql
Normal file
142
script/sql/bm_patch.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- ============================================================================
|
||||
-- 智能飞禽物联平台 · 项目自有补丁脚本(PROJECT PATCH,非框架基线)
|
||||
-- 文件:script/sql/bm_patch.sql
|
||||
-- 说明:本文件承载「项目自有的库表变更」,与框架基线 ry_vue.sql 分离维护,
|
||||
-- 以便将来框架(RuoYi-Vue-Plus)升级时按补丁重放、避免与基线冲突。
|
||||
-- 【请勿修改 ry_vue.sql 以保持与上游基线一致】。
|
||||
-- 执行方式:连接业务库(bird_monitoring)后整体执行本文件。
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 补丁 1:为 sys_user.phone_number 增加唯一索引(uk_sys_user_phone_number)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 背景:
|
||||
-- 框架基线 ry_vue.sql 中 sys_user.phone_number 仅有普通索引
|
||||
-- key idx_sys_user_phone (phone_number) -- 非唯一
|
||||
-- 无法在 DB 层阻止「同一手机号注册多个账号」。
|
||||
-- APP 账号闭环(迭代 I2)的注册为「先查后插」,存在并发同号双写(TOCTOU)风险;
|
||||
-- 应用层已用「按手机号加 Redisson 锁 + 判重」将其降到接近零(见 AppAuthServiceImpl#register),
|
||||
-- 但 **DB 唯一键才是最终防线**,故以本补丁补充该约束;应用层锁保留作为双保险。
|
||||
--
|
||||
-- ★ 执行前必读 · 前置检查(务必先跑 SELECT,确认无重复再执行 ALTER)
|
||||
-- 1) 检查是否存在重复的「非空」手机号:
|
||||
-- SELECT phone_number, COUNT(*) AS cnt
|
||||
-- FROM sys_user
|
||||
-- WHERE phone_number IS NOT NULL AND phone_number <> ''
|
||||
-- GROUP BY phone_number
|
||||
-- HAVING COUNT(*) > 1;
|
||||
-- → 期望返回 0 行。若返回了行,须先人工清理重复数据(每个手机号仅保留一条),
|
||||
-- 再执行本补丁;否则第 2 步 ALTER 会因重复键失败(1062)。
|
||||
--
|
||||
-- 2) 空字符串归一化说明:
|
||||
-- phone_number 列默认值为 ''(可存在多行同为 ''),而 MySQL 唯一索引
|
||||
-- **不允许多个 ''(空串)**、但**允许多个 NULL**(NULL 不参与唯一性判定)。
|
||||
-- 因此本补丁先将 '' 归一化为 NULL,使唯一约束只作用于「非空手机号」。
|
||||
--
|
||||
-- ★ 执行前已核验(2026-09-16 实测线上库 bird_monitoring.sys_user)
|
||||
-- - 总行数:3;
|
||||
-- - 非空 phone_number:仅 1 行(属 sys_user);
|
||||
-- - 重复手机号:无(前置检查 SELECT 返回 0 行);
|
||||
-- - app_user 记录:0 条。
|
||||
-- => 满足前置条件,可安全执行。执行后如需复核:
|
||||
-- SHOW INDEX FROM sys_user WHERE Key_name = 'uk_sys_user_phone_number';
|
||||
--
|
||||
-- ★ MySQL 限制说明(不要用存储过程硬凑 IF NOT EXISTS)
|
||||
-- MySQL 不支持 "ALTER TABLE ... ADD UNIQUE KEY IF NOT EXISTS"。
|
||||
-- 本补丁**未**用存储过程模拟幂等;若重复执行本文件,将出现如下**预期报错**(可忽略):
|
||||
-- ERROR 1061 (42000): Duplicate key name 'uk_sys_user_phone_number'
|
||||
-- 看到该报错即表示索引已存在,无需再次创建。
|
||||
--
|
||||
-- ★ 影响面提示(重要)
|
||||
-- 该唯一索引作用于整张 sys_user 表(含 sys_user 与 app_user 两类 user_type),
|
||||
-- 即执行后**手机号在全表范围内唯一**(非空手机号不可在「后台用户」与「APP 用户」间重复)。
|
||||
-- 这与迭代 I2 早期「两类用户手机号互不冲突」的表述不同:现在由 DB 强制全局唯一。
|
||||
-- 若后台新增用户时用到已被 APP 占用的手机号,将因该唯一键报 1062(属预期约束)。
|
||||
--
|
||||
-- ★ 回滚(如需撤销)
|
||||
-- ALTER TABLE sys_user DROP INDEX uk_sys_user_phone_number;
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- 步骤 1:将空字符串手机号归一化为 NULL(唯一索引允许多个 NULL)
|
||||
UPDATE sys_user SET phone_number = NULL WHERE phone_number = '';
|
||||
|
||||
-- 步骤 2:新增唯一索引
|
||||
ALTER TABLE sys_user ADD UNIQUE KEY uk_sys_user_phone_number (phone_number);
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 补丁 2:为 sys_user.email 增加唯一索引(uk_sys_user_email)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 背景:
|
||||
-- 迭代「邮箱注册」新增邮箱账号入口后,email 成为与 phone_number 并列的账号标识,
|
||||
-- 同样需要「先查后插」之外的第二道防线(DB 唯一键)。
|
||||
-- 框架基线 ry_vue.sql 中 sys_user.email 无任何索引。
|
||||
--
|
||||
-- ★ 执行前必读 · 前置检查
|
||||
-- 1) 重复非空邮箱检查:
|
||||
-- SELECT email, COUNT(*) AS cnt FROM sys_user
|
||||
-- WHERE email IS NOT NULL AND email <> ''
|
||||
-- GROUP BY email HAVING COUNT(*) > 1;
|
||||
-- → 期望返回 0 行。
|
||||
-- 2) 空串归一化:email 列默认值为 ''(实测线上 9 行中 8 行为 ''),
|
||||
-- 而 MySQL 唯一索引允许多个 NULL 但**不允许多个 ''**,故必须先归一化。
|
||||
--
|
||||
-- ★ 执行前已核验(2026-09-17 实测线上库 bird_monitoring.sys_user)
|
||||
-- - 总行数 9;非空 email 仅 1 行(admin 的 crazyLionLi@163.com);
|
||||
-- - 重复邮箱:无(前置检查 SELECT 返回 0 行);
|
||||
-- - app_user 记录 8 条,email 全为 ''。
|
||||
-- => 满足前置条件,可安全执行。
|
||||
--
|
||||
-- ★ 影响面提示
|
||||
-- 与手机号同一口径:该唯一索引作用于整张 sys_user 表(含 sys_user 与 app_user),
|
||||
-- 即邮箱在全表范围内唯一。若后台新增用户填写了已被 APP 占用的邮箱,将报 1062(属预期约束)。
|
||||
--
|
||||
-- ★ 重复执行本文件时的预期报错(可忽略)
|
||||
-- ERROR 1061 (42000): Duplicate key name 'uk_sys_user_email'
|
||||
--
|
||||
-- ★ 回滚
|
||||
-- ALTER TABLE sys_user DROP INDEX uk_sys_user_email;
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- 步骤 1:将空字符串邮箱归一化为 NULL
|
||||
UPDATE sys_user SET email = NULL WHERE email = '';
|
||||
|
||||
-- 步骤 2:新增唯一索引
|
||||
ALTER TABLE sys_user ADD UNIQUE KEY uk_sys_user_email (email);
|
||||
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 补丁 3:将 sys_user.phone_number / email 的列默认值 '' 改为 NULL
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ★ 本补丁是「邮箱注册」能否落地的关键前提,不可省略。
|
||||
--
|
||||
-- 背景(两个坑叠加才会暴露):
|
||||
-- 1) 本项目 MyBatis-Plus 全局字段策略为 insertStrategy: NOT_NULL(见
|
||||
-- ruoyi-common-mybatis/src/main/resources/common-mybatis.yml)——
|
||||
-- 实体字段为 null 时,该列会被**整个从 INSERT 语句中省略**,而不是写入 NULL;
|
||||
-- 2) 此时生效的是**列默认值**,而 phone_number / email 的基线默认值都是 ''。
|
||||
--
|
||||
-- 于是:邮箱注册创建的账号不填手机号 ⇒ INSERT 省略 phone_number ⇒ 落库为 ''
|
||||
-- ⇒ 第二个邮箱注册账号再落 '' ⇒ 撞 uk_sys_user_phone_number 报 1062。
|
||||
-- 同一问题对称存在于 email:手机号注册的账号不填邮箱 ⇒ 落 '' ⇒ 第二个撞 uk_sys_user_email。
|
||||
--
|
||||
-- 修法:把两列的默认值改为 NULL(NULL 不参与唯一性判定,可多行共存)。
|
||||
-- 这样「字段为 null ⇒ INSERT 省略该列 ⇒ 落库 NULL」成为一致且唯一的行为,
|
||||
-- 无需修改框架实体 SysUser(不改框架文件的铁律得以保持)。
|
||||
--
|
||||
-- ★ 影响面提示(有意为之的正向变化)
|
||||
-- 后台「新增用户」不填邮箱/手机号时,落库由 '' 变为 NULL。
|
||||
-- 应用层所有比较均已用 StringUtils.isBlank 处理('' 与 null 等价),
|
||||
-- 实测已确认无逻辑依赖 '' 这一具体取值。
|
||||
--
|
||||
-- ★ 回滚(恢复到基线默认值,回滚前须先处理已落 NULL 的行)
|
||||
-- ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(11) DEFAULT '' COMMENT '手机号码';
|
||||
-- ALTER TABLE sys_user MODIFY COLUMN email varchar(50) DEFAULT '' COMMENT '用户邮箱';
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- 步骤 1:手机号列默认值改为 NULL
|
||||
ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(11) DEFAULT NULL COMMENT '手机号码';
|
||||
|
||||
-- 步骤 2:邮箱列默认值改为 NULL
|
||||
ALTER TABLE sys_user MODIFY COLUMN email varchar(50) DEFAULT NULL COMMENT '用户邮箱';
|
||||
239
script/sql/ry_device.sql
Normal file
239
script/sql/ry_device.sql
Normal file
@@ -0,0 +1,239 @@
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- 智能飞禽物联平台 · 设备域建表脚本(阶段1 / 迭代 I1)
|
||||
-- 说明:
|
||||
-- 1. 6 张设备表 + 8 个字典 + 设备菜单 + 8 个权限字 + 2 个角色及其菜单关联。
|
||||
-- 2. 严禁出现任何直播字段(stream_url / play_url / live_*)与医疗模板字段。
|
||||
-- 3. 逻辑删除取值与框架配置一致:0 存在 / 1 删除(common-mybatis.yml: logicDeleteValue=1)。
|
||||
-- 4. 唯一索引(device_no / imei)叠加逻辑删除,存在「逻辑删除后编号不可复用」的既有约束,
|
||||
-- 与 RuoYi 既有模式一致,已在字段注释中写明;如需复用时改用物理删除策略。
|
||||
-- 作者:Kou 日期:2026-09-16
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- ============================ 一、设备主表 ============================
|
||||
drop table if exists bm_device;
|
||||
create table bm_device (
|
||||
device_id bigint(20) not null comment '主键(雪花,仅内部关联)',
|
||||
device_no varchar(32) not null comment '业务编号,唯一(逻辑删除后编号不可复用)',
|
||||
imei varchar(32) not null comment '绑定码IMEI,唯一(逻辑删除后不可复用)',
|
||||
name varchar(64) default null comment '用户侧名称≤20字符',
|
||||
model varchar(64) default null comment '型号',
|
||||
sn varchar(64) default null comment '出厂序列号',
|
||||
hw_version varchar(32) default null comment '硬件版本',
|
||||
sw_version varchar(32) default null comment '软件版本',
|
||||
fw_version varchar(32) default null comment '固件版本',
|
||||
pkg_version varchar(32) default null comment '数据包版本',
|
||||
ip varchar(64) default null comment '设备IP',
|
||||
mac varchar(64) default null comment 'MAC',
|
||||
wifi_ssid varchar(64) default null comment 'WiFi SSID',
|
||||
status char(1) not null default '0' comment '状态(0未激活 1在线 2离线)',
|
||||
bind_user_id bigint(20) default null comment '当前绑定APP用户',
|
||||
bind_time datetime default null comment '最近一次绑定时间',
|
||||
hood_connected char(1) not null default '0' comment '头套(0断开 1已连接)',
|
||||
hood_cover_on char(1) not null default '0' comment '眼罩(0打开 1遮盖)',
|
||||
signal_level tinyint not null default 0 comment '信号等级(0离线 1弱 2一般 3强)',
|
||||
hood_battery tinyint default null comment '头套电量0-100',
|
||||
ring_battery tinyint default null comment '脚环电量0-100',
|
||||
lng decimal(10,7) default null comment '最新经度',
|
||||
lat decimal(10,7) default null comment '最新纬度',
|
||||
address varchar(255) default null comment '地址(设备上报)',
|
||||
last_online_time datetime default null comment '最后在线时间',
|
||||
remark varchar(500) default null comment '备注',
|
||||
create_dept bigint(20) default null comment '创建部门',
|
||||
create_by bigint(20) default null comment '创建者',
|
||||
create_time datetime comment '创建时间',
|
||||
update_by bigint(20) default null comment '更新者',
|
||||
update_time datetime comment '更新时间',
|
||||
del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)',
|
||||
primary key (device_id),
|
||||
unique key uk_device_no (device_no),
|
||||
unique key uk_imei (imei),
|
||||
key idx_bind_user (bind_user_id),
|
||||
key idx_status (status),
|
||||
key idx_last_online (last_online_time)
|
||||
) engine=innodb comment = '设备主表';
|
||||
|
||||
-- ============================ 二、设备绑定流水 ============================
|
||||
drop table if exists bm_device_bind;
|
||||
create table bm_device_bind (
|
||||
bind_id bigint(20) not null comment '主键(雪花)',
|
||||
device_id bigint(20) not null comment '设备物理主键',
|
||||
device_no varchar(32) default null comment '设备编号(冗余,便于审计/后台展示)',
|
||||
user_id bigint(20) not null comment '操作APP用户',
|
||||
imei varchar(32) default null comment 'IMEI(冗余快照)',
|
||||
action varchar(16) not null comment '动作(bind/unbind/rebind)',
|
||||
bind_time datetime not null comment '操作时间',
|
||||
remark varchar(255) default null comment '备注',
|
||||
primary key (bind_id),
|
||||
key idx_device_id (device_id),
|
||||
key idx_user_id (user_id),
|
||||
key idx_device_action_time (device_id, action, bind_time)
|
||||
) engine=innodb comment = '设备绑定流水(无逻辑删除,禁止抹除历史)';
|
||||
|
||||
-- ============================ 三、设备遥测历史 ============================
|
||||
drop table if exists bm_device_telemetry;
|
||||
create table bm_device_telemetry (
|
||||
telemetry_id bigint(20) not null comment '主键(雪花)',
|
||||
device_id bigint(20) not null comment '设备主键',
|
||||
device_no varchar(32) default null comment '设备编号(冗余)',
|
||||
hood_battery tinyint default null comment '头套电量0-100',
|
||||
ring_battery tinyint default null comment '脚环电量0-100',
|
||||
signal_level tinyint default null comment '信号等级(0离线 1弱 2一般 3强)',
|
||||
hood_connected char(1) default null comment '头套连接(0断开 1已连接)',
|
||||
hood_cover_on char(1) default null comment '眼罩状态(0打开 1遮盖)',
|
||||
lng decimal(10,7) default null comment '经度',
|
||||
lat decimal(10,7) default null comment '纬度',
|
||||
address varchar(255) default null comment '地址',
|
||||
report_time datetime not null comment '设备上报时间',
|
||||
primary key (telemetry_id),
|
||||
key idx_device_time (device_id, report_time)
|
||||
) engine=innodb comment = '设备遥测历史(一期仅落库,不做轨迹回放)';
|
||||
|
||||
-- ============================ 四、设备告警 ============================
|
||||
drop table if exists bm_device_alert;
|
||||
create table bm_device_alert (
|
||||
alert_id bigint(20) not null comment '主键(雪花)',
|
||||
device_id bigint(20) not null comment '设备主键',
|
||||
device_no varchar(32) default null comment '设备编号(冗余)',
|
||||
alert_type varchar(32) not null comment '告警类型(offline/low_battery_hood/low_battery_ring)',
|
||||
alert_level char(1) not null comment '告警级别(0提示 1警告 2严重)',
|
||||
alert_value varchar(64) default null comment '触发值(如电量)',
|
||||
content varchar(255) default null comment '告警文案',
|
||||
status char(1) not null comment '状态(0未处理 1已处理,一期只读展示)',
|
||||
alert_time datetime not null comment '告警时间',
|
||||
primary key (alert_id),
|
||||
key idx_device_id (device_id),
|
||||
key idx_alert_type (alert_type),
|
||||
key idx_alert_time (alert_time)
|
||||
) engine=innodb comment = '设备告警';
|
||||
|
||||
-- ============================ 五、OTA 升级包 ============================
|
||||
drop table if exists bm_ota_package;
|
||||
create table bm_ota_package (
|
||||
package_id bigint(20) not null comment '主键(雪花)',
|
||||
model varchar(64) not null comment '适用型号',
|
||||
version varchar(32) not null comment '版本号',
|
||||
oss_id bigint(20) default null comment '关联sys_oss.oss_id',
|
||||
file_url varchar(500) default null comment '文件URL(冗余)',
|
||||
file_name varchar(255) default null comment '文件名',
|
||||
file_size bigint(20) default null comment '文件字节数',
|
||||
md5 varchar(64) default null comment '校验值MD5',
|
||||
changelog varchar(500) default null comment '更新说明',
|
||||
status char(1) not null default '0' comment '状态(0停用 1启用)',
|
||||
remark varchar(500) default null comment '备注',
|
||||
create_dept bigint(20) default null comment '创建部门',
|
||||
create_by bigint(20) default null comment '创建者',
|
||||
create_time datetime comment '创建时间',
|
||||
update_by bigint(20) default null comment '更新者',
|
||||
update_time datetime comment '更新时间',
|
||||
del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)',
|
||||
primary key (package_id),
|
||||
unique key uk_model_version (model, version),
|
||||
key idx_status (status)
|
||||
) engine=innodb comment = 'OTA升级包';
|
||||
|
||||
-- ============================ 六、OTA 升级任务 ============================
|
||||
drop table if exists bm_ota_task;
|
||||
create table bm_ota_task (
|
||||
task_id bigint(20) not null comment '主键(雪花)',
|
||||
package_id bigint(20) not null comment '升级包',
|
||||
version varchar(32) default null comment '冗余版本',
|
||||
device_id bigint(20) not null comment '设备主键',
|
||||
device_no varchar(32) default null comment '冗余编号',
|
||||
status char(1) not null default '0' comment '状态(0待升级 1升级中 2成功 3失败)',
|
||||
result_msg varchar(500) default null comment '结果信息',
|
||||
progress tinyint default null comment '进度0-100',
|
||||
operator_id bigint(20) default null comment '操作人(后台用户)',
|
||||
times int(4) not null default 0 comment '下发次数',
|
||||
start_time datetime default null comment '开始时间',
|
||||
finish_time datetime default null comment '完成时间',
|
||||
create_dept bigint(20) default null comment '创建部门',
|
||||
create_by bigint(20) default null comment '创建者',
|
||||
create_time datetime comment '创建时间',
|
||||
update_by bigint(20) default null comment '更新者',
|
||||
update_time datetime comment '更新时间',
|
||||
del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)',
|
||||
primary key (task_id),
|
||||
key idx_device_no (device_no),
|
||||
key idx_status (status),
|
||||
key idx_package_id (package_id)
|
||||
) engine=innodb comment = 'OTA升级任务';
|
||||
|
||||
-- ============================ 七、字典类型(8 个) ============================
|
||||
insert into sys_dict_type values(1771500000000000001, '设备状态', 'bm_device_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备状态列表');
|
||||
insert into sys_dict_type values(1771500000000000002, '信号等级', 'bm_signal_level', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '信号等级列表');
|
||||
insert into sys_dict_type values(1771500000000000003, '头套连接', 'bm_hood_connected', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '头套连接列表');
|
||||
insert into sys_dict_type values(1771500000000000004, '眼罩状态', 'bm_hood_cover', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '眼罩状态列表');
|
||||
insert into sys_dict_type values(1771500000000000005, 'OTA任务状态', 'bm_ota_task_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'OTA任务状态列表');
|
||||
insert into sys_dict_type values(1771500000000000006, '升级包状态', 'bm_ota_package_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '升级包状态列表');
|
||||
insert into sys_dict_type values(1771500000000000007, '告警类型', 'bm_alert_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '告警类型列表');
|
||||
insert into sys_dict_type values(1771500000000000008, '绑定动作', 'bm_bind_action', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '绑定动作列表');
|
||||
|
||||
-- ============================ 八、字典数据 ============================
|
||||
-- 设备状态
|
||||
insert into sys_dict_data values(1771600000000000001, 1, '未激活', '0', 'bm_device_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '未激活');
|
||||
insert into sys_dict_data values(1771600000000000002, 2, '在线', '1', 'bm_device_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '在线');
|
||||
insert into sys_dict_data values(1771600000000000003, 3, '离线', '2', 'bm_device_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '离线');
|
||||
-- 信号等级
|
||||
insert into sys_dict_data values(1771600000000000004, 1, '离线', '0', 'bm_signal_level', '', 'danger', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '离线');
|
||||
insert into sys_dict_data values(1771600000000000005, 2, '弱', '1', 'bm_signal_level', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '弱');
|
||||
insert into sys_dict_data values(1771600000000000006, 3, '一般', '2', 'bm_signal_level', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '一般');
|
||||
insert into sys_dict_data values(1771600000000000007, 4, '强', '3', 'bm_signal_level', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '强');
|
||||
-- 头套连接
|
||||
insert into sys_dict_data values(1771600000000000008, 1, '已断开', '0', 'bm_hood_connected', '', 'danger', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '已断开');
|
||||
insert into sys_dict_data values(1771600000000000009, 2, '已连接', '1', 'bm_hood_connected', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '已连接');
|
||||
-- 眼罩状态
|
||||
insert into sys_dict_data values(1771600000000000010, 1, '打开', '0', 'bm_hood_cover', '', 'primary', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '打开');
|
||||
insert into sys_dict_data values(1771600000000000011, 2, '遮盖', '1', 'bm_hood_cover', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '遮盖');
|
||||
-- OTA任务状态
|
||||
insert into sys_dict_data values(1771600000000000012, 1, '待升级', '0', 'bm_ota_task_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '待升级');
|
||||
insert into sys_dict_data values(1771600000000000013, 2, '升级中', '1', 'bm_ota_task_status', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '升级中');
|
||||
insert into sys_dict_data values(1771600000000000014, 3, '成功', '2', 'bm_ota_task_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '成功');
|
||||
insert into sys_dict_data values(1771600000000000015, 4, '失败', '3', 'bm_ota_task_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '失败');
|
||||
-- 升级包状态
|
||||
insert into sys_dict_data values(1771600000000000016, 1, '停用', '0', 'bm_ota_package_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '停用');
|
||||
insert into sys_dict_data values(1771600000000000017, 2, '启用', '1', 'bm_ota_package_status', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '启用');
|
||||
-- 告警类型
|
||||
insert into sys_dict_data values(1771600000000000018, 1, '设备离线', 'offline', 'bm_alert_type', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备离线');
|
||||
insert into sys_dict_data values(1771600000000000019, 2, '头套低电', 'low_battery_hood', 'bm_alert_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '头套低电');
|
||||
insert into sys_dict_data values(1771600000000000020, 3, '脚环低电', 'low_battery_ring', 'bm_alert_type', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '脚环低电');
|
||||
-- 绑定动作
|
||||
insert into sys_dict_data values(1771600000000000021, 1, '绑定', 'bind', 'bm_bind_action', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '绑定');
|
||||
insert into sys_dict_data values(1771600000000000022, 2, '解绑', 'unbind', 'bm_bind_action', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '解绑');
|
||||
insert into sys_dict_data values(1771600000000000023, 3, '换绑', 'rebind', 'bm_bind_action', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '换绑');
|
||||
|
||||
-- ============================ 九、菜单 + 权限字 ============================
|
||||
-- 一级目录
|
||||
insert into sys_menu values(1771400000000000001, '设备管理', 0, 6, 'device', null, '', 'N', 'Y', 'M', '0', '0', '', 'dashboard', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备管理目录');
|
||||
-- 二级菜单
|
||||
insert into sys_menu values(1771400000000000002, '设备列表', 1771400000000000001, 1, 'device', 'device/device/index', '', 'N', 'Y', 'C', '0', '0', 'device:device:list', 'form', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备列表菜单');
|
||||
insert into sys_menu values(1771400000000000003, 'OTA管理', 1771400000000000001, 2, 'ota', 'device/ota/index', '', 'N', 'Y', 'C', '0', '0', 'device:ota:list', 'upload', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'OTA管理菜单');
|
||||
-- 设备列表按钮
|
||||
insert into sys_menu values(1771400000000000011, '设备查询', 1771400000000000002, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
|
||||
insert into sys_menu values(1771400000000000012, '设备新增', 1771400000000000002, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
|
||||
insert into sys_menu values(1771400000000000013, '设备修改', 1771400000000000002, 3, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
|
||||
insert into sys_menu values(1771400000000000014, '设备删除', 1771400000000000002, 4, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
|
||||
insert into sys_menu values(1771400000000000015, '设备导出', 1771400000000000002, 5, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:device:export', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
|
||||
-- OTA 按钮
|
||||
insert into sys_menu values(1771400000000000016, 'OTA新增', 1771400000000000003, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:ota:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
|
||||
|
||||
-- ============================ 十、角色 ============================
|
||||
insert into sys_role values(1771300000000000001, '设备管理员', 'device_admin', 5, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域管理角色');
|
||||
insert into sys_role values(1771300000000000002, '审计员', 'device_auditor', 6, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域只读审计角色');
|
||||
|
||||
-- ============================ 十一、角色-菜单关联 ============================
|
||||
-- 设备管理员:全部设备菜单
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000001);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000002);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000003);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000011);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000012);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000013);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000014);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000015);
|
||||
insert into sys_role_menu values (1771300000000000001, 1771400000000000016);
|
||||
-- 审计员:目录 + 列表 + 详情 + 导出 + OTA 列表
|
||||
insert into sys_role_menu values (1771300000000000002, 1771400000000000001);
|
||||
insert into sys_role_menu values (1771300000000000002, 1771400000000000002);
|
||||
insert into sys_role_menu values (1771300000000000002, 1771400000000000003);
|
||||
insert into sys_role_menu values (1771300000000000002, 1771400000000000011);
|
||||
insert into sys_role_menu values (1771300000000000002, 1771400000000000015);
|
||||
Reference in New Issue
Block a user