From 5738dfb9e3ad73d736d47119074b1359eceaf6fb Mon Sep 17 00:00:00 2001 From: zhugongping Date: Sun, 27 Sep 2026 15:17:17 +0800 Subject: [PATCH] =?UTF-8?q?MINI=EF=BC=9A=E4=BC=98=E5=8C=96=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8FP0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/iot-measure-point.sql | 4 +- yack-mini/docs/CODE_REVIEW.md | 307 ++++++++++++++++++ .../pages/device/detail/detail.wxml | 2 +- .../miniprogram/pages/device/scan/scan.js | 13 +- yack-mini/miniprogram/pages/mine/mine.js | 13 +- yack-mini/miniprogram/services/mqtt-client.js | 28 +- yack-mini/miniprogram/utils/crypto.js | 116 ++++--- yack-mini/miniprogram/utils/storage.js | 13 +- yack-mini/tests/p0-regressions.test.js | 247 ++++++++++++++ yack-mini/tools/detail-layout-check.js | 101 ------ yack-web/src/views/iot/device/device-edit.vue | 1 + 11 files changed, 689 insertions(+), 156 deletions(-) create mode 100644 yack-mini/docs/CODE_REVIEW.md create mode 100644 yack-mini/tests/p0-regressions.test.js delete mode 100644 yack-mini/tools/detail-layout-check.js diff --git a/sql/iot-measure-point.sql b/sql/iot-measure-point.sql index 4c675836..9c2d40a7 100644 --- a/sql/iot-measure-point.sql +++ b/sql/iot-measure-point.sql @@ -6,7 +6,7 @@ -- ============================================= -- 测点表(measure_point_id 为测点唯一识别号,前端手动输入) -CREATE TABLE IF NOT EXISTS `iot_measure_point` ( +CREATE TABLE `iot_measure_point` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `measure_point_id` VARCHAR(64) NOT NULL COMMENT '唯一采集ID(测点唯一识别号,前端输入,正则^[A-Za-z0-9_-]{1,64}$)', `product_id` BIGINT DEFAULT NULL COMMENT '产品id', @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS `iot_measure_point` ( -- 物模型改名后历史帧仍保留采集当时的名称,列表列头也直接由数据自身提供。 -- ============================================= -CREATE TABLE IF NOT EXISTS `iot_measure_point_data` ( +CREATE TABLE `iot_measure_point_data` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键(帧标识)', `measure_point_id` VARCHAR(64) NOT NULL COMMENT '测点唯一识别号', `product_id` BIGINT DEFAULT NULL COMMENT '产品id', diff --git a/yack-mini/docs/CODE_REVIEW.md b/yack-mini/docs/CODE_REVIEW.md new file mode 100644 index 00000000..f464ed82 --- /dev/null +++ b/yack-mini/docs/CODE_REVIEW.md @@ -0,0 +1,307 @@ +# yack-mini 小程序 Code Review + +> 评审范围:`yack-mini/miniprogram/**`(业务代码约 6.0k 行,不含第三方 `libs/mqtt-wx.min.js` 887 行) +> 评审日期:2026-09-27 +> 结论:**架构分层清晰、BLE 状态机工程质量高**,主要债务集中在「页面层的脏边界」与「重构后未清理的死代码」。 + +## 一、总体评价 + +| 层 | 文件 | 评价 | +| --- | --- | --- | +| BLE 状态机 | `services/ble.js` (1213) | ⭐ 优秀。把微信 BLE 的三条硬约束(already connect / 并发建链 / 切设备作废)用 seq + 单 Promise 闸门建模,每条约束都有注释说明来源,超时兜底齐全。全项目最扎实的模块 | +| 采集网关 | `services/gateway.js` (872) | ⭐ 良好。串行队列 `_chain` + 状态快照订阅 + 收帧快照归属(测点/productId/reportSn)设计正确,注释解释了"为什么"而非"是什么" | +| MQTT 包装 | `services/mqtt-client.js` (491) | 良好。实例校验防幽灵客户端、建连去重、用户意图标记都到位 | +| 页面层 | `pages/**` | ⚠️ 一般。脏边界集中在此:参数无校验、高频 setData、过期注释 | +| 工具层 | `utils/**` | ⚠️ 一般。死函数多,且存在一处会损坏数据的编码缺陷 | +| 回归测试 | `tests/**` | 良好质量,但**覆盖缺口大**:只锁了"设备停用不连蓝牙"和"三个开关"两个点,主链路(收帧→上报→离线补传)零覆盖 | + +--- + +## 二、P0 — 会导致功能错误或数据损坏,必修 + +> ✅ **以下 5 条已于 2026-09-27 全部修复**,回归用例见 `tests/p0-regressions.test.js`(45 断言,含反向验证: +> 回退成缺陷写法后 5 项转红)。改动文件:`pages/mine/mine.js`、`utils/storage.js`、`pages/device/scan/scan.js`、 +> `utils/crypto.js`、`services/mqtt-client.js`。P1 及以下未动。 + +### P0-1 产品ID 用 `parseInt` 无整数校验,NaN 会写进上报 topic + +- 位置:`pages/mine/mine.js:98`、`mine.js:103` +- 根因:`parseInt('12.5') === 12`(静默截断小数)、`parseInt('abc') === NaN`。`if (!productId)` 只挡空串,NaN 能一路穿到 `storage.setMqttConfig({ productId: NaN })`。后续 `gateway._resolveProductId()` 的判空是 `cfg.productId == null || cfg.productId === ''`,**NaN 两个都不满足**,于是构造出 `/NaN/{sn}/{测点}/measure/post` 这样的脏 topic 并持续上报。 +- 修复: + +```js +// mine.js handleMqttConnect() +const pid = Number(productId); +if (!Number.isInteger(pid) || pid <= 0) { + wx.showToast({ title: '产品ID必须是正整数', icon: 'none' }); + return; +} +const creds = auth.getMqttCredentials(serialNumber, pid, productSecret); +... +storage.setMqttConfig({ serialNumber, productId: pid, productSecret }); +``` + +### P0-2 设备数达上限时 `addDevice` 静默失败,UI 谎报"已连接" + +- 位置:`utils/storage.js:132-139`(超限 `return`,无返回值)+ `pages/device/scan/scan.js:145-160` +- 根因:`addDevice` 成功路径没有 `return`,超限路径 `return undefined`。scan 页不检查返回值,继续执行 `activateDevice`(找不到该 SN,空操作)→ `getDevice` 返回 null → `gateway.start(deviceConfig)` 传入一个**根本没落盘**的设备 → `_startNow` 里 `_isEnabled()` 为 false → 不采集。用户看到 toast "已连接设备",实际既没保存也没连。 +- 修复(两处): + +```js +// storage.js +function addDevice(device) { + const devices = getDeviceList(); + const index = devices.findIndex(d => d.serialNumber === device.serialNumber); + const encryptedDevice = encryptFields(device, ENCRYPT_FIELDS); + if (index >= 0) { + devices[index] = encryptedDevice; + } else { + if (devices.length >= LIMITS.MAX_DEVICE_COUNT) { + console.warn('[Storage] 设备数量已达上限:', LIMITS.MAX_DEVICE_COUNT); + return false; // ← 明确失败语义 + } + devices.push(encryptedDevice); + } + saveDeviceList(devices); + return true; +} + +// scan.js handleSelectDevice() +if (!storage.addDevice(deviceConfig)) { + wx.hideLoading(); + wx.showToast({ title: `最多添加 ${50} 个设备`, icon: 'none' }); + return; +} +storage.activateDevice(serialNumber); +``` + +### P0-3 Base64 编码用 `setUint8` 静默截断,非 ASCII 密钥加密后不可逆损坏 + +- 位置:`utils/crypto.js:19-24` +- 根因:`dataView.setUint8(i, str.charCodeAt(i))` 只写低 8 位。XOR 后码点可能 > 255(例如中文 `0x4E2D` XOR `0x66` = `0x4E4B` = 20043),被静默截断 → 解密得到乱码 → **产品密钥永久损坏且无报错**。密钥通常纯 ASCII 所以没暴露,但这是"输入一旦含非 Latin-1 字符就毁数据"的定时炸弹。 +- 修复: + +```js +function base64Encode(str) { + try { + const bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code > 0xFF) { + console.error('[Crypto] 含非 Latin-1 字符,拒绝编码:', i); + return ''; + } + bytes[i] = code; + } + return wx.arrayBufferToBase64(bytes.buffer); + } catch (e) { + console.error('[Crypto] Base64 编码失败:', e); + return ''; + } +} +``` + +> 附带:`encrypt/decrypt` 异常时 **fail-open 返回原文**(`crypto.js:71`、`crypto.js:94`),会把明文当密文落盘。建议改为返回空串并让调用方拒绝写入。 + +### P0-4 切换 clientId 时订阅表被清空,`_resubscribe` 恢复不到任何东西 + +- 位置:`services/mqtt-client.js:177-181` vs `mqtt-client.js:409-417` +- 根因:`connect()` 在拆旧客户端时 `this.subscriptions.clear()`,而 `_resubscribe()` 只在 `client.on('connect')` 里执行 —— 执行时表已空。**任何一次换 clientId 重连都会永久丢失全部订阅**。当前业务只 publish 不 subscribe,所以未暴露;一旦接入下行指令就是线上事故。 +- 修复:把"订阅表"的生命周期从"连接"解耦 —— 只在 `disconnect()`(用户意图)时清,换 clientId 时保留: + +```js +// connect() 内 +if (this.client) { + console.log('[MQTT] 终止旧客户端:', this.currentClientId, '->', newClientId); + this._teardownClient(this.client); // 保留 this.subscriptions,交由 _resubscribe 恢复 +} +``` + +### P0-5 离线补传串行 `await publish`,publish 无超时 → 一次不结算就永久堵死 + +- 位置:`services/gateway.js:733-772`(`_flushOffline`)+ `services/mqtt-client.js:332-349`(`publish`) +- 根因:`_flushOffline` 用 `await mqttClient.publish(...)` 逐条串行发送,且被 `_flushingOffline` 布尔闸门保护。而 `publish` 的 resolve/reject **完全依赖底层回调**:QoS1 的回调要等 PUBACK 或重试用尽。若底层在 ws 已断、`_inflight` 条目回调路径异常时永不结算,`_flushingOffline` 将永远停在 `true`(`finally` 永远执行不到)→ **此后所有离线补传静默失效,直到重启小程序**。BLE 侧所有 GATT 操作都做了超时兜底,唯独 publish 没有,属于防御不一致。 +- 修复:给 publish 加超时出口(与 BLE 的 `_callWithTimeout` 同一模式): + +```js +// mqtt-client.js +publish(topic, message, options = { qos: 1 }) { + const timeout = options.publishTimeout != null ? options.publishTimeout : 15000; + return new Promise((resolve, reject) => { + if (!this.client || !this.client.connected) { + reject(new Error('MQTT 未连接')); + return; + } + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error('发布超时')); + }, timeout); + this.client.publish(topic, message, options, (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) { console.error('[MQTT] 发布失败:', err); reject(new Error('发布失败')); } + else { resolve(); } + }); + }); +} +``` + +> 另外 `_flushOffline` 建议加并发上限或分批,避免 1000 条缓存时逐条串行 await 拖很久。 + +--- + +## 三、P1 — 隐患 / 性能 / 一致性 + +### P1-1 通知风暴:每次 `_notify` 全量构造快照,页面再全量重建列表 + +- 位置:`services/gateway.js:820-829`(`getSnapshot` 里 `dataList.map(it => ({...it}))`,最多 200 条)+ `pages/device/detail/detail.js:190-196`(`slice().reverse().map()`,ASCII 模式下每条再跑一次 `hexToBytes`) +- 根因:收帧节流 100ms(`TIMEOUT.FLUSH_INTERVAL`),即每秒 10 次 × 200 条对象浅拷贝 × N 个订阅者,ASCII 视图下还要叠加 200 次 hex 解析。高频采集时主线程和 setData 都会明显吃紧。 +- 建议:① `_notify` 合并到同一帧(`setTimeout(..., 16)` 去重,状态类更新仍可即时);② `getSnapshot` 增加 `dataListSince(id)` 增量接口,页面只 setData 新增部分;③ ASCII 转换结果缓存到 item 上(`item._ascii`),避免每次重算。 + +### P1-2 「我的」页在每次网关通知时同步读全量离线缓存 + +- 位置:`pages/mine/mine.js:165`(`checkStatus()` 内 `storage.getPendingData()`) +- 根因:`getPendingData()` 会做过期过滤 + 写回(同步 IO)。mine 页订阅了 gateway,收帧期间每 100ms 触发一次 → 每 100ms 一次全量读盘 + 可能的写盘。而 gateway 快照里**已经有 `offlineCount`**,这次读取完全多余。 +- 修复:`const pendingCount = snap.offlineCount;` + +### P1-3 `_upload` 无并发上限 + +- 位置:`services/gateway.js:596` +- 根因:自动模式下每批帧逐条 fire-and-forget 调 `_upload`,无在途数限制。MQTT 变慢时会堆积大量 QoS1 在途消息,触发底层的"在途消息过多"错误后又全部转离线缓存,形成雪崩。建议加在途计数器(如上限 20,超出直接进离线缓存)。 + +### P1-4 `_toOfflineItem` 可能返回 null 却被 push 进缓存数组 + +- 位置:`services/gateway.js:721-730` + `gateway.js:599` +- 根因:`device` 为 null 时返回 null,调用处未过滤 → 缓存里出现 null 元素 → `_flushOffline` 遍历到 `it.deviceId` 抛 TypeError,整批补传中断。改为 `const item = this._toOfflineItem(...); if (item) offline.push(item);` + +### P1-5 环境配置硬编码,dev 还混搭了两个环境 + +- 位置:`env.config.js:7-20` +- 根因:`const ENV = 'development'` 是手改开关,发布时漏改就会把生产包指向 `http://192.168.110.117:8080`(内网 IP + 明文 HTTP)。更隐蔽的是 DEV 配置本身自相矛盾:`baseUrl` 是内网 IP,而 `mqttServer` 是公网 `47.121.180.229` —— 同一个"开发环境"里 API 和 MQTT 不在一处,真机联调极易误判"接口通了但数据上不去"。 +- 建议:① 用 `wx.getAccountInfoSync().miniProgram.envVersion` 自动判定环境,彻底取消手改开关;② 生产环境必须 `https://` + `wxs://`(`project.config.json` 里 `urlCheck: true`,HTTP 域名真机必然被拦)。 + +### P1-6 `BLE_STATE.CONNECTED` 是永不出现的状态 + +- 位置:`services/ble.js:141`、`ble.js:1027`、`ble.js:1035`,`services/gateway.js:520` +- 根因:`_setState(BLE_STATE.CONNECTED)` 在整个项目里**从未被调用过**(状态机只经过 DISCONNECTED → CONNECTING → DISCOVERING → READY)。因此这 4 处的 `|| state === CONNECTED` 全是死分支,`isConnected()` 与 `isReady()` 是两份完全相同的实现。要么删掉 CONNECTED,要么在建链成功时显式置位(让"已连但未发现服务"有独立状态)。 + +### P1-7 超时配置双真相源 + +- 位置:`utils/constants.js:7-8,26` vs `services/ble.js:16-32` +- 根因:`TIMEOUT.BLE_SCAN` / `TIMEOUT.BLE_CONNECT` / `RETRY.BLE_RECONNECT_DELAY` **全项目无引用**;而 `ble.js` 的 `DEFAULT_CONFIG` 里重新硬编码了相同数值(10000 / 10000 / 重连基数 1000)。改一处不改另一处就会静默失效。 +- 修复:让 `DEFAULT_CONFIG` 从 constants 取值: + +```js +const { TIMEOUT, RETRY } = require('../utils/constants.js'); +const DEFAULT_CONFIG = { + scanTimeout: TIMEOUT.BLE_SCAN, + connectTimeout: TIMEOUT.BLE_CONNECT, + reconnectBaseDelay: RETRY.BLE_RECONNECT_DELAY, + ... +}; +``` + +> 另外 `services/command-manager.js:6` 引入了 `LIMITS` 却从未使用,是无用 require。 + +### P1-8 回归测试只覆盖了两个点,主链路零覆盖 + +- 现状:`tests/gateway-disabled-device.test.js`(33 断言)+ `tests/detail-console-toggles.test.js`(31 断言),质量很高(含反向验证防假绿),但覆盖的是**开关语义**与**设备停用**两条边角。 +- 缺口:完全没有覆盖主链路 —— 收帧 → 测点归属快照 → 自动上报 / 手动确认 / 离线缓存 → 补传。而 P0-2、P0-4、P0-5 全部出在这条链路上。建议按现有 `tests/helpers/fake-wx.js` 的可编程桩,补一套 `tests/gateway-measure-flow.test.js`(MQTT 桩可设 connected / publish hang / publish fail 三种态)。 + +### P1-9 `app.json` 未声明定位相关私有接口 + +- 位置:`app.json` +- 说明:Android 端 BLE 扫描依赖系统定位授权,且微信要求小程序在后台配置隐私协议并在 `app.json` 用 `requiredPrivateInfos` 声明所用接口。当前文件里没有相关声明,真机(尤其 Android)可能出现"扫描无任何结果且无报错"。建议核对微信后台隐私协议并补齐声明。 + +### P1-10 ⚡(性能类最优先)收帧链路上每 100ms 有 4~6 次同步读盘,且同一 key 被读两遍 + +- 位置:`services/gateway.js:572-577` → `measure-point.js:37,70` + `gateway.js:615,630` +- 根因:`_flush()` 每 100ms 执行一次,其中: + + | 调用 | 位置 | 实际动作 | + | --- | --- | --- | + | `measurePoint.getCollectMode()` | `gateway.js:572` | `wx.getStorageSync` | + | `measurePoint.getMeasurePointId()` | `gateway.js:573` | `wx.getStorageSync` | + | `_resolveProductId()` | `gateway.js:576` → `615` | `getMqttConfig()` = `getStorageSync` + `decryptFields` | + | `_resolveSerialNumber()` | `gateway.js:577` → `630` | `getMqttConfig()` **再读一遍同一个 key** + 再解密一次 | + + 叠加 `mine.js:165` 的 `getPendingData()`(读盘 + 过期过滤 + 可能写回)与 `gateway.js:832` 的 `_refreshOfflineCount()`,收帧期间每秒约 40~60 次 `getStorageSync`。`wx.getStorageSync` 是**同步阻塞**调用,缓存量大(离线缓存上限 1000 条、设备上限 50 台)时单次可达毫秒级 —— 这正好卡在 BLE 高频收帧的关键路径上。 +- 修复方向:上报上下文(productId / reportSn / 测点ID / 模式)本质是**会话级快照**,不该每次读盘。建议在 `_startNow` 时快照一次,`_flush` 直接读内存: + +```js +// gateway.js +_refreshContext() { + const cfg = storage.getMqttConfig(); + const dev = this._device; + this._ctx = { + productId: (cfg && cfg.productId != null && cfg.productId !== '') + ? cfg.productId : (dev ? dev.productId : null), + reportSn: (cfg && cfg.serialNumber) ? cfg.serialNumber : (dev ? dev.serialNumber : null), + measurePointId: measurePoint.getMeasurePointId(), + mode: measurePoint.getCollectMode(), + }; +} +// 失效时机:_startNow / resume / 「我的」页保存 MQTT 配置后 / 详情页改测点ID 后 +``` + + > 注意:测点ID 与模式允许在采集过程中被用户修改(详情页),所以失效时机必须覆盖这两处,否则会退回"归属串点"的老问题。 + +### P1-11 测试桩能力缺口,恰好卡住主链路用例 + +- 位置:`tests/helpers/fake-wx.js:193-229`(`createFakeMqtt`) +- 根因:假 mqtt-client 的 `publish` **恒 `Promise.resolve()`**,无法模拟 hang / fail;且缺少 `allowAutoConnect()`(`app.js:101` 会调用它);`createWx` 里没有 `wx.request`。这直接导致 P1-8 建议补的主链路用例(含 P0-5 的"publish 永不结算"场景)现在写不出来 —— 桩要先行增强: + +```js +// fake-wx.js: createFakeMqtt 增加 +publishMode: 'ok', // 'ok' | 'hang' | 'fail' +publish: (topic, message) => { + rec.publishCalls.push({ topic, message }); + if (rec.publishMode === 'hang') return new Promise(() => {}); // 永不结算 + if (rec.publishMode === 'fail') return Promise.reject(new Error('发布失败')); + return Promise.resolve(); +}, +allowAutoConnect: () => { client.userDisconnected = false; }, +``` + +--- + +## 四、P2 — 死代码与僵尸注释(建议一次性清理) + +重构(product-device-measurePoint)后遗留较多,用户偏好"僵尸代码及时删"。 + +| 位置 | 内容 | 说明 | +| --- | --- | --- | +| `utils/auth.js:61,72,83,91,101` | `buildPropertyPostTopic` / `buildFunctionGetTopic` / `buildPropertyGetTopic` / `checkLoginStatus` / `getCurrentUserId` | 5 个导出函数全无调用方 | +| `utils/hex.js:111,137` | `crc16` / `verifyCrc16` | 无调用方(且 `verifyCrc16` 用 `===` 比大小写的 CRC,含小写输入会误判失败) | +| `utils/storage.js:45,81,159` | `removeToken` / `removeUserInfo` / `updateDevice` | 无调用方。⚠️ 其中 `updateDevice` **不加密敏感字段**(与 `addDevice`/`saveDeviceList` 不对称),一旦启用就把 productSecret 明文落盘 —— 要么删掉,要么补 `encryptFields` | +| `services/command-manager.js:112-170` | `getCommands` / `addCommand` / `updateCommand` / `removeCommand` / `incrementUseCount` / `clearHistory` | 6 个方法全无调用方;预设命令功能随 `pages/device/command` 页一起消亡 | +| `utils/command-storage.js:13-93,178` | `getCommands` / `saveCommands` / `addCommand` / `updateCommand` / `removeCommand` / `clearAll` | 同上,整层预设命令存储已死 | +| `services/command-manager.js:22` | `sendCommand(hex, method, device)` 的 `method` 参数 | 僵尸参数,永远是 `'BLE'`,函数体从不读它。建议删参数或改名为 `_`(更彻底:直接删) | +| `pages/index/index.wxml:4-11` | 注释掉的 `user-bar` 块 | 导致 `index.js:144 handleLogout` 成为死方法。要么恢复 UI,要么连方法一起删 | +| `pages/device/detail/detail.js:15-16` | 注释声称"「发送命令」页(pages/device/command)保留未删" | ❌ **该页在仓库里根本不存在**。过期注释比没注释更有害,必须更正 | +| `components/device-card/device-card.wxss:1-51` | `.swipe-wrap` / `.swipe-delete` / `.swipe-delete-text` / `.is-dragging` | 滑动删除交互已改成按钮删除,约 36 行样式残留 | +| `utils/crypto.js:9,31` + `constants.js:31` | `CRYPTO_KEY` 硬编码 | 注释自己承认"生产应从服务端动态获取"。客户端常量 key 的 XOR 只是防君子,**建议明确降级为"防明文落盘的混淆"并在注释里写清,不要给人安全错觉** | + +--- + +## 五、P3 — 打磨项 + +1. `pages/device/detail/detail.js:444-451`:radio 取消用 `setData({collectMode: 0})` + `setTimeout(0)` 回拉,是绕过小程序受控组件限制的 hack。可行但脆弱,建议在注释里写明依赖的是"radio 内部选中态先于回调切换"这一未文档化行为。 +2. `services/gateway.js`:`_flushOffline` 成功补传后不回写 `dataList` 里对应 item 的 `offline` 标记,UI 上"已缓存"标签会残留到下次清空。 +3. `pages/device/detail/detail.js:492-499`:`handleFlushOffline` 用前后差算条数,若被 `_flushingOffline` 短路会显示"已上传 0 条",误导用户。建议用返回值条数而非快照差。 +4. `pages/device/scan/scan.js:26`:`gateway.stop()` 的注释写"暂停正在跑的网关",实际是完整 teardown(phase → STOPPED)。命名与语义不符,建议改注释或提供真正的 `suspend`。 +5. `services/ble.js:305`:`scanDevices` 的超时定时器无句柄,`stopScan()` 无法取消它 → 点"停止"后 10s 到点仍会回填扫描结果并覆盖 UI。建议保存 timer 并在 `stopScan` 里 clear,或给扫描加 seq 作废。 +6. `pages/mine/mine.js:18`:`appVersion: '1.0.0'` 硬编码,与 `project.config.json` 无联动,发版必漏改。 +7. `utils/request.js`:未设 `timeout`(默认 60s),登录流程弱网下会长时间 loading;401 分支只 reject 不做登录态收敛(与 `app.isLoggedIn()` 只看 userId 的设计一致,但两处口径应写在同一处注释里)。 +8. `components/device-card/device-card.wxml:8`:启用但未连上显示"连接中",实际可能是"异常/未连接",语义偏乐观。 + +--- + +## 六、本次未验证项(需真机确认) + +- 真机上 MQTT 底层 QoS1 在 ws 断连时的回调是否必然结算(决定 P0-5 是"防御性加固"还是"已发生的事故")。 +- Android 端 BLE 扫描在缺少定位声明时的实际表现(P1-9)。 +- 200 条 × 10 次/秒 setData 在中低端机上的实际帧率(P1-1)。 diff --git a/yack-mini/miniprogram/pages/device/detail/detail.wxml b/yack-mini/miniprogram/pages/device/detail/detail.wxml index 2c467a2a..f7f99a4c 100644 --- a/yack-mini/miniprogram/pages/device/detail/detail.wxml +++ b/yack-mini/miniprogram/pages/device/detail/detail.wxml @@ -109,7 +109,7 @@ - [{{item.time}}]: {{item.text}} + [{{item.time}}]:{{item.text}} ↑已上报 已缓存 确认 diff --git a/yack-mini/miniprogram/pages/device/scan/scan.js b/yack-mini/miniprogram/pages/device/scan/scan.js index 7fd086c2..9e5c059e 100644 --- a/yack-mini/miniprogram/pages/device/scan/scan.js +++ b/yack-mini/miniprogram/pages/device/scan/scan.js @@ -10,6 +10,7 @@ const ble = require('../../../services/ble.js'); const storage = require('../../../utils/storage.js'); const { gateway } = require('../../../services/gateway.js'); +const { LIMITS } = require('../../../utils/constants.js'); Page({ data: { @@ -142,7 +143,17 @@ Page({ createTime: Date.now(), }; - storage.addDevice(deviceConfig); + // 必须检查返回值:数量达上限时这里是失败的,继续往下会变成 + // "提示已连接、实际没保存也没连"的静默失败 + if (!storage.addDevice(deviceConfig)) { + wx.hideLoading(); + wx.showToast({ + title: `最多添加 ${LIMITS.MAX_DEVICE_COUNT} 个设备`, + icon: 'none', + duration: 2500, + }); + return; + } // 成为唯一启用设备(单 enabled 不变量) storage.activateDevice(serialNumber); diff --git a/yack-mini/miniprogram/pages/mine/mine.js b/yack-mini/miniprogram/pages/mine/mine.js index cd34310c..74798589 100644 --- a/yack-mini/miniprogram/pages/mine/mine.js +++ b/yack-mini/miniprogram/pages/mine/mine.js @@ -88,6 +88,15 @@ Page({ wx.showToast({ title: '请输入产品密钥', icon: 'none' }); return; } + // productId 是上报 topic 的第 1 段,必须是正整数。 + // parseInt 会把 '12.5' 截成 12、把 'abc' 变成 NaN,而网关侧的判空 + // (cfg.productId == null / === '')两个都挡不住 NaN —— 会一路构造出 + // /NaN/{sn}/{测点}/measure/post 这样的脏 topic 并持续上报 + const pid = Number(productId); + if (!Number.isInteger(pid) || pid <= 0) { + wx.showToast({ title: '产品ID必须是正整数', icon: 'none' }); + return; + } if (!this.data.userInfo || !this.data.userInfo.userId) { wx.showToast({ title: '请先登录', icon: 'none' }); return; @@ -95,12 +104,12 @@ Page({ this.setData({ mqttConnectingLocal: true }); try { - const creds = auth.getMqttCredentials(serialNumber, parseInt(productId), productSecret); + const creds = auth.getMqttCredentials(serialNumber, pid, productSecret); if (!creds) { throw new Error('生成 MQTT 凭证失败,请重新登录'); } // 保存配置(加密 productSecret),下次进入自动回填 - storage.setMqttConfig({ serialNumber, productId: parseInt(productId), productSecret }); + storage.setMqttConfig({ serialNumber, productId: pid, productSecret }); await mqttClient.connect(creds); wx.showToast({ title: 'MQTT 已连接', icon: 'success' }); } catch (err) { diff --git a/yack-mini/miniprogram/services/mqtt-client.js b/yack-mini/miniprogram/services/mqtt-client.js index 396858f7..c4b48752 100644 --- a/yack-mini/miniprogram/services/mqtt-client.js +++ b/yack-mini/miniprogram/services/mqtt-client.js @@ -174,10 +174,14 @@ class MqttClient { } // 已有客户端:先彻底终止,杜绝"幽灵客户端 + 同 clientId 互踢" + // + // ⚠️ 此处**不得**清空 subscriptions:订阅表的生命周期属于"业务意图"而非"某次连接"。 + // 清空后 connect 成功事件里的 _resubscribe() 只能拿到一张空表,订阅被永久丢失; + // 而换 clientId 重连恰恰是最需要自动恢复订阅的场景。 + // 唯一该清订阅的是 disconnect()(用户明确断开,不再需要这些回调)。 if (this.client) { console.log('[MQTT] 终止旧客户端:', this.currentClientId, '->', newClientId); this._teardownClient(this.client); - this.subscriptions.clear(); } this._setState(MQTT_STATE.CONNECTING); @@ -324,24 +328,42 @@ class MqttClient { * 发布消息 * QoS1 由底层等待 PUBACK 才算成功(超时会按 dup 重发,重试用尽才回调错误), * 因此这里的成功回调代表"broker 已确认收到" + * + * ⚠️ 必须有超时出口:底层的成功/失败**完全依赖回调**,而回调要等 PUBACK 或重试用尽。 + * 一旦某条消息卡在在途队列里不结算,这个 Promise 就永久 pending —— 网关的离线补传 + * (_flushOffline 串行 await publish,且被 _flushingOffline 闸门保护)会被它一次性 + * 锁死,之后所有补传静默失效直到重启小程序。BLE 侧每个 GATT 操作都做了超时兜底, + * 这里不能是唯一的例外。 * @param {string} topic - 主题 * @param {string} message - 消息内容 - * @param {Object} options - 发布选项 {qos: 0|1|2, retain: boolean} + * @param {Object} options - 发布选项 {qos: 0|1|2, retain: boolean, publishTimeout: number} * @returns {Promise} */ publish(topic, message, options = { qos: 1 }) { + const timeout = options.publishTimeout != null ? options.publishTimeout : 15000; return new Promise((resolve, reject) => { if (!this.client || !this.client.connected) { reject(new Error('MQTT 未连接')); return; } + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + console.warn('[MQTT] 发布超时, topic:', topic); + reject(new Error('发布超时')); + }, timeout); + this.client.publish(topic, message, options, (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); if (err) { console.error('[MQTT] 发布失败:', err); reject(new Error('发布失败')); } else { - console.log('[MQTT] 发布成功, topic:', topic, 'message:', message); + console.log('[MQTT] 发布成功, topic:', topic); resolve(); } }); diff --git a/yack-mini/miniprogram/utils/crypto.js b/yack-mini/miniprogram/utils/crypto.js index 8c3b85ad..2b915472 100644 --- a/yack-mini/miniprogram/utils/crypto.js +++ b/yack-mini/miniprogram/utils/crypto.js @@ -9,48 +9,87 @@ const { CRYPTO_KEY } = require('./constants.js'); /** - * Base64 编码(兼容小程序环境) - * @param {string} str - 字符串 - * @returns {string} Base64 编码字符串 + * UTF-16 字符串 → UTF-8 字节数组 + * + * 加密必须落在**字节**上:早期实现按 UTF-16 charCode 做 XOR,再用 + * `DataView.setUint8` 写缓冲区 —— setUint8 只保留低 8 位,中文这类 + * >255 的码点(如 '中' 0x4E2D XOR key 后仍 >255)会被静默截断, + * 解密回来是乱码,密钥被永久损坏且没有任何报错。 + * 代理对(emoji)按 CESU-8 拆成两个三字节序列,编解码对称,往返无损。 + * @param {string} str + * @returns {Array} */ -function base64Encode(str) { - try { - // 使用小程序提供的 ArrayBuffer 方式 - const buffer = new ArrayBuffer(str.length); - const dataView = new DataView(buffer); - for (let i = 0; i < str.length; i++) { - dataView.setUint8(i, str.charCodeAt(i)); +function utf8Bytes(str) { + const bytes = []; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x80) { + bytes.push(code); + } else if (code < 0x800) { + bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F)); + } else { + bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F)); } - return wx.arrayBufferToBase64(buffer); + } + return bytes; +} + +/** + * UTF-8 字节数组 → UTF-16 字符串(utf8Bytes 的逆运算) + * @param {Array} bytes + * @returns {string} + */ +function bytesToUtf8(bytes) { + let out = ''; + for (let i = 0; i < bytes.length;) { + const b = bytes[i++]; + if (b < 0x80) { + out += String.fromCharCode(b); + } else if (b < 0xE0) { + out += String.fromCharCode(((b & 0x1F) << 6) | (bytes[i++] & 0x3F)); + } else { + out += String.fromCharCode( + ((b & 0x0F) << 12) | ((bytes[i++] & 0x3F) << 6) | (bytes[i++] & 0x3F) + ); + } + } + return out; +} + +/** + * 字节数组 → Base64(兼容小程序环境) + * @param {Array} bytes + * @returns {string} Base64 编码字符串;失败返回空串 + */ +function base64Encode(bytes) { + try { + return wx.arrayBufferToBase64(new Uint8Array(bytes).buffer); } catch (e) { console.error('[Crypto] Base64 编码失败:', e); - return str; + return ''; } } /** - * Base64 解码(兼容小程序环境) + * Base64 → 字节数组(兼容小程序环境) * @param {string} base64 - Base64 编码字符串 - * @returns {string} 解码后的字符串 + * @returns {Array} 字节数组;失败返回空数组 */ function base64Decode(base64) { try { - // 使用小程序提供的 ArrayBuffer 方式 const buffer = wx.base64ToArrayBuffer(base64); - const bytes = new Uint8Array(buffer); - let str = ''; - for (let i = 0; i < bytes.length; i++) { - str += String.fromCharCode(bytes[i]); - } - return str; + return Array.from(new Uint8Array(buffer)); } catch (e) { console.error('[Crypto] Base64 解码失败:', e); - return base64; + return []; } } /** - * 简单对称加密(XOR + Base64) + * 对称加密(XOR + Base64),按字节运算,任意字符均可无损往返 + * + * 失败一律返回空串,**不 fail-open 返回明文** —— 把明文当密文落盘 + * 比"加密失败"危险得多(后者是可感知的,前者只是悄悄不加密)。 * @param {string} text - 明文 * @param {string} key - 密钥 * @returns {string} 密文(Base64 编码) @@ -58,17 +97,17 @@ function base64Decode(base64) { function encrypt(text, key = CRYPTO_KEY) { if (!text) return ''; try { - // XOR 加密 - let encrypted = ''; - for (let i = 0; i < text.length; i++) { - const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length); - encrypted += String.fromCharCode(charCode); + const bytes = utf8Bytes(text); + const keyBytes = utf8Bytes(key); + if (keyBytes.length === 0) { + console.error('[Crypto] 密钥为空,拒绝加密'); + return ''; } - // Base64 编码 + const encrypted = bytes.map((b, i) => b ^ keyBytes[i % keyBytes.length]); return base64Encode(encrypted); } catch (e) { console.error('[Crypto] 加密失败:', e); - return text; + return ''; } } @@ -76,23 +115,18 @@ function encrypt(text, key = CRYPTO_KEY) { * 解密 * @param {string} ciphertext - 密文(Base64 编码) * @param {string} key - 密钥 - * @returns {string} 明文 + * @returns {string} 明文;失败返回空串(同样不返回密文冒充明文) */ function decrypt(ciphertext, key = CRYPTO_KEY) { if (!ciphertext) return ''; try { - // Base64 解码 - const encrypted = base64Decode(ciphertext); - // XOR 解密(与加密过程相同) - let decrypted = ''; - for (let i = 0; i < encrypted.length; i++) { - const charCode = encrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length); - decrypted += String.fromCharCode(charCode); - } - return decrypted; + const bytes = base64Decode(ciphertext); + const keyBytes = utf8Bytes(key); + if (bytes.length === 0 || keyBytes.length === 0) return ''; + return bytesToUtf8(bytes.map((b, i) => b ^ keyBytes[i % keyBytes.length])); } catch (e) { console.error('[Crypto] 解密失败:', e); - return ciphertext; + return ''; } } diff --git a/yack-mini/miniprogram/utils/storage.js b/yack-mini/miniprogram/utils/storage.js index 4aa0b328..42e103d0 100644 --- a/yack-mini/miniprogram/utils/storage.js +++ b/yack-mini/miniprogram/utils/storage.js @@ -117,7 +117,13 @@ function saveDeviceList(devices) { /** * 添加设备(加密敏感字段) + * + * 返回值必须被检查:设备数量达上限时是**失败**,调用方若不看返回值继续走 + * "激活 → 取回 → 启动采集",会得到"提示已连接、实际既没保存也没连"的假象 + * (scan 页曾因此静默吞掉一次添加)。 + * 工具层不弹 toast:UI 呈现交给调用方,避免"上限提示"与"连接成功"两个 toast 打架。 * @param {Object} device + * @returns {boolean} 是否写入成功 */ function addDevice(device) { const devices = getDeviceList(); @@ -131,15 +137,12 @@ function addDevice(device) { // 检查设备数量限制 if (devices.length >= LIMITS.MAX_DEVICE_COUNT) { console.warn('[Storage] 设备数量已达上限:', LIMITS.MAX_DEVICE_COUNT); - wx.showToast({ - title: `最多添加 ${LIMITS.MAX_DEVICE_COUNT} 个设备`, - icon: 'none', - }); - return; + return false; } devices.push(encryptedDevice); } saveDeviceList(devices); + return true; } /** diff --git a/yack-mini/tests/p0-regressions.test.js b/yack-mini/tests/p0-regressions.test.js new file mode 100644 index 00000000..c3f8f1cc --- /dev/null +++ b/yack-mini/tests/p0-regressions.test.js @@ -0,0 +1,247 @@ +/** + * 回归:本轮 code review 修掉的 5 个 P0 缺陷 + * + * 每条都锁死"修复后的语义",并尽可能给出**反向证明**(展示修复前的写法会产出什么), + * 防止将来有人"顺手简化"时把缺陷放回去。 + * + * 红线: + * 1. crypto 必须按**字节**做 XOR(UTF-8),非 ASCII 无损往返;失败不 fail-open + * 2. addDevice 必须返回布尔,数量达上限时返回 false(调用方据此中断) + * 3. publish 必须有超时出口,底层永不回调时也能结算 + * 4. subscriptions 的生命周期属于"业务意图",只有 disconnect() 能清 + * 5. 产品ID 必须是正整数,NaN / 小数不得进入 topic + * + * 跑法:node tests/p0-regressions.test.js + */ +const fs = require('fs'); +const path = require('path'); +const { MINI_ROOT, loadRuntime, clearMiniCache } = require('./helpers/fake-wx.js'); + +/* ============================ 迷你断言器 ============================ */ + +let passed = 0; +let failed = 0; + +function ok(cond, label, extra) { + if (cond) { + passed++; + process.stdout.write(` ✓ ${label}\n`); + } else { + failed++; + process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${JSON.stringify(extra)}` : ''}\n`); + } +} + +function eq(actual, expected, label) { + ok(actual === expected, `${label} (期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)})`); +} + +/* ============================ 加载真实 MQTT 模块 ============================ */ + +/** + * ⚠️ 必须在 loadRuntime() 之前调用:loadRuntime 会用假 mqtt-client 顶掉 + * services/mqtt-client.js,之后 require 拿到的是桩而不是被测实现 + */ +function loadRealMqtt() { + clearMiniCache(); + global.wx = { + getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0), + arrayBufferToBase64: () => '', + base64ToArrayBuffer: () => new ArrayBuffer(0), + }; + return require(path.join(MINI_ROOT, 'services/mqtt-client.js')); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/* ============================ 用例 ============================ */ + +/** P0-5:publish 超时出口 */ +async function casePublishTimeout() { + process.stdout.write('\n[1] P0-5 publish 必须有超时出口(底层永不回调也能结算)\n'); + + const { mqttClient } = loadRealMqtt(); + + // 坏桩:publish 永不回调,模拟卡在在途队列里的 QoS1 消息 + mqttClient.client = { connected: true, publish: () => {} }; + const t0 = Date.now(); + let msg = null; + try { + await mqttClient.publish('/1/BT_01/001/measure/post', 'AABB', { publishTimeout: 120 }); + } catch (err) { + msg = err.message; + } + const cost = Date.now() - t0; + eq(msg, '发布超时', '超时后 reject'); + ok(cost >= 110 && cost < 1000, `约 120ms 内结算,不是永久挂起 (实际 ${cost}ms)`); + + // 未连接仍应立刻拒绝,不进超时路径 + mqttClient.client = null; + let msg2 = null; + try { await mqttClient.publish('/t', 'x'); } catch (err) { msg2 = err.message; } + eq(msg2, 'MQTT 未连接', '未连接时立即 reject'); + + // 成功路径不受影响 + mqttClient.client = { connected: true, publish: (t, m, o, cb) => cb && cb(null) }; + let resolved = false; + await mqttClient.publish('/t', 'x').then(() => { resolved = true; }).catch(() => {}); + ok(resolved, '正常回调时仍然 resolve'); +} + +/** P0-4:订阅表不得随连接被清空 */ +async function caseSubscriptions() { + process.stdout.write('\n[2] P0-4 订阅表生命周期属于业务意图,只有 disconnect() 能清\n'); + + const { mqttClient } = loadRealMqtt(); + + mqttClient.subscriptions.set('/p/1/sn/function/get', () => {}); + mqttClient.client = { end: () => {} }; + mqttClient._teardownClient(mqttClient.client); + eq(mqttClient.subscriptions.size, 1, '拆掉客户端后订阅表仍在(供 _resubscribe 恢复)'); + + const SRC = fs.readFileSync(path.join(MINI_ROOT, 'services/mqtt-client.js'), 'utf8'); + const clears = SRC.match(/this\.subscriptions\.clear\(\)/g) || []; + eq(clears.length, 1, 'subscriptions.clear() 全文件只允许 1 处'); + ok(SRC.indexOf('this.subscriptions.clear()') > SRC.indexOf(' disconnect()'), + '唯一那处在 disconnect() 内(用户主动断开才清)'); + ok(SRC.includes('_resubscribe'), 'connect 成功后仍有 _resubscribe 恢复订阅'); +} + +/** P0-3:crypto 字节级编解码 */ +async function caseCrypto() { + process.stdout.write('\n[3] P0-3 crypto 按字节做 XOR,非 ASCII 无损往返\n'); + + const rt = loadRuntime(); + const { encrypt, decrypt, encryptFields, decryptFields } = require(path.join(MINI_ROOT, 'utils/crypto.js')); + const KEY = 'fastbee-mini-2026'; + + // 修复前的写法(按 UTF-16 charCode XOR + setUint8 截断),用于兼容性与反向证明 + const oldEncrypt = (text, key = KEY) => { + let s = ''; + for (let i = 0; i < text.length; i++) s += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length)); + const bytes = []; + for (let i = 0; i < s.length; i++) bytes.push(s.charCodeAt(i) & 0xFF); + return rt.wx.arrayBufferToBase64(new Uint8Array(bytes).buffer); + }; + + // 存量兼容:纯 ASCII 的新密文必须与旧实现逐字节一致,老数据才能继续解开 + for (const s of ['secret', 'fastbee', 'Abc123!@#', '0123456789abcdefXYZ']) { + eq(encrypt(s), oldEncrypt(s), `新密文与旧实现一致(存量可解): ${s}`); + eq(decrypt(oldEncrypt(s)), s, `新实现能解开旧密文: ${s}`); + } + + // 非 ASCII:修复前会静默损坏,修复后必须无损 + for (const s of ['密钥中文', '密码abc中文', '测点·2026', '🔧扳手', 'Ünïcödé']) { + eq(decrypt(encrypt(s)), s, `往返无损: ${s}`); + } + ok(decrypt(oldEncrypt('密钥中文')) !== '密钥中文', + '反向证明:旧实现对中文确实不可逆损坏', decrypt(oldEncrypt('密钥中文'))); + + // 不得 fail-open(旧实现异常时返回明文/密文本身) + eq(encrypt(''), '', 'encrypt("") 返回空串'); + eq(decrypt(''), '', 'decrypt("") 返回空串'); + const realDecode = global.wx.base64ToArrayBuffer; + global.wx.base64ToArrayBuffer = () => { throw new Error('decode boom'); }; + eq(decrypt('anycipher'), '', '解码异常时返回空串,不拿密文冒充明文'); + global.wx.base64ToArrayBuffer = realDecode; + + // 字段级 + const enc = encryptFields({ productSecret: '我的密钥123', serialNumber: 'BT_01' }, ['productSecret']); + ok(enc.productSecret !== '我的密钥123' && enc._productSecret_encrypted === true, '敏感字段已加密并打标记'); + eq(enc.serialNumber, 'BT_01', '非敏感字段不受影响'); + const dec = decryptFields(enc, ['productSecret']); + eq(dec.productSecret, '我的密钥123', '字段级往返无损'); + eq(dec._productSecret_encrypted, undefined, '解密后清除标记'); +} + +/** P0-2:addDevice 返回值 */ +async function caseAddDevice() { + process.stdout.write('\n[4] P0-2 addDevice 数量达上限必须返回 false(供调用方中断)\n'); + + const rt = loadRuntime(); + let added = 0; + for (let i = 0; i < 60; i++) { + const sn = 'DEV_' + String(i).padStart(3, '0'); + if (rt.storage.addDevice({ deviceId: 'ID_' + i, serialNumber: sn, name: sn, enabled: false }) === true) added++; + } + eq(added, 50, '恰好写入 50 台后开始返回 false'); + eq(rt.storage.addDevice({ serialNumber: 'DEV_OVER', deviceId: 'X' }), false, '第 51 台返回 false'); + eq(rt.storage.getDeviceList().length, 50, '列表未被超限写入污染'); + eq(rt.storage.addDevice({ serialNumber: 'DEV_000', deviceId: 'ID_0', name: '改名' }), true, '覆盖既有编号仍返回 true'); + + // 调用方确实检查了返回值:scan.js 里 addDevice 之后不得无条件继续激活+启动 + const SCAN = fs.readFileSync(path.join(MINI_ROOT, 'pages/device/scan/scan.js'), 'utf8'); + ok(/if\s*\(\s*!storage\.addDevice\(/.test(SCAN), 'scan.js 对 addDevice 的返回值做了失败分支'); + ok(!/^\s*storage\.addDevice\(deviceConfig\);\s*$/m.test(SCAN), '不再有「无视返回值直接调用」的写法'); +} + +/** P0-1:产品ID 必须是正整数 */ +async function caseProductId() { + process.stdout.write('\n[5] P0-1 产品ID 必须校验为整数,NaN/小数不得进入 topic\n'); + + const rt = loadRuntime(); + // 必须种进 storage:auth.getMqttCredentials 读的是存储里的用户信息,不是页面 data。 + // 不种的话合法用例会走到"未登录"分支而失败,非法用例也会因提前 return 而假绿 + rt.storage.setUserInfo({ userId: 1, userName: 'tester' }); + + const toasts = []; + global.wx.showToast = (o) => toasts.push(o.title); + global.wx.showModal = (o) => toasts.push('MODAL:' + o.title); + global.wx.showLoading = () => {}; + global.wx.hideLoading = () => {}; + + global.getApp = () => ({ globalData: {} }); + let cfg = null; + global.Page = (c) => { cfg = c; }; + require(path.join(MINI_ROOT, 'pages/mine/mine.js')); + if (!cfg) throw new Error('mine.js 未调用 Page(),加载失败'); + + const page = Object.assign({}, cfg); + page.data = JSON.parse(JSON.stringify(cfg.data)); + page.setData = function (patch) { Object.assign(this.data, patch); }; + page.setData({ userInfo: { userId: 1 } }); + + const auth = require(path.join(MINI_ROOT, 'utils/auth.js')); + + // 反向证明:修复前的 parseInt 会产出 NaN,而网关的判空两道都挡不住它 + eq(auth.buildMeasurePostTopic(parseInt('abc'), 'BT_01', '001'), '/NaN/BT_01/001/measure/post', + '反向证明:parseInt("abc") 会造出 /NaN/ 脏 topic'); + eq(auth.buildMeasurePostTopic(parseInt('12.5'), 'BT_01', '001'), '/12/BT_01/001/measure/post', + '反向证明:parseInt("12.5") 会静默截断成 12'); + + for (const bad of ['abc', '12.5', '0', '-3', '']) { + rt.storage.removeMqttConfig(); + toasts.length = 0; + page.setData({ mqttConnectingLocal: false }); + page.data.mqttForm = { serialNumber: 'BT_01', productId: bad, productSecret: 'sec' }; + await page.handleMqttConnect(); + eq(rt.storage.getMqttConfig(), null, `非法产品ID "${bad}" 不落盘`); + } + + // 合法输入仍正常 + rt.storage.removeMqttConfig(); + toasts.length = 0; + page.setData({ mqttConnectingLocal: false }); + page.data.mqttForm = { serialNumber: 'BT_01', productId: '12', productSecret: 'sec' }; + await page.handleMqttConnect(); + const saved = rt.storage.getMqttConfig(); + ok(saved !== null, '合法产品ID 正常保存'); + eq(saved && saved.productId, 12, '保存为数字 12(不是字符串)'); + eq(auth.buildMeasurePostTopic(saved.productId, saved.serialNumber, '001'), '/12/BT_01/001/measure/post', + '构造出的 topic 正确'); +} + +/* ============================ 执行 ============================ */ + +(async () => { + process.stdout.write('\n===== P0 缺陷回归 =====\n'); + // 顺序敏感:[1][2] 必须先加载真实 mqtt-client,之后 loadRuntime 才会注入假模块 + await casePublishTimeout(); + await caseSubscriptions(); + await caseCrypto(); + await caseAddDevice(); + await caseProductId(); + + process.stdout.write(`\n===== 通过 ${passed} / 失败 ${failed} =====\n`); + process.exit(failed === 0 ? 0 : 1); +})(); diff --git a/yack-mini/tools/detail-layout-check.js b/yack-mini/tools/detail-layout-check.js deleted file mode 100644 index fcc74939..00000000 --- a/yack-mini/tools/detail-layout-check.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 详情页接收行/发送行 视觉回归校验(headless Chrome) - * - * 做法:直接读取真实 detail.wxss(rpx→px 换算) + 按 detail.wxml 结构生成 DOM, - * 并把小程序基础库的 button 默认样式(含 wx-button:not([size=mini]){width:184px}) - * 一起注入 → 得到的布局与真机同源,可直接量出「上传离线是否被挤出屏幕」。 - * - * 用法:node tools/detail-layout-check.js (生成 /tmp/detail-layout.html) - */ -const fs = require('fs'); -const path = require('path'); - -const BASE = path.join(__dirname, '../miniprogram'); -const DEVICE_W = 390; // iPhone 12/13/14 逻辑宽 -const RATIO = DEVICE_W / 750; // 1rpx -> px (0.52) - -// 1) 真实 wxss:rpx → px(保持其余原样,含选择器与特性权重) -const rawCss = fs.readFileSync(path.join(BASE, 'pages/device/detail/detail.wxss'), 'utf8'); -const css = rawCss.replace(/(-?\d*\.?\d+)rpx/g, (_, n) => `${(parseFloat(n) * RATIO).toFixed(3)}px`); - -// 2) 基础库 button 默认样式(从 WAWebview.js 原样抄,选择器 wx-button → button 等价替换) -const BASE_CSS = ` -button{-webkit-tap-highlight-color:transparent;background-color:#f8f8f8;border-radius:5px;box-sizing:border-box;color:#000;cursor:pointer;display:block;font-size:18px;line-height:2.55555556;overflow:hidden;padding-left:14px;padding-right:14px;position:relative;text-align:center;text-decoration:none;border:none} -button:not([size=mini]){margin-left:auto;margin-right:auto;width:184px} -button::after{content:" ";position:absolute;top:0;left:0;width:200%;height:200%;border:1px solid rgba(0,0,0,.2);border-radius:10px;box-sizing:border-box;transform:scale(.5);transform-origin:0 0} -button[disabled]:not([type]){background-color:#fafafa;color:rgba(0,0,0,.18)} -`; - -// 3) 按 detail.wxml 结构写 DOM(含 {{}} 取值) -const DATA = { - device: { serialNumber: 'BT_ECB0D1002F84', deviceId: 'EC:B0:D1:00:2F:84', enabled: true }, - bleConnected: true, mqttConnected: true, - canRetryBle: false, canRetryMqtt: false, - displayList: [], stats: { received: 0, uploaded: 1, failed: 0 }, - offlineCount: 128, autoScroll: true, recvHex: true, - logFollowing: true, lastError: null, - measurePointId: '371883', measurePointIdValid: true, - collectMode: 1, modeManual: true, canFlush: true, canSend: true, sendHex: true, sendInput: '', -}; -const ev = (expr) => { try { return new Function('d', `with(d){return (${expr})}`)(DATA); } catch { return `?${expr}`; } }; -const bind = (s) => s.replace(/\{\{([^}]+)\}\}/g, (_, e) => ev(e.trim())); - -const html = ` -
-
-
-
设备编号${bind('{{device.serialNumber}}')}
-
BLE 状态 - ${bind("{{bleConnected ? '已连接' : (canRetryBle ? '未连接 · 点击重连' : '未连接')}}")}
-
MQTT 状态 - ${bind("{{mqttConnected ? '已连接' : (canRetryMqtt ? '未连接 · 点击重连' : '未连接')}}")}
-
-
-
-
- 数据接收: -
- - - - -
-
-
等待设备上报数据…
-
接收 0已上报 1失败 0离线 ${DATA.offlineCount}
-
数据发送: -
-
- - -
-
-
-`; - -const outPath = '/tmp/detail-layout.html'; -fs.writeFileSync(outPath, html); -console.log('written:', outPath); diff --git a/yack-web/src/views/iot/device/device-edit.vue b/yack-web/src/views/iot/device/device-edit.vue index 66552dca..e6e4c8bd 100644 --- a/yack-web/src/views/iot/device/device-edit.vue +++ b/yack-web/src/views/iot/device/device-edit.vue @@ -54,6 +54,7 @@ +