feat(M06-B): 建立设备资产与拓扑管理
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { DeviceError, DeviceRepository } from '../dist/devices/device-repository.js';
|
||||
|
||||
const storeActor = {
|
||||
tenantId: '7', userId: '22',
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'], capabilities: ['device.read', 'device.write'], storeIds: ['11']
|
||||
},
|
||||
traceId: 'trace', ip: '127.0.0.1', userAgent: 'test'
|
||||
};
|
||||
const repository = new DeviceRepository({
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_devices d')) {
|
||||
return [[{
|
||||
id: 51, storeId: 11, roomId: 31, deviceId: 'BOX_001', imei: '123',
|
||||
iccid: null, deviceType: 'CONTROL_BOX', model: 'BOX', firmwareVersion: '1.0',
|
||||
status: 'ONLINE', signalStrength: 20, capabilities: '["LOCK","POWER"]',
|
||||
stateSnapshot: '{"power":true}', lastSeenAt: null, lastHeartbeatAt: null,
|
||||
maintenanceStatus: 'NORMAL'
|
||||
}], []];
|
||||
}
|
||||
return [[], []];
|
||||
}
|
||||
});
|
||||
const assets = await repository.listAssets(storeActor, '11');
|
||||
assert.equal(assets[0].id, '51');
|
||||
assert.deepEqual(assets[0].capabilities, ['LOCK', 'POWER']);
|
||||
assert.deepEqual(assets[0].stateSnapshot, { power: true });
|
||||
await assert.rejects(
|
||||
() => repository.listAssets(storeActor, '12'),
|
||||
(error) => error instanceof DeviceError && 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 assetInput;
|
||||
let channelInput;
|
||||
const app = await buildApp({
|
||||
devices: {
|
||||
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 storeActor.access; } },
|
||||
repository: {
|
||||
async listAssets() { return []; },
|
||||
async getTopology() {
|
||||
return { assets: [], channels: [], links: [], openAlerts: [], maintenance: [] };
|
||||
},
|
||||
async createAsset(_actor, input) { assetInput = input; return { assetId: '51' }; },
|
||||
async bindChannel(_actor, input) { channelInput = input; return { assetId: '51' }; },
|
||||
async bindSubDevice() { return { childAssetId: '52', parentAssetId: '51' }; },
|
||||
async recordStatus() { return { assetId: '51' }; },
|
||||
async addMaintenance() { return { maintenanceId: '61' }; }
|
||||
}
|
||||
}
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/admin-api/devices',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
storeId: '11', roomId: '31', deviceId: 'BOX_001', imei: '123456789012345',
|
||||
deviceType: 'CONTROL_BOX', model: 'JL-BOX', firmwareVersion: '1.0.0',
|
||||
signalStrength: 18, capabilities: ['POWER', 'LOCK', 'POWER']
|
||||
}
|
||||
});
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(created.json().data.assetId, '51');
|
||||
assert.equal(assetInput.deviceType, 'CONTROL_BOX');
|
||||
|
||||
const channel = await app.inject({
|
||||
method: 'POST', url: '/admin-api/device-channels',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
assetId: '51', storeId: '11', roomId: '31',
|
||||
channelCode: 'SLOT1', purpose: 'ROOM_POWER'
|
||||
}
|
||||
});
|
||||
assert.equal(channel.statusCode, 201);
|
||||
assert.equal(channelInput.purpose, 'ROOM_POWER');
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: '/admin-api/device-channels',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
assetId: '51', storeId: '11', roomId: '31',
|
||||
channelCode: 'SLOT9', purpose: 'ROOM_POWER'
|
||||
}
|
||||
});
|
||||
assert.equal(invalid.statusCode, 400);
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: M06-B device assets, scope, topology routes and target validation work.');
|
||||
@@ -63,6 +63,9 @@ const thirdPartyVerifySql = read('database/migrations/2026062217_m05c_third_part
|
||||
const profitSharingUpSql = read('database/migrations/2026062218_m05d_profit_sharing.up.sql');
|
||||
const profitSharingDownSql = read('database/migrations/2026062218_m05d_profit_sharing.down.sql');
|
||||
const profitSharingVerifySql = read('database/migrations/2026062218_m05d_profit_sharing.verify.sql');
|
||||
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 coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -285,5 +288,18 @@ assert.doesNotMatch(
|
||||
profitSharingUpSql,
|
||||
/\bprivate_key\b|\bapi_v3_key\b|\breceiver_account\b/i
|
||||
);
|
||||
for (const table of [
|
||||
'qipai_device_alerts', 'qipai_device_channels', 'qipai_device_links',
|
||||
'qipai_device_status_snapshots', 'qipai_device_maintenance_records'
|
||||
]) {
|
||||
assert.match(deviceTopologyUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(deviceTopologyDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(deviceTopologyVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(deviceTopologyUpSql, /uq_qipai_device_channel_target/);
|
||||
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/);
|
||||
|
||||
console.log('PASS: M01-B through M05-D migration contracts are present.');
|
||||
console.log('PASS: M01-B through M06-B migration contracts are present.');
|
||||
|
||||
@@ -29,7 +29,8 @@ assert.match(plan.file, /2026062014_m04d_order_shares\.up\.sql/);
|
||||
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, /2026062218_m05d_profit_sharing\.up\.sql/);
|
||||
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user