feat(M06-D): 接入控制箱与Sub-1G门锁业务控制

This commit is contained in:
Codex
2026-06-22 18:38:28 +08:00
parent f71ac09a0b
commit d15fd3f0ff
10 changed files with 685 additions and 9 deletions
+7
View File
@@ -42,6 +42,9 @@ import {
registerThirdPartyRoutes, type ThirdPartyRouteOptions
} from './routes/third-party.js';
import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.js';
import {
registerDeviceControlRoutes, type DeviceControlRouteOptions
} from './routes/device-control.js';
export interface BuildAppOptions {
config?: AppConfig;
@@ -60,6 +63,7 @@ export interface BuildAppOptions {
thirdParty?: ThirdPartyRouteOptions;
mqtt?: MqttHealthProvider;
devices?: DeviceRouteOptions;
deviceControl?: DeviceControlRouteOptions;
}
declare module 'fastify' {
@@ -146,6 +150,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.devices) {
await registerDeviceRoutes(app, options.devices);
}
if (options.deviceControl) {
await registerDeviceControlRoutes(app, options.deviceControl);
}
return app;
}
@@ -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;
}
+115 -2
View File
@@ -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;
}
+153
View File
@@ -0,0 +1,153 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
DeviceControlError,
type CommandContext,
type DeviceControlService
} from '../devices/device-control-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const contextSchema = z.object({
storeId: id,
roomId: id,
orderId: id.nullable().optional()
});
const powerSchema = contextSchema.extend({
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);
const doorSchema = contextSchema.extend({
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)
});
const ttsSchema = contextSchema.extend({
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)
});
const minuteSchema = contextSchema.extend({
minute: z.number().int().min(0).max(10080)
});
const taskSchema = contextSchema.extend({
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)
});
const extendSchema = contextSchema.extend({
addminute: z.number().int().min(1).max(10080)
});
const pairSchema = contextSchema.extend({
timeout: z.number().int().min(10).max(300).default(60)
});
const subLockSchema = contextSchema.extend({
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(),
dangerConfirmation: z.string().max(64).optional()
});
export interface DeviceControlRouteOptions {
service: Pick<DeviceControlService,
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerDeviceControlRoutes(
app: FastifyInstance, options: DeviceControlRouteOptions
) {
register('/admin-api/device-control/power', powerSchema,
(context, body) => options.service.controlPower(context, body));
register('/admin-api/device-control/door', doorSchema,
(context, body) => options.service.controlDoor(context, body));
register('/admin-api/device-control/tts', ttsSchema,
(context, body) => options.service.playTts(context, body));
register('/admin-api/device-control/tts/stop', contextSchema,
(context) => options.service.stopTts(context));
register('/admin-api/device-control/led', minuteSchema,
(context, body) => options.service.controlLed(context, body.minute));
register('/admin-api/device-control/task/start', taskSchema,
(context, body) => options.service.startTask(context, body));
register('/admin-api/device-control/task/extend', extendSchema,
(context, body) => options.service.extendTask(context, body.addminute));
register('/admin-api/device-control/task/cancel', contextSchema,
(context) => options.service.cancelTask(context));
register('/admin-api/device-control/sub-lock/pair', pairSchema,
(context, body) => options.service.pairSubLock(context, body.timeout));
register('/admin-api/device-control/sub-lock/action', subLockSchema,
(context, body) => options.service.controlSubLock(context, body));
function register<T extends z.ZodTypeAny>(
url: string,
schema: T,
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
) {
app.post(url, async (request, reply) => {
const parsed = schema.safeParse(request.body);
if (!parsed.success) return invalid(reply, request.traceId);
const context = await requireContext(request, reply, options, parsed.data);
if (!context) return;
try {
return {
code: 0, data: await handler(context, parsed.data), traceId: request.traceId
};
} catch (error) {
if (!(error instanceof DeviceControlError)) throw error;
const status = error.code.endsWith('_FORBIDDEN') ? 403
: error.code === 'DEVICE_OFFLINE' ? 409 : 400;
return reply.status(status).send({
code: error.code, message: 'Device control was rejected.', traceId: request.traceId
});
}
});
}
}
async function requireContext(
request: FastifyRequest, reply: FastifyReply, options: DeviceControlRouteOptions,
input: { storeId: string; roomId: string; orderId?: string | null }
) {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
return {
tenantId: auth.session.tenantId,
storeId: input.storeId,
roomId: input.roomId,
orderId: input.orderId,
traceId: request.traceId,
access
};
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_DEVICE_CONTROL_REQUEST',
message: 'The device control request is invalid.', traceId
});
}
+9
View File
@@ -29,6 +29,8 @@ 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';
import { DeviceCommandService } from './devices/device-command-service.js';
import { DeviceControlService } from './devices/device-control-service.js';
const config = loadConfig();
const pool = createMySqlPool(config);
@@ -43,6 +45,7 @@ const iotMessages = new IotMessageService(pool);
const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) =>
iotMessages.handle(topic, payload)
);
const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
const app = await buildApp({
config,
mqtt,
@@ -144,6 +147,12 @@ const app = await buildApp({
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
deviceControl: {
service: new DeviceControlService(pool, deviceCommands),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
}
});
app.addHook('onClose', async () => {