feat(M08-C): 补管理员订单处置与代下单
This commit is contained in:
@@ -41,6 +41,7 @@ export class OrderQueryRepository {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: OrderStatus;
|
||||
storeId?: string;
|
||||
}) {
|
||||
const where = [
|
||||
'o.tenant_id = ?',
|
||||
@@ -52,6 +53,10 @@ export class OrderQueryRepository {
|
||||
where.push('o.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.storeId) {
|
||||
where.push('o.store_id = ?');
|
||||
params.push(input.storeId);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
|
||||
@@ -45,6 +45,7 @@ interface RoomPricingRow extends RowDataPacket {
|
||||
roomCategoryId: string | null;
|
||||
}
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface UserRow extends RowDataPacket { id: string }
|
||||
|
||||
export class PricingError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
@@ -69,8 +70,16 @@ export class PricingRepository {
|
||||
holdMinutes?: number;
|
||||
allowedStoreIds?: string[] | null;
|
||||
benefits?: OrderBenefitInput | null;
|
||||
actor?: {
|
||||
userId: string;
|
||||
source: 'APP' | 'ADMIN';
|
||||
traceId: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
};
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.assertActiveTenantUser(connection, input.tenantId, input.userId);
|
||||
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
|
||||
if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) {
|
||||
throw new PricingError('STORE_SCOPE_FORBIDDEN');
|
||||
@@ -158,9 +167,24 @@ export class PricingRepository {
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
actor_id, source, reason, trace_id, metadata)
|
||||
VALUES (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?,
|
||||
'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`,
|
||||
[input.tenantId, orderId, input.userId, `order-created-${orderId}`]
|
||||
?, ?, ?, JSON_OBJECT('statusVersion', 1, 'targetUserId', ?))`,
|
||||
[input.tenantId, orderId, input.actor?.userId ?? input.userId,
|
||||
input.actor?.source ?? 'APP',
|
||||
input.actor?.source === 'ADMIN' ? 'Order created on behalf' : 'Room hold created',
|
||||
input.actor?.traceId ?? `order-created-${orderId}`, input.userId]
|
||||
);
|
||||
if (input.actor?.source === 'ADMIN') {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, 'ORDER_CREATED_ON_BEHALF', 'ORDER', ?, ?, ?, ?,
|
||||
JSON_OBJECT('targetUserId', ?, 'storeId', ?, 'roomId', ?))`,
|
||||
[input.tenantId, input.actor.userId, orderId, input.actor.traceId,
|
||||
input.actor.ip, input.actor.userAgent.slice(0, 255), input.userId,
|
||||
String(room.storeId), input.roomId]
|
||||
);
|
||||
}
|
||||
return {
|
||||
orderId,
|
||||
orderNo,
|
||||
@@ -173,6 +197,17 @@ export class PricingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private async assertActiveTenantUser(
|
||||
connection: PoolConnection, tenantId: string, userId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<UserRow[]>(
|
||||
`SELECT id FROM qipai_users
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'ACTIVE' LIMIT 1`,
|
||||
[tenantId, userId]
|
||||
);
|
||||
if (!rows[0]) throw new PricingError('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
async releaseExpired(
|
||||
tenantId?: string,
|
||||
roomId?: string,
|
||||
|
||||
@@ -91,23 +91,25 @@ export async function registerOrderManagementRoutes(
|
||||
] as const;
|
||||
|
||||
for (const [path, schema, execute] of adminActions) {
|
||||
app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = schema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
|
||||
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
};
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await execute(actor, params.data.orderId, body.data as never),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const prefix of ['/admin-api/orders', '/app-api/management/orders']) {
|
||||
app.post(`${prefix}/:orderId/${path}`, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = schema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
|
||||
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
};
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await execute(actor, params.data.orderId, body.data as never),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ const orderStatuses = [
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||
status: z.enum(orderStatuses).optional()
|
||||
status: z.enum(orderStatuses).optional(),
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
|
||||
|
||||
@@ -62,22 +62,27 @@ export async function registerOrderStateRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin-api/orders/:orderId/actions', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = transitionSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.transition({
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'ADMIN', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
}, params.data.orderId, body.data.action, body.data.reason),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/orders/:orderId/actions',
|
||||
'/app-api/management/orders/:orderId/actions'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = transitionSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.transition({
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'ADMIN', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
}, params.data.orderId, body.data.action, body.data.reason),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
|
||||
@@ -80,35 +80,47 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const body = adminReserveSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
||||
const unrestricted = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
|
||||
return reply.status(403).send({
|
||||
code: 'ORDER_MANAGEMENT_FORBIDDEN',
|
||||
message: 'Order management permission is required.',
|
||||
for (const path of [
|
||||
'/admin-api/orders/reserve-on-behalf',
|
||||
'/app-api/management/orders/reserve-on-behalf'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const body = adminReserveSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
||||
const unrestricted = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
|
||||
return reply.status(403).send({
|
||||
code: 'ORDER_MANAGEMENT_FORBIDDEN',
|
||||
message: 'Order management permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.reserve({
|
||||
tenantId: auth.tenantId,
|
||||
userId: body.data.userId,
|
||||
roomId: body.data.roomId,
|
||||
startAt: body.data.startAt,
|
||||
endAt: body.data.endAt,
|
||||
pricingMode: body.data.pricingMode,
|
||||
allowedStoreIds: unrestricted ? null : access.storeIds,
|
||||
actor: {
|
||||
userId: auth.userId,
|
||||
source: 'ADMIN',
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: String(request.headers['user-agent'] ?? '')
|
||||
}
|
||||
}),
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.reserve({
|
||||
tenantId: auth.tenantId,
|
||||
userId: body.data.userId,
|
||||
roomId: body.data.roomId,
|
||||
startAt: body.data.startAt,
|
||||
endAt: body.data.endAt,
|
||||
pricingMode: body.data.pricingMode,
|
||||
allowedStoreIds: unrestricted ? null : access.storeIds
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
@@ -131,8 +143,9 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
} catch (error) {
|
||||
if (!(error instanceof PricingError)) throw error;
|
||||
const conflict = error.code === 'TIME_SLOT_CONFLICT';
|
||||
const notFound = error.code === 'ROOM_NOT_FOUND';
|
||||
return reply.status(conflict ? 409 : notFound ? 404 : 400).send({
|
||||
const forbidden = error.code === 'STORE_SCOPE_FORBIDDEN';
|
||||
const notFound = error.code === 'ROOM_NOT_FOUND' || error.code === 'USER_NOT_FOUND';
|
||||
return reply.status(conflict ? 409 : forbidden ? 403 : notFound ? 404 : 400).send({
|
||||
code: error.code,
|
||||
message: 'The requested price or time slot is not available.',
|
||||
traceId
|
||||
|
||||
Reference in New Issue
Block a user