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
+107
View File
@@ -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.');
+17 -1
View File
@@ -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.');
+2 -1
View File
@@ -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 {