MINI:优化小程序P0问题

This commit is contained in:
zhugongping 2026-09-28 09:01:38 +08:00
parent 5738dfb9e3
commit 6a803952a8
11 changed files with 30 additions and 1139 deletions

View File

@ -175,11 +175,10 @@ public class ToolController extends BaseController {
if (mqttConfig.getUsername().equals(username) && mqttConfig.getPassword().equals(password)) {
log.info("-----------服务端mqtt认证成功,clientId:{}---------------", clientid);
ret.put("result", "allow");
return ResponseEntity.ok().body(ret);
} else {
ret.put("result", "deny");
return ResponseEntity.ok().body(ret);
}
return ResponseEntity.ok().body(ret);
} else if (clientid.startsWith("web") || clientid.startsWith("phone")) {
// web端和移动端认证:token认证
String token = password;

View File

@ -1,8 +1,6 @@
package com.fastbee.iot.model;
import com.fastbee.common.annotation.Excel;
public class ProductAuthenticateModel {
/**
* 产品分类ID
@ -59,6 +57,9 @@ public class ProductAuthenticateModel {
*/
private String mqttSecret;
/**
* 1=简单认证,2=加密认证,3=简单+加密认证
*/
private int vertificateMethod;
public int getVertificateMethod() {

View File

@ -310,7 +310,7 @@ public class ToolServiceImpl implements IToolService {
if (productModel.getIsAuthorize() == 1) {
// 授权码验证和处理
String resultMessage = authCodeProcess(authCode, mqttModel, productModel);
if (!resultMessage.equals("")) {
if (!resultMessage.isEmpty()) {
return returnUnauthorized(mqttModel, resultMessage);
}
}
@ -318,13 +318,13 @@ public class ToolServiceImpl implements IToolService {
if (productModel.getStatus() == 2) {
return returnUnauthorized(mqttModel, "设备简单认证,设备处于禁用状态");
}
log.info("-----------设备简单认证成功,clientId:" + mqttModel.getClientId() + "---------------");
log.info("-----------设备简单认证成功,clientId:{}---------------", mqttModel.getClientId());
return ResponseEntity.ok().body("ok");
} else {
// 自动添加设备
int result = deviceService.insertDeviceAuto(mqttModel.getDeviceNumber(), mqttModel.getUserId(), mqttModel.getProductId());
if (result == 1) {
log.info("-----------设备简单认证成功,并自动添加设备到系统,clientId:" + mqttModel.getClientId() + "---------------");
log.info("-----------设备简单认证成功,并自动添加设备到系统,clientId:{}---------------", mqttModel.getClientId());
return ResponseEntity.ok().body("ok");
}
return returnUnauthorized(mqttModel, "设备简单认证,自动添加设备失败");
@ -343,7 +343,7 @@ public class ToolServiceImpl implements IToolService {
return returnUnauthorized(mqttModel, "设备加密认证,设备对应产品不支持加密认证");
}
String decryptPassword = AESUtils.decrypt(mqttModel.getPassword(), productModel.getMqttSecret());
if (decryptPassword == null || decryptPassword.equals("")) {
if (decryptPassword == null || decryptPassword.isEmpty()) {
return returnUnauthorized(mqttModel, "设备加密认证,mqtt密码解密失败");
}
String[] passwordArray = decryptPassword.split("&");
@ -352,7 +352,7 @@ public class ToolServiceImpl implements IToolService {
return returnUnauthorized(mqttModel, "设备加密认证,mqtt密码加密格式为:密码 & 过期时间 & 授权码,其中授权码为可选");
}
String mqttPassword = passwordArray[0];
Long expireTime = Long.valueOf(passwordArray[1]);
long expireTime = Long.parseLong(passwordArray[1]);
String authCode = passwordArray.length == 3 ? passwordArray[2] : "";
// 验证用户名
if (!mqttModel.getUserName().equals(productModel.getMqttAccount())) {
@ -370,7 +370,7 @@ public class ToolServiceImpl implements IToolService {
if (productModel.getIsAuthorize() == 1) {
// 授权码验证和处理
String resultMessage = authCodeProcess(authCode, mqttModel, productModel);
if (!resultMessage.equals("")) {
if (!resultMessage.isEmpty()) {
return returnUnauthorized(mqttModel, resultMessage);
}
}
@ -379,13 +379,13 @@ public class ToolServiceImpl implements IToolService {
if (productModel.getStatus() == 2) {
return returnUnauthorized(mqttModel, "设备加密认证,设备处于禁用状态");
}
log.info("-----------设备加密认证成功,clientId:" + mqttModel.getClientId() + "---------------");
log.info("-----------设备加密认证成功,clientId:{}---------------", mqttModel.getClientId());
return ResponseEntity.ok().body("ok");
} else {
// 自动添加设备
int result = deviceService.insertDeviceAuto(mqttModel.getDeviceNumber(), mqttModel.getUserId(), mqttModel.getProductId());
if (result == 1) {
log.info("-----------设备加密认证成功,并自动添加设备到系统,clientId:" + mqttModel.getClientId() + "---------------");
log.info("-----------设备加密认证成功,并自动添加设备到系统,clientId:{}---------------", mqttModel.getClientId());
return ResponseEntity.ok().body("ok");
}
return returnUnauthorized(mqttModel, "设备加密认证,自动添加设备失败");
@ -434,8 +434,8 @@ public class ToolServiceImpl implements IToolService {
ret.put("is_superuser", false);
// 设备端认证:加密认证(E)和简单认证(S,配置的账号密码认证)
String[] clientArray = clientid.split("&");
if (clientArray.length != 4 || clientArray[0].equals("") || clientArray[1].equals("") || clientArray[2].equals("") || clientArray[3].equals("")) {
return this.returnUnauthorized(new MqttAuthenticationModel(clientid, username, password), "设备mqtt客户端Id格式为:认证类型 & 设备编号 & 产品ID & 用户ID");
if (clientArray.length != 4 || clientArray[0].isEmpty() || clientArray[1].isEmpty() || clientArray[2].isEmpty() || clientArray[3].isEmpty()) {
return this.returnUnauthorized(new MqttAuthenticationModel(clientid, username, password), "设备MQTT客户端Id格式为:认证类型 & 设备编号 & 产品ID & 用户ID");
}
String authType = clientArray[0];
String deviceNumber = clientArray[1];
@ -444,13 +444,13 @@ public class ToolServiceImpl implements IToolService {
// 产品认证信息
ProductAuthenticateModel model = deviceService.selectProductAuthenticate(new AuthenticateInputModel(deviceNumber, productId));
if (model == null) {
log.error("-----------设备认证,通过产品ID查询不到信息,clientId:" + clientid + "---------------");
log.error("-----------设备认证,通过产品ID查询不到信息,clientId:{}---------------", clientid);
ret.put("result", "deny");
return ResponseEntity.ok().body(ret);
}
if (model.getProductStatus() != 2) {
// 产品必须为发布状态:1-未发布,2-已发布
log.error("-----------设备认证,设备对应产品还未发布,clientId:" + clientid + "---------------");
log.error("-----------设备认证,设备对应产品还未发布,clientId:{}---------------", clientid);
ret.put("result", "deny");
return ResponseEntity.ok().body(ret);
}
@ -460,24 +460,22 @@ public class ToolServiceImpl implements IToolService {
ResponseEntity res = this.simpleMqttAuthentication(new MqttAuthenticationModel(clientid, username, password, deviceNumber, productId, userId), model);
if (res.getStatusCodeValue() == HttpStatus.OK.value()) {
ret.put("result", "allow");
return ResponseEntity.ok().body(ret);
} else {
ret.put("result", "deny");
return ResponseEntity.ok().body(ret);
}
return ResponseEntity.ok().body(ret);
} else if (authType.equals("E")) {
// 设备加密认证
ResponseEntity res = this.encryptAuthentication(new MqttAuthenticationModel(clientid, username, password, deviceNumber, productId, userId), model);
if (res.getStatusCodeValue() == HttpStatus.OK.value()) {
ret.put("result", "allow");
return ResponseEntity.ok().body(ret);
} else {
ret.put("result", "deny");
return ResponseEntity.ok().body(ret);
}
return ResponseEntity.ok().body(ret);
} else {
log.error("-----------设备认证,认证类型有误,clientId:" + clientid + "---------------");
log.error("-----------设备认证,认证类型有误,clientId:{}---------------", clientid);
ret.put("result", "deny");
return ResponseEntity.ok().body(ret);
}
@ -488,8 +486,8 @@ public class ToolServiceImpl implements IToolService {
*/
private String authCodeProcess(String authCode, MqttAuthenticationModel mqttModel, ProductAuthenticateModel productModel) {
String message = "";
if (authCode.equals("")) {
return message = "设备认证,设备授权码不能为空";
if (authCode.isEmpty()) {
return "设备认证,设备授权码不能为空";
}
// 查询授权码是否存在
ProductAuthorize authorize = productAuthorizeMapper.selectFirstAuthorizeByAuthorizeCode(new ProductAuthorize(authCode, productModel.getProductId()));

View File

@ -13,10 +13,10 @@ const DEV_CONFIG = {
// 生产环境
const PROD_CONFIG = {
baseUrl: 'https://ycdat.cn/prod-api',
mqttServer: 'wxs://ycdat.cn/mqtt',
mqttServer: 'wss://ycdat.cn/mqtt',
};
// 当前环境:development | production
const ENV = 'development';
// 当前环境:development wxede2cc5d39b2c6ea | production wx8bcff4708d1f6d2b
const ENV = 'production';
module.exports = ENV === 'development' ? DEV_CONFIG : PROD_CONFIG;

View File

@ -38,16 +38,16 @@
<view class="status-section">
<view class="section-title">MQTT 连接</view>
<view class="mqtt-form">
<view class="mqtt-item">
<text class="mqtt-label">设备编号(SN)</text>
<input class="mqtt-input" placeholder="客户端ID依据" value="{{mqttForm.serialNumber}}" bindinput="onMqttInput" data-field="serialNumber" />
</view>
<view class="mqtt-item">
<text class="mqtt-label">产品ID</text>
<input class="mqtt-input" type="number" placeholder="产品ID" value="{{mqttForm.productId}}" bindinput="onMqttInput" data-field="productId" />
</view>
<view class="mqtt-item">
<text class="mqtt-label">产品密钥</text>
<text class="mqtt-label">产品Mqtt账号</text>
<input class="mqtt-input" placeholder="产品Mqtt账号" value="{{mqttForm.serialNumber}}" bindinput="onMqttInput" data-field="serialNumber" />
</view>
<view class="mqtt-item">
<text class="mqtt-label">产品Mqtt密码</text>
<input class="mqtt-input" password placeholder="产品MQTT密码" value="{{mqttForm.productSecret}}" bindinput="onMqttInput" data-field="productSecret" />
</view>
<view class="mqtt-actions">

View File

@ -48,7 +48,7 @@
},
"compileType": "miniprogram",
"libVersion": "3.3.4",
"appid": "wxede2cc5d39b2c6ea",
"appid": "wx8bcff4708d1f6d2b",
"projectname": "yack-mini",
"condition": {},
"editorSetting": {

View File

@ -1,205 +0,0 @@
/**
* 回归:设备详情页底部收发控制台的三个开关(滚动 / 接收Hex / 发送Hex)
*
* 缺陷(修复前):
* 三处都是「裸 checkbox + bindchange」——而 bindchange 是 checkbox-group 的事件,
* checkbox 自身不提供(官方属性表只有 value/disabled/checked/color)。
* handler 又按 group 的数组语义写成 `e.detail.value.length > 0`:
* 布尔值下 `true.length === undefined` → `undefined > 0 === false`,三个开关恒定失效。
* 现场表现:取消「接收Hex」切不到 ASCII(曾被误判成 asciiOfHex 显示「·」的设计行为)、
* 「滚动」关不掉也开不了、发送区一直按 hex 语义走。
*
* 红线(本套用例锁死):
* 1. wxml 结构:每个 checkbox 必须在 checkbox-group 内,group 各绑一个 change
* 2. detail.js 不得回退成 `e.detail.value.length > 0` 这种数组语义误用
* 3. group 派发 value 数组时,三个开关能正确切换 data
* 4. 取消「接收Hex」→ 日志切成 ASCII 视图;勾上 → 回到 hex 视图(用最近快照重渲染)
* 5. 取消「滚动」→ autoScroll 与 logFollowing 同时关闭;勾上 → 同时恢复
*
* 跑法:node tests/detail-console-toggles.test.js
*/
const fs = require('fs');
const path = require('path');
const { MINI_ROOT, loadRuntime } = 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)})`);
}
/* ============================ 被测文件与页面实例 ============================ */
const DETAIL_DIR = path.join(MINI_ROOT, 'pages/device/detail');
const WXML = fs.readFileSync(path.join(DETAIL_DIR, 'detail.wxml'), 'utf8');
const DETAIL_JS = fs.readFileSync(path.join(DETAIL_DIR, 'detail.js'), 'utf8');
const DETAIL_SRC = path.join(DETAIL_DIR, 'detail.js');
/**
* 每次调用都重建一套干净的运行时 + 全新的页面实例,
* 用例之间不共享 data,避免"上一条用例把开关改过"造成的假绿/假红
*/
function loadPage() {
const rt = loadRuntime(); // 内部 clearMiniCache + 重建 global.wx
global.getApp = () => ({ globalData: {} });
let cfg = null;
global.Page = (c) => { cfg = c; };
require(DETAIL_SRC);
if (!cfg) throw new Error('detail.js 未调用 Page(),加载失败');
const page = Object.assign({}, cfg);
page.data = JSON.parse(JSON.stringify(cfg.data));
page.setData = function (patch, cb) {
Object.assign(this.data, patch);
if (cb) cb();
};
return { rt, page };
}
/* ============================ 用例 ============================ */
/** 结构红线:勾选框必须被 checkbox-group 包着 */
function case1_wxmlStructure() {
process.stdout.write('\n[1] wxml 结构:checkbox 必须在 checkbox-group 内\n');
const groups = WXML.match(/<checkbox-group[^>]*>[\s\S]*?<\/checkbox-group>/g) || [];
eq(groups.length, 3, '三个开关各有一个 checkbox-group');
eq((WXML.match(/<checkbox[\s>]/g) || []).length, 3, '页面共 3 个 checkbox');
ok(!/<checkbox\s[^>]*bindchange/.test(WXML),
'没有任何「裸 checkbox 直接挂 bindchange」的写法(这是本次缺陷的形态)');
ok(groups.some((g) => g.includes('bindchange="onAutoScrollToggle"') && g.includes('value="scroll"')),
'「滚动」group 绑 onAutoScrollToggle,子项 value=scroll');
ok(groups.some((g) => g.includes('bindchange="onRecvHexToggle"') && g.includes('value="hex"')),
'接收「Hex」group 绑 onRecvHexToggle,子项 value=hex');
ok(groups.some((g) => g.includes('bindchange="onSendHexToggle"') && g.includes('value="hex"')),
'发送「Hex」group 绑 onSendHexToggle,子项 value=hex');
}
/** 代码红线:不得回退成数组语义误用 */
function case2_codeGuards() {
process.stdout.write('\n[2] detail.js 取值方式:读 group 的 value 数组,而非误判为布尔\n');
ok(!/\.detail\.value\.length\s*>\s*0/.test(DETAIL_JS),
'没有 `e.detail.value.length > 0` 的残留(该写法在布尔值下恒 false)');
ok(/onAutoScrollToggle\(e\)\s*\{[^}]*includes\('scroll'\)/.test(DETAIL_JS),
'onAutoScrollToggle 用 includes(\'scroll\') 读值');
ok(/onRecvHexToggle\(e\)\s*\{[^}]*includes\('hex'\)/.test(DETAIL_JS),
'onRecvHexToggle 用 includes(\'hex\') 读值');
ok(/onSendHexToggle\(e\)\s*\{[^}]*includes\('hex'\)/.test(DETAIL_JS),
'onSendHexToggle 用 includes(\'hex\') 读值');
// 反例固化:裸 checkbox 若派发布尔值,旧写法必然恒 false —— 本次缺陷的数学根因
const legacy = (v) => v.length > 0;
eq(legacy(true), false, '反例:布尔 true 在旧写法下取到 false');
eq(legacy(false), false, '反例:布尔 false 在旧写法下取到 false');
}
/** 滚动开关:勾选/取消都要同时管住 autoScroll 与 logFollowing */
function case3_autoScrollToggle() {
process.stdout.write('\n[3] 「滚动」开关\n');
const { page } = loadPage();
eq(page.data.autoScroll, true, '初始勾选(默认自动跟随)');
eq(page.data.logFollowing, true, '初始处于跟随态');
page.onAutoScrollToggle({ detail: { value: [] } }); // 用户取消勾选
eq(page.data.autoScroll, false, '取消勾选 → 停止自动滚动');
eq(page.data.logFollowing, false, '取消勾选 → 同时退出跟随态');
page.onAutoScrollToggle({ detail: { value: ['scroll'] } }); // 用户重新勾选
eq(page.data.autoScroll, true, '重新勾选 → 恢复自动滚动');
eq(page.data.logFollowing, true, '重新勾选 → 恢复跟随态');
}
/** 接收 Hex 开关:切换 data 并用最近快照重渲染日志视图 */
function case4_recvHexToggle() {
process.stdout.write('\n[4] 接收「Hex」开关(含视图重渲染)\n');
const { page } = loadPage();
// 造一份最近快照:gateway 的 dataList 最新在前
page.data.device = { serialNumber: 'BT_1' };
page._lastSnap = {
device: { serialNumber: 'BT_1' },
dataList: [
{ id: '2', hex: '41424344' }, // 最新一帧
{ id: '1', hex: '31323334' },
],
};
eq(page.data.recvHex, true, '初始勾选(hex 视图)');
page.onRecvHexToggle({ detail: { value: [] } }); // 取消勾选 → ASCII 视图
eq(page.data.recvHex, false, '取消勾选 → 切到 ASCII 视图');
eq(page.data.displayList.length, 2, 'ASCII 视图:用最近快照重渲染出 2 行');
eq(page.data.displayList[1].text, 'ABCD', '最新在后:末行为最新帧的 ASCII 文本');
eq(page.data.displayList[0].text, '1234', 'ASCII 视图:可打印字节原样显示');
page.onRecvHexToggle({ detail: { value: ['hex'] } }); // 勾上 → 回 hex 视图
eq(page.data.recvHex, true, '勾选 → 回到 hex 视图');
eq(page.data.displayList[1].text, '41424344', 'hex 视图:末行为原始 hex 帧');
// 非本设备的快照不得上屏
page._lastSnap = { device: { serialNumber: 'BT_OTHER' }, dataList: [{ id: '9', hex: 'AA' }] };
page.onRecvHexToggle({ detail: { value: [] } });
eq(page.data.displayList.length, 0, '非本设备快照:不上屏(保持空列表)');
}
/** 发送 Hex 开关:切换语义 + 已有输入大小写归一 */
function case5_sendHexToggle() {
process.stdout.write('\n[5] 发送「Hex」开关\n');
const { page } = loadPage();
eq(page.data.sendHex, true, '初始勾选(按 hex 字节发送)');
page.data.sendInput = 'at+csq';
page.onSendHexToggle({ detail: { value: [] } }); // 取消 → ASCII 文本发送
eq(page.data.sendHex, false, '取消勾选 → 按 ASCII 文本发送');
eq(page.data.sendInput, 'at+csq', 'ASCII 模式保留原文大小写(大小写是内容)');
page.data.sendInput = 'at+csq';
page.onSendHexToggle({ detail: { value: ['hex'] } }); // 勾上 → hex 语义
eq(page.data.sendHex, true, '勾选 → 按 hex 字节发送');
eq(page.data.sendInput, 'AT+CSQ', 'hex 模式统一大写(便于阅读)');
}
/* ============================ 主流程 ============================ */
function main() {
process.stdout.write('===== 详情页收发控制台开关 回归 =====\n');
const cases = [
case1_wxmlStructure,
case2_codeGuards,
case3_autoScrollToggle,
case4_recvHexToggle,
case5_sendHexToggle,
];
for (const c of cases) {
try {
c();
} catch (e) {
failed++;
process.stdout.write(` ✗ 用例抛异常: ${e && e.stack}\n`);
}
}
process.stdout.write(`\n===== 详情页收发控制台开关:通过 ${passed} / 失败 ${failed} =====\n`);
process.exit(failed ? 1 : 0);
}
main();

View File

@ -1,329 +0,0 @@
/**
* 回归:设备开关关闭后不得再尝试连接蓝牙
*
* 现场日志(复现源):
* [Storage] 禁用设备: BT_ECB0D1002F84
* [BLE] 连接超时: EC:B0:D1:00:2F:84
* [BLE] 连续 3 次建链失败,复位适配器 ← 替一个已经关掉的设备复位整个蓝牙栈
* [GW] 启动失败: Error: 连接失败: 连接超时。 ← 还把失败上屏 + 排 5s 自动重试
*
* 红线(本套用例锁死):
* 1. 设备开关关闭后,蓝牙**不再发起任何建链尝试**(不建链、不排自动重试、不复位适配器)
* 2. 关闭动作必须**立即生效**,不能等队列里在途的 _boot(最坏一次建链超时 + 适配器复位)
* 3. MQTT 是应用级连接,**不受设备开关影响**(防止有人把 MQTT 也绑进设备开关)
*
* 跑法:node tests/gateway-disabled-device.test.js
*/
const {
loadRuntime, seedScene, sleep,
} = require('./helpers/fake-wx.js');
/* ============================ 迷你断言器 ============================ */
let passed = 0;
let failed = 0;
/** 安全的展示值(_reconnectTimer 是 Timeout 对象,直接 JSON.stringify 会因循环引用抛错) */
function safe(v) {
if (v === undefined) return 'undefined';
if (v === null || typeof v !== 'object') return JSON.stringify(v);
try {
return JSON.stringify(v);
} catch (e) {
return String(v);
}
}
function ok(cond, label, extra) {
if (cond) {
passed++;
process.stdout.write(` ✓ ${label}\n`);
} else {
failed++;
process.stdout.write(` ✗ ${label}${extra !== undefined ? ` ← ${safe(extra)}` : ''}\n`);
}
}
function eq(actual, expected, label) {
ok(actual === expected, `${label} (期望 ${safe(expected)},实际 ${safe(actual)})`);
}
/* ============================ 用例 ============================ */
const DEV = { serialNumber: 'BT_ECB0D1002F84', deviceId: 'EC:B0:D1:00:2F:84' };
/** 关掉 console 噪音,只留我们自己写的结果 */
function muteConsole() {
const raw = { log: console.log, warn: console.warn, error: console.error };
console.log = () => {};
console.warn = () => {};
console.error = () => {};
return () => Object.assign(console, raw);
}
/**
* 用例 1:无启用设备时,不碰蓝牙;MQTT 照旧(应用级)
*/
async function case1_noEnabledDevice() {
process.stdout.write('\n[1] 无启用设备 → 不碰蓝牙,MQTT 仍按应用级自动补连\n');
const restore = muteConsole();
const rt = loadRuntime();
seedScene(rt, { ...DEV, enabled: false });
rt.bleService.setConfig({ connectTimeout: 150, opTimeout: 150, adapterOpTimeout: 120, closeWaitTimeout: 60 });
await rt.gateway.ensureStarted();
await rt.gateway.resume();
await sleep(120);
restore();
eq(rt.state.calls.create.length, 0, '没有发起任何建链');
eq(rt.state.calls.openAdapter, 0, '没有打开蓝牙适配器');
eq(rt.gateway.getSnapshot().phase, 'stopped', '相位收敛到 stopped');
ok(rt.mqtt._rec.connectCalls.length > 0, 'MQTT 仍被自动补连(应用级,不受设备开关影响)', rt.mqtt._rec.connectCalls);
}
/**
* 用例 2:运行中关闭设备开关(等价首页 index.handleDeviceSwitch 关闭分支)
* → 立即中止在途建链,不复位适配器,不上屏错误,不排自动重试
*/
async function case2_disableWhileConnecting() {
process.stdout.write('\n[2] 采集中关闭设备 → 立即中止在途建链 / 不复位适配器 / 不上屏 / 不重试\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'hang' }); // 建链永不回调:模拟"连接超时"现场
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 150, opTimeout: 150, adapterOpTimeout: 120, closeWaitTimeout: 60 });
// 已经连续失败 2 次:再来 1 次就够 3 次阈值 → 差一点就要"复位适配器"。
// 这正是现场日志里那一步;关闭设备后它必须被跳过
rt.bleService._consecutiveCreateFails = 2;
const boot = rt.gateway.ensureStarted(); // 首页 onShow / 启用设备后的启动
await sleep(40);
eq(rt.state.calls.create.length, 1, '建链已在途(1 次)');
// 记录整条状态轨迹:关闭设备后**任何时刻**都不该出现"异常/启动失败",
// 这样断言与"收尾拆除"的先后无关(看单次快照会被 teardown 清干净而假绿)
const trace = [];
const unsub = rt.gateway.subscribe((s) => trace.push({ phase: s.phase, lastError: s.lastError }));
// === 用户按下设备开关的关闭 ==(index.handleDeviceSwitch)
rt.storage.deactivateDevice(DEV.serialNumber);
const stop = rt.gateway.ensureStarted();
await Promise.allSettled([boot, stop]);
await sleep(120);
const snap = rt.gateway.getSnapshot();
const createAfter = rt.state.calls.create.length;
const closeConnAfter = rt.state.calls.closeConn.length;
const adapterResets = rt.state.calls.closeAdapter;
// 观察窗口:远超建链超时,任何新的自动重连/失败重试都会冒头
await sleep(500);
const createSettled = rt.state.calls.create.length;
unsub();
restore();
ok(closeConnAfter >= 1, '关闭时立即拆链(closeBLEConnection 已调用)', closeConnAfter);
eq(adapterResets, 0, '不复位蓝牙适配器(作废流程不得升级为全局复位)');
eq(createSettled, createAfter, '关闭后窗口内没有新增建链尝试');
ok(!trace.some((t) => t.phase === 'error'), '状态轨迹里从未出现 error 相位', trace);
ok(trace.every((t) => t.lastError === null), '状态轨迹里从未上屏"启动失败"', trace);
eq(snap.lastError, null, '终态无残留错误');
eq(rt.gateway._initRetryTimer, null, '没有排下"启动失败自动重试"');
eq(rt.gateway._device, null, '网关已无采集目标');
eq(snap.phase, 'stopped', '相位收敛到 stopped');
}
/**
* 用例 3:bleService 隔离 —— 作废的流程抛 cancelled,且不升级为"复位适配器"
*/
async function case3_obsoleteFlowNoEscalate() {
process.stdout.write('\n[3] bleService:作废流程抛 cancelled,不复位适配器\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'hang' });
rt.bleService.setConfig({ connectTimeout: 120, opTimeout: 120, adapterOpTimeout: 100, closeWaitTimeout: 50 });
await rt.bleService.initBluetooth();
const opensAfterInit = rt.state.calls.openAdapter;
rt.bleService._consecutiveCreateFails = 2;
const p = rt.bleService.connectDevice(DEV.deviceId);
await sleep(30);
await rt.bleService.disconnect(); // 用户关闭设备 → 作废在途流程
const err = await p.then(() => null, (e) => e);
await sleep(160);
restore();
ok(err && err.cancelled === true, '在途流程以 cancelled 结算(上层按"不算失败"处理)', err && err.message);
eq(rt.state.calls.closeAdapter, 0, '没有复位适配器');
eq(rt.state.calls.openAdapter, opensAfterInit, '没有重新打开适配器');
eq(rt.bleService.getState(), 'disconnected', '状态为 disconnected');
}
/**
* 用例 4:resetAdapter 必须"复原"手动断开意图,而不是无条件清零
*/
async function case4_resetAdapterKeepsIntent() {
process.stdout.write('\n[4] resetAdapter 复原 _manuallyDisconnected(不得抹掉用户意图)\n');
const restore = muteConsole();
const rt = loadRuntime();
rt.bleService.setConfig({ adapterOpTimeout: 100 });
rt.bleService._manuallyDisconnected = true; // 场景:设备已被关闭
await rt.bleService.resetAdapter();
eq(rt.bleService._manuallyDisconnected, true, '复位后仍保持"已停用/主动断开"意图');
rt.bleService._manuallyDisconnected = false; // 场景:正常自愈重连中复位
await rt.bleService.resetAdapter();
eq(rt.bleService._manuallyDisconnected, false, '正常复位后不残留 true(否则进入死态)');
restore();
}
/**
* 用例 5:主动断开后,自动重连不得把链路拉回来
*/
async function case5_noReconnectAfterDisconnect() {
process.stdout.write('\n[5] 断开后不排自动重连(入口 + 定时器触发双重把关)\n');
const restore = muteConsole();
const rt = loadRuntime();
rt.bleService.setConfig({ connectTimeout: 120, opTimeout: 120 });
await rt.bleService.initBluetooth();
await rt.bleService.disconnect();
rt.bleService._scheduleReconnect();
eq(rt.bleService._reconnectTimer, null, '主动断开后 _scheduleReconnect 不排定时器');
// 反过来:允许重连时排下的定时器,在触发前用户关了设备 → 到点也不得重连
rt.bleService._lastDeviceId = DEV.deviceId;
rt.bleService._manuallyDisconnected = false;
rt.bleService._reconnectAttempts = 0;
rt.bleService._scheduleReconnect(); // 1s 后触发
await rt.bleService.disconnect(); // 触发前关闭设备
const before = rt.state.calls.create.length;
await sleep(1300);
const after = rt.state.calls.create.length;
restore();
eq(after, before, '定时器到点后没有重连');
}
/**
* 用例 6:重新开启设备后一切照旧(防止"关掉就再也连不上"的修过头)
*/
async function case6_reenableWorks() {
process.stdout.write('\n[6] 重新开启设备 → 正常建链并进入采集中\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'ok' });
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
const boot = rt.gateway.ensureStarted();
await sleep(60);
eq(rt.state.calls.create.length, 1, '建链 1 次');
rt.storage.deactivateDevice(DEV.serialNumber);
await rt.gateway.ensureStarted();
await boot.catch(() => {});
await sleep(80);
eq(rt.gateway.getSnapshot().phase, 'stopped', '关闭后停止');
rt.storage.activateDevice(DEV.serialNumber);
await rt.gateway.ensureStarted();
await sleep(150);
const snap = rt.gateway.getSnapshot();
restore();
ok(rt.state.calls.create.length >= 2, '重新开启后再次建链', rt.state.calls.create.length);
eq(snap.bleConnected, true, 'BLE 已就绪');
eq(snap.phase, 'running', '相位 running(采集中)');
eq(snap.lastError, null, '无残留错误');
}
/**
* 用例 7:gateway.start(已停用的设备) 既不建链,也不连累当前正在跑的采集
*/
async function case7_startDisabledDeviceIsIgnored() {
process.stdout.write('\n[7] start(已停用设备) → 不建链且不打断当前采集\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'ok' });
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
await rt.gateway.ensureStarted();
await sleep(150);
eq(rt.gateway.getSnapshot().phase, 'running', '设备 A 采集中');
const closeConnBefore = rt.state.calls.closeConn.length;
await rt.gateway.start({
serialNumber: 'BT_OTHER_DEVICE',
deviceId: 'AA:BB:CC:DD:EE:FF',
enabled: false,
});
await sleep(80);
const snap = rt.gateway.getSnapshot();
restore();
ok(!rt.state.calls.create.includes('AA:BB:CC:DD:EE:FF'), '没有替已停用设备建链', rt.state.calls.create);
eq(rt.state.calls.closeConn.length, closeConnBefore, '没有拆掉当前设备的链路');
eq(snap.phase, 'running', '当前采集不受影响');
eq(snap.device && snap.device.serialNumber, DEV.serialNumber, '目标仍是设备 A');
}
/**
* 用例 8:直接切换设备(A 开 → 开 B,A 自动失活)不得被同步中止逻辑弄坏
* 单 enabled 不变量下开 B 等于关 A,_syncTarget 会立即拆 A 的链
*/
async function case8_switchDeviceStillWorks() {
process.stdout.write('\n[8] 切换到另一台设备(原设备自动失活)仍能正常接管\n');
const restore = muteConsole();
const rt = loadRuntime({ createMode: 'ok' });
seedScene(rt, DEV);
rt.bleService.setConfig({ connectTimeout: 500, opTimeout: 500, adapterOpTimeout: 300, closeWaitTimeout: 100 });
await rt.gateway.ensureStarted();
await sleep(120);
eq(rt.gateway.getSnapshot().phase, 'running', '设备 A 采集中');
const B = { serialNumber: 'BT_AAAABBBBCCCC', deviceId: 'AA:AA:BB:BB:CC:CC', name: 'B' };
rt.storage.addDevice({ ...B, bleName: B.serialNumber, enabled: false, createTime: Date.now() });
rt.storage.activateDevice(B.serialNumber); // 开 B = 关 A
await rt.gateway.ensureStarted();
await sleep(200);
const snap = rt.gateway.getSnapshot();
const adapterResets = rt.state.calls.closeAdapter;
const creates = rt.state.calls.create.slice();
restore();
eq(rt.storage.getEnabledDevice().serialNumber, B.serialNumber, 'B 成为唯一启用设备');
eq(rt.storage.getDevice(DEV.serialNumber).enabled, false, 'A 已失活');
ok(creates.includes(B.deviceId), '已为 B 建链', creates);
eq(snap.device && snap.device.serialNumber, B.serialNumber, '网关目标切到 B');
eq(snap.bleConnected, true, 'B 的 BLE 已就绪');
eq(snap.phase, 'running', '采集中');
eq(adapterResets, 0, '切换设备不需要复位适配器');
eq(snap.lastError, null, '切换过程无错误上屏');
}
/* ============================ 主流程 ============================ */
async function main() {
const cases = [
case1_noEnabledDevice,
case2_disableWhileConnecting,
case3_obsoleteFlowNoEscalate,
case4_resetAdapterKeepsIntent,
case5_noReconnectAfterDisconnect,
case6_reenableWorks,
case7_startDisabledDeviceIsIgnored,
case8_switchDeviceStillWorks,
];
for (const c of cases) {
try {
await c();
} catch (e) {
failed++;
process.stdout.write(` ✗ 用例抛异常: ${e && e.stack}\n`);
}
}
process.stdout.write(`\n===== 设备开关关闭后不再连蓝牙:通过 ${passed} / 失败 ${failed} =====\n`);
process.exit(failed ? 1 : 0);
}
main();

View File

@ -1,307 +0,0 @@
/**
* 假 wx 运行时(供 yack-mini 纯 JS 模块的 Node 回归测试使用)
*
* 设计要点(踩过的坑,勿改回去):
* - **storage 必须深拷贝**:真机 wx.setStorageSync 是序列化存储,直接存对象引用会让
* "读列表 → 改字段 → 存回"与内存里的对象互为别名,测出假的通过。
* - **native 桩必须能"失败"与"永不回调"**:被测状态机的分支几乎都由失败码驱动,
* 只回 success 会让一半用例假绿(链接不存在时要回 10002、适配器未开时要回 10001)。
* - **异步回调**:wx 的回调都是异步的,同步触发会打乱"先注册监听再连接"的顺序。
* - 只暴露被测代码真正会用到的 API,**新增 wx.onXxx 用法时只改这一个文件**。
*/
const path = require('path');
const MINI_ROOT = path.resolve(__dirname, '../../miniprogram');
/* ============================ 存储 ============================ */
function createStorageArea() {
const map = new Map();
const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
return {
_map: map,
getStorageSync: (k) => (map.has(k) ? clone(map.get(k)) : ''),
setStorageSync: (k, v) => { map.set(k, clone(v)); },
removeStorageSync: (k) => { map.delete(k); },
clearStorageSync: () => map.clear(),
};
}
/* ============================ BLE 桩 ============================ */
// 一个非通用(非 1800/1801/180a/180f)服务,带 1 个 notify + 1 个 write 特征,
// 让 _discoverServices/_discoverCharacteristics 能走到 READY
const SVC = '0000FFF0-0000-1000-8000-00805F9B34FB';
const CHAR_NOTIFY = '0000FFF1-0000-1000-8000-00805F9B34FB';
const CHAR_WRITE = '0000FFF2-0000-1000-8000-00805F9B34FB';
function createBleState() {
return {
adapterOpen: false,
links: new Set(), // 已建立的链路 deviceId(createBLEConnection 成功才加入)
createMode: 'ok', // 'ok' | 'hang'(native 永不回调)| 'fail'
createErrMsg: '连接超时',
listeners: { conn: [], char: [], adapter: [] },
calls: {
openAdapter: 0, closeAdapter: 0,
create: [], closeConn: [], mtu: [], services: [], chars: [], notify: [],
},
};
}
function createBleApi(st) {
const emitConn = (deviceId, connected) => {
st.listeners.conn.forEach((cb) => cb({ deviceId, connected }));
};
return {
onBLEConnectionStateChange: (cb) => st.listeners.conn.push(cb),
onBLECharacteristicValueChange: (cb) => st.listeners.char.push(cb),
onBluetoothAdapterStateChange: (cb) => st.listeners.adapter.push(cb),
openBluetoothAdapter: (o = {}) => {
st.calls.openAdapter++;
st.adapterOpen = true;
setTimeout(() => o.success && o.success({}), 0);
},
closeBluetoothAdapter: (o = {}) => {
st.calls.closeAdapter++;
st.adapterOpen = false;
// native 关适配器会同时拆掉全部链路并派发断链事件(实测如此)
const ids = Array.from(st.links);
st.links.clear();
setTimeout(() => {
ids.forEach((id) => emitConn(id, false));
o.success && o.success({});
o.complete && o.complete({});
}, 0);
},
createBLEConnection: (o = {}) => {
st.calls.create.push(o.deviceId);
if (st.createMode === 'hang') return; // 坏桩:永不回调(测超时兜底)
setTimeout(() => {
if (st.createMode === 'fail') {
o.fail && o.fail({ errCode: 10003, errMsg: st.createErrMsg });
return;
}
st.links.add(o.deviceId);
o.success && o.success({});
}, 0);
},
closeBLEConnection: (o = {}) => {
st.calls.closeConn.push(o.deviceId);
const had = st.links.has(o.deviceId);
setTimeout(() => {
if (!had) {
// 链路本就不存在:真机回 fail 且**不派发** onBLEConnectionStateChange
o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' });
o.complete && o.complete({});
return;
}
st.links.delete(o.deviceId);
emitConn(o.deviceId, false);
o.success && o.success({});
o.complete && o.complete({});
}, 0);
},
setBLEMTU: (o = {}) => {
st.calls.mtu.push(o.deviceId);
setTimeout(() => { o.success && o.success({}); o.complete && o.complete({}); }, 0);
},
getBLEDeviceServices: (o = {}) => {
st.calls.services.push(o.deviceId);
setTimeout(() => {
if (!st.links.has(o.deviceId)) {
// 链路不存在时必须失败,否则"GATT 探活"永远返回 alive(死链发现不了)
o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' });
return;
}
o.success && o.success({
services: [
{ uuid: '00001800-0000-1000-8000-00805F9B34FB', isPrimary: true },
{ uuid: SVC, isPrimary: true },
],
});
}, 0);
},
getBLEDeviceCharacteristics: (o = {}) => {
st.calls.chars.push(o.characteristicId || o.serviceId);
setTimeout(() => {
if (!st.links.has(o.deviceId)) {
o.fail && o.fail({ errCode: 10002, errMsg: 'no connection' });
return;
}
o.success && o.success({
characteristics: [
{ uuid: CHAR_NOTIFY, properties: { notify: true, read: true } },
{ uuid: CHAR_WRITE, properties: { write: true, writeNoResponse: true } },
],
});
}, 0);
},
notifyBLECharacteristicValueChange: (o = {}) => {
st.calls.notify.push(o.characteristicId);
setTimeout(() => o.success && o.success({}), 0);
},
getSystemInfoSync: () => ({ platform: 'android' }),
};
}
/* ============================ wx 组装 ============================ */
function createWx(storageArea, bleState) {
return {
...createBleApi(bleState),
...storageArea,
arrayBufferToBase64: (buf) => Buffer.from(new Uint8Array(buf)).toString('base64'),
base64ToArrayBuffer: (b64) => {
const buf = Buffer.from(b64, 'base64');
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
},
getNetworkType: (o = {}) => setTimeout(() => o.success && o.success({ networkType: 'wifi' }), 0),
onNetworkStatusChange: () => {},
showToast: () => {},
showModal: () => {},
showLoading: () => {},
hideLoading: () => {},
};
}
/* ============================ 模块加载 ============================ */
function clearMiniCache() {
Object.keys(require.cache).forEach((k) => {
if (k.startsWith(MINI_ROOT + path.sep) || k.startsWith(MINI_ROOT + '/')) delete require.cache[k];
});
}
function stubModule(id, exports) {
return { id, filename: id, loaded: true, exports, children: [], paths: [] };
}
/**
* 假 MQTT 客户端(隔离无关依赖:只记录"被要求建连"的次数)
*/
function createFakeMqtt() {
const rec = { connectCalls: [], publishCalls: [], disconnectCalls: 0, reconnectCalls: 0 };
let held = false; // 是否已持有客户端(canAutoConnect 语义:持有即视为已连/自愈中)
const client = {
userDisconnected: false,
isConnected: () => false,
getState: () => 'disconnected',
canAutoConnect: () => !client.userDisconnected && !held,
onStateChange: () => {},
connect: (opts) => {
rec.connectCalls.push((opts && opts.clientId) || '');
held = true;
return Promise.resolve();
},
reconnect: (opts) => {
rec.reconnectCalls++;
rec.connectCalls.push((opts && opts.clientId) || '');
held = true;
return Promise.resolve();
},
disconnect: () => {
rec.disconnectCalls++;
client.userDisconnected = true;
held = false;
return Promise.resolve();
},
publish: (topic, message) => {
rec.publishCalls.push({ topic, message });
return Promise.resolve();
},
};
return {
MQTT_STATE: { DISCONNECTED: 'disconnected', CONNECTING: 'connecting', CONNECTED: 'connected' },
mqttClient: client,
_rec: rec,
};
}
/**
* 准备一套干净的运行时并加载被测模块
* @param {Object} [opts]
* @param {'ok'|'hang'|'fail'} [opts.createMode] - 建链桩行为
* @returns {Object} { wx, state, storageArea, gateway, bleService, storage, measurePoint, mqtt, GATEWAY_PHASE, BLE_STATE }
*/
function loadRuntime(opts = {}) {
clearMiniCache();
const storageArea = createStorageArea();
const bleState = createBleState();
if (opts.createMode) bleState.createMode = opts.createMode;
global.wx = createWx(storageArea, bleState);
const mqtt = createFakeMqtt();
const mqttPath = require.resolve(path.join(MINI_ROOT, 'services/mqtt-client.js'));
require.cache[mqttPath] = stubModule(mqttPath, {
MQTT_STATE: mqtt.MQTT_STATE,
mqttClient: mqtt.mqttClient,
});
const { gateway, GATEWAY_PHASE } = require(path.join(MINI_ROOT, 'services/gateway.js'));
const { bleService, BLE_STATE } = require(path.join(MINI_ROOT, 'services/ble.js'));
const storage = require(path.join(MINI_ROOT, 'utils/storage.js'));
const measurePoint = require(path.join(MINI_ROOT, 'utils/measure-point.js'));
return {
wx: global.wx,
state: bleState,
storageArea,
mqtt,
gateway,
bleService,
storage,
measurePoint,
GATEWAY_PHASE,
BLE_STATE,
};
}
/**
* 造一个已登录 + 已配 MQTT 凭证 + 一台启用设备的常规现场
*/
function seedScene(rt, { serialNumber = 'BT_ECB0D1002F84', deviceId = 'EC:B0:D1:00:2F:84', enabled = true } = {}) {
rt.storage.setUserInfo({ userId: 1, userName: 'tester' });
rt.storage.setMqttConfig({ serialNumber, productId: 12, productSecret: 'secret' });
rt.measurePoint.setMeasurePointId('371883');
rt.storage.addDevice({
deviceId,
serialNumber,
name: serialNumber,
bleName: serialNumber,
enabled,
connected: false,
createTime: Date.now(),
});
if (enabled) rt.storage.activateDevice(serialNumber);
return rt.storage.getDevice(serialNumber);
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
module.exports = {
MINI_ROOT,
SVC,
CHAR_NOTIFY,
CHAR_WRITE,
createStorageArea,
createBleState,
createWx,
createFakeMqtt,
clearMiniCache,
stubModule,
loadRuntime,
seedScene,
sleep,
};

View File

@ -1,247 +0,0 @@
/**
* 回归:本轮 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);
})();

View File

@ -55,25 +55,6 @@
</el-input>
</el-form-item>
<!-- 基准点先删掉 -->
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量。不做 deviceType 限制,任意子设备均可配置 -->
<el-form-item prop="baselineDeviceId">
<template slot="label">
<el-tooltip placement="top">
<div slot="content">
相对测量类设备(如静力水准)需指定基准点。<br/>
累计变化量按「(当前读数−基准点读数)−(初始读数−基准点初始读数)」换算。<br/>
留空表示绝对量测量,不参与基准点换算。<br/>
deviceId 全局唯一,支持跨网关引用(如 GW2 监测点引用 GW1 参考点)。
</div>
<span>基准点设备 <i class="el-icon-question"></i></span>
</el-tooltip>
</template>
<el-select v-model="form.baselineDeviceId" placeholder="留空=绝对量测量,不参与基准点换算" clearable
filterable size="small" style="width: 100%">
<el-option v-for="item in baselineDeviceOptions" :key="item.deviceId"
:label="item.deviceName + '(' + item.serialNumber + ')'" :value="Number(item.deviceId)"/>
</el-select>
</el-form-item>
<el-form-item v-if="openServerTip">
<template>
<el-alert type="info" show-icon :description="$t('device.device-edit.148398-10')"></el-alert>