diff --git a/backend/package.json b/backend/package.json index f2250df..3bec866 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.ts b/backend/src/app.ts index 0bdefac..9455e40 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 = { '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 = { '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( diff --git a/backend/src/devices/device-repository.ts b/backend/src/devices/device-repository.ts new file mode 100644 index 0000000..634c1b4 --- /dev/null +++ b/backend/src/devices/device-repository.ts @@ -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 | 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( + `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 = [actor.tenantId, ...scope.params]; + const storeFilter = storeId ? ' AND d.store_id = ?' : ''; + if (storeId) params.push(storeId); + const [rows] = await this.pool.execute( + `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(row.capabilities, []), + stateSnapshot: parseJson>(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( + `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( + `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( + `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( + `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; + }) { + 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( + `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( + '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( + `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>( + `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 + ) { + 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(work: (connection: PoolConnection) => Promise) { + 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(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 + ]) + )); +} diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts new file mode 100644 index 0000000..3e7313e --- /dev/null +++ b/backend/src/routes/devices.ts @@ -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; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + 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 { + 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) { + 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 + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index f184632..abb1abc 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => { diff --git a/backend/tests/device.test.mjs b/backend/tests/device.test.mjs new file mode 100644 index 0000000..3e65853 --- /dev/null +++ b/backend/tests/device.test.mjs @@ -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.'); diff --git a/backend/tests/migration-contract.test.mjs b/backend/tests/migration-contract.test.mjs index 82251c6..372ecea 100644 --- a/backend/tests/migration-contract.test.mjs +++ b/backend/tests/migration-contract.test.mjs @@ -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.'); diff --git a/backend/tests/migration-runner.test.mjs b/backend/tests/migration-runner.test.mjs index 44ef812..c0301b6 100644 --- a/backend/tests/migration-runner.test.mjs +++ b/backend/tests/migration-runner.test.mjs @@ -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); diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 4035785..1285a96 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -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 { diff --git a/database/migrations/2026062219_m06b_device_topology.down.sql b/database/migrations/2026062219_m06b_device_topology.down.sql new file mode 100644 index 0000000..ef209ab --- /dev/null +++ b/database/migrations/2026062219_m06b_device_topology.down.sql @@ -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; diff --git a/database/migrations/2026062219_m06b_device_topology.up.sql b/database/migrations/2026062219_m06b_device_topology.up.sql new file mode 100644 index 0000000..2e9d63e --- /dev/null +++ b/database/migrations/2026062219_m06b_device_topology.up.sql @@ -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'); diff --git a/database/migrations/2026062219_m06b_device_topology.verify.sql b/database/migrations/2026062219_m06b_device_topology.verify.sql new file mode 100644 index 0000000..5b55209 --- /dev/null +++ b/database/migrations/2026062219_m06b_device_topology.verify.sql @@ -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'; diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index 4cf3108..b88d40d 100644 --- a/scripts/dev/wsl/mysql-migration-roundtrip.sh +++ b/scripts/dev/wsl/mysql-migration-roundtrip.sh @@ -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."