feat(M06-D): 接入控制箱与Sub-1G门锁业务控制
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import {
|
||||
DeviceControlError,
|
||||
DeviceControlService
|
||||
} from '../dist/devices/device-control-service.js';
|
||||
|
||||
const managerAccess = {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['device.read', 'device.write'],
|
||||
storeIds: ['11']
|
||||
};
|
||||
const issued = [];
|
||||
const service = new DeviceControlService({
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_devices')) {
|
||||
return [[{
|
||||
id: 51, deviceId: 'BOX_001', storeId: 11, roomId: 31,
|
||||
deviceType: 'CONTROL_BOX', status: 'ONLINE'
|
||||
}], []];
|
||||
}
|
||||
return [[], []];
|
||||
}
|
||||
}, {
|
||||
async issue(input) {
|
||||
const payload = input.payloadFactory('1782120000001');
|
||||
issued.push({ ...input, payload });
|
||||
return { commandId: '1782120000001', status: 'PUBLISHED' };
|
||||
}
|
||||
});
|
||||
const context = {
|
||||
tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace',
|
||||
access: managerAccess
|
||||
};
|
||||
|
||||
await service.controlPower(context, { slot1: 'on', slot3: 'off' });
|
||||
assert.equal(issued.at(-1).payload.action, 'ConctolPower');
|
||||
await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 });
|
||||
assert.equal(issued.at(-1).payload.action, 'Crldoor');
|
||||
await service.playTts(context, { content: '欢迎光临' });
|
||||
assert.equal(issued.at(-1).payload.action, 'PlayTTS');
|
||||
await service.stopTts(context);
|
||||
assert.equal(issued.at(-1).payload.action, 'stopTTS');
|
||||
await service.controlLed(context, 30);
|
||||
assert.equal(issued.at(-1).payload.action, 'CrlLED');
|
||||
await service.startTask(context, { minute: 120, type: 2, subID: 'SUB001' });
|
||||
assert.equal(issued.at(-1).payload.action, 'task');
|
||||
await service.extendTask(context, 30);
|
||||
assert.equal(issued.at(-1).payload.action, 'addtask');
|
||||
await service.cancelTask(context);
|
||||
assert.equal(issued.at(-1).payload.action, 'canceltask');
|
||||
await service.pairSubLock(context, 60);
|
||||
assert.equal(issued.at(-1).payload.action, 'AddDevice');
|
||||
await service.controlSubLock(context, {
|
||||
subID: 'SUB001', order: 'open', delayTime: 4
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.action, 'CtrlDevice');
|
||||
|
||||
await assert.rejects(
|
||||
() => service.controlSubLock(context, {
|
||||
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET'
|
||||
}),
|
||||
(error) => error instanceof DeviceControlError
|
||||
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
);
|
||||
const platformContext = {
|
||||
...context,
|
||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] }
|
||||
};
|
||||
await service.controlSubLock(platformContext, {
|
||||
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET'
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.order, 'factoryreset');
|
||||
assert.equal('dangerConfirmation' in issued.at(-1).payload, false);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.controlPower({
|
||||
...context,
|
||||
access: { roles: ['STAFF'], capabilities: ['device.write'], storeIds: ['12'] }
|
||||
}, { slot1: 'on' }),
|
||||
(error) => error.code === 'DEVICE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
|
||||
const secret = 'test-only-jwt-secret-with-at-least-32-characters';
|
||||
const token = signAccessToken({
|
||||
sub: '22', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let routed;
|
||||
const app = await buildApp({
|
||||
deviceControl: {
|
||||
jwtSecret: secret,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: '22', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return managerAccess; } },
|
||||
service: {
|
||||
async controlPower(_context, input) { routed = input; return { status: 'PUBLISHED' }; },
|
||||
async controlDoor() { return {}; },
|
||||
async playTts() { return {}; },
|
||||
async stopTts() { return {}; },
|
||||
async controlLed() { return {}; },
|
||||
async startTask() { return {}; },
|
||||
async extendTask() { return {}; },
|
||||
async cancelTask() { return {}; },
|
||||
async pairSubLock() { return {}; },
|
||||
async controlSubLock() { return {}; }
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/power',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', slot1: 'on' }
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(routed.slot1, 'on');
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/door',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', order: 'open', delayTime: 15 }
|
||||
});
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: M06-D control-box and Sub-1G command gates and routes work.');
|
||||
@@ -86,7 +86,7 @@ assert.equal(generateCommandId(1782120000000, 7), '1782120000007');
|
||||
assert.match(generateCommandId(), /^\d{13}$/);
|
||||
|
||||
const calls = [];
|
||||
let eventInsertCount = 0;
|
||||
const eventHashes = new Set();
|
||||
const service = new IotMessageService({
|
||||
async execute(sql, params) {
|
||||
calls.push({ sql, params });
|
||||
@@ -97,8 +97,10 @@ const service = new IotMessageService({
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO qipai_iot_device_events')) {
|
||||
eventInsertCount += 1;
|
||||
return [{ affectedRows: eventInsertCount === 1 ? 1 : 2 }, []];
|
||||
const hash = params[7];
|
||||
const duplicate = eventHashes.has(hash);
|
||||
eventHashes.add(hash);
|
||||
return [{ affectedRows: duplicate ? 2 : 1 }, []];
|
||||
}
|
||||
return [{ affectedRows: 1, insertId: 81 }, []];
|
||||
}
|
||||
@@ -125,6 +127,30 @@ assert.equal(
|
||||
true
|
||||
);
|
||||
|
||||
const recordPayload = Buffer.from(JSON.stringify({
|
||||
DeviceID: 'BOX_001', event: 'record', type: 'card',
|
||||
state: 'open', content: 'CARD-PRIVATE-1234', battery: 9
|
||||
}));
|
||||
await service.handle('/devicesend/BOX_001', recordPayload);
|
||||
const recordInsert = calls.findLast((item) =>
|
||||
item.sql.includes('INSERT INTO qipai_iot_device_events')
|
||||
);
|
||||
assert.equal(recordInsert.params.some((value) =>
|
||||
typeof value === 'string' && value.includes('CARD-PRIVATE-1234')
|
||||
), false);
|
||||
assert.equal(calls.some((item) =>
|
||||
item.sql.includes('INSERT INTO qipai_device_alerts')
|
||||
&& item.params.includes('LOW_BATTERY')
|
||||
), true);
|
||||
|
||||
await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({
|
||||
DeviceID: 'BOX_001', id: '124', action: 'task', result: 'unconfirm'
|
||||
})));
|
||||
assert.equal(calls.some((item) =>
|
||||
item.sql.includes('INSERT INTO qipai_device_alerts')
|
||||
&& item.params.includes('DEVICE_UNCONFIRM')
|
||||
), true);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.createCommand({
|
||||
tenantId: '7', assetId: '51', storeId: '11',
|
||||
|
||||
@@ -1727,6 +1727,62 @@ async function assertIotMessages(pool, context) {
|
||||
FROM qipai_iot_dead_letters WHERE topic = '/invalid/topic'`
|
||||
);
|
||||
assert.deepEqual(deadRows, [{ errorCode: 'MQTT_TOPIC_INVALID', receiveCount: 1 }]);
|
||||
|
||||
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
||||
DeviceID: device.deviceId,
|
||||
event: 'record',
|
||||
type: 'card',
|
||||
state: 'open',
|
||||
content: 'PRIVATE-CARD-001',
|
||||
battery: 9,
|
||||
timestamp: 1782120001
|
||||
})));
|
||||
const [recordRows] = await pool.query(
|
||||
`SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.content')) AS content,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.contentHash')) AS contentHash
|
||||
FROM qipai_iot_device_events
|
||||
WHERE tenant_id = ? AND event_type = 'record'`,
|
||||
[context.tenantId]
|
||||
);
|
||||
assert.equal(recordRows[0].content, null);
|
||||
assert.match(recordRows[0].contentHash, /^[a-f0-9]{64}$/);
|
||||
const [alertRows] = await pool.query(
|
||||
`SELECT alert_type AS alertType, severity
|
||||
FROM qipai_device_alerts
|
||||
WHERE tenant_id = ? AND device_id = ? AND status = 'OPEN'`,
|
||||
[context.tenantId, device.id]
|
||||
);
|
||||
assert.deepEqual(alertRows, [{ alertType: 'LOW_BATTERY', severity: 'HIGH' }]);
|
||||
|
||||
await service.createCommand({
|
||||
tenantId: context.tenantId,
|
||||
assetId: String(device.id),
|
||||
storeId: String(device.storeId),
|
||||
roomId: String(device.roomId),
|
||||
commandId: '1782120000002',
|
||||
commandType: 'AddDevice',
|
||||
payload: { action: 'AddDevice', id: '1782120000002', timeout: 60 },
|
||||
traceId: 'm06d-pair-test'
|
||||
});
|
||||
await service.markPublished(context.tenantId, '1782120000002');
|
||||
await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({
|
||||
DeviceID: device.deviceId,
|
||||
id: '1782120000002',
|
||||
action: 'AddDevice',
|
||||
result: 'ok',
|
||||
subID: 'SUB-AUTO-001',
|
||||
subtype: '14',
|
||||
timestamp: 1782120002
|
||||
})));
|
||||
const [linkRows] = await pool.query(
|
||||
`SELECT l.sub_id AS subId, l.subtype, d.model
|
||||
FROM qipai_device_links l
|
||||
INNER JOIN qipai_devices d
|
||||
ON d.tenant_id = l.tenant_id AND d.id = l.child_device_id
|
||||
WHERE l.tenant_id = ? AND l.parent_device_id = ? AND l.sub_id = 'SUB-AUTO-001'`,
|
||||
[context.tenantId, device.id]
|
||||
);
|
||||
assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]);
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -1797,7 +1853,7 @@ try {
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M06-C tables.');
|
||||
console.log('PASS: down removed all M01-B through M06-C migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -1925,6 +1981,10 @@ try {
|
||||
'QoS 1 duplicate event receive count',
|
||||
'ACK correlation without duplicate side effects',
|
||||
'invalid Topic dead-letter persistence'
|
||||
,
|
||||
'door record credential hashing and masking',
|
||||
'low-battery alert upsert',
|
||||
'AddDevice ACK creates 701C parent-child topology'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user