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
+1 -1
View File
@@ -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",
+5
View File
@@ -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;
}
+4 -2
View File
@@ -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]
);
+4 -2
View File
@@ -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]
);
+7 -3
View File
@@ -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(
+366
View File
@@ -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
])
));
}
+194
View File
@@ -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
});
}
+7
View File
@@ -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 () => {
+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 {
@@ -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';
+2 -2
View File
@@ -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."