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:
yuhaiming
2026-09-17 10:10:38 +08:00
parent 926426fe4f
commit 79b739fe02
142 changed files with 14627 additions and 7 deletions

1
script/mqtt/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

View 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
View 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
View 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
View 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
View 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);