diff --git a/backend/src/devices/customer-device-access-repository.ts b/backend/src/devices/customer-device-access-repository.ts new file mode 100644 index 0000000..ab5eb34 --- /dev/null +++ b/backend/src/devices/customer-device-access-repository.ts @@ -0,0 +1,39 @@ +import type { RowDataPacket } from 'mysql2/promise'; +import type { MySqlPool } from '../db/mysql.js'; + +export class CustomerDeviceAccessError extends Error { + constructor(public readonly code: string) { super(code); } +} + +interface OrderDeviceRow extends RowDataPacket { + orderId: string; + storeId: string; + roomId: string; + status: string; +} + +export class CustomerDeviceAccessRepository { + constructor(private readonly pool: MySqlPool) {} + + async getDoorContext(input: { tenantId: string; userId: string; orderId: string }) { + const [rows] = await this.pool.execute( + `SELECT o.id AS orderId, o.store_id AS storeId, o.room_id AS roomId, o.status + FROM qipai_orders o + INNER JOIN qipai_order_user_access a + ON a.tenant_id = o.tenant_id AND a.order_id = o.id + AND a.user_id = ? AND a.revoked_at IS NULL + WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL + AND o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS') + AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at + LIMIT 1`, + [input.userId, input.tenantId, input.orderId] + ); + if (!rows[0]) throw new CustomerDeviceAccessError('ORDER_DOOR_ACCESS_FORBIDDEN'); + return { + orderId: String(rows[0].orderId), + storeId: String(rows[0].storeId), + roomId: String(rows[0].roomId), + status: rows[0].status + }; + } +} diff --git a/backend/src/routes/device-control.ts b/backend/src/routes/device-control.ts index 2395fed..c173a8e 100644 --- a/backend/src/routes/device-control.ts +++ b/backend/src/routes/device-control.ts @@ -3,6 +3,10 @@ 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, @@ -75,6 +79,10 @@ const socketTaskSchema = contextSchema.extend({ 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; @@ -119,6 +128,70 @@ export async function registerDeviceControlRoutes( 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, diff --git a/backend/src/server.ts b/backend/src/server.ts index 11b5be0..700850b 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -29,6 +29,7 @@ import { import { ThirdPartyService } from './third-party/third-party-service.js'; import { MqttService } from './mqtt/mqtt-service.js'; import { DeviceRepository } from './devices/device-repository.js'; +import { CustomerDeviceAccessRepository } from './devices/customer-device-access-repository.js'; import { IotMessageService } from './devices/iot-message-service.js'; import { DeviceCommandService } from './devices/device-command-service.js'; import { DeviceControlService } from './devices/device-control-service.js'; @@ -157,6 +158,7 @@ const app = await buildApp({ }, deviceControl: { service: new DeviceControlService(pool, deviceCommands), + customerAccess: new CustomerDeviceAccessRepository(pool), authRepository, accessControl, jwtSecret: config.auth.jwtSecret diff --git a/backend/tests/device-control.test.mjs b/backend/tests/device-control.test.mjs index abbe549..1ac7d2c 100644 --- a/backend/tests/device-control.test.mjs +++ b/backend/tests/device-control.test.mjs @@ -107,6 +107,7 @@ const token = signAccessToken({ tid: '7', aid: '9', rv: 1 }, secret, 900); let routed; +let routedContext; const app = await buildApp({ deviceControl: { jwtSecret: secret, @@ -123,9 +124,19 @@ const app = await buildApp({ } }, accessControl: { async getAccessProfile() { return managerAccess; } }, + customerAccess: { + async getDoorContext(input) { + assert.deepEqual(input, { tenantId: '7', userId: '22', orderId: '31' }); + return { orderId: '31', storeId: '11', roomId: '31', status: 'IN_PROGRESS' }; + } + }, service: { async controlPower(_context, input) { routed = input; return { status: 'PUBLISHED' }; }, - async controlDoor() { return {}; }, + async controlDoor(context, input) { + routedContext = context; + routed = input; + return { commandId: 'cmd-door', status: 'PUBLISHED' }; + }, async playTts() { return {}; }, async stopTts() { return {}; }, async controlLed() { return {}; }, @@ -164,6 +175,25 @@ const invalid = await app.inject({ payload: { storeId: '11', roomId: '31', order: 'open', delayTime: 15 } }); assert.equal(invalid.statusCode, 400); +const appOpenDoor = await app.inject({ + method: 'POST', + url: '/app-api/orders/31/open-door', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-open-door' }, + payload: { delayTime: 4, holdopen: 1 } +}); +assert.equal(appOpenDoor.statusCode, 400); +const appOpenDoorOk = await app.inject({ + method: 'POST', + url: '/app-api/orders/31/open-door', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-open-door' }, + payload: { delayTime: 4 } +}); +assert.equal(appOpenDoorOk.statusCode, 200); +assert.equal(routed.order, 'open'); +assert.equal(routed.holdopen, 0); +assert.equal(routedContext.orderId, '31'); +assert.equal(routedContext.traceId, 'm08a-open-door'); +assert.deepEqual(routedContext.access.capabilities, ['device.write']); await app.close(); console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.'); diff --git a/miniapp/pages/orders/detail.js b/miniapp/pages/orders/detail.js index 5b407d6..976efbc 100644 --- a/miniapp/pages/orders/detail.js +++ b/miniapp/pages/orders/detail.js @@ -66,6 +66,19 @@ Page({ }) }, + async openDoor() { + if (!this.data.orderId) return + await this.withOrderRequest(async () => { + const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/open-door`, { + method: 'POST', + data: { delayTime: 4 }, + }) + this.setData({ + successMessage: `开门指令已发送:${response.data.status || 'PUBLISHED'}`, + }) + }) + }, + async withOrderRequest(work) { this.setData({ loading: true, errorMessage: '', successMessage: '' }) try { diff --git a/miniapp/pages/orders/detail.wxml b/miniapp/pages/orders/detail.wxml index 554e060..146fb09 100644 --- a/miniapp/pages/orders/detail.wxml +++ b/miniapp/pages/orders/detail.wxml @@ -21,6 +21,7 @@ + diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index 32a5858..33aa9bd 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -50,7 +50,8 @@ for (const route of [ '/orders/${encodeURIComponent', '/history', '/cancellation-quote', - '/cancel' + '/cancel', + '/open-door' ]) { assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }