feat(M08-A): 接入顾客端订单开门
This commit is contained in:
@@ -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<OrderDeviceRow[]>(
|
||||
`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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceControlService,
|
||||
@@ -82,6 +90,7 @@ export interface DeviceControlRouteOptions {
|
||||
| '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;
|
||||
@@ -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<T extends z.ZodTypeAny>(
|
||||
url: string,
|
||||
schema: T,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user