feat(M06-D): 接入控制箱与Sub-1G门锁业务控制

This commit is contained in:
Codex
2026-06-22 18:38:28 +08:00
parent f71ac09a0b
commit d15fd3f0ff
10 changed files with 685 additions and 9 deletions
+138
View File
@@ -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.');
+29 -3
View File
@@ -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 {