feat(M06-C): 完成设备协议适配与消息幂等

This commit is contained in:
Codex
2026-06-22 18:29:07 +08:00
parent c96045f6ef
commit e795cdb693
15 changed files with 867 additions and 17 deletions
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter
} from '../dist/devices/jilian-adapters.js';
import {
generateCommandId,
IotMessageService
} from '../dist/devices/iot-message-service.js';
import { DeviceCommandService } from '../dist/devices/device-command-service.js';
const control = new JilianControlBoxAdapter();
assert.deepEqual(control.read('basicInfo'), { read: 'basicInfo' });
assert.deepEqual(control.controlPower({
id: '1234567890123', slot1: 'on', slot3: 'off'
}), {
action: 'ConctolPower', id: '1234567890123', slot1: 'on', slot3: 'off'
});
assert.equal(control.controlDoor({
id: '1234567890123', order: 'open'
}).action, 'Crldoor');
assert.equal(control.playTts({
id: '1234567890123', content: '欢迎光临'
}).action, 'PlayTTS');
assert.deepEqual(control.stopTts('1234567890123'), {
action: 'stopTTS', id: '1234567890123'
});
assert.equal(control.controlLed({ id: '1234567890123', minute: 30 }).action, 'CrlLED');
assert.equal(control.startTask({
id: '1234567890123', minute: 120, type: 2, subID: 'SUB001'
}).action, 'task');
assert.equal(control.extendTask({
id: '1234567890123', addminute: 30
}).action, 'addtask');
assert.equal(control.cancelTask('1234567890123').action, 'canceltask');
assert.throws(
() => control.controlDoor({ id: '12345678901234', order: 'open' }),
/Invalid/
);
assert.throws(
() => control.controlDoor({ id: '123', order: 'open', delayTime: 15 }),
/less than or equal to 14/
);
const lock = new JilianSub1GLockAdapter();
assert.deepEqual(lock.pair({ id: '123', timeout: 60 }), {
action: 'AddDevice', id: '123', timeout: 60
});
assert.equal(lock.control({
id: '123', subID: 'SUB001', order: 'open', delayTime: 4
}).action, 'CtrlDevice');
assert.throws(
() => lock.control({ id: '123', subID: 'SUB001', order: 'setkey' }),
/content is required/
);
const socket = new JilianSmartSocketAdapter();
assert.deepEqual(socket.read('workInfo'), { read: 'workInfo' });
assert.deepEqual(socket.switch({ id: '123', on: true }), {
action: 'on', id: '123', slotNum: 1
});
assert.equal(socket.localTask({
id: '123', taskNum: 20, action: 'off', mode: 'weekly',
time: '23:00', weekdays: [1, 5]
}).action, 'localtask');
assert.deepEqual(socket.clearTask({ id: '123', taskNum: 0 }), {
action: 'clearTask', id: '123', taskNum: 0
});
const ack = control.parseUplink({
DeviceID: 'BOX_001', id: '123', result: 'unconfirm', action: 'task'
});
assert.equal(ack.kind, 'ACK');
assert.equal(ack.result, 'unconfirm');
assert.equal(ack.eventType, 'task');
const event = lock.parseUplink({
deviceID: 'BOX_001', event: 'record', type: 'card', state: 'open',
timestamp: 1782120000
});
assert.equal(event.kind, 'EVENT');
assert.equal(event.eventType, 'record');
assert.equal(event.eventAt?.getUTCFullYear(), 2026);
assert.equal(generateCommandId(1782120000000, 7), '1782120000007');
assert.match(generateCommandId(), /^\d{13}$/);
const calls = [];
let eventInsertCount = 0;
const service = new IotMessageService({
async execute(sql, params) {
calls.push({ sql, params });
if (sql.includes('FROM qipai_devices')) {
return [[{
id: 51, tenantId: 7, storeId: 11, roomId: 31,
deviceId: 'BOX_001', deviceType: 'CONTROL_BOX'
}], []];
}
if (sql.includes('INSERT INTO qipai_iot_device_events')) {
eventInsertCount += 1;
return [{ affectedRows: eventInsertCount === 1 ? 1 : 2 }, []];
}
return [{ affectedRows: 1, insertId: 81 }, []];
}
});
const payload = Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', id: '123', result: 'ok', action: 'ConctolPower',
slot1: 'on'
}));
await service.handle('/devicesend/BOX_001', payload);
await service.handle('/devicesend/BOX_001', payload);
assert.equal(
calls.filter((item) => item.sql.includes('UPDATE qipai_iot_commands')).length,
1,
'duplicate QoS 1 payload must not repeat command/device side effects'
);
assert.equal(
calls.some((item) => item.sql.includes('qipai_iot_dead_letters')),
false
);
await service.handle('/bad-topic/BOX_001', Buffer.from('{}'));
assert.equal(
calls.some((item) => item.sql.includes('qipai_iot_dead_letters')),
true
);
await assert.rejects(
() => service.createCommand({
tenantId: '7', assetId: '51', storeId: '11',
commandId: '12345678901234', commandType: 'ConctolPower',
payload: {}, traceId: 'trace'
}),
/IOT_COMMAND_ID_INVALID/
);
const commandCalls = [];
const commandService = new DeviceCommandService({
async createCommand(input) { commandCalls.push(['create', input]); },
async markPublished(tenantId, commandId) {
commandCalls.push(['published', tenantId, commandId]);
},
async markPublishFailed() { throw new Error('unexpected publish failure'); }
}, {
async publishDeviceCommand(deviceId, payloadText) {
commandCalls.push(['mqtt', deviceId, JSON.parse(payloadText)]);
}
});
const issued = await commandService.issue({
tenantId: '7', assetId: '51', deviceId: 'BOX_001', storeId: '11',
commandType: 'ConctolPower',
payloadFactory: (id) => control.controlPower({ id, slot1: 'on' }),
traceId: 'trace'
});
assert.equal(issued.status, 'PUBLISHED');
assert.match(issued.commandId, /^\d{13}$/);
assert.equal(commandCalls[0][0], 'create');
assert.equal(commandCalls[1][0], 'mqtt');
assert.equal(commandCalls[2][0], 'published');
console.log('PASS: M06-C adapters, vendor spelling, command IDs, ACKs and QoS 1 dedup work.');
+15 -1
View File
@@ -66,6 +66,9 @@ const profitSharingVerifySql = read('database/migrations/2026062218_m05d_profit_
const deviceTopologyUpSql = read('database/migrations/2026062219_m06b_device_topology.up.sql');
const deviceTopologyDownSql = read('database/migrations/2026062219_m06b_device_topology.down.sql');
const deviceTopologyVerifySql = read('database/migrations/2026062219_m06b_device_topology.verify.sql');
const iotMessagesUpSql = read('database/migrations/2026062220_m06c_iot_messages.up.sql');
const iotMessagesDownSql = read('database/migrations/2026062220_m06c_iot_messages.down.sql');
const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messages.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -301,5 +304,16 @@ assert.match(deviceTopologyUpSql, /uq_qipai_device_link_child/);
assert.match(deviceTopologyUpSql, /uq_qipai_device_link_room_type/);
assert.match(deviceTopologyUpSql, /device\.read/);
assert.match(deviceTopologyUpSql, /device\.write/);
for (const table of [
'qipai_iot_commands', 'qipai_iot_device_events', 'qipai_iot_dead_letters'
]) {
assert.match(iotMessagesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(iotMessagesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(iotMessagesVerifySql, new RegExp(`'${table}'`));
}
assert.match(iotMessagesUpSql, /command_id VARCHAR\(13\)/);
assert.match(iotMessagesUpSql, /uq_qipai_iot_event_dedup/);
assert.match(iotMessagesUpSql, /receive_count INT UNSIGNED/);
assert.match(iotMessagesUpSql, /PENDING/);
console.log('PASS: M01-B through M06-B migration contracts are present.');
console.log('PASS: M01-B through M06-C migration contracts are present.');
+2 -1
View File
@@ -30,7 +30,8 @@ assert.match(plan.file, /2026062015_m05a_payment_domain\.up\.sql/);
assert.match(plan.file, /2026062216_m05b_wechat_refunds\.up\.sql/);
assert.match(plan.file, /2026062217_m05c_third_party\.up\.sql/);
assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/);
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql$/);
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -36,6 +36,7 @@ import { WechatPayClient } from '../dist/payments/wechat-pay-client.js';
import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
import { DeviceRepository } from '../dist/devices/device-repository.js';
import { IotMessageService } from '../dist/devices/iot-message-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -58,6 +59,9 @@ const expectedTables = [
'qipai_group_redemptions',
'qipai_group_vouchers',
'qipai_holiday_calendar',
'qipai_iot_commands',
'qipai_iot_dead_letters',
'qipai_iot_device_events',
'qipai_legacy_table_mappings',
'qipai_media_assets',
'qipai_members',
@@ -121,12 +125,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219']
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220']
);
return rows;
}
@@ -1670,6 +1675,60 @@ async function assertDeviceTopology(pool, context) {
}]);
}
async function assertIotMessages(pool, context) {
const [deviceRows] = await pool.query(
`SELECT id, store_id AS storeId, room_id AS roomId, device_id AS deviceId
FROM qipai_devices
WHERE tenant_id = ? AND device_id = 'M06B_BOX_001'`,
[context.tenantId]
);
const device = deviceRows[0];
const service = new IotMessageService(pool);
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000001',
commandType: 'ConctolPower',
payload: {
action: 'ConctolPower', id: '1782120000001', slot1: 'on'
},
traceId: 'm06c-live-test'
});
assert.equal(await service.markPublished(context.tenantId, '1782120000001'), true);
const payload = Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000001',
action: 'ConctolPower',
result: 'ok',
slot1: 'on',
timestamp: 1782120000
}));
await service.handle(`/devicesend/${device.deviceId}`, payload);
await service.handle(`/devicesend/${device.deviceId}`, payload);
const [commandRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000001'`,
[context.tenantId]
);
assert.deepEqual(commandRows, [{ status: 'ACKED', failureCode: '' }]);
const [eventRows] = await pool.query(
`SELECT receive_count AS receiveCount, processing_status AS processingStatus
FROM qipai_iot_device_events
WHERE tenant_id = ? AND command_id = '1782120000001'`,
[context.tenantId]
);
assert.deepEqual(eventRows, [{ receiveCount: 2, processingStatus: 'PROCESSED' }]);
await service.handle('/invalid/topic', Buffer.from('{bad-json'));
const [deadRows] = await pool.query(
`SELECT error_code AS errorCode, receive_count AS receiveCount
FROM qipai_iot_dead_letters WHERE topic = '/invalid/topic'`
);
assert.deepEqual(deadRows, [{ errorCode: 'MQTT_TOPIC_INVALID', receiveCount: 1 }]);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -1712,7 +1771,8 @@ try {
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' }
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1730,13 +1790,14 @@ try {
await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertDeviceTopology(pool, loginContext);
await assertIotMessages(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M06-B tables.');
console.log('PASS: down removed all M01-B through M06-C tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1760,7 +1821,8 @@ try {
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' }
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1858,6 +1920,11 @@ try {
'control target conflict across control box and smart socket',
'Sub-1G parent-child topology',
'device status snapshots and maintenance state'
,
'13-digit IoT command state transition',
'QoS 1 duplicate event receive count',
'ACK correlation without duplicate side effects',
'invalid Topic dead-letter persistence'
]
}, null, 2));
} finally {