254 lines
9.8 KiB
TypeScript
254 lines
9.8 KiB
TypeScript
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<DeviceControlService,
|
|
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
|
|
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
|
|
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
|
|
| 'clearSmartSocketTask'>;
|
|
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
|
|
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));
|
|
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<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
|
|
});
|
|
}
|