From 65932a80000967ed59d74366fb4debee63229319 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 10:52:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(M08-C):=20=E8=A1=A5=E4=BF=9D=E6=B4=81?= =?UTF-8?q?=E4=B8=8E=E8=AE=BE=E5=A4=87=E8=BF=90=E8=90=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/devices/device-control-service.ts | 37 +++ .../order-device-automation-service.ts | 1 + backend/src/routes/cleaning.ts | 165 +++++++----- backend/src/routes/device-control.ts | 48 +++- backend/src/routes/devices.ts | 44 ++-- backend/tests/cleaning-route.test.mjs | 48 ++++ backend/tests/device-control.test.mjs | 24 ++ backend/tests/device.test.mjs | 14 + .../tests/mysql-migration-roundtrip.test.mjs | 42 +++ miniapp/app.json | 1 + miniapp/pages/manager/dashboard.js | 11 + miniapp/pages/manager/dashboard.wxml | 8 + miniapp/pages/manager/operations.js | 242 ++++++++++++++++++ miniapp/pages/manager/operations.json | 4 + miniapp/pages/manager/operations.wxml | 71 +++++ miniapp/pages/manager/operations.wxss | 121 +++++++++ scripts/check-miniapp-m08-c.mjs | 38 +++ 17 files changed, 815 insertions(+), 104 deletions(-) create mode 100644 miniapp/pages/manager/operations.js create mode 100644 miniapp/pages/manager/operations.json create mode 100644 miniapp/pages/manager/operations.wxml create mode 100644 miniapp/pages/manager/operations.wxss diff --git a/backend/src/devices/device-control-service.ts b/backend/src/devices/device-control-service.ts index 7ba0b93..68a7afd 100644 --- a/backend/src/devices/device-control-service.ts +++ b/backend/src/devices/device-control-service.ts @@ -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; } diff --git a/backend/src/devices/order-device-automation-service.ts b/backend/src/devices/order-device-automation-service.ts index a3256c3..0d65bdf 100644 --- a/backend/src/devices/order-device-automation-service.ts +++ b/backend/src/devices/order-device-automation-service.ts @@ -107,6 +107,7 @@ export class OrderDeviceAutomationService { roomId, orderId: order.id, traceId, + actorType: 'SYSTEM' as const, access: systemDeviceAccess() }; } diff --git a/backend/src/routes/cleaning.ts b/backend/src/routes/cleaning.ts index d54bb82..8295d35 100644 --- a/backend/src/routes/cleaning.ts +++ b/backend/src/routes/cleaning.ts @@ -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'); diff --git a/backend/src/routes/device-control.ts b/backend/src/routes/device-control.ts index c173a8e..c8fb41e 100644 --- a/backend/src/routes/device-control.ts +++ b/backend/src/routes/device-control.ts @@ -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( + suffix: string, + schema: T, + handler: (context: CommandContext, body: z.infer) => Promise + ) { + 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 }; } diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 3e7313e..08963a8 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -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); diff --git a/backend/tests/cleaning-route.test.mjs b/backend/tests/cleaning-route.test.mjs index b6c98de..522698a 100644 --- a/backend/tests/cleaning-route.test.mjs +++ b/backend/tests/cleaning-route.test.mjs @@ -324,6 +324,15 @@ assert.equal(calls.at(-1)[1].status, 'SUBMITTED'); assert.equal(calls.at(-1)[1].storeId, '11'); assert.equal(calls.at(-1)[1].cleanerUserId, '31'); +const appManageList = await app.inject({ + method: 'GET', + url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(appManageList.statusCode, 200); +assert.equal(calls.at(-1)[0], 'listManage'); +assert.equal(calls.at(-1)[1].storeId, '11'); + const managerStats = await app.inject({ method: 'GET', url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31', @@ -341,6 +350,15 @@ assert.equal(managerStats.json().data.members[0].rejectedTaskCount, 1); assert.equal(managerStats.json().data.trend[0].exempted, 1); assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600); +const appManagerStats = await app.inject({ + method: 'GET', + url: '/app-api/management/cleaning/statistics?storeId=11', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(appManagerStats.statusCode, 200); +assert.equal(calls.at(-1)[0], 'managerStatistics'); +assert.equal(calls.at(-1)[1].storeId, '11'); + const claim = await app.inject({ method: 'POST', url: '/app-api/cleaning/tasks/101/claim', @@ -410,6 +428,16 @@ const complete = await app.inject({ assert.equal(complete.statusCode, 200); assert.equal(calls.at(-1)[0], 'complete'); +const appComplete = await app.inject({ + method: 'POST', + url: '/app-api/management/cleaning/tasks/101/complete', + headers: { authorization: `Bearer ${token}` }, + payload: { note: 'miniapp approval' } +}); +assert.equal(appComplete.statusCode, 200); +assert.equal(calls.at(-1)[0], 'complete'); +assert.equal(calls.at(-1)[1].note, 'miniapp approval'); + const reject = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/reject', @@ -420,6 +448,16 @@ assert.equal(reject.statusCode, 200); assert.equal(calls.at(-1)[0], 'reject'); assert.equal(calls.at(-1)[1].reason, 'photo is unclear'); +const appReject = await app.inject({ + method: 'POST', + url: '/app-api/management/cleaning/tasks/101/reject', + headers: { authorization: `Bearer ${token}` }, + payload: { reason: 'miniapp asks for rework' } +}); +assert.equal(appReject.statusCode, 200); +assert.equal(calls.at(-1)[0], 'reject'); +assert.equal(calls.at(-1)[1].reason, 'miniapp asks for rework'); + const exempt = await app.inject({ method: 'POST', url: '/admin-api/cleaning/tasks/101/exempt', @@ -431,6 +469,16 @@ assert.equal(exempt.json().data.status, 'EXEMPT'); assert.equal(calls.at(-1)[0], 'exempt'); assert.equal(calls.at(-1)[1].note, 'no cleaning required'); +const appExempt = await app.inject({ + method: 'POST', + url: '/app-api/management/cleaning/tasks/101/exempt', + headers: { authorization: `Bearer ${token}` }, + payload: { note: 'miniapp exemption' } +}); +assert.equal(appExempt.statusCode, 200); +assert.equal(calls.at(-1)[0], 'exempt'); +assert.equal(calls.at(-1)[1].note, 'miniapp exemption'); + const members = await app.inject({ method: 'GET', url: '/admin-api/cleaning/tasks/101/members', diff --git a/backend/tests/device-control.test.mjs b/backend/tests/device-control.test.mjs index 1ac7d2c..07e3307 100644 --- a/backend/tests/device-control.test.mjs +++ b/backend/tests/device-control.test.mjs @@ -12,6 +12,7 @@ const managerAccess = { storeIds: ['11'] }; const issued = []; +const audits = []; const service = new DeviceControlService({ async execute(sql) { if (sql.includes('FROM qipai_devices')) { @@ -26,6 +27,7 @@ const service = new DeviceControlService({ deviceType: 'CONTROL_BOX', status: 'ONLINE' }], []]; } + if (sql.includes('DEVICE_COMMAND_REQUESTED')) audits.push(sql); return [[], []]; } }, { @@ -37,6 +39,7 @@ const service = new DeviceControlService({ }); const context = { tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace', + actorType: 'ADMIN', actorId: '22', ip: '127.0.0.1', userAgent: 'test', access: managerAccess }; @@ -75,6 +78,8 @@ assert.equal(issued.at(-1).payload.action, 'localtask'); assert.equal(issued.at(-1).payload.switch, 'on'); await service.clearSmartSocketTask(context, 1); assert.equal(issued.at(-1).payload.action, 'clearTask'); +assert.equal(audits.length, 14); +assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/); await assert.rejects( () => service.controlSubLock(context, { @@ -160,6 +165,25 @@ const response = await app.inject({ }); assert.equal(response.statusCode, 200); assert.equal(routed.slot1, 'on'); +const appPowerResponse = await app.inject({ + method: 'POST', + url: '/app-api/management/device-control/power', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', roomId: '31', slotall: 'off' } +}); +assert.equal(appPowerResponse.statusCode, 200); +assert.equal(routed.slotall, 'off'); +const appDoorResponse = await app.inject({ + method: 'POST', + url: '/app-api/management/device-control/door', + headers: { authorization: `Bearer ${token}` }, + payload: { storeId: '11', roomId: '31', order: 'open', holdopen: 0, delayTime: 4 } +}); +assert.equal(appDoorResponse.statusCode, 200); +assert.equal(routed.order, 'open'); +assert.equal(routedContext.storeId, '11'); +assert.equal(routedContext.actorType, 'ADMIN'); +assert.equal(routedContext.actorId, '22'); const socketResponse = await app.inject({ method: 'POST', url: '/admin-api/device-control/socket/switch', diff --git a/backend/tests/device.test.mjs b/backend/tests/device.test.mjs index 3e65853..a36916a 100644 --- a/backend/tests/device.test.mjs +++ b/backend/tests/device.test.mjs @@ -69,6 +69,20 @@ const app = await buildApp({ } } }); +const appAssets = await app.inject({ + method: 'GET', url: '/app-api/management/devices?storeId=11', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(appAssets.statusCode, 200); +assert.deepEqual(appAssets.json().data, []); + +const appTopology = await app.inject({ + method: 'GET', url: '/app-api/management/device-topology?storeId=11', + headers: { authorization: `Bearer ${token}` } +}); +assert.equal(appTopology.statusCode, 200); +assert.deepEqual(appTopology.json().data.openAlerts, []); + const created = await app.inject({ method: 'POST', url: '/admin-api/devices', headers: { authorization: `Bearer ${token}` }, diff --git a/backend/tests/mysql-migration-roundtrip.test.mjs b/backend/tests/mysql-migration-roundtrip.test.mjs index c0726b6..fdc33fd 100644 --- a/backend/tests/mysql-migration-roundtrip.test.mjs +++ b/backend/tests/mysql-migration-roundtrip.test.mjs @@ -37,6 +37,8 @@ import { ThirdPartyClient } from '../dist/third-party/third-party-client.js'; import { ThirdPartyService } from '../dist/third-party/third-party-service.js'; import { DeviceRepository } from '../dist/devices/device-repository.js'; import { IotMessageService } from '../dist/devices/iot-message-service.js'; +import { DeviceCommandService } from '../dist/devices/device-command-service.js'; +import { DeviceControlService } from '../dist/devices/device-control-service.js'; import { MemberProfileService } from '../dist/wallets/member-profile-service.js'; import { executeMigrationPlan, @@ -1724,6 +1726,44 @@ async function assertDeviceTopology(pool, context) { signalStrength: 22, firmwareVersion: '1.0.1', snapshot: { slot1: true, door: 'closed' } }); + const published = []; + const control = new DeviceControlService( + pool, + new DeviceCommandService(new IotMessageService(pool), { + async publishDeviceCommand(deviceId, payload) { + published.push({ deviceId, payload: JSON.parse(payload) }); + } + }) + ); + const doorResult = await control.controlDoor({ + tenantId: context.tenantId, + storeId, + roomId, + traceId: 'm08c-manager-door', + actorType: 'ADMIN', + actorId: adminId, + ip: '127.0.0.1', + userAgent: 'M08-C manager miniapp', + access + }, { order: 'open', holdopen: 0, delayTime: 4 }); + assert.equal(doorResult.status, 'PUBLISHED'); + assert.equal(published[0].deviceId, 'M06B_BOX_001'); + assert.equal(published[0].payload.action, 'Crldoor'); + const [commandAuditRows] = await pool.query( + `SELECT actor_type AS actorType, actor_id AS actorId, action, + JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.commandType')) AS commandType, + JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.roomId')) AS roomId + FROM qipai_audit_logs + WHERE tenant_id = ? AND trace_id = 'm08c-manager-door'`, + [context.tenantId] + ); + assert.deepEqual(commandAuditRows, [{ + actorType: 'ADMIN', + actorId: Number(adminId), + action: 'DEVICE_COMMAND_REQUESTED', + commandType: 'Crldoor', + roomId + }]); await repository.addMaintenance(actor, { assetId: controlBox.assetId, storeId, roomId, recordType: 'INSPECTION', status: 'OPEN', description: 'M06-B inspection' @@ -2051,6 +2091,8 @@ try { 'Sub-1G parent-child topology', 'device status snapshots and maintenance state' , + 'manager device command actor audit attribution' + , '13-digit IoT command state transition', 'QoS 1 duplicate event receive count', 'ACK correlation without duplicate side effects', diff --git a/miniapp/app.json b/miniapp/app.json index d12724d..f0d1b62 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -10,6 +10,7 @@ "pages/recharge/index", "pages/cleaner/tasks", "pages/manager/dashboard", + "pages/manager/operations", "pages/manager/people", "pages/manager/order-create", "pages/logs/logs" diff --git a/miniapp/pages/manager/dashboard.js b/miniapp/pages/manager/dashboard.js index 82fa95c..def5b12 100644 --- a/miniapp/pages/manager/dashboard.js +++ b/miniapp/pages/manager/dashboard.js @@ -38,6 +38,7 @@ Page({ errorMessage: '', canWrite: false, canReadUsers: false, + canReadOperations: false, stores: [], selectedStoreId: '', selectedStoreName: '', @@ -98,6 +99,9 @@ Page({ canReadUsers: access.capabilities.includes('user.read') || access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN'), + canReadOperations: access.capabilities.some((code) => [ + 'cleaning.task.read', 'device.read', 'device.write', 'tenant.manage', + ].includes(code)) || access.roles.includes('PLATFORM_ADMIN'), stores, selectedStoreId, selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '', @@ -225,6 +229,13 @@ Page({ wx.navigateTo({ url: '/pages/manager/people' }) }, + openOperations() { + if (!this.data.canReadOperations || !this.data.selectedStoreId) return + wx.navigateTo({ + url: `/pages/manager/operations?storeId=${encodeURIComponent(this.data.selectedStoreId)}&storeName=${encodeURIComponent(this.data.selectedStoreName)}`, + }) + }, + manageOrder(event) { if (!this.data.canWrite) return const orderId = event.currentTarget.dataset.orderId diff --git a/miniapp/pages/manager/dashboard.wxml b/miniapp/pages/manager/dashboard.wxml index 8acf499..c71814d 100644 --- a/miniapp/pages/manager/dashboard.wxml +++ b/miniapp/pages/manager/dashboard.wxml @@ -32,6 +32,14 @@ + + + 保洁与设备 + 任务验收、设备状态与临时开门/电控 + + + + {{summary.roomTotal}}房间 {{summary.available}}空闲 diff --git a/miniapp/pages/manager/operations.js b/miniapp/pages/manager/operations.js new file mode 100644 index 0000000..53b6170 --- /dev/null +++ b/miniapp/pages/manager/operations.js @@ -0,0 +1,242 @@ +const { request, cents, ensureLogin } = require('../../utils/api.js') + +const cleaningStatusLabels = { + WAITING: '待接单', + CLAIMED: '已接单', + STARTED: '清洁中', + SUBMITTED: '待验收', + COMPLETED: '已完成', + REJECTED: '已驳回', + EXEMPT: '免清洁', + SETTLED: '已结算', + CANCELLED: '已取消', +} + +const deviceStatusLabels = { + ONLINE: '在线', + OFFLINE: '离线', + FAULT: '故障', +} + +const deviceTypeLabels = { + CONTROL_BOX: '控制箱', + SUB_LOCK: '子锁', + SMART_SOCKET: '智能插座', +} + +Page({ + data: { + storeId: '', + storeName: '', + loading: false, + errorMessage: '', + canReadCleaning: false, + canWriteCleaning: false, + canReadDevices: false, + canWriteDevices: false, + cleaningSummary: { + taskTotal: 0, + pendingReview: 0, + active: 0, + rejected: 0, + completed: 0, + }, + cleaningTasks: [], + devices: [], + openAlerts: [], + maintenance: [], + busyTaskId: '', + busyDeviceKey: '', + }, + + async onLoad(options) { + this.setData({ + storeId: options.storeId || '', + storeName: options.storeName || '', + }) + await this.loadOperations() + }, + + async onPullDownRefresh() { + await this.loadOperations() + wx.stopPullDownRefresh() + }, + + async loadOperations() { + if (!this.data.storeId) { + this.setData({ errorMessage: '缺少门店信息' }) + return + } + this.setData({ loading: true, errorMessage: '' }) + try { + await ensureLogin() + const me = await request('/auth/me') + const access = me.data?.access || { roles: [], capabilities: [] } + const isPlatform = access.roles.includes('PLATFORM_ADMIN') + const canManageTenant = access.capabilities.includes('tenant.manage') || isPlatform + const canReadCleaning = access.capabilities.includes('cleaning.task.read') || canManageTenant + const canWriteCleaning = access.capabilities.includes('cleaning.task.write') || canManageTenant + const canReadDevices = access.capabilities.some((code) => ['device.read', 'device.write'].includes(code)) || canManageTenant + const canWriteDevices = access.capabilities.includes('device.write') || canManageTenant + this.setData({ canReadCleaning, canWriteCleaning, canReadDevices, canWriteDevices }) + await Promise.all([ + canReadCleaning ? this.loadCleaning() : Promise.resolve(), + canReadDevices ? this.loadDevices() : Promise.resolve(), + ]) + } catch (error) { + this.setData({ errorMessage: error.message || '运营数据加载失败' }) + } finally { + this.setData({ loading: false }) + } + }, + + async loadCleaning() { + const storeId = encodeURIComponent(this.data.storeId) + const [tasksResponse, statsResponse] = await Promise.all([ + request(`/management/cleaning/tasks?page=1&pageSize=50&storeId=${storeId}`), + request(`/management/cleaning/statistics?storeId=${storeId}`), + ]) + const cleaningTasks = (tasksResponse.data?.items || []).map((item) => ({ + ...item, + statusText: cleaningStatusLabels[item.status] || item.status, + rewardText: cents(item.rewardCents), + roomText: item.roomName ? `${item.roomName} · ${item.roomNo || ''}` : `房间 ${item.roomId}`, + canReview: item.status === 'SUBMITTED', + canExempt: ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(item.status), + })) + this.setData({ + cleaningTasks, + cleaningSummary: statsResponse.data?.summary || this.data.cleaningSummary, + }) + }, + + async loadDevices() { + const storeId = encodeURIComponent(this.data.storeId) + const response = await request(`/management/device-topology?storeId=${storeId}`) + const topology = response.data || {} + const devices = (topology.assets || []).map((item) => ({ + ...item, + typeText: deviceTypeLabels[item.deviceType] || item.deviceType, + statusText: deviceStatusLabels[item.status] || item.status, + roomText: item.roomId ? `房间 ${item.roomId}` : '门店公共设备', + lastSeenText: this.formatTime(item.lastSeenAt || item.lastHeartbeatAt), + })) + this.setData({ + devices, + openAlerts: topology.openAlerts || [], + maintenance: (topology.maintenance || []).filter((item) => item.status === 'OPEN'), + }) + }, + + reviewTask(event) { + if (!this.data.canWriteCleaning) return + const taskId = event.currentTarget.dataset.taskId + const action = event.currentTarget.dataset.action + if (action === 'reject') { + wx.showModal({ + title: '驳回保洁任务', + editable: true, + placeholderText: '请输入补做原因', + success: ({ confirm, content }) => { + const reason = String(content || '').trim() + if (confirm && reason) this.submitCleaningAction(taskId, 'reject', { reason }, '任务已驳回') + }, + }) + return + } + const labels = action === 'complete' + ? { title: '验收通过', content: '确认照片与现场清洁结果已达标吗?', toast: '验收已完成' } + : { title: '设为免清洁', content: '确认该房间本次无需清洁吗?', toast: '已设为免清洁' } + wx.showModal({ + title: labels.title, + content: labels.content, + success: ({ confirm }) => { + if (confirm) this.submitCleaningAction(taskId, action, { note: `管理员小程序:${labels.title}` }, labels.toast) + }, + }) + }, + + async submitCleaningAction(taskId, action, data, toast) { + if (!taskId || this.data.busyTaskId) return + this.setData({ busyTaskId: taskId, errorMessage: '' }) + try { + await request(`/management/cleaning/tasks/${encodeURIComponent(taskId)}/${action}`, { + method: 'POST', + data, + }) + await this.loadCleaning() + wx.showToast({ title: toast, icon: 'success' }) + } catch (error) { + this.setData({ errorMessage: error.message || '保洁任务处理失败' }) + } finally { + this.setData({ busyTaskId: '' }) + } + }, + + controlDevice(event) { + if (!this.data.canWriteDevices) return + const { roomId, action } = event.currentTarget.dataset + if (!roomId || !action) return + const actions = { + 'door-open': { + title: '临时开门', + endpoint: '/management/device-control/door', + data: { order: 'open', holdopen: 0, delayTime: 4 }, + }, + 'power-on': { + title: '全屋通电', + endpoint: '/management/device-control/power', + data: { slotall: 'on' }, + }, + 'power-off': { + title: '全屋断电', + endpoint: '/management/device-control/power', + data: { slotall: 'off' }, + }, + 'socket-on': { + title: '插座通电', + endpoint: '/management/device-control/socket/switch', + data: { on: true, slotNum: 1 }, + }, + 'socket-off': { + title: '插座断电', + endpoint: '/management/device-control/socket/switch', + data: { on: false, slotNum: 1 }, + }, + } + const selected = actions[action] + if (!selected) return + wx.showModal({ + title: selected.title, + content: `确认对房间 ${roomId} 执行“${selected.title}”吗?`, + success: ({ confirm }) => { + if (confirm) this.submitDeviceControl(roomId, action, selected) + }, + }) + }, + + async submitDeviceControl(roomId, action, selected) { + const busyDeviceKey = `${roomId}:${action}` + if (this.data.busyDeviceKey) return + this.setData({ busyDeviceKey, errorMessage: '' }) + try { + await request(selected.endpoint, { + method: 'POST', + data: { storeId: this.data.storeId, roomId, ...selected.data }, + }) + wx.showToast({ title: '指令已发送', icon: 'success' }) + } catch (error) { + this.setData({ errorMessage: error.message || '设备控制失败' }) + } finally { + this.setData({ busyDeviceKey: '' }) + } + }, + + formatTime(value) { + if (!value) return '暂无心跳' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + const pad = (part) => String(part).padStart(2, '0') + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` + }, +}) diff --git a/miniapp/pages/manager/operations.json b/miniapp/pages/manager/operations.json new file mode 100644 index 0000000..c1d3bde --- /dev/null +++ b/miniapp/pages/manager/operations.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "保洁与设备", + "enablePullDownRefresh": true +} diff --git a/miniapp/pages/manager/operations.wxml b/miniapp/pages/manager/operations.wxml new file mode 100644 index 0000000..77a2ada --- /dev/null +++ b/miniapp/pages/manager/operations.wxml @@ -0,0 +1,71 @@ + + + + + 保洁与设备 + {{storeName || '当前门店'}} + + {{canWriteCleaning || canWriteDevices ? '可操作' : '只读'}} + + + {{errorMessage}} + 正在加载运营数据... + + + 保洁任务 + + {{cleaningSummary.taskTotal}}任务 + {{cleaningSummary.pendingReview}}待验收 + {{cleaningSummary.active}}处理中 + {{cleaningSummary.rejected}}已驳回 + {{cleaningSummary.completed}}已完成 + + 当前门店暂无保洁任务 + + + + {{item.roomText}} + {{item.taskNo}} · {{item.rewardText}} + + {{item.statusText}} + + 要求:{{item.requirement}} + 驳回:{{item.rejectReason}} + + + + + + + + + + + 设备状态 + 告警 {{openAlerts.length}} · 维护 {{maintenance.length}} + + 当前门店暂无设备 + + + + {{item.typeText}} · {{item.deviceId}} + {{item.roomText}} / {{item.model}} + + {{item.statusText}} + + 最近心跳:{{item.lastSeenText}} + + + + + + + + + + + + + 当前账号没有保洁或设备查看权限 + + diff --git a/miniapp/pages/manager/operations.wxss b/miniapp/pages/manager/operations.wxss new file mode 100644 index 0000000..19dcd11 --- /dev/null +++ b/miniapp/pages/manager/operations.wxss @@ -0,0 +1,121 @@ +.operations-page { + padding-bottom: 48rpx; +} + +.page-heading, +.card-main, +.section-heading { + align-items: center; + display: flex; + justify-content: space-between; +} + +.subtitle, +.card-meta, +.detail-line, +.section-count { + color: #64748b; + font-size: 23rpx; + margin-top: 8rpx; +} + +.permission-tag, +.status-pill { + background: #eef2ff; + border-radius: 999rpx; + color: #3730a3; + font-size: 22rpx; + padding: 8rpx 16rpx; +} + +.section-title { + font-size: 30rpx; + font-weight: 600; + margin: 30rpx 0 16rpx; +} + +.section-count { + margin-top: 18rpx; +} + +.summary-grid { + display: grid; + gap: 12rpx; + grid-template-columns: repeat(5, 1fr); + margin-bottom: 22rpx; +} + +.summary-card { + background: #f8fafc; + border: 1rpx solid #e2e8f0; + border-radius: 14rpx; + display: flex; + flex-direction: column; + min-width: 0; + padding: 14rpx 10rpx; +} + +.summary-card strong { + font-size: 32rpx; +} + +.summary-card text { + color: #64748b; + font-size: 20rpx; +} + +.summary-card.warning strong { color: #c2410c; } +.summary-card.primary strong { color: #2563eb; } +.summary-card.danger strong { color: #b91c1c; } +.summary-card.success strong { color: #15803d; } + +.operation-card { + background: #fff; + border: 1rpx solid #e2e8f0; + border-radius: 18rpx; + margin-bottom: 16rpx; + padding: 22rpx; +} + +.card-title { + font-size: 28rpx; + font-weight: 600; +} + +.detail-line, +.reject-reason { + margin-top: 16rpx; +} + +.reject-reason { + color: #b91c1c; + font-size: 23rpx; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 12rpx; + justify-content: flex-end; + margin-top: 18rpx; +} + +.card-actions button { + margin: 0; +} + +.status-SUBMITTED { background: #fef3c7; color: #92400e; } +.status-COMPLETED, +.status-SETTLED, +.device-ONLINE { background: #dcfce7; color: #166534; } +.status-REJECTED, +.device-FAULT { background: #fee2e2; color: #991b1b; } +.status-EXEMPT { background: #f1f5f9; color: #475569; } +.device-OFFLINE { background: #e2e8f0; color: #334155; } + +.loading, +.empty { + color: #64748b; + padding: 30rpx 0; + text-align: center; +} diff --git a/scripts/check-miniapp-m08-c.mjs b/scripts/check-miniapp-m08-c.mjs index f66dc15..e081163 100644 --- a/scripts/check-miniapp-m08-c.mjs +++ b/scripts/check-miniapp-m08-c.mjs @@ -7,6 +7,7 @@ const read = (path) => readFileSync(join(root, path), 'utf8'); const appJson = JSON.parse(read('miniapp/app.json')); assert.ok(appJson.pages.includes('pages/manager/dashboard')); +assert.ok(appJson.pages.includes('pages/manager/operations')); assert.ok(appJson.pages.includes('pages/manager/people')); assert.ok(appJson.pages.includes('pages/manager/order-create')); @@ -38,7 +39,9 @@ for (const pattern of [ 'store.operation.write', 'tenant.manage', 'canReadUsers', + 'canReadOperations', 'openPeople', + 'openOperations', 'changeRoomStatus', 'toggleRoomConfiguration', 'activeOrders', @@ -119,6 +122,41 @@ const mysqlPaginationRepositories = peopleRoutes assert.doesNotMatch(mysqlPaginationRepositories, /LIMIT \? OFFSET \?/); assert.match(mysqlPaginationRepositories, /Math\.trunc/); +const operations = read('miniapp/pages/manager/operations.js') + + read('miniapp/pages/manager/operations.wxml') + + read('miniapp/pages/manager/operations.wxss'); +for (const pattern of [ + '/management/cleaning/tasks?page=1&pageSize=50&storeId=${storeId}', + '/management/cleaning/statistics?storeId=${storeId}', + '/management/device-topology?storeId=${storeId}', + '/management/device-control/door', + '/management/device-control/power', + '/management/device-control/socket/switch', + 'cleaning.task.write', + 'device.write', + '验收通过', + '驳回补做', + '免清洁', + '临时开门', + '全屋断电' +]) { + assert.match(operations, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} + +const operationRoutes = read('backend/src/routes/cleaning.ts') + + read('backend/src/routes/devices.ts') + + read('backend/src/routes/device-control.ts'); +for (const pattern of [ + '/app-api/management/cleaning/tasks', + '/app-api/management/cleaning/statistics', + '/app-api/management/devices', + '/app-api/management/device-topology', + '/app-api/management/device-control${suffix}' +]) { + assert.match(operationRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); +} +assert.match(read('backend/src/devices/device-control-service.ts'), /DEVICE_COMMAND_REQUESTED/); + const routes = read('backend/src/routes/store-room-management.ts'); for (const pattern of [ '/app-api/management/stores',