20 KiB
yack-mini 小程序 Code Review
评审范围:
yack-mini/miniprogram/**(业务代码约 6.0k 行,不含第三方libs/mqtt-wx.min.js887 行) 评审日期: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 并持续上报。 - 修复:
// 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 "已连接设备",实际既没保存也没连。 - 修复(两处):
// 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(例如中文0x4E2DXOR0x66=0x4E4B= 20043),被静默截断 → 解密得到乱码 → 产品密钥永久损坏且无报错。密钥通常纯 ASCII 所以没暴露,但这是"输入一旦含非 Latin-1 字符就毁数据"的定时炸弹。 - 修复:
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-181vsmqtt-client.js:409-417 - 根因:
connect()在拆旧客户端时this.subscriptions.clear(),而_resubscribe()只在client.on('connect')里执行 —— 执行时表已空。任何一次换 clientId 重连都会永久丢失全部订阅。当前业务只 publish 不 subscribe,所以未暴露;一旦接入下行指令就是线上事故。 - 修复:把"订阅表"的生命周期从"连接"解耦 —— 只在
disconnect()(用户意图)时清,换 clientId 时保留:
// 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同一模式):
// 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,26vsservices/ble.js:16-32 - 根因:
TIMEOUT.BLE_SCAN/TIMEOUT.BLE_CONNECT/RETRY.BLE_RECONNECT_DELAY全项目无引用;而ble.js的DEFAULT_CONFIG里重新硬编码了相同数值(10000 / 10000 / 重连基数 1000)。改一处不改另一处就会静默失效。 - 修复:让
DEFAULT_CONFIG从 constants 取值:
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:572wx.getStorageSyncmeasurePoint.getMeasurePointId()gateway.js:573wx.getStorageSync_resolveProductId()gateway.js:576→615getMqttConfig()=getStorageSync+decryptFields_resolveSerialNumber()gateway.js:577→630getMqttConfig()再读一遍同一个 key + 再解密一次叠加
mine.js:165的getPendingData()(读盘 + 过期过滤 + 可能写回)与gateway.js:832的_refreshOfflineCount(),收帧期间每秒约 40~60 次getStorageSync。wx.getStorageSync是同步阻塞调用,缓存量大(离线缓存上限 1000 条、设备上限 50 台)时单次可达毫秒级 —— 这正好卡在 BLE 高频收帧的关键路径上。 -
修复方向:上报上下文(productId / reportSn / 测点ID / 模式)本质是会话级快照,不该每次读盘。建议在
_startNow时快照一次,_flush直接读内存:
// 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 永不结算"场景)现在写不出来 —— 桩要先行增强:
// 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 — 打磨项
pages/device/detail/detail.js:444-451:radio 取消用setData({collectMode: 0})+setTimeout(0)回拉,是绕过小程序受控组件限制的 hack。可行但脆弱,建议在注释里写明依赖的是"radio 内部选中态先于回调切换"这一未文档化行为。services/gateway.js:_flushOffline成功补传后不回写dataList里对应 item 的offline标记,UI 上"已缓存"标签会残留到下次清空。pages/device/detail/detail.js:492-499:handleFlushOffline用前后差算条数,若被_flushingOffline短路会显示"已上传 0 条",误导用户。建议用返回值条数而非快照差。pages/device/scan/scan.js:26:gateway.stop()的注释写"暂停正在跑的网关",实际是完整 teardown(phase → STOPPED)。命名与语义不符,建议改注释或提供真正的suspend。services/ble.js:305:scanDevices的超时定时器无句柄,stopScan()无法取消它 → 点"停止"后 10s 到点仍会回填扫描结果并覆盖 UI。建议保存 timer 并在stopScan里 clear,或给扫描加 seq 作废。pages/mine/mine.js:18:appVersion: '1.0.0'硬编码,与project.config.json无联动,发版必漏改。utils/request.js:未设timeout(默认 60s),登录流程弱网下会长时间 loading;401 分支只 reject 不做登录态收敛(与app.isLoggedIn()只看 userId 的设计一致,但两处口径应写在同一处注释里)。components/device-card/device-card.wxml:8:启用但未连上显示"连接中",实际可能是"异常/未连接",语义偏乐观。
六、本次未验证项(需真机确认)
- 真机上 MQTT 底层 QoS1 在 ws 断连时的回调是否必然结算(决定 P0-5 是"防御性加固"还是"已发生的事故")。
- Android 端 BLE 扫描在缺少定位声明时的实际表现(P1-9)。
- 200 条 × 10 次/秒 setData 在中低端机上的实际帧率(P1-1)。