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 { CustomerDeviceAccessError, type CustomerDeviceAccessRepository } from '../devices/customer-device-access-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() }); const socketReadSchema = contextSchema.extend({ target: z.enum(['basicInfo', 'workInfo']).default('workInfo') }); const socketSwitchSchema = contextSchema.extend({ on: z.boolean(), slotNum: z.number().int().min(1).max(20).default(1) }); const socketTaskSchema = contextSchema.extend({ taskNum: z.number().int().min(1).max(20), action: z.enum(['on', 'off']), mode: z.enum(['once', 'daily', 'weekly']), time: z.string().regex(/^\d{2}:\d{2}$/), weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional() }); const socketClearTaskSchema = contextSchema.extend({ taskNum: z.number().int().min(0).max(20) }); const orderParams = z.object({ orderId: id }); const customerOpenDoorSchema = z.object({ delayTime: z.number().int().min(1).max(14).default(4) }).strict(); export interface DeviceControlRouteOptions { service: Pick; customerAccess?: Pick; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; 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)); register('/admin-api/device-control/socket/read', socketReadSchema, (context, body) => options.service.readSmartSocket(context, body.target)); register('/admin-api/device-control/socket/switch', socketSwitchSchema, (context, body) => options.service.switchSmartSocket(context, body)); register('/admin-api/device-control/socket/task', socketTaskSchema, (context, body) => options.service.scheduleSmartSocket(context, body)); register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema, (context, body) => options.service.clearSmartSocketTask(context, body.taskNum)); app.post('/app-api/orders/:orderId/open-door', async (request, reply) => { if (!options.customerAccess) { return reply.status(404).send({ code: 'CUSTOMER_DEVICE_CONTROL_NOT_AVAILABLE', message: 'Customer device control is not available.', traceId: request.traceId }); } const params = orderParams.safeParse(request.params); const body = customerOpenDoorSchema.safeParse(request.body ?? {}); if (!params.success || !body.success) return invalid(reply, request.traceId); const auth = await authenticateAccessToken( request.headers.authorization, options.authRepository, options.jwtSecret ); if (!auth) { return reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); } try { const order = await options.customerAccess.getDoorContext({ tenantId: auth.session.tenantId, userId: auth.session.user.id, orderId: params.data.orderId }); const context: CommandContext = { tenantId: auth.session.tenantId, storeId: order.storeId, roomId: order.roomId, orderId: order.orderId, traceId: request.traceId, access: { roles: ['CUSTOMER'], capabilities: ['device.write'], storeIds: [order.storeId] }, expiresAt: new Date(Date.now() + 30000) }; return { code: 0, data: await options.service.controlDoor(context, { order: 'open', holdopen: 0, delayTime: body.data.delayTime }), traceId: request.traceId }; } catch (error) { if (error instanceof CustomerDeviceAccessError) { return reply.status(403).send({ code: error.code, message: 'The order does not allow door access.', traceId: request.traceId }); } if (!(error instanceof DeviceControlError)) throw error; const status = error.code === 'DEVICE_OFFLINE' ? 409 : 400; return reply.status(status).send({ code: error.code, message: 'Device control was rejected.', traceId: request.traceId }); } }); function register( url: string, schema: T, handler: (context: CommandContext, body: z.infer) => Promise ) { 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 }); }