feat(M06-B): 建立设备资产与拓扑管理

This commit is contained in:
Codex
2026-06-22 17:40:54 +08:00
parent 07790a7924
commit 74a67ac92d
16 changed files with 1026 additions and 17 deletions
@@ -35,6 +35,7 @@ import { ProfitSharingService } from '../dist/payments/profit-sharing-service.js
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 {
executeMigrationPlan,
loadMigrationPlan,
@@ -47,6 +48,11 @@ const expectedTables = [
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_collection_accounts',
'qipai_device_alerts',
'qipai_device_channels',
'qipai_device_links',
'qipai_device_maintenance_records',
'qipai_device_status_snapshots',
'qipai_devices',
'qipai_direct_bookings',
'qipai_group_redemptions',
@@ -115,12 +121,12 @@ 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']
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219']
);
return rows;
}
@@ -1578,6 +1584,92 @@ async function assertContentManagement(pool, context) {
);
}
async function assertDeviceTopology(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const [storeRows] = await pool.query(
`SELECT s.id AS storeId, r.id AS roomId
FROM qipai_stores s
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
WHERE s.tenant_id = ? AND s.name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const rbac = new RbacRepository(pool);
const adminId = String(adminRows[0].id);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
assert.ok(access.capabilities.includes('device.read'));
assert.ok(access.capabilities.includes('device.write'));
const storeId = String(storeRows[0].storeId);
const roomId = String(storeRows[0].roomId);
const actor = {
tenantId: context.tenantId, userId: adminId, access,
traceId: 'm06b-live-test', ip: '127.0.0.1', userAgent: 'M06-B live test'
};
const repository = new DeviceRepository(pool);
const controlBox = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_BOX_001', imei: '860000000000001',
iccid: '89860000000000000001', deviceType: 'CONTROL_BOX',
model: 'JL-CONTROL', firmwareVersion: '1.0.0', signalStrength: 18,
capabilities: ['POWER', 'LOCK', 'TTS']
});
const socket = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_SOCKET_001', imei: '860000000000002',
iccid: '89860000000000000002', deviceType: 'SMART_SOCKET',
model: 'JL-SOCKET', firmwareVersion: '1.0.0', signalStrength: 16,
capabilities: ['POWER', 'METERING']
});
const lock = await repository.createAsset(actor, {
storeId, roomId, deviceId: 'M06B_LOCK_001', imei: '',
iccid: null, deviceType: 'SUB_LOCK', model: '701C',
firmwareVersion: '1.0.0', capabilities: ['LOCK', 'CARD', 'PASSWORD']
});
await repository.bindChannel(actor, {
assetId: controlBox.assetId, storeId, roomId,
channelCode: 'SLOT1', purpose: 'ROOM_POWER'
});
await assert.rejects(
() => repository.bindChannel(actor, {
assetId: socket.assetId, storeId, roomId,
channelCode: 'MAIN', purpose: 'ROOM_POWER'
}),
(error) => error.code === 'DEVICE_CONTROL_TARGET_CONFLICT'
);
await repository.bindSubDevice(actor, {
parentAssetId: controlBox.assetId, childAssetId: lock.assetId,
storeId, roomId, subId: 'SUB-001', subtype: '701C'
});
await repository.recordStatus(actor, {
assetId: controlBox.assetId, storeId, onlineStatus: 'ONLINE',
signalStrength: 22, firmwareVersion: '1.0.1',
snapshot: { slot1: true, door: 'closed' }
});
await repository.addMaintenance(actor, {
assetId: controlBox.assetId, storeId, roomId,
recordType: 'INSPECTION', status: 'OPEN', description: 'M06-B inspection'
});
const assets = await repository.listAssets(actor, storeId);
const box = assets.find((item) => item.id === controlBox.assetId);
assert.deepEqual(box?.capabilities, ['LOCK', 'POWER', 'TTS']);
assert.equal(box?.status, 'ONLINE');
assert.equal(box?.maintenanceStatus, 'MAINTENANCE');
const [topologyRows] = await pool.query(
`SELECT c.purpose, l.sub_id AS subId, l.subtype
FROM qipai_device_channels c
INNER JOIN qipai_device_links l
ON l.tenant_id = c.tenant_id AND l.parent_device_id = c.device_id
WHERE c.tenant_id = ? AND c.device_id = ?`,
[context.tenantId, controlBox.assetId]
);
assert.deepEqual(topologyRows, [{
purpose: 'ROOM_POWER', subId: 'SUB-001', subtype: '701C'
}]);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -1619,7 +1711,8 @@ try {
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' }
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1636,13 +1729,14 @@ try {
await assertPaymentDomain(pool, loginContext);
await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertDeviceTopology(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 M05-D tables.');
console.log('PASS: down removed all M01-B through M06-B tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1665,7 +1759,8 @@ try {
{ version: '2026062015', name: 'm05a_payment_domain' },
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' }
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1758,6 +1853,11 @@ try {
'profit-share percentage total validation',
'receiver hash and masked storage',
'payment and receiver idempotent profit sharing'
,
'device asset identity and capabilities',
'control target conflict across control box and smart socket',
'Sub-1G parent-child topology',
'device status snapshots and maintenance state'
]
}, null, 2));
} finally {