feat(M06-D): 接入控制箱与Sub-1G门锁业务控制
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user