feat(M06-D): 接入控制箱与Sub-1G门锁业务控制
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import type { DeviceCommandService } from './device-command-service.js';
|
||||
import {
|
||||
JilianControlBoxAdapter,
|
||||
JilianSub1GLockAdapter
|
||||
} from './jilian-adapters.js';
|
||||
|
||||
interface DeviceRow extends RowDataPacket {
|
||||
id: string; deviceId: string; storeId: string; roomId: string | null;
|
||||
deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
|
||||
status: string;
|
||||
}
|
||||
|
||||
export class DeviceControlError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class DeviceControlService {
|
||||
private readonly controlBox = new JilianControlBoxAdapter();
|
||||
private readonly subLock = new JilianSub1GLockAdapter();
|
||||
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly commands: Pick<DeviceCommandService, 'issue'>
|
||||
) {}
|
||||
|
||||
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<string, unknown>,
|
||||
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<DeviceRow[]>(
|
||||
`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;
|
||||
}
|
||||
@@ -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<DeviceRow['deviceType'], ProtocolAdapter> = {
|
||||
@@ -65,6 +68,8 @@ export class IotMessageService {
|
||||
return;
|
||||
}
|
||||
|
||||
const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
|
||||
normalized = { ...normalized, payload: safePayload };
|
||||
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,
|
||||
@@ -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<CommandRow[]>(
|
||||
`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<IdRow[]>(
|
||||
`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, unknown>): string | null {
|
||||
const value = record.DeviceID ?? record.deviceID;
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function sanitizeSensitivePayload(
|
||||
payload: unknown, normalized: NormalizedVendorMessage
|
||||
): Record<string, unknown> {
|
||||
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 = '<redacted>';
|
||||
}
|
||||
if (typeof record.card === 'string') {
|
||||
record.card = '<redacted>';
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user