feat(M08-C): 补保洁与设备运营

This commit is contained in:
Codex
2026-08-10 10:52:06 +08:00
parent a95e8bf8c8
commit 65932a8000
17 changed files with 815 additions and 104 deletions
@@ -143,6 +143,7 @@ export class DeviceControlService {
this.assertWriteScope(context.access, context.storeId);
const device = await this.resolveControlBox(context);
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
await this.auditCommand(context, device, commandType, orderId);
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
@@ -166,6 +167,7 @@ export class DeviceControlService {
this.assertWriteScope(context.access, context.storeId);
const device = await this.resolveSmartSocket(context);
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
await this.auditCommand(context, device, commandType, orderId);
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
@@ -217,6 +219,37 @@ export class DeviceControlService {
}
}
private async auditCommand(
context: CommandContext,
device: DeviceRow,
commandType: string,
orderId?: string | null
) {
await this.pool.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, ?, ?, 'DEVICE_COMMAND_REQUESTED', 'DEVICE', ?, ?, ?, ?, ?)`,
[
context.tenantId,
context.actorType ?? 'SYSTEM',
context.actorId ?? null,
device.id,
context.traceId,
(context.ip ?? '').slice(0, 64),
(context.userAgent ?? '').slice(0, 255),
JSON.stringify({
commandType,
storeId: context.storeId,
roomId: context.roomId,
orderId: orderId ?? context.orderId ?? null,
deviceId: device.deviceId,
deviceType: device.deviceType
})
]
);
}
private isManager(access: AccessProfile) {
return access.roles.some((role) =>
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
@@ -230,6 +263,10 @@ export interface CommandContext {
roomId: string;
orderId?: string | null;
traceId: string;
actorType?: 'CUSTOMER' | 'STAFF' | 'ADMIN' | 'SYSTEM';
actorId?: string | null;
ip?: string;
userAgent?: string;
access: AccessProfile;
expiresAt?: Date | null;
}
@@ -107,6 +107,7 @@ export class OrderDeviceAutomationService {
roomId,
orderId: order.id,
traceId,
actorType: 'SYSTEM' as const,
access: systemDeviceAccess()
};
}
+95 -70
View File
@@ -164,29 +164,39 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/tasks', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = managerTaskListSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listManage({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/tasks',
'/app-api/management/cleaning/tasks'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = managerTaskListSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listManage({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
}
app.get('/admin-api/cleaning/statistics', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = statisticsSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/statistics',
'/app-api/management/cleaning/statistics'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = statisticsSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
}
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
@@ -385,56 +395,71 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/complete', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = completeSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.complete({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/tasks/:taskId/complete',
'/app-api/management/cleaning/tasks/:taskId/complete'
]) {
app.post(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = completeSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.complete({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
app.post('/admin-api/cleaning/tasks/:taskId/reject', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = rejectSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.reject({
...actor,
taskId: params.data.taskId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/tasks/:taskId/reject',
'/app-api/management/cleaning/tasks/:taskId/reject'
]) {
app.post(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = rejectSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.reject({
...actor,
taskId: params.data.taskId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
}
app.post('/admin-api/cleaning/tasks/:taskId/exempt', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = exemptSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.exempt({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/tasks/:taskId/exempt',
'/app-api/management/cleaning/tasks/:taskId/exempt'
]) {
app.post(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = exemptSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.exempt({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
+34 -14
View File
@@ -99,33 +99,33 @@ export interface DeviceControlRouteOptions {
export async function registerDeviceControlRoutes(
app: FastifyInstance, options: DeviceControlRouteOptions
) {
register('/admin-api/device-control/power', powerSchema,
registerManagement('/power', powerSchema,
(context, body) => options.service.controlPower(context, body));
register('/admin-api/device-control/door', doorSchema,
registerManagement('/door', doorSchema,
(context, body) => options.service.controlDoor(context, body));
register('/admin-api/device-control/tts', ttsSchema,
registerManagement('/tts', ttsSchema,
(context, body) => options.service.playTts(context, body));
register('/admin-api/device-control/tts/stop', contextSchema,
registerManagement('/tts/stop', contextSchema,
(context) => options.service.stopTts(context));
register('/admin-api/device-control/led', minuteSchema,
registerManagement('/led', minuteSchema,
(context, body) => options.service.controlLed(context, body.minute));
register('/admin-api/device-control/task/start', taskSchema,
registerManagement('/task/start', taskSchema,
(context, body) => options.service.startTask(context, body));
register('/admin-api/device-control/task/extend', extendSchema,
registerManagement('/task/extend', extendSchema,
(context, body) => options.service.extendTask(context, body.addminute));
register('/admin-api/device-control/task/cancel', contextSchema,
registerManagement('/task/cancel', contextSchema,
(context) => options.service.cancelTask(context));
register('/admin-api/device-control/sub-lock/pair', pairSchema,
registerManagement('/sub-lock/pair', pairSchema,
(context, body) => options.service.pairSubLock(context, body.timeout));
register('/admin-api/device-control/sub-lock/action', subLockSchema,
registerManagement('/sub-lock/action', subLockSchema,
(context, body) => options.service.controlSubLock(context, body));
register('/admin-api/device-control/socket/read', socketReadSchema,
registerManagement('/socket/read', socketReadSchema,
(context, body) => options.service.readSmartSocket(context, body.target));
register('/admin-api/device-control/socket/switch', socketSwitchSchema,
registerManagement('/socket/switch', socketSwitchSchema,
(context, body) => options.service.switchSmartSocket(context, body));
register('/admin-api/device-control/socket/task', socketTaskSchema,
registerManagement('/socket/task', socketTaskSchema,
(context, body) => options.service.scheduleSmartSocket(context, body));
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
registerManagement('/socket/task/clear', socketClearTaskSchema,
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
@@ -160,6 +160,10 @@ export async function registerDeviceControlRoutes(
roomId: order.roomId,
orderId: order.orderId,
traceId: request.traceId,
actorType: 'CUSTOMER',
actorId: auth.session.user.id,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
access: {
roles: ['CUSTOMER'],
capabilities: ['device.write'],
@@ -216,6 +220,15 @@ export async function registerDeviceControlRoutes(
}
});
}
function registerManagement<T extends z.ZodTypeAny>(
suffix: string,
schema: T,
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
) {
register(`/admin-api/device-control${suffix}`, schema, handler);
register(`/app-api/management/device-control${suffix}`, schema, handler);
}
}
async function requireContext(
@@ -235,12 +248,19 @@ async function requireContext(
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
const actorType: CommandContext['actorType'] = access.roles.some((role) =>
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
) ? 'ADMIN' : 'STAFF';
return {
tenantId: auth.session.tenantId,
storeId: input.storeId,
roomId: input.roomId,
orderId: input.orderId,
traceId: request.traceId,
actorType,
actorId: auth.session.user.id,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
access
};
}
+24 -20
View File
@@ -64,16 +64,18 @@ export interface DeviceRouteOptions {
}
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
app.get('/admin-api/devices', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
traceId: request.traceId
}));
});
for (const path of ['/admin-api/devices', '/app-api/management/devices']) {
app.get(path, async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
traceId: request.traceId
}));
});
}
app.post('/admin-api/devices', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = assetSchema.safeParse(request.body);
@@ -83,16 +85,18 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
traceId: request.traceId
}));
});
app.get('/admin-api/device-topology', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const query = z.object({ storeId: id }).safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getTopology(actor, query.data.storeId),
traceId: request.traceId
}));
});
for (const path of ['/admin-api/device-topology', '/app-api/management/device-topology']) {
app.get(path, async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const query = z.object({ storeId: id }).safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getTopology(actor, query.data.storeId),
traceId: request.traceId
}));
});
}
app.post('/admin-api/device-channels', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = channelSchema.safeParse(request.body);