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
@@ -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 {