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
+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
});
}