feat(M08-A): 接入顾客端订单开门

This commit is contained in:
Codex
2026-06-24 22:39:15 +08:00
parent d2c73a2c28
commit eaa86547c2
7 changed files with 161 additions and 2 deletions
@@ -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
};
}
}
+73
View File
@@ -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,
+2
View File
@@ -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
+31 -1
View File
@@ -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.');
+13
View File
@@ -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 {
+1
View File
@@ -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>
+2 -1
View File
@@ -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, '\\$&')));
} }