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 type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import {
|
||||||
|
CustomerDeviceAccessError,
|
||||||
|
type CustomerDeviceAccessRepository
|
||||||
|
} from '../devices/customer-device-access-repository.js';
|
||||||
import {
|
import {
|
||||||
DeviceControlError,
|
DeviceControlError,
|
||||||
type CommandContext,
|
type CommandContext,
|
||||||
@@ -75,6 +79,10 @@ const socketTaskSchema = contextSchema.extend({
|
|||||||
const socketClearTaskSchema = contextSchema.extend({
|
const socketClearTaskSchema = contextSchema.extend({
|
||||||
taskNum: z.number().int().min(0).max(20)
|
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 {
|
export interface DeviceControlRouteOptions {
|
||||||
service: Pick<DeviceControlService,
|
service: Pick<DeviceControlService,
|
||||||
@@ -82,6 +90,7 @@ export interface DeviceControlRouteOptions {
|
|||||||
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
|
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
|
||||||
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
|
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
|
||||||
| 'clearSmartSocketTask'>;
|
| 'clearSmartSocketTask'>;
|
||||||
|
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
|
||||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
jwtSecret: string;
|
jwtSecret: string;
|
||||||
@@ -119,6 +128,70 @@ export async function registerDeviceControlRoutes(
|
|||||||
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
|
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
|
||||||
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
|
(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>(
|
function register<T extends z.ZodTypeAny>(
|
||||||
url: string,
|
url: string,
|
||||||
schema: T,
|
schema: T,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { ThirdPartyService } from './third-party/third-party-service.js';
|
import { ThirdPartyService } from './third-party/third-party-service.js';
|
||||||
import { MqttService } from './mqtt/mqtt-service.js';
|
import { MqttService } from './mqtt/mqtt-service.js';
|
||||||
import { DeviceRepository } from './devices/device-repository.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 { IotMessageService } from './devices/iot-message-service.js';
|
||||||
import { DeviceCommandService } from './devices/device-command-service.js';
|
import { DeviceCommandService } from './devices/device-command-service.js';
|
||||||
import { DeviceControlService } from './devices/device-control-service.js';
|
import { DeviceControlService } from './devices/device-control-service.js';
|
||||||
@@ -157,6 +158,7 @@ const app = await buildApp({
|
|||||||
},
|
},
|
||||||
deviceControl: {
|
deviceControl: {
|
||||||
service: new DeviceControlService(pool, deviceCommands),
|
service: new DeviceControlService(pool, deviceCommands),
|
||||||
|
customerAccess: new CustomerDeviceAccessRepository(pool),
|
||||||
authRepository,
|
authRepository,
|
||||||
accessControl,
|
accessControl,
|
||||||
jwtSecret: config.auth.jwtSecret
|
jwtSecret: config.auth.jwtSecret
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const token = signAccessToken({
|
|||||||
tid: '7', aid: '9', rv: 1
|
tid: '7', aid: '9', rv: 1
|
||||||
}, secret, 900);
|
}, secret, 900);
|
||||||
let routed;
|
let routed;
|
||||||
|
let routedContext;
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
deviceControl: {
|
deviceControl: {
|
||||||
jwtSecret: secret,
|
jwtSecret: secret,
|
||||||
@@ -123,9 +124,19 @@ const app = await buildApp({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
accessControl: { async getAccessProfile() { return managerAccess; } },
|
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: {
|
service: {
|
||||||
async controlPower(_context, input) { routed = input; return { status: 'PUBLISHED' }; },
|
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 playTts() { return {}; },
|
||||||
async stopTts() { return {}; },
|
async stopTts() { return {}; },
|
||||||
async controlLed() { return {}; },
|
async controlLed() { return {}; },
|
||||||
@@ -164,6 +175,25 @@ const invalid = await app.inject({
|
|||||||
payload: { storeId: '11', roomId: '31', order: 'open', delayTime: 15 }
|
payload: { storeId: '11', roomId: '31', order: 'open', delayTime: 15 }
|
||||||
});
|
});
|
||||||
assert.equal(invalid.statusCode, 400);
|
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();
|
await app.close();
|
||||||
|
|
||||||
console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.');
|
console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.');
|
||||||
|
|||||||
@@ -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) {
|
async withOrderRequest(work) {
|
||||||
this.setData({ loading: true, errorMessage: '', successMessage: '' })
|
this.setData({ loading: true, errorMessage: '', successMessage: '' })
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<view class="action-grid">
|
<view class="action-grid">
|
||||||
<button loading="{{loading}}" bindtap="loadOrder">刷新</button>
|
<button loading="{{loading}}" bindtap="loadOrder">刷新</button>
|
||||||
|
<button loading="{{loading}}" bindtap="openDoor">开门</button>
|
||||||
<button loading="{{loading}}" bindtap="loadCancellationQuote">取消测算</button>
|
<button loading="{{loading}}" bindtap="loadCancellationQuote">取消测算</button>
|
||||||
<button loading="{{loading}}" bindtap="cancelOrder">取消订单</button>
|
<button loading="{{loading}}" bindtap="cancelOrder">取消订单</button>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ for (const route of [
|
|||||||
'/orders/${encodeURIComponent',
|
'/orders/${encodeURIComponent',
|
||||||
'/history',
|
'/history',
|
||||||
'/cancellation-quote',
|
'/cancellation-quote',
|
||||||
'/cancel'
|
'/cancel',
|
||||||
|
'/open-door'
|
||||||
]) {
|
]) {
|
||||||
assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user