feat(M06-C): 完成设备协议适配与消息幂等
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user