feat(M06-C): 完成设备协议适配与消息幂等

This commit is contained in:
Codex
2026-06-22 18:29:07 +08:00
parent c96045f6ef
commit e795cdb693
15 changed files with 867 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 && node tests/device.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"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+7 -3
View File
@@ -39,7 +39,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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/2026062219_m06b_device_topology.up.sql'
'database/migrations/2026062219_m06b_device_topology.up.sql',
'database/migrations/2026062220_m06c_iot_messages.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -60,9 +61,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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/2026062219_m06b_device_topology.verify.sql'
'database/migrations/2026062219_m06b_device_topology.verify.sql',
'database/migrations/2026062220_m06c_iot_messages.verify.sql'
],
down: [
'database/migrations/2026062220_m06c_iot_messages.down.sql',
'database/migrations/2026062219_m06b_device_topology.down.sql',
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
'database/migrations/2026062217_m05c_third_party.down.sql',
@@ -216,7 +219,8 @@ export async function executeMigrationPlan(
1, 5, 3, 1,
5, 6, 1,
3, 7, 5, 1,
5, 8, 5, 2, 1
5, 8, 5, 2, 1,
3, 3, 9, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -0,0 +1,42 @@
import type { MqttTransport } from '../mqtt/mqtt-service.js';
import { generateCommandId, type IotMessageService } from './iot-message-service.js';
export class DeviceCommandService {
private sequence = 0;
constructor(
private readonly messages: Pick<IotMessageService,
'createCommand' | 'markPublished' | 'markPublishFailed'>,
private readonly transport: Pick<MqttTransport, 'publishDeviceCommand'>
) {}
async issue(input: {
tenantId: string; assetId: string; deviceId: string; storeId: string;
roomId?: string | null; orderId?: string | null; commandType: string;
payloadFactory: (commandId: string) => Record<string, unknown>;
traceId: string; expiresAt?: Date | null;
}) {
const commandId = generateCommandId(Date.now(), this.sequence++);
const payload = input.payloadFactory(commandId);
await this.messages.createCommand({
tenantId: input.tenantId,
assetId: input.assetId,
storeId: input.storeId,
roomId: input.roomId,
orderId: input.orderId,
commandId,
commandType: input.commandType,
payload,
traceId: input.traceId,
expiresAt: input.expiresAt
});
try {
await this.transport.publishDeviceCommand(input.deviceId, JSON.stringify(payload));
await this.messages.markPublished(input.tenantId, commandId);
return { commandId, status: 'PUBLISHED' as const };
} catch (error) {
await this.messages.markPublishFailed(input.tenantId, commandId, 'MQTT_PUBLISH_FAILED');
throw error;
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import { createHash } from 'node:crypto';
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter,
type NormalizedVendorMessage,
type ProtocolAdapter
} from './jilian-adapters.js';
interface DeviceRow extends RowDataPacket {
id: string; tenantId: string; storeId: string; roomId: string | null;
deviceId: string; deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
}
interface IdRow extends RowDataPacket { id: string }
export class IotMessageService {
private readonly adapters: Record<DeviceRow['deviceType'], ProtocolAdapter> = {
CONTROL_BOX: new JilianControlBoxAdapter(),
SUB_LOCK: new JilianSub1GLockAdapter(),
SMART_SOCKET: new JilianSmartSocketAdapter()
};
constructor(private readonly pool: MySqlPool) {}
async handle(topic: string, payload: Buffer): Promise<void> {
const payloadText = payload.toString('utf8');
const payloadHash = createHash('sha256').update(payload).digest('hex');
const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic);
if (!topicMatch) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_TOPIC_INVALID');
return;
}
const deviceCode = topicMatch[2];
const [devices] = await this.pool.execute<DeviceRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
device_id AS deviceId, device_type AS deviceType
FROM qipai_devices
WHERE device_id = ? AND deleted_at IS NULL`,
[deviceCode]
);
const device = devices[0];
if (!device) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_DEVICE_UNKNOWN');
return;
}
let rawPayload: unknown;
let normalized: NormalizedVendorMessage;
try {
rawPayload = JSON.parse(payloadText);
normalized = topicMatch[1] === 'devicewill'
? normalizeWill(rawPayload)
: this.adapters[device.deviceType].parseUplink(rawPayload);
if (normalized.deviceId && normalized.deviceId !== device.deviceId) {
throw new Error('Payload DeviceID does not match MQTT topic.');
}
} catch (error) {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, payloadText,
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
);
return;
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_device_events
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
payload_hash, raw_payload, normalized_payload, event_at, processing_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PROCESSED')
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
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),
normalized.eventAt]
);
if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, rawPayload);
if (normalized.kind === 'ACK' && normalized.commandId) {
await this.applyAcknowledgement(device, normalized);
}
}
async createCommand(input: {
tenantId: string; assetId: string; storeId: string; roomId?: string | null;
orderId?: string | null; commandId: string; commandType: string;
payload: Record<string, unknown>; traceId: string; expiresAt?: Date | null;
}) {
if (!/^\d{1,13}$/.test(input.commandId)) {
throw new Error('IOT_COMMAND_ID_INVALID');
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_commands
(tenant_id, device_id, store_id, room_id, order_id, command_id, command_type,
request_payload, trace_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.assetId, input.storeId, input.roomId ?? null,
input.orderId ?? null, input.commandId, input.commandType,
JSON.stringify(input.payload), input.traceId, input.expiresAt ?? null]
);
return { recordId: String(result.insertId), commandId: input.commandId };
}
async markPublished(tenantId: string, commandId: string) {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_iot_commands SET status = 'PUBLISHED', published_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND command_id = ? AND status = 'PENDING'
AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP(3))`,
[tenantId, commandId]
);
return result.affectedRows === 1;
}
async markTimedOut(tenantId: string, commandId: string) {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_iot_commands SET status = 'TIMEOUT', failure_code = 'ACK_TIMEOUT'
WHERE tenant_id = ? AND command_id = ? AND status = 'PUBLISHED'`,
[tenantId, commandId]
);
return result.affectedRows === 1;
}
async markPublishFailed(tenantId: string, commandId: string, failureCode: string) {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_iot_commands SET status = 'FAILED', failure_code = ?
WHERE tenant_id = ? AND command_id = ? AND status = 'PENDING'`,
[failureCode.slice(0, 64), tenantId, commandId]
);
return result.affectedRows === 1;
}
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
const successful = message.result === 'ok';
const status = successful ? 'ACKED' : 'FAILED';
const failureCode = successful ? '' : `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
await this.pool.execute(
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
WHERE tenant_id = ? AND device_id = ? AND command_id = ?
AND status IN ('PENDING', 'PUBLISHED', 'TIMEOUT')`,
[status, JSON.stringify(message.payload), failureCode,
device.tenantId, device.id, message.commandId]
);
}
private async updateDevice(
device: DeviceRow, message: NormalizedVendorMessage, rawPayload: unknown
) {
const offline = message.eventType === 'will';
await this.pool.execute(
`UPDATE qipai_devices SET status = ?, state_snapshot = ?,
last_seen_at = UTC_TIMESTAMP(3),
last_heartbeat_at = CASE WHEN ? = 0 THEN UTC_TIMESTAMP(3) ELSE last_heartbeat_at END
WHERE tenant_id = ? AND id = ?`,
[offline ? 'OFFLINE' : 'ONLINE', JSON.stringify(rawPayload), offline ? 1 : 0,
device.tenantId, device.id]
);
}
private async deadLetter(
tenantId: string | null, deviceId: string | null, topic: string,
payloadHash: string, rawPayload: string, code: string, message = code
) {
await this.pool.execute(
`INSERT INTO qipai_iot_dead_letters
(tenant_id, device_id, topic, payload_hash, raw_payload, error_code, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
last_received_at = UTC_TIMESTAMP(3), error_code = VALUES(error_code),
error_message = VALUES(error_message)`,
[tenantId, deviceId, topic, payloadHash, rawPayload.slice(0, 1_000_000),
code, message.slice(0, 500)]
);
}
}
export function generateCommandId(now = Date.now(), sequence = 0): string {
const seconds = Math.floor(now / 1000) % 10_000_000_000;
return `${seconds.toString().padStart(10, '0')}${(sequence % 1000).toString().padStart(3, '0')}`;
}
function normalizeWill(payload: unknown): NormalizedVendorMessage {
const record = zRecord(payload);
return {
kind: 'EVENT',
commandId: null,
eventType: 'will',
result: null,
deviceId: readDeviceId(record),
eventAt: null,
payload: record
};
}
function zRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('MQTT payload must be a JSON object.');
}
return payload as Record<string, unknown>;
}
function readDeviceId(record: Record<string, unknown>): string | null {
const value = record.DeviceID ?? record.deviceID;
return typeof value === 'string' ? value : null;
}
+218
View File
@@ -0,0 +1,218 @@
import { z } from 'zod';
const commandId = z.string().regex(/^\d{1,13}$/);
const resultCode = z.enum([
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
]);
const vendorMessage = z.object({
id: commandId.optional(),
DeviceID: z.string().min(1).max(64).optional(),
deviceID: z.string().min(1).max(64).optional(),
IMEI: z.string().max(64).optional(),
result: resultCode.optional(),
event: z.string().max(64).optional(),
action: z.string().max(64).optional(),
read: z.string().max(64).optional(),
timestamp: z.union([z.string(), z.number()]).optional()
}).passthrough();
export type NormalizedVendorMessage = {
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
commandId: string | null;
eventType: string;
result: z.infer<typeof resultCode> | null;
deviceId: string | null;
eventAt: Date | null;
payload: Record<string, unknown>;
};
export interface ProtocolAdapter {
parseUplink(payload: unknown): NormalizedVendorMessage;
}
export class JilianControlBoxAdapter implements ProtocolAdapter {
read(target: 'basicInfo' | 'mqttConfig' | 'startVoice' | 'task' | 'taskconfig') {
return z.object({ read: z.literal(target) }).parse({ read: target });
}
controlPower(input: {
id: string; slot1?: 'on' | 'off'; slot2?: 'on' | 'off';
slot3?: 'on' | 'off'; slotall?: 'on' | 'off';
}) {
return z.object({
action: z.literal('ConctolPower'), id: commandId,
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)
.parse({ action: 'ConctolPower', ...input });
}
controlDoor(input: {
id: string; order: 'open' | 'close'; holdopen?: 0 | 1; delayTime?: number;
}) {
return z.object({
action: z.literal('Crldoor'), id: commandId,
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)
}).parse({ action: 'Crldoor', ...input });
}
playTts(input: {
id: string; content: string; volume?: number; playCount?: number;
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
}) {
return z.object({
action: z.literal('PlayTTS'), id: commandId,
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),
speaker: z.number().int().min(0).max(20).default(0),
style: z.number().int().min(0).max(20).default(0),
speed: z.number().int().min(-500).max(500).default(0),
pitch: z.number().int().min(-500).max(500).default(0)
}).parse({ action: 'PlayTTS', ...input });
}
stopTts(id: string) {
return z.object({ action: z.literal('stopTTS'), id: commandId })
.parse({ action: 'stopTTS', id });
}
controlLed(input: { id: string; minute: number }) {
return z.object({
action: z.literal('CrlLED'), id: commandId,
minute: z.number().int().min(0).max(10080)
}).parse({ action: 'CrlLED', ...input });
}
startTask(input: {
id: string; minute: number; type: 1 | 2 | 3;
subID?: string; holdopen?: 0 | 1; delayTime?: number;
}) {
return z.object({
action: z.literal('task'), id: commandId,
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)
}).parse({ action: 'task', ...input });
}
extendTask(input: { id: string; addminute: number }) {
return z.object({
action: z.literal('addtask'), id: commandId,
addminute: z.number().int().min(1).max(10080)
}).parse({ action: 'addtask', ...input });
}
cancelTask(id: string) {
return z.object({ action: z.literal('canceltask'), id: commandId })
.parse({ action: 'canceltask', id });
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
}
export class JilianSub1GLockAdapter implements ProtocolAdapter {
pair(input: { id: string; timeout?: number }) {
return z.object({
action: z.literal('AddDevice'), id: commandId,
timeout: z.number().int().min(10).max(300).default(60)
}).parse({ action: 'AddDevice', ...input });
}
control(input: {
id: string; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
}) {
return z.object({
action: z.literal('CtrlDevice'), id: commandId,
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()
}).superRefine((value, context) => {
if (['setkey', 'setcard'].includes(value.order) && !value.content) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' });
}
}).parse({ action: 'CtrlDevice', ...input });
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
}
export class JilianSmartSocketAdapter implements ProtocolAdapter {
read(target: 'basicInfo' | 'workInfo') {
return z.object({ read: z.literal(target) }).parse({ read: target });
}
switch(input: { id: string; on: boolean; slotNum?: number }) {
return z.object({
action: z.enum(['on', 'off']), id: commandId,
slotNum: z.number().int().min(1).max(20).default(1)
}).parse({ action: input.on ? 'on' : 'off', id: input.id, slotNum: input.slotNum });
}
localTask(input: {
id: string; taskNum: number; action: 'on' | 'off';
mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
}) {
return z.object({
action: z.literal('localtask'), id: commandId,
taskNum: z.number().int().min(1).max(20),
switch: z.enum(['on', 'off']),
mode: z.enum(['once', 'daily', 'weekly']),
time: z.string().regex(/^\d{2}:\d{2}$/),
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional()
}).parse({
action: 'localtask', id: input.id, taskNum: input.taskNum,
switch: input.action, mode: input.mode, time: input.time, weekdays: input.weekdays
});
}
clearTask(input: { id: string; taskNum: number }) {
return z.object({
action: z.literal('clearTask'), id: commandId,
taskNum: z.number().int().min(0).max(20)
}).parse({ action: 'clearTask', ...input });
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
}
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
const parsed = vendorMessage.parse(payload);
const record = parsed as Record<string, unknown>;
const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot';
return {
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
commandId: parsed.id ?? null,
eventType: eventName,
result: parsed.result ?? null,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp),
payload: record
};
}
function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null;
const date = typeof value === 'number'
? new Date(value < 10_000_000_000 ? value * 1000 : value)
: new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
+17 -3
View File
@@ -26,6 +26,7 @@ export interface MqttClientLike {
}
export type MqttClientFactory = (url: string, options: IClientOptions) => MqttClientLike;
export type MqttMessageHandler = (topic: string, payload: Buffer) => Promise<void>;
export interface MqttHealthSnapshot {
configured: boolean;
@@ -39,7 +40,14 @@ export interface MqttHealthSnapshot {
lastError: string | null;
}
export class MqttService {
export interface MqttTransport {
start(): void;
stop(): Promise<void>;
publishDeviceCommand(deviceId: string, payload: Buffer | string): Promise<void>;
health(): MqttHealthSnapshot;
}
export class MqttService implements MqttTransport {
private client: MqttClientLike | null = null;
private subscriptionsReady = false;
private reconnectCount = 0;
@@ -51,7 +59,8 @@ export class MqttService {
constructor(
private readonly config: AppConfig['mqtt'],
private readonly clientFactory: MqttClientFactory = connect as MqttClientFactory
private readonly clientFactory: MqttClientFactory = connect as MqttClientFactory,
private readonly messageHandler?: MqttMessageHandler
) {}
start(): void {
@@ -89,7 +98,7 @@ export class MqttService {
this.client.on('error', (error: Error) => {
this.lastError = sanitizeError(error);
});
this.client.on('message', (_topic: string, payload: Buffer) => {
this.client.on('message', (topic: string, payload: Buffer) => {
if (payload.length > this.config.maxMessageBytes) {
this.rejectedOversizeMessages += 1;
this.lastError = `MQTT message exceeded ${this.config.maxMessageBytes} bytes`;
@@ -97,6 +106,11 @@ export class MqttService {
}
this.receivedMessages += 1;
this.lastMessageAt = new Date().toISOString();
void this.messageHandler?.(topic, payload).catch((error: unknown) => {
this.lastError = sanitizeError(
error instanceof Error ? error : new Error('MQTT message handler failed')
);
});
});
}
+5 -1
View File
@@ -28,6 +28,7 @@ import {
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';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -38,7 +39,10 @@ const paymentRepository = new PaymentRepository(pool);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
const mqtt = new MqttService(config.mqtt);
const iotMessages = new IotMessageService(pool);
const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) =>
iotMessages.handle(topic, payload)
);
const app = await buildApp({
config,
mqtt,
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter
} from '../dist/devices/jilian-adapters.js';
import {
generateCommandId,
IotMessageService
} from '../dist/devices/iot-message-service.js';
import { DeviceCommandService } from '../dist/devices/device-command-service.js';
const control = new JilianControlBoxAdapter();
assert.deepEqual(control.read('basicInfo'), { read: 'basicInfo' });
assert.deepEqual(control.controlPower({
id: '1234567890123', slot1: 'on', slot3: 'off'
}), {
action: 'ConctolPower', id: '1234567890123', slot1: 'on', slot3: 'off'
});
assert.equal(control.controlDoor({
id: '1234567890123', order: 'open'
}).action, 'Crldoor');
assert.equal(control.playTts({
id: '1234567890123', content: '欢迎光临'
}).action, 'PlayTTS');
assert.deepEqual(control.stopTts('1234567890123'), {
action: 'stopTTS', id: '1234567890123'
});
assert.equal(control.controlLed({ id: '1234567890123', minute: 30 }).action, 'CrlLED');
assert.equal(control.startTask({
id: '1234567890123', minute: 120, type: 2, subID: 'SUB001'
}).action, 'task');
assert.equal(control.extendTask({
id: '1234567890123', addminute: 30
}).action, 'addtask');
assert.equal(control.cancelTask('1234567890123').action, 'canceltask');
assert.throws(
() => control.controlDoor({ id: '12345678901234', order: 'open' }),
/Invalid/
);
assert.throws(
() => control.controlDoor({ id: '123', order: 'open', delayTime: 15 }),
/less than or equal to 14/
);
const lock = new JilianSub1GLockAdapter();
assert.deepEqual(lock.pair({ id: '123', timeout: 60 }), {
action: 'AddDevice', id: '123', timeout: 60
});
assert.equal(lock.control({
id: '123', subID: 'SUB001', order: 'open', delayTime: 4
}).action, 'CtrlDevice');
assert.throws(
() => lock.control({ id: '123', subID: 'SUB001', order: 'setkey' }),
/content is required/
);
const socket = new JilianSmartSocketAdapter();
assert.deepEqual(socket.read('workInfo'), { read: 'workInfo' });
assert.deepEqual(socket.switch({ id: '123', on: true }), {
action: 'on', id: '123', slotNum: 1
});
assert.equal(socket.localTask({
id: '123', taskNum: 20, action: 'off', mode: 'weekly',
time: '23:00', weekdays: [1, 5]
}).action, 'localtask');
assert.deepEqual(socket.clearTask({ id: '123', taskNum: 0 }), {
action: 'clearTask', id: '123', taskNum: 0
});
const ack = control.parseUplink({
DeviceID: 'BOX_001', id: '123', result: 'unconfirm', action: 'task'
});
assert.equal(ack.kind, 'ACK');
assert.equal(ack.result, 'unconfirm');
assert.equal(ack.eventType, 'task');
const event = lock.parseUplink({
deviceID: 'BOX_001', event: 'record', type: 'card', state: 'open',
timestamp: 1782120000
});
assert.equal(event.kind, 'EVENT');
assert.equal(event.eventType, 'record');
assert.equal(event.eventAt?.getUTCFullYear(), 2026);
assert.equal(generateCommandId(1782120000000, 7), '1782120000007');
assert.match(generateCommandId(), /^\d{13}$/);
const calls = [];
let eventInsertCount = 0;
const service = new IotMessageService({
async execute(sql, params) {
calls.push({ sql, params });
if (sql.includes('FROM qipai_devices')) {
return [[{
id: 51, tenantId: 7, storeId: 11, roomId: 31,
deviceId: 'BOX_001', deviceType: 'CONTROL_BOX'
}], []];
}
if (sql.includes('INSERT INTO qipai_iot_device_events')) {
eventInsertCount += 1;
return [{ affectedRows: eventInsertCount === 1 ? 1 : 2 }, []];
}
return [{ affectedRows: 1, insertId: 81 }, []];
}
});
const payload = Buffer.from(JSON.stringify({
DeviceID: 'BOX_001', id: '123', result: 'ok', action: 'ConctolPower',
slot1: 'on'
}));
await service.handle('/devicesend/BOX_001', payload);
await service.handle('/devicesend/BOX_001', payload);
assert.equal(
calls.filter((item) => item.sql.includes('UPDATE qipai_iot_commands')).length,
1,
'duplicate QoS 1 payload must not repeat command/device side effects'
);
assert.equal(
calls.some((item) => item.sql.includes('qipai_iot_dead_letters')),
false
);
await service.handle('/bad-topic/BOX_001', Buffer.from('{}'));
assert.equal(
calls.some((item) => item.sql.includes('qipai_iot_dead_letters')),
true
);
await assert.rejects(
() => service.createCommand({
tenantId: '7', assetId: '51', storeId: '11',
commandId: '12345678901234', commandType: 'ConctolPower',
payload: {}, traceId: 'trace'
}),
/IOT_COMMAND_ID_INVALID/
);
const commandCalls = [];
const commandService = new DeviceCommandService({
async createCommand(input) { commandCalls.push(['create', input]); },
async markPublished(tenantId, commandId) {
commandCalls.push(['published', tenantId, commandId]);
},
async markPublishFailed() { throw new Error('unexpected publish failure'); }
}, {
async publishDeviceCommand(deviceId, payloadText) {
commandCalls.push(['mqtt', deviceId, JSON.parse(payloadText)]);
}
});
const issued = await commandService.issue({
tenantId: '7', assetId: '51', deviceId: 'BOX_001', storeId: '11',
commandType: 'ConctolPower',
payloadFactory: (id) => control.controlPower({ id, slot1: 'on' }),
traceId: 'trace'
});
assert.equal(issued.status, 'PUBLISHED');
assert.match(issued.commandId, /^\d{13}$/);
assert.equal(commandCalls[0][0], 'create');
assert.equal(commandCalls[1][0], 'mqtt');
assert.equal(commandCalls[2][0], 'published');
console.log('PASS: M06-C adapters, vendor spelling, command IDs, ACKs and QoS 1 dedup work.');
+15 -1
View File
@@ -66,6 +66,9 @@ const profitSharingVerifySql = read('database/migrations/2026062218_m05d_profit_
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 iotMessagesUpSql = read('database/migrations/2026062220_m06c_iot_messages.up.sql');
const iotMessagesDownSql = read('database/migrations/2026062220_m06c_iot_messages.down.sql');
const iotMessagesVerifySql = read('database/migrations/2026062220_m06c_iot_messages.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -301,5 +304,16 @@ 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/);
for (const table of [
'qipai_iot_commands', 'qipai_iot_device_events', 'qipai_iot_dead_letters'
]) {
assert.match(iotMessagesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(iotMessagesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(iotMessagesVerifySql, new RegExp(`'${table}'`));
}
assert.match(iotMessagesUpSql, /command_id VARCHAR\(13\)/);
assert.match(iotMessagesUpSql, /uq_qipai_iot_event_dedup/);
assert.match(iotMessagesUpSql, /receive_count INT UNSIGNED/);
assert.match(iotMessagesUpSql, /PENDING/);
console.log('PASS: M01-B through M06-B migration contracts are present.');
console.log('PASS: M01-B through M06-C migration contracts are present.');
+2 -1
View File
@@ -30,7 +30,8 @@ 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, /2026062219_m06b_device_topology\.up\.sql$/);
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -36,6 +36,7 @@ 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 { IotMessageService } from '../dist/devices/iot-message-service.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -58,6 +59,9 @@ const expectedTables = [
'qipai_group_redemptions',
'qipai_group_vouchers',
'qipai_holiday_calendar',
'qipai_iot_commands',
'qipai_iot_dead_letters',
'qipai_iot_device_events',
'qipai_legacy_table_mappings',
'qipai_media_assets',
'qipai_members',
@@ -121,12 +125,13 @@ 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', '2026062219']
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220']
);
return rows;
}
@@ -1670,6 +1675,60 @@ async function assertDeviceTopology(pool, context) {
}]);
}
async function assertIotMessages(pool, context) {
const [deviceRows] = await pool.query(
`SELECT id, store_id AS storeId, room_id AS roomId, device_id AS deviceId
FROM qipai_devices
WHERE tenant_id = ? AND device_id = 'M06B_BOX_001'`,
[context.tenantId]
);
const device = deviceRows[0];
const service = new IotMessageService(pool);
await service.createCommand({
tenantId: context.tenantId,
assetId: String(device.id),
storeId: String(device.storeId),
roomId: String(device.roomId),
commandId: '1782120000001',
commandType: 'ConctolPower',
payload: {
action: 'ConctolPower', id: '1782120000001', slot1: 'on'
},
traceId: 'm06c-live-test'
});
assert.equal(await service.markPublished(context.tenantId, '1782120000001'), true);
const payload = Buffer.from(JSON.stringify({
DeviceID: device.deviceId,
id: '1782120000001',
action: 'ConctolPower',
result: 'ok',
slot1: 'on',
timestamp: 1782120000
}));
await service.handle(`/devicesend/${device.deviceId}`, payload);
await service.handle(`/devicesend/${device.deviceId}`, payload);
const [commandRows] = await pool.query(
`SELECT status, failure_code AS failureCode
FROM qipai_iot_commands
WHERE tenant_id = ? AND command_id = '1782120000001'`,
[context.tenantId]
);
assert.deepEqual(commandRows, [{ status: 'ACKED', failureCode: '' }]);
const [eventRows] = await pool.query(
`SELECT receive_count AS receiveCount, processing_status AS processingStatus
FROM qipai_iot_device_events
WHERE tenant_id = ? AND command_id = '1782120000001'`,
[context.tenantId]
);
assert.deepEqual(eventRows, [{ receiveCount: 2, processingStatus: 'PROCESSED' }]);
await service.handle('/invalid/topic', Buffer.from('{bad-json'));
const [deadRows] = await pool.query(
`SELECT error_code AS errorCode, receive_count AS receiveCount
FROM qipai_iot_dead_letters WHERE topic = '/invalid/topic'`
);
assert.deepEqual(deadRows, [{ errorCode: 'MQTT_TOPIC_INVALID', receiveCount: 1 }]);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -1712,7 +1771,8 @@ try {
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' }
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -1730,13 +1790,14 @@ try {
await assertThirdPartyDomain(pool, loginContext);
await assertProfitSharingDomain(pool, loginContext);
await assertDeviceTopology(pool, loginContext);
await assertIotMessages(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 M06-B tables.');
console.log('PASS: down removed all M01-B through M06-C tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -1760,7 +1821,8 @@ try {
{ version: '2026062216', name: 'm05b_wechat_refunds' },
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' }
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -1858,6 +1920,11 @@ try {
'control target conflict across control box and smart socket',
'Sub-1G parent-child topology',
'device status snapshots and maintenance state'
,
'13-digit IoT command state transition',
'QoS 1 duplicate event receive count',
'ACK correlation without duplicate side effects',
'invalid Topic dead-letter persistence'
]
}, null, 2));
} finally {