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
@@ -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;
}