feat(M06-B): 建立设备资产与拓扑管理
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -41,6 +41,7 @@ import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/paymen
|
||||
import {
|
||||
registerThirdPartyRoutes, type ThirdPartyRouteOptions
|
||||
} from './routes/third-party.js';
|
||||
import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
@@ -58,6 +59,7 @@ export interface BuildAppOptions {
|
||||
payment?: PaymentRouteOptions;
|
||||
thirdParty?: ThirdPartyRouteOptions;
|
||||
mqtt?: MqttHealthProvider;
|
||||
devices?: DeviceRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -141,6 +143,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.thirdParty) {
|
||||
await registerThirdPartyRoutes(app, options.thirdParty);
|
||||
}
|
||||
if (options.devices) {
|
||||
await registerDeviceRoutes(app, options.devices);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ export class AuthRepository {
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write'))
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'))
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -38,9 +38,11 @@ export class RbacRepository {
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write'))
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage'))
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[tenantId, tenantId]
|
||||
);
|
||||
|
||||
@@ -38,7 +38,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062015_m05a_payment_domain.up.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.up.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.up.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.up.sql'
|
||||
'database/migrations/2026062218_m05d_profit_sharing.up.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -58,9 +59,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062015_m05a_payment_domain.verify.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.verify.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.verify.sql'
|
||||
'database/migrations/2026062218_m05d_profit_sharing.verify.sql',
|
||||
'database/migrations/2026062219_m06b_device_topology.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062219_m06b_device_topology.down.sql',
|
||||
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
|
||||
'database/migrations/2026062217_m05c_third_party.down.sql',
|
||||
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
|
||||
@@ -212,7 +215,8 @@ export async function executeMigrationPlan(
|
||||
5, 8, 4, 1,
|
||||
1, 5, 3, 1,
|
||||
5, 6, 1,
|
||||
3, 7, 5, 1
|
||||
3, 7, 5, 1,
|
||||
5, 8, 5, 2, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export type DeviceType = 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
|
||||
|
||||
export interface DeviceAssetInput {
|
||||
storeId: string;
|
||||
roomId?: string | null;
|
||||
deviceId: string;
|
||||
imei?: string;
|
||||
iccid?: string | null;
|
||||
deviceType: DeviceType;
|
||||
model: string;
|
||||
firmwareVersion: string;
|
||||
signalStrength?: number | null;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface DeviceRow extends RowDataPacket {
|
||||
id: string; storeId: string; roomId: string | null; deviceId: string; imei: string;
|
||||
iccid: string | null; deviceType: DeviceType; model: string; firmwareVersion: string;
|
||||
status: string; signalStrength: number | null; capabilities: string | string[] | null;
|
||||
stateSnapshot: string | Record<string, unknown> | null; lastSeenAt: Date | null;
|
||||
lastHeartbeatAt: Date | null; maintenanceStatus: string;
|
||||
}
|
||||
|
||||
export class DeviceError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class DeviceRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async createAsset(actor: ManagementActor, input: DeviceAssetInput) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId ?? null);
|
||||
try {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_devices
|
||||
(tenant_id, store_id, room_id, device_id, imei, iccid, device_type, model,
|
||||
firmware_version, signal_strength, capabilities)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.storeId, input.roomId ?? null, input.deviceId,
|
||||
input.imei ?? '', input.iccid || null, input.deviceType, input.model,
|
||||
input.firmwareVersion, input.signalStrength ?? null,
|
||||
JSON.stringify([...new Set(input.capabilities)].sort())]
|
||||
);
|
||||
const assetId = String(result.insertId);
|
||||
await this.audit(connection, actor, 'DEVICE_ASSET_CREATED', assetId, {
|
||||
deviceType: input.deviceType, storeId: input.storeId, roomId: input.roomId ?? null
|
||||
});
|
||||
return { assetId };
|
||||
} catch (error) {
|
||||
if (isDuplicate(error)) throw new DeviceError('DEVICE_IDENTITY_CONFLICT');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async listAssets(actor: ManagementActor, storeId?: string) {
|
||||
if (storeId) this.assertStoreScope(actor, storeId, false);
|
||||
const scope = this.scope(actor, 'd.store_id');
|
||||
const params: Array<string> = [actor.tenantId, ...scope.params];
|
||||
const storeFilter = storeId ? ' AND d.store_id = ?' : '';
|
||||
if (storeId) params.push(storeId);
|
||||
const [rows] = await this.pool.execute<DeviceRow[]>(
|
||||
`SELECT d.id, d.store_id AS storeId, d.room_id AS roomId, d.device_id AS deviceId,
|
||||
d.imei, d.iccid, d.device_type AS deviceType, d.model,
|
||||
d.firmware_version AS firmwareVersion, d.status, d.signal_strength AS signalStrength,
|
||||
d.capabilities, d.state_snapshot AS stateSnapshot,
|
||||
d.last_seen_at AS lastSeenAt, d.last_heartbeat_at AS lastHeartbeatAt,
|
||||
d.maintenance_status AS maintenanceStatus
|
||||
FROM qipai_devices d
|
||||
WHERE d.tenant_id = ? AND d.deleted_at IS NULL AND ${scope.sql}${storeFilter}
|
||||
ORDER BY d.store_id, d.room_id, d.id`,
|
||||
params
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
storeId: String(row.storeId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId),
|
||||
capabilities: parseJson<string[]>(row.capabilities, []),
|
||||
stateSnapshot: parseJson<Record<string, unknown>>(row.stateSnapshot, {})
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopology(actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId, false);
|
||||
const [assets, channelsResult, linksResult, alertsResult, maintenanceResult] = await Promise.all([
|
||||
this.listAssets(actor, storeId),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, device_id AS assetId, room_id AS roomId, channel_code AS channelCode,
|
||||
purpose, target_key AS targetKey, enabled
|
||||
FROM qipai_device_channels
|
||||
WHERE tenant_id = ? AND store_id = ? ORDER BY device_id, channel_code`,
|
||||
[actor.tenantId, storeId]
|
||||
),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, parent_device_id AS parentAssetId, child_device_id AS childAssetId,
|
||||
room_id AS roomId, link_type AS linkType, sub_id AS subId, subtype, status
|
||||
FROM qipai_device_links
|
||||
WHERE tenant_id = ? AND store_id = ? ORDER BY parent_device_id, child_device_id`,
|
||||
[actor.tenantId, storeId]
|
||||
),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, device_id AS assetId, room_id AS roomId, alert_type AS alertType,
|
||||
severity, status, summary, first_seen_at AS firstSeenAt,
|
||||
last_seen_at AS lastSeenAt
|
||||
FROM qipai_device_alerts
|
||||
WHERE tenant_id = ? AND store_id = ? AND status = 'OPEN'
|
||||
ORDER BY severity DESC, last_seen_at DESC`,
|
||||
[actor.tenantId, storeId]
|
||||
),
|
||||
this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, device_id AS assetId, room_id AS roomId, record_type AS recordType,
|
||||
status, description, created_at AS createdAt, resolved_at AS resolvedAt
|
||||
FROM qipai_device_maintenance_records
|
||||
WHERE tenant_id = ? AND store_id = ?
|
||||
ORDER BY created_at DESC LIMIT 200`,
|
||||
[actor.tenantId, storeId]
|
||||
)
|
||||
]);
|
||||
return {
|
||||
assets,
|
||||
channels: normalizeIds(channelsResult[0]),
|
||||
links: normalizeIds(linksResult[0]),
|
||||
openAlerts: normalizeIds(alertsResult[0]),
|
||||
maintenance: normalizeIds(maintenanceResult[0])
|
||||
};
|
||||
}
|
||||
|
||||
async bindChannel(actor: ManagementActor, input: {
|
||||
assetId: string; storeId: string; roomId: string; channelCode: string; purpose: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
const device = await this.lockDevice(connection, actor, input.assetId, input.storeId);
|
||||
if (!['CONTROL_BOX', 'SMART_SOCKET'].includes(device.deviceType)) {
|
||||
throw new DeviceError('DEVICE_CHANNEL_UNSUPPORTED');
|
||||
}
|
||||
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId);
|
||||
const targetKey = `room:${input.roomId}:target:${input.purpose}`;
|
||||
try {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_device_channels
|
||||
(tenant_id, device_id, store_id, room_id, channel_code, purpose, target_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.assetId, input.storeId, input.roomId,
|
||||
input.channelCode, input.purpose, targetKey]
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDuplicate(error)) throw new DeviceError('DEVICE_CONTROL_TARGET_CONFLICT');
|
||||
throw error;
|
||||
}
|
||||
await connection.execute(
|
||||
'UPDATE qipai_devices SET room_id = ? WHERE tenant_id = ? AND id = ?',
|
||||
[input.roomId, actor.tenantId, input.assetId]
|
||||
);
|
||||
await this.audit(connection, actor, 'DEVICE_CHANNEL_BOUND', input.assetId, {
|
||||
roomId: input.roomId, channelCode: input.channelCode, purpose: input.purpose
|
||||
});
|
||||
return { assetId: input.assetId, targetKey };
|
||||
});
|
||||
}
|
||||
|
||||
async bindSubDevice(actor: ManagementActor, input: {
|
||||
parentAssetId: string; childAssetId: string; storeId: string; roomId: string;
|
||||
subId: string; subtype: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
const parent = await this.lockDevice(connection, actor, input.parentAssetId, input.storeId);
|
||||
const child = await this.lockDevice(connection, actor, input.childAssetId, input.storeId);
|
||||
if (parent.deviceType !== 'CONTROL_BOX' || child.deviceType !== 'SUB_LOCK') {
|
||||
throw new DeviceError('DEVICE_LINK_TYPE_INVALID');
|
||||
}
|
||||
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId);
|
||||
try {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_device_links
|
||||
(tenant_id, parent_device_id, child_device_id, store_id, room_id,
|
||||
link_type, sub_id, subtype)
|
||||
VALUES (?, ?, ?, ?, ?, 'SUB_1G', ?, ?)`,
|
||||
[actor.tenantId, input.parentAssetId, input.childAssetId, input.storeId,
|
||||
input.roomId, input.subId, input.subtype]
|
||||
);
|
||||
} catch (error) {
|
||||
if (isDuplicate(error)) throw new DeviceError('DEVICE_LINK_CONFLICT');
|
||||
throw error;
|
||||
}
|
||||
await connection.execute(
|
||||
'UPDATE qipai_devices SET room_id = ? WHERE tenant_id = ? AND id = ?',
|
||||
[input.roomId, actor.tenantId, input.childAssetId]
|
||||
);
|
||||
await this.audit(connection, actor, 'SUB_DEVICE_BOUND', input.childAssetId, {
|
||||
parentAssetId: input.parentAssetId, roomId: input.roomId,
|
||||
subId: maskIdentifier(input.subId), subtype: input.subtype
|
||||
});
|
||||
return { childAssetId: input.childAssetId, parentAssetId: input.parentAssetId };
|
||||
});
|
||||
}
|
||||
|
||||
async recordStatus(actor: ManagementActor, input: {
|
||||
assetId: string; storeId: string; onlineStatus: 'ONLINE' | 'OFFLINE' | 'FAULT';
|
||||
signalStrength?: number | null; firmwareVersion?: string; snapshot: Record<string, unknown>;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockDevice(connection, actor, input.assetId, input.storeId);
|
||||
const capturedAt = new Date();
|
||||
await connection.execute(
|
||||
`UPDATE qipai_devices SET status = ?, signal_strength = ?,
|
||||
firmware_version = COALESCE(NULLIF(?, ''), firmware_version),
|
||||
state_snapshot = ?, last_seen_at = ?, last_heartbeat_at = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.onlineStatus, input.signalStrength ?? null, input.firmwareVersion ?? '',
|
||||
JSON.stringify(input.snapshot), capturedAt, capturedAt, actor.tenantId, input.assetId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_device_status_snapshots
|
||||
(tenant_id, device_id, online_status, signal_strength, firmware_version, snapshot,
|
||||
captured_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.assetId, input.onlineStatus, input.signalStrength ?? null,
|
||||
input.firmwareVersion ?? '', JSON.stringify(input.snapshot), capturedAt]
|
||||
);
|
||||
return { assetId: input.assetId, capturedAt: capturedAt.toISOString() };
|
||||
});
|
||||
}
|
||||
|
||||
async addMaintenance(actor: ManagementActor, input: {
|
||||
assetId: string; storeId: string; roomId?: string | null;
|
||||
recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
|
||||
status: 'OPEN' | 'RESOLVED'; description: string;
|
||||
}) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockDevice(connection, actor, input.assetId, input.storeId);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_device_maintenance_records
|
||||
(tenant_id, device_id, store_id, room_id, record_type, status, description,
|
||||
created_by, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.assetId, input.storeId, input.roomId ?? null,
|
||||
input.recordType, input.status, input.description, actor.userId,
|
||||
input.status === 'RESOLVED' ? new Date() : null]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_devices SET maintenance_status = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.status === 'OPEN' ? 'MAINTENANCE' : 'NORMAL', actor.tenantId, input.assetId]
|
||||
);
|
||||
await this.audit(connection, actor, 'DEVICE_MAINTENANCE_RECORDED', input.assetId, {
|
||||
recordType: input.recordType, status: input.status
|
||||
});
|
||||
return { maintenanceId: String(result.insertId) };
|
||||
});
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string, write: boolean) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
const capability = write ? 'device.write' : 'device.read';
|
||||
if (!actor.access.capabilities.includes(capability)
|
||||
|| !actor.access.storeIds.includes(storeId)) {
|
||||
throw new DeviceError('DEVICE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private scope(actor: ManagementActor, expression: string) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return { sql: '1 = 1', params: [] as string[] };
|
||||
if (actor.access.storeIds.length === 0) return { sql: '1 = 0', params: [] as string[] };
|
||||
return {
|
||||
sql: `${expression} IN (${actor.access.storeIds.map(() => '?').join(',')})`,
|
||||
params: actor.access.storeIds
|
||||
};
|
||||
}
|
||||
|
||||
private async assertStoreAndRoom(
|
||||
connection: PoolConnection, actor: ManagementActor, storeId: string, roomId: string | null
|
||||
) {
|
||||
const [stores] = await connection.execute<IdRow[]>(
|
||||
'SELECT id FROM qipai_stores WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL',
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
if (!stores[0]) throw new DeviceError('DEVICE_STORE_NOT_FOUND');
|
||||
if (!roomId) return;
|
||||
const [rooms] = await connection.execute<IdRow[]>(
|
||||
`SELECT id FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[actor.tenantId, storeId, roomId]
|
||||
);
|
||||
if (!rooms[0]) throw new DeviceError('DEVICE_ROOM_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async lockDevice(
|
||||
connection: PoolConnection, actor: ManagementActor, assetId: string, storeId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { id: string; deviceType: DeviceType }>>(
|
||||
`SELECT id, device_type AS deviceType FROM qipai_devices
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||
[actor.tenantId, storeId, assetId]
|
||||
);
|
||||
if (!rows[0]) throw new DeviceError('DEVICE_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async audit(
|
||||
connection: PoolConnection, actor: ManagementActor,
|
||||
action: string, resourceId: string, metadata: Record<string, unknown>
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, ?, 'DEVICE', ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, actor.userId, action, resourceId, actor.traceId,
|
||||
actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const result = await work(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDuplicate(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null
|
||||
&& 'code' in error && error.code === 'ER_DUP_ENTRY';
|
||||
}
|
||||
|
||||
function parseJson<T>(value: unknown, fallback: T): T {
|
||||
if (value && typeof value === 'object') return value as T;
|
||||
if (typeof value !== 'string' || value.length === 0) return fallback;
|
||||
try { return JSON.parse(value) as T; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function maskIdentifier(value: string): string {
|
||||
if (value.length <= 4) return '*'.repeat(value.length);
|
||||
return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function normalizeIds(rows: RowDataPacket[]) {
|
||||
return rows.map((row) => Object.fromEntries(
|
||||
Object.entries(row).map(([key, value]) => [
|
||||
key,
|
||||
(key === 'id' || key.endsWith('Id')) && value !== null ? String(value) : value
|
||||
])
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { DeviceError, type DeviceRepository } from '../devices/device-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const optionalId = id.nullable().optional();
|
||||
const deviceIdentity = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
|
||||
const assetSchema = z.object({
|
||||
storeId: id,
|
||||
roomId: optionalId,
|
||||
deviceId: deviceIdentity,
|
||||
imei: z.string().trim().max(64).default(''),
|
||||
iccid: z.string().trim().max(32).nullable().optional(),
|
||||
deviceType: z.enum(['CONTROL_BOX', 'SUB_LOCK', 'SMART_SOCKET']),
|
||||
model: z.string().trim().min(1).max(64),
|
||||
firmwareVersion: z.string().trim().max(64).default(''),
|
||||
signalStrength: z.number().int().min(-200).max(200).nullable().optional(),
|
||||
capabilities: z.array(z.string().trim().regex(/^[A-Z0-9_]{1,64}$/)).max(64).default([])
|
||||
});
|
||||
const channelSchema = z.object({
|
||||
assetId: id,
|
||||
storeId: id,
|
||||
roomId: id,
|
||||
channelCode: z.enum(['SLOT1', 'SLOT2', 'SLOT3', 'MAIN', 'LOCK', 'LED', 'TTS']),
|
||||
purpose: z.enum([
|
||||
'ROOM_POWER', 'AIR_CONDITIONER', 'LIGHTING', 'DOOR_MAGNET',
|
||||
'STORE_DOOR', 'ROOM_DOOR', 'TTS', 'LED'
|
||||
])
|
||||
});
|
||||
const linkSchema = z.object({
|
||||
parentAssetId: id,
|
||||
childAssetId: id,
|
||||
storeId: id,
|
||||
roomId: id,
|
||||
subId: z.string().trim().min(1).max(64),
|
||||
subtype: z.string().trim().min(1).max(32)
|
||||
});
|
||||
const statusSchema = z.object({
|
||||
storeId: id,
|
||||
onlineStatus: z.enum(['ONLINE', 'OFFLINE', 'FAULT']),
|
||||
signalStrength: z.number().int().min(-200).max(200).nullable().optional(),
|
||||
firmwareVersion: z.string().trim().max(64).optional(),
|
||||
snapshot: z.record(z.unknown()).default({})
|
||||
});
|
||||
const maintenanceSchema = z.object({
|
||||
storeId: id,
|
||||
roomId: optionalId,
|
||||
recordType: z.enum(['INSPECTION', 'REPAIR', 'REPLACEMENT']),
|
||||
status: z.enum(['OPEN', 'RESOLVED']),
|
||||
description: z.string().trim().max(500).default('')
|
||||
});
|
||||
|
||||
export interface DeviceRouteOptions {
|
||||
repository: Pick<DeviceRepository,
|
||||
'createAsset' | 'listAssets' | 'getTopology' | 'bindChannel' | 'bindSubDevice'
|
||||
| 'recordStatus' | 'addMaintenance'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
|
||||
app.get('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = assetSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.createAsset(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/admin-api/device-topology', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/device-channels', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = channelSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.bindChannel(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/device-links', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = linkSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.bindSubDevice(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/devices/:id/status', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = statusSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.recordStatus(actor, {
|
||||
assetId: params.data.id, ...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/devices/:id/maintenance', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = maintenanceSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.addMaintenance(actor, {
|
||||
assetId: params.data.id, ...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireDeviceOperator(
|
||||
request: FastifyRequest, reply: FastifyReply, options: DeviceRouteOptions
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.some((code) =>
|
||||
code === 'device.read' || code === 'device.write' || code === 'tenant.manage'
|
||||
) && !access.roles.includes('PLATFORM_ADMIN')) {
|
||||
reply.status(403).send({
|
||||
code: 'DEVICE_OPERATION_FORBIDDEN',
|
||||
message: 'Device permission is required.', traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function mutate(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof DeviceError)) throw error;
|
||||
const forbidden = error.code.endsWith('_FORBIDDEN');
|
||||
const conflict = error.code.endsWith('_CONFLICT');
|
||||
return reply.status(forbidden ? 403 : conflict ? 409 : 400).send({
|
||||
code: error.code, message: 'The device operation is not allowed.', traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_DEVICE_REQUEST', message: 'The device request is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from './third-party/third-party-client.js';
|
||||
import { ThirdPartyService } from './third-party/third-party-service.js';
|
||||
import { MqttService } from './mqtt/mqtt-service.js';
|
||||
import { DeviceRepository } from './devices/device-repository.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
@@ -133,6 +134,12 @@ const app = await buildApp({
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
devices: {
|
||||
repository: new DeviceRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
}
|
||||
});
|
||||
app.addHook('onClose', async () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
DELETE FROM qipai_schema_migrations WHERE version = '2026062219';
|
||||
DELETE rp FROM qipai_role_permissions rp
|
||||
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
|
||||
WHERE p.code IN ('device.read', 'device.write');
|
||||
DELETE FROM qipai_permissions WHERE code IN ('device.read', 'device.write');
|
||||
DROP TABLE IF EXISTS qipai_device_maintenance_records;
|
||||
DROP TABLE IF EXISTS qipai_device_alerts;
|
||||
DROP TABLE IF EXISTS qipai_device_status_snapshots;
|
||||
DROP TABLE IF EXISTS qipai_device_links;
|
||||
DROP TABLE IF EXISTS qipai_device_channels;
|
||||
ALTER TABLE qipai_devices
|
||||
DROP INDEX idx_qipai_devices_store_room,
|
||||
DROP INDEX uq_qipai_devices_tenant_iccid,
|
||||
DROP INDEX idx_qipai_devices_tenant_imei,
|
||||
DROP COLUMN maintenance_status,
|
||||
DROP COLUMN last_heartbeat_at,
|
||||
DROP COLUMN state_snapshot,
|
||||
DROP COLUMN capabilities,
|
||||
DROP COLUMN signal_strength,
|
||||
DROP COLUMN firmware_version,
|
||||
DROP COLUMN model,
|
||||
DROP COLUMN iccid;
|
||||
@@ -0,0 +1,153 @@
|
||||
ALTER TABLE qipai_devices
|
||||
ADD COLUMN iccid VARCHAR(32) NULL AFTER imei,
|
||||
ADD COLUMN model VARCHAR(64) NOT NULL DEFAULT '' AFTER device_type,
|
||||
ADD COLUMN firmware_version VARCHAR(64) NOT NULL DEFAULT '' AFTER model,
|
||||
ADD COLUMN signal_strength SMALLINT NULL AFTER status,
|
||||
ADD COLUMN capabilities JSON NULL AFTER signal_strength,
|
||||
ADD COLUMN state_snapshot JSON NULL AFTER capabilities,
|
||||
ADD COLUMN last_heartbeat_at DATETIME(3) NULL AFTER last_seen_at,
|
||||
ADD COLUMN maintenance_status VARCHAR(32) NOT NULL DEFAULT 'NORMAL' AFTER last_heartbeat_at,
|
||||
ADD KEY idx_qipai_devices_tenant_imei (tenant_id, imei),
|
||||
ADD UNIQUE KEY uq_qipai_devices_tenant_iccid (tenant_id, iccid),
|
||||
ADD KEY idx_qipai_devices_store_room (tenant_id, store_id, room_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_device_channels (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
room_id BIGINT UNSIGNED NOT NULL,
|
||||
channel_code VARCHAR(32) NOT NULL,
|
||||
purpose VARCHAR(32) NOT NULL,
|
||||
target_key VARCHAR(255) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_device_channel_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_device_channel_device
|
||||
FOREIGN KEY (device_id) REFERENCES qipai_devices(id),
|
||||
CONSTRAINT fk_qipai_device_channel_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_device_channel_room
|
||||
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
UNIQUE KEY uq_qipai_device_channel_code (tenant_id, device_id, channel_code),
|
||||
UNIQUE KEY uq_qipai_device_channel_target (tenant_id, target_key),
|
||||
KEY idx_qipai_device_channel_room (tenant_id, store_id, room_id, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_device_links (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
parent_device_id BIGINT UNSIGNED NOT NULL,
|
||||
child_device_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
room_id BIGINT UNSIGNED NOT NULL,
|
||||
link_type VARCHAR(32) NOT NULL DEFAULT 'SUB_1G',
|
||||
sub_id VARCHAR(64) NOT NULL,
|
||||
subtype VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'BOUND',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_device_link_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_device_link_parent
|
||||
FOREIGN KEY (parent_device_id) REFERENCES qipai_devices(id),
|
||||
CONSTRAINT fk_qipai_device_link_child
|
||||
FOREIGN KEY (child_device_id) REFERENCES qipai_devices(id),
|
||||
CONSTRAINT fk_qipai_device_link_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_device_link_room
|
||||
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
UNIQUE KEY uq_qipai_device_link_child (tenant_id, child_device_id),
|
||||
UNIQUE KEY uq_qipai_device_link_sub (tenant_id, parent_device_id, sub_id),
|
||||
UNIQUE KEY uq_qipai_device_link_room_type (tenant_id, room_id, link_type),
|
||||
KEY idx_qipai_device_link_parent (tenant_id, parent_device_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_device_status_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id BIGINT UNSIGNED NOT NULL,
|
||||
online_status VARCHAR(32) NOT NULL,
|
||||
signal_strength SMALLINT NULL,
|
||||
firmware_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
snapshot JSON NULL,
|
||||
captured_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_device_snapshot_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_device_snapshot_device
|
||||
FOREIGN KEY (device_id) REFERENCES qipai_devices(id),
|
||||
KEY idx_qipai_device_snapshot_device (tenant_id, device_id, captured_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_device_alerts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
room_id BIGINT UNSIGNED NULL,
|
||||
alert_type VARCHAR(64) NOT NULL,
|
||||
severity VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'OPEN',
|
||||
summary VARCHAR(255) NOT NULL DEFAULT '',
|
||||
first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
last_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
resolved_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_device_alert_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_device_alert_device
|
||||
FOREIGN KEY (device_id) REFERENCES qipai_devices(id),
|
||||
CONSTRAINT fk_qipai_device_alert_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_device_alert_room
|
||||
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
UNIQUE KEY uq_qipai_device_alert_open
|
||||
(tenant_id, device_id, alert_type, status),
|
||||
KEY idx_qipai_device_alert_store
|
||||
(tenant_id, store_id, status, severity, last_seen_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_device_maintenance_records (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
device_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NOT NULL,
|
||||
room_id BIGINT UNSIGNED NULL,
|
||||
record_type VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
description VARCHAR(500) NOT NULL DEFAULT '',
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
resolved_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_device_maintenance_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_device_maintenance_device
|
||||
FOREIGN KEY (device_id) REFERENCES qipai_devices(id),
|
||||
CONSTRAINT fk_qipai_device_maintenance_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_device_maintenance_room
|
||||
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
CONSTRAINT fk_qipai_device_maintenance_user
|
||||
FOREIGN KEY (created_by) REFERENCES qipai_users(id),
|
||||
KEY idx_qipai_device_maintenance_status
|
||||
(tenant_id, store_id, status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
|
||||
('device.read', '查看设备', 'device'),
|
||||
('device.write', '管理设备', 'device');
|
||||
|
||||
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
|
||||
SELECT r.tenant_id, r.id, p.id
|
||||
FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p
|
||||
ON (r.code = 'STORE_ADMIN' AND p.code IN ('device.read', 'device.write'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('device.read', 'device.write'))
|
||||
WHERE r.deleted_at IS NULL;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062219', 'm06b_device_topology');
|
||||
@@ -0,0 +1,30 @@
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name IN (
|
||||
'qipai_device_alerts', 'qipai_device_channels', 'qipai_device_links',
|
||||
'qipai_device_status_snapshots', 'qipai_device_maintenance_records'
|
||||
) ORDER BY table_name;
|
||||
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'qipai_devices'
|
||||
AND column_name IN (
|
||||
'iccid', 'model', 'firmware_version', 'signal_strength', 'capabilities',
|
||||
'state_snapshot', 'last_heartbeat_at', 'maintenance_status'
|
||||
) ORDER BY column_name;
|
||||
|
||||
SELECT table_name, index_name FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND ((table_name = 'qipai_device_channels'
|
||||
AND index_name IN (
|
||||
'uq_qipai_device_channel_code', 'uq_qipai_device_channel_target'
|
||||
))
|
||||
OR (table_name = 'qipai_device_links'
|
||||
AND index_name IN (
|
||||
'uq_qipai_device_link_child', 'uq_qipai_device_link_sub',
|
||||
'uq_qipai_device_link_room_type'
|
||||
)))
|
||||
GROUP BY table_name, index_name ORDER BY table_name, index_name;
|
||||
|
||||
SELECT code FROM qipai_permissions
|
||||
WHERE code IN ('device.read', 'device.write') ORDER BY code;
|
||||
|
||||
SELECT version, name FROM qipai_schema_migrations WHERE version = '2026062219';
|
||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||
|
||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M05-D migration roundtrip in a temporary database."
|
||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M06-B migration roundtrip in a temporary database."
|
||||
npm --prefix backend run test:mysql:migration
|
||||
echo "PASS: M01-B through M05-D live MySQL migration roundtrip completed."
|
||||
echo "PASS: M01-B through M06-B live MySQL migration roundtrip completed."
|
||||
|
||||
Reference in New Issue
Block a user