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