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 { 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,
+2
View File
@@ -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