diff --git a/backend/package.json b/backend/package.json index 8bd5e95..dc9957e 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 && node tests/device.test.mjs && node tests/iot-protocol.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 && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs" }, "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 9455e40..07b870e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -42,6 +42,9 @@ import { registerThirdPartyRoutes, type ThirdPartyRouteOptions } from './routes/third-party.js'; import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.js'; +import { + registerDeviceControlRoutes, type DeviceControlRouteOptions +} from './routes/device-control.js'; export interface BuildAppOptions { config?: AppConfig; @@ -60,6 +63,7 @@ export interface BuildAppOptions { thirdParty?: ThirdPartyRouteOptions; mqtt?: MqttHealthProvider; devices?: DeviceRouteOptions; + deviceControl?: DeviceControlRouteOptions; } declare module 'fastify' { @@ -146,6 +150,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise + ) {} + + async controlPower(context: CommandContext, input: { + slot1?: 'on' | 'off'; slot2?: 'on' | 'off'; + slot3?: 'on' | 'off'; slotall?: 'on' | 'off'; + }) { + return this.issueControlBox(context, 'ConctolPower', + (id) => this.controlBox.controlPower({ id, ...input })); + } + + async controlDoor(context: CommandContext, input: { + order: 'open' | 'close'; holdopen?: 0 | 1; delayTime?: number; + }) { + if (input.holdopen === 1 && !this.isManager(context.access)) { + throw new DeviceControlError('DEVICE_HOLD_OPEN_FORBIDDEN'); + } + return this.issueControlBox(context, 'Crldoor', + (id) => this.controlBox.controlDoor({ id, ...input })); + } + + async playTts(context: CommandContext, input: { + content: string; volume?: number; playCount?: number; priority?: number; + }) { + return this.issueControlBox(context, 'PlayTTS', + (id) => this.controlBox.playTts({ id, ...input })); + } + + async stopTts(context: CommandContext) { + return this.issueControlBox(context, 'stopTTS', (id) => this.controlBox.stopTts(id)); + } + + async controlLed(context: CommandContext, minute: number) { + return this.issueControlBox(context, 'CrlLED', + (id) => this.controlBox.controlLed({ id, minute })); + } + + async startTask(context: CommandContext, input: { + minute: number; type: 1 | 2 | 3; subID?: string; + holdopen?: 0 | 1; delayTime?: number; + }) { + return this.issueControlBox(context, 'task', + (id) => this.controlBox.startTask({ id, ...input }), context.orderId); + } + + async extendTask(context: CommandContext, addminute: number) { + return this.issueControlBox(context, 'addtask', + (id) => this.controlBox.extendTask({ id, addminute }), context.orderId); + } + + async cancelTask(context: CommandContext) { + return this.issueControlBox(context, 'canceltask', + (id) => this.controlBox.cancelTask(id), context.orderId); + } + + async pairSubLock(context: CommandContext, timeout = 60) { + return this.issueControlBox(context, 'AddDevice', + (id) => this.subLock.pair({ id, timeout })); + } + + async controlSubLock(context: CommandContext, input: { + subID: string; + order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset'; + holdopen?: 0 | 1; delayTime?: number; content?: string; + dangerConfirmation?: string; + }) { + if (['factoryreset'].includes(input.order)) { + if (!context.access.roles.includes('PLATFORM_ADMIN') + || input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') { + throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN'); + } + } + if (['delkey', 'delcard'].includes(input.order) && !input.content) { + if (!context.access.roles.includes('PLATFORM_ADMIN') + || input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') { + throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN'); + } + } + const { dangerConfirmation: _, ...vendorInput } = input; + return this.issueControlBox(context, 'CtrlDevice', + (id) => this.subLock.control({ id, ...vendorInput })); + } + + private async issueControlBox( + context: CommandContext, + commandType: string, + payloadFactory: (id: string) => Record, + orderId?: string | null + ) { + this.assertWriteScope(context.access, context.storeId); + const device = await this.resolveControlBox(context); + if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE'); + return this.commands.issue({ + tenantId: context.tenantId, + assetId: String(device.id), + deviceId: device.deviceId, + storeId: context.storeId, + roomId: context.roomId, + orderId, + commandType, + payloadFactory, + traceId: context.traceId, + expiresAt: context.expiresAt + }); + } + + private async resolveControlBox(context: CommandContext) { + const [rows] = await this.pool.execute( + `SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId, + device_type AS deviceType, status + FROM qipai_devices + WHERE tenant_id = ? AND store_id = ? AND room_id = ? + AND device_type = 'CONTROL_BOX' AND deleted_at IS NULL + ORDER BY id LIMIT 1`, + [context.tenantId, context.storeId, context.roomId] + ); + if (!rows[0]) throw new DeviceControlError('CONTROL_BOX_NOT_BOUND'); + return rows[0]; + } + + private assertWriteScope(access: AccessProfile, storeId: string) { + if (access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('tenant.manage')) { + return; + } + if (!access.capabilities.includes('device.write') || !access.storeIds.includes(storeId)) { + throw new DeviceControlError('DEVICE_SCOPE_FORBIDDEN'); + } + } + + private isManager(access: AccessProfile) { + return access.roles.some((role) => + ['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role) + ); + } +} + +export interface CommandContext { + tenantId: string; + storeId: string; + roomId: string; + orderId?: string | null; + traceId: string; + access: AccessProfile; + expiresAt?: Date | null; +} diff --git a/backend/src/devices/iot-message-service.ts b/backend/src/devices/iot-message-service.ts index 25b1306..97665f7 100644 --- a/backend/src/devices/iot-message-service.ts +++ b/backend/src/devices/iot-message-service.ts @@ -14,6 +14,9 @@ interface DeviceRow extends RowDataPacket { deviceId: string; deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET'; } interface IdRow extends RowDataPacket { id: string } +interface CommandRow extends RowDataPacket { + id: string; commandType: string; storeId: string; roomId: string | null; +} export class IotMessageService { private readonly adapters: Record = { @@ -65,6 +68,8 @@ export class IotMessageService { return; } + const safePayload = sanitizeSensitivePayload(rawPayload, normalized); + normalized = { ...normalized, payload: safePayload }; const [result] = await this.pool.execute( `INSERT INTO qipai_iot_device_events (tenant_id, device_id, store_id, room_id, command_id, topic, event_type, @@ -74,12 +79,13 @@ export class IotMessageService { received_at = UTC_TIMESTAMP(3)`, [device.tenantId, device.id, device.storeId, device.roomId, normalized.commandId, topic, normalized.eventType, payloadHash, - JSON.stringify(rawPayload), JSON.stringify(normalized), + JSON.stringify(safePayload), JSON.stringify(normalized), normalized.eventAt] ); if (result.affectedRows !== 1) return; - await this.updateDevice(device, normalized, rawPayload); + await this.updateDevice(device, normalized, safePayload); + await this.applyAlerts(device, normalized); if (normalized.kind === 'ACK' && normalized.commandId) { await this.applyAcknowledgement(device, normalized); } @@ -145,6 +151,80 @@ export class IotMessageService { [status, JSON.stringify(message.payload), failureCode, device.tenantId, device.id, message.commandId] ); + if (successful && message.eventType === 'AddDevice') { + await this.persistPairedSubLock(device, message); + } + } + + private async persistPairedSubLock(device: DeviceRow, message: NormalizedVendorMessage) { + const subId = readString(message.payload.subID ?? message.payload.subId); + const subtype = readString(message.payload.subtype); + if (!subId || !subtype || !message.commandId) return; + const [commands] = await this.pool.execute( + `SELECT id, command_type AS commandType, store_id AS storeId, room_id AS roomId + FROM qipai_iot_commands + WHERE tenant_id = ? AND device_id = ? AND command_id = ?`, + [device.tenantId, device.id, message.commandId] + ); + const command = commands[0]; + if (!command?.roomId || command.commandType !== 'AddDevice') return; + const childDeviceId = `${device.deviceId}_SUB_${subId}`.slice(0, 64); + await this.pool.execute( + `INSERT INTO qipai_devices + (tenant_id, store_id, room_id, device_id, device_type, model, capabilities, status) + VALUES (?, ?, ?, ?, 'SUB_LOCK', ?, JSON_ARRAY('LOCK'), 'ONLINE') + ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), room_id = VALUES(room_id), + model = VALUES(model), status = 'ONLINE'`, + [device.tenantId, command.storeId, command.roomId, childDeviceId, + subtype === '14' ? '701C' : subtype === '15' ? '701G' : `SUBTYPE_${subtype}`] + ); + const [childRows] = await this.pool.execute( + `SELECT id FROM qipai_devices + WHERE tenant_id = ? AND device_id = ? AND deleted_at IS NULL`, + [device.tenantId, childDeviceId] + ); + if (!childRows[0]) return; + await this.pool.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', ?, ?) + ON DUPLICATE KEY UPDATE parent_device_id = VALUES(parent_device_id), + child_device_id = VALUES(child_device_id), room_id = VALUES(room_id), + sub_id = VALUES(sub_id), subtype = VALUES(subtype), status = 'BOUND'`, + [device.tenantId, device.id, childRows[0].id, command.storeId, + command.roomId, subId, subtype] + ); + } + + private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) { + const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '') + ? `DEVICE_${message.result?.toUpperCase()}` + : null; + if (alertResult) { + await this.upsertAlert(device, alertResult, + message.result === 'unconfirm' ? 'HIGH' : 'MEDIUM', + `Device command returned ${message.result}.`); + } + const battery = readNumber(message.payload.battery); + if (battery !== null && battery <= 20) { + await this.upsertAlert(device, 'LOW_BATTERY', battery <= 10 ? 'HIGH' : 'MEDIUM', + `Device battery is ${battery}%.`); + } + } + + private async upsertAlert( + device: DeviceRow, alertType: string, severity: string, summary: string + ) { + await this.pool.execute( + `INSERT INTO qipai_device_alerts + (tenant_id, device_id, store_id, room_id, alert_type, severity, summary) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE severity = VALUES(severity), summary = VALUES(summary), + last_seen_at = UTC_TIMESTAMP(3)`, + [device.tenantId, device.id, device.storeId, device.roomId, + alertType, severity, summary] + ); } private async updateDevice( @@ -207,3 +287,36 @@ function readDeviceId(record: Record): string | null { const value = record.DeviceID ?? record.deviceID; return typeof value === 'string' ? value : null; } + +function sanitizeSensitivePayload( + payload: unknown, normalized: NormalizedVendorMessage +): Record { + const record = { ...zRecord(payload) }; + if (normalized.eventType === 'record' && typeof record.content === 'string') { + const content = record.content; + record.contentHash = createHash('sha256').update(content).digest('hex'); + record.contentMasked = content.length <= 4 + ? '*'.repeat(content.length) + : `${content.slice(0, 2)}${'*'.repeat(Math.min(8, content.length - 4))}${content.slice(-2)}`; + delete record.content; + } + if (typeof record.password === 'string') { + record.password = ''; + } + if (typeof record.card === 'string') { + record.card = ''; + } + return record; +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function readNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) { + return Number(value); + } + return null; +} diff --git a/backend/src/routes/device-control.ts b/backend/src/routes/device-control.ts new file mode 100644 index 0000000..6cde555 --- /dev/null +++ b/backend/src/routes/device-control.ts @@ -0,0 +1,153 @@ +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 { + DeviceControlError, + type CommandContext, + type DeviceControlService +} from '../devices/device-control-service.js'; + +const id = z.string().regex(/^[1-9]\d{0,19}$/); +const contextSchema = z.object({ + storeId: id, + roomId: id, + orderId: id.nullable().optional() +}); +const powerSchema = contextSchema.extend({ + slot1: z.enum(['on', 'off']).optional(), + slot2: z.enum(['on', 'off']).optional(), + slot3: z.enum(['on', 'off']).optional(), + slotall: z.enum(['on', 'off']).optional() +}).refine((value) => value.slot1 || value.slot2 || value.slot3 || value.slotall); +const doorSchema = contextSchema.extend({ + order: z.enum(['open', 'close']), + holdopen: z.union([z.literal(0), z.literal(1)]).default(0), + delayTime: z.number().int().min(1).max(14).default(4) +}); +const ttsSchema = contextSchema.extend({ + content: z.string().trim().min(1).max(500), + volume: z.number().int().min(0).max(100).default(80), + playCount: z.number().int().min(1).max(10).default(1), + priority: z.number().int().min(0).max(10).default(0) +}); +const minuteSchema = contextSchema.extend({ + minute: z.number().int().min(0).max(10080) +}); +const taskSchema = contextSchema.extend({ + minute: z.number().int().min(1).max(10080), + type: z.union([z.literal(1), z.literal(2), z.literal(3)]), + subID: z.string().min(1).max(64).optional(), + holdopen: z.union([z.literal(0), z.literal(1)]).default(0), + delayTime: z.number().int().min(1).max(14).default(4) +}); +const extendSchema = contextSchema.extend({ + addminute: z.number().int().min(1).max(10080) +}); +const pairSchema = contextSchema.extend({ + timeout: z.number().int().min(10).max(300).default(60) +}); +const subLockSchema = contextSchema.extend({ + subID: z.string().min(1).max(64), + order: z.enum([ + 'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset' + ]), + holdopen: z.union([z.literal(0), z.literal(1)]).optional(), + delayTime: z.number().int().min(1).max(14).optional(), + content: z.string().min(1).max(128).optional(), + dangerConfirmation: z.string().max(64).optional() +}); + +export interface DeviceControlRouteOptions { + service: Pick; + authRepository: Pick; + accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; + jwtSecret: string; +} + +export async function registerDeviceControlRoutes( + app: FastifyInstance, options: DeviceControlRouteOptions +) { + register('/admin-api/device-control/power', powerSchema, + (context, body) => options.service.controlPower(context, body)); + register('/admin-api/device-control/door', doorSchema, + (context, body) => options.service.controlDoor(context, body)); + register('/admin-api/device-control/tts', ttsSchema, + (context, body) => options.service.playTts(context, body)); + register('/admin-api/device-control/tts/stop', contextSchema, + (context) => options.service.stopTts(context)); + register('/admin-api/device-control/led', minuteSchema, + (context, body) => options.service.controlLed(context, body.minute)); + register('/admin-api/device-control/task/start', taskSchema, + (context, body) => options.service.startTask(context, body)); + register('/admin-api/device-control/task/extend', extendSchema, + (context, body) => options.service.extendTask(context, body.addminute)); + register('/admin-api/device-control/task/cancel', contextSchema, + (context) => options.service.cancelTask(context)); + register('/admin-api/device-control/sub-lock/pair', pairSchema, + (context, body) => options.service.pairSubLock(context, body.timeout)); + register('/admin-api/device-control/sub-lock/action', subLockSchema, + (context, body) => options.service.controlSubLock(context, body)); + + function register( + url: string, + schema: T, + handler: (context: CommandContext, body: z.infer) => Promise + ) { + app.post(url, async (request, reply) => { + const parsed = schema.safeParse(request.body); + if (!parsed.success) return invalid(reply, request.traceId); + const context = await requireContext(request, reply, options, parsed.data); + if (!context) return; + try { + return { + code: 0, data: await handler(context, parsed.data), traceId: request.traceId + }; + } catch (error) { + if (!(error instanceof DeviceControlError)) throw error; + const status = error.code.endsWith('_FORBIDDEN') ? 403 + : error.code === 'DEVICE_OFFLINE' ? 409 : 400; + return reply.status(status).send({ + code: error.code, message: 'Device control was rejected.', traceId: request.traceId + }); + } + }); + } +} + +async function requireContext( + request: FastifyRequest, reply: FastifyReply, options: DeviceControlRouteOptions, + input: { storeId: string; roomId: string; orderId?: string | 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 + ); + return { + tenantId: auth.session.tenantId, + storeId: input.storeId, + roomId: input.roomId, + orderId: input.orderId, + traceId: request.traceId, + access + }; +} + +function invalid(reply: FastifyReply, traceId: string) { + return reply.status(400).send({ + code: 'INVALID_DEVICE_CONTROL_REQUEST', + message: 'The device control request is invalid.', traceId + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 0e2f284..2d3eeea 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -29,6 +29,8 @@ import { ThirdPartyService } from './third-party/third-party-service.js'; import { MqttService } from './mqtt/mqtt-service.js'; import { DeviceRepository } from './devices/device-repository.js'; import { IotMessageService } from './devices/iot-message-service.js'; +import { DeviceCommandService } from './devices/device-command-service.js'; +import { DeviceControlService } from './devices/device-control-service.js'; const config = loadConfig(); const pool = createMySqlPool(config); @@ -43,6 +45,7 @@ const iotMessages = new IotMessageService(pool); const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) => iotMessages.handle(topic, payload) ); +const deviceCommands = new DeviceCommandService(iotMessages, mqtt); const app = await buildApp({ config, mqtt, @@ -144,6 +147,12 @@ const app = await buildApp({ authRepository, accessControl, jwtSecret: config.auth.jwtSecret + }, + deviceControl: { + service: new DeviceControlService(pool, deviceCommands), + authRepository, + accessControl, + jwtSecret: config.auth.jwtSecret } }); app.addHook('onClose', async () => { diff --git a/backend/tests/device-control.test.mjs b/backend/tests/device-control.test.mjs new file mode 100644 index 0000000..911821a --- /dev/null +++ b/backend/tests/device-control.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { buildApp } from '../dist/app.js'; +import { signAccessToken } from '../dist/auth/jwt.js'; +import { + DeviceControlError, + DeviceControlService +} from '../dist/devices/device-control-service.js'; + +const managerAccess = { + roles: ['STORE_ADMIN'], + capabilities: ['device.read', 'device.write'], + storeIds: ['11'] +}; +const issued = []; +const service = new DeviceControlService({ + async execute(sql) { + if (sql.includes('FROM qipai_devices')) { + return [[{ + id: 51, deviceId: 'BOX_001', storeId: 11, roomId: 31, + deviceType: 'CONTROL_BOX', status: 'ONLINE' + }], []]; + } + return [[], []]; + } +}, { + async issue(input) { + const payload = input.payloadFactory('1782120000001'); + issued.push({ ...input, payload }); + return { commandId: '1782120000001', status: 'PUBLISHED' }; + } +}); +const context = { + tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace', + access: managerAccess +}; + +await service.controlPower(context, { slot1: 'on', slot3: 'off' }); +assert.equal(issued.at(-1).payload.action, 'ConctolPower'); +await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 }); +assert.equal(issued.at(-1).payload.action, 'Crldoor'); +await service.playTts(context, { content: '欢迎光临' }); +assert.equal(issued.at(-1).payload.action, 'PlayTTS'); +await service.stopTts(context); +assert.equal(issued.at(-1).payload.action, 'stopTTS'); +await service.controlLed(context, 30); +assert.equal(issued.at(-1).payload.action, 'CrlLED'); +await service.startTask(context, { minute: 120, type: 2, subID: 'SUB001' }); +assert.equal(issued.at(-1).payload.action, 'task'); +await service.extendTask(context, 30); +assert.equal(issued.at(-1).payload.action, 'addtask'); +await service.cancelTask(context); +assert.equal(issued.at(-1).payload.action, 'canceltask'); +await service.pairSubLock(context, 60); +assert.equal(issued.at(-1).payload.action, 'AddDevice'); +await service.controlSubLock(context, { + subID: 'SUB001', order: 'open', delayTime: 4 +}); +assert.equal(issued.at(-1).payload.action, 'CtrlDevice'); + +await assert.rejects( + () => service.controlSubLock(context, { + subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET' + }), + (error) => error instanceof DeviceControlError + && error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN' +); +const platformContext = { + ...context, + access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] } +}; +await service.controlSubLock(platformContext, { + subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET' +}); +assert.equal(issued.at(-1).payload.order, 'factoryreset'); +assert.equal('dangerConfirmation' in issued.at(-1).payload, false); + +await assert.rejects( + () => service.controlPower({ + ...context, + access: { roles: ['STAFF'], capabilities: ['device.write'], storeIds: ['12'] } + }, { slot1: 'on' }), + (error) => 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 routed; +const app = await buildApp({ + deviceControl: { + 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 managerAccess; } }, + service: { + async controlPower(_context, input) { routed = input; return { status: 'PUBLISHED' }; }, + async controlDoor() { return {}; }, + async playTts() { return {}; }, + async stopTts() { return {}; }, + async controlLed() { return {}; }, + async startTask() { return {}; }, + async extendTask() { return {}; }, + async cancelTask() { return {}; }, + async pairSubLock() { return {}; }, + async controlSubLock() { return {}; } + } + } +}); +const response = await app.inject({ + method: 'POST', + url: '/admin-api/device-control/power', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', roomId: '31', slot1: 'on' } +}); +assert.equal(response.statusCode, 200); +assert.equal(routed.slot1, 'on'); +const invalid = await app.inject({ + method: 'POST', + url: '/admin-api/device-control/door', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', roomId: '31', order: 'open', delayTime: 15 } +}); +assert.equal(invalid.statusCode, 400); +await app.close(); + +console.log('PASS: M06-D control-box and Sub-1G command gates and routes work.'); diff --git a/backend/tests/iot-protocol.test.mjs b/backend/tests/iot-protocol.test.mjs index 834a1d1..4309f22 100644 --- a/backend/tests/iot-protocol.test.mjs +++ b/backend/tests/iot-protocol.test.mjs @@ -86,7 +86,7 @@ assert.equal(generateCommandId(1782120000000, 7), '1782120000007'); assert.match(generateCommandId(), /^\d{13}$/); const calls = []; -let eventInsertCount = 0; +const eventHashes = new Set(); const service = new IotMessageService({ async execute(sql, params) { calls.push({ sql, params }); @@ -97,8 +97,10 @@ const service = new IotMessageService({ }], []]; } if (sql.includes('INSERT INTO qipai_iot_device_events')) { - eventInsertCount += 1; - return [{ affectedRows: eventInsertCount === 1 ? 1 : 2 }, []]; + const hash = params[7]; + const duplicate = eventHashes.has(hash); + eventHashes.add(hash); + return [{ affectedRows: duplicate ? 2 : 1 }, []]; } return [{ affectedRows: 1, insertId: 81 }, []]; } @@ -125,6 +127,30 @@ assert.equal( true ); +const recordPayload = Buffer.from(JSON.stringify({ + DeviceID: 'BOX_001', event: 'record', type: 'card', + state: 'open', content: 'CARD-PRIVATE-1234', battery: 9 +})); +await service.handle('/devicesend/BOX_001', recordPayload); +const recordInsert = calls.findLast((item) => + item.sql.includes('INSERT INTO qipai_iot_device_events') +); +assert.equal(recordInsert.params.some((value) => + typeof value === 'string' && value.includes('CARD-PRIVATE-1234') +), false); +assert.equal(calls.some((item) => + item.sql.includes('INSERT INTO qipai_device_alerts') + && item.params.includes('LOW_BATTERY') +), true); + +await service.handle('/devicesend/BOX_001', Buffer.from(JSON.stringify({ + DeviceID: 'BOX_001', id: '124', action: 'task', result: 'unconfirm' +}))); +assert.equal(calls.some((item) => + item.sql.includes('INSERT INTO qipai_device_alerts') + && item.params.includes('DEVICE_UNCONFIRM') +), true); + await assert.rejects( () => service.createCommand({ tenantId: '7', assetId: '51', storeId: '11', diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index 9d0e313..f0107e8 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -1727,6 +1727,62 @@ async function assertIotMessages(pool, context) { FROM qipai_iot_dead_letters WHERE topic = '/invalid/topic'` ); assert.deepEqual(deadRows, [{ errorCode: 'MQTT_TOPIC_INVALID', receiveCount: 1 }]); + + await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({ + DeviceID: device.deviceId, + event: 'record', + type: 'card', + state: 'open', + content: 'PRIVATE-CARD-001', + battery: 9, + timestamp: 1782120001 + }))); + const [recordRows] = await pool.query( + `SELECT JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.content')) AS content, + JSON_UNQUOTE(JSON_EXTRACT(raw_payload, '$.contentHash')) AS contentHash + FROM qipai_iot_device_events + WHERE tenant_id = ? AND event_type = 'record'`, + [context.tenantId] + ); + assert.equal(recordRows[0].content, null); + assert.match(recordRows[0].contentHash, /^[a-f0-9]{64}$/); + const [alertRows] = await pool.query( + `SELECT alert_type AS alertType, severity + FROM qipai_device_alerts + WHERE tenant_id = ? AND device_id = ? AND status = 'OPEN'`, + [context.tenantId, device.id] + ); + assert.deepEqual(alertRows, [{ alertType: 'LOW_BATTERY', severity: 'HIGH' }]); + + await service.createCommand({ + tenantId: context.tenantId, + assetId: String(device.id), + storeId: String(device.storeId), + roomId: String(device.roomId), + commandId: '1782120000002', + commandType: 'AddDevice', + payload: { action: 'AddDevice', id: '1782120000002', timeout: 60 }, + traceId: 'm06d-pair-test' + }); + await service.markPublished(context.tenantId, '1782120000002'); + await service.handle(`/devicesend/${device.deviceId}`, Buffer.from(JSON.stringify({ + DeviceID: device.deviceId, + id: '1782120000002', + action: 'AddDevice', + result: 'ok', + subID: 'SUB-AUTO-001', + subtype: '14', + timestamp: 1782120002 + }))); + const [linkRows] = await pool.query( + `SELECT l.sub_id AS subId, l.subtype, d.model + FROM qipai_device_links l + INNER JOIN qipai_devices d + ON d.tenant_id = l.tenant_id AND d.id = l.child_device_id + WHERE l.tenant_id = ? AND l.parent_device_id = ? AND l.sub_id = 'SUB-AUTO-001'`, + [context.tenantId, device.id] + ); + assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]); } const config = loadConfig(); @@ -1797,7 +1853,7 @@ try { await executeMigrationPlan(pool, plans.down); assert.deepEqual(await readCoreTables(pool), []); await assertLegacyCompatibility(pool); - console.log('PASS: down removed all M01-B through M06-C tables.'); + console.log('PASS: down removed all M01-B through M06-C migration tables.'); await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.verify); @@ -1925,6 +1981,10 @@ try { 'QoS 1 duplicate event receive count', 'ACK correlation without duplicate side effects', 'invalid Topic dead-letter persistence' + , + 'door record credential hashing and masking', + 'low-battery alert upsert', + 'AddDevice ACK creates 701C parent-child topology' ] }, null, 2)); } finally { diff --git a/scripts/dev/wsl/mysql-migration-roundtrip.sh b/scripts/dev/wsl/mysql-migration-roundtrip.sh index e5f2d4a..c93ca0d 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 M06-C migration roundtrip in a temporary database." +echo "INFO: MySQL ${mysql_version}; running M01-B through M06-D integration roundtrip in a temporary database." npm --prefix backend run test:mysql:migration -echo "PASS: M01-B through M06-C live MySQL migration roundtrip completed." +echo "PASS: M01-B through M06-D live MySQL integration roundtrip completed."