feat(M06-C): 完成设备协议适配与消息幂等
This commit is contained in:
@@ -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.');
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user