feat(M08-C): 补管理员订单处置与代下单

This commit is contained in:
Codex
2026-08-10 10:20:32 +08:00
parent 8b2156cdd5
commit 566e61b5d9
20 changed files with 701 additions and 96 deletions
@@ -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[]>(
+37 -2
View File
@@ -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,
+19 -17
View File
@@ -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
}));
});
}
}
}
+2 -1
View File
@@ -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}$/) });
+21 -16
View File
@@ -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(
+43 -30
View File
@@ -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
@@ -671,6 +671,57 @@ async function assertPricingAndReservations(pool, context) {
startAt, endAt, pricingMode: 'FULL_DAY'
});
assert.equal(replacement.quote.unitPriceCents, 9000);
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const managedStartAt = new Date(startAt.getTime() + 24 * 3600000);
const managedEndAt = new Date(managedStartAt.getTime() + 2 * 3600000);
const managed = await repository.reserve({
tenantId: context.tenantId, userId: customerId, roomId,
startAt: managedStartAt, endAt: managedEndAt, pricingMode: 'HOURLY',
actor: {
userId: adminId, source: 'ADMIN', traceId: 'm08c-reserve-on-behalf',
ip: '127.0.0.1', userAgent: 'M08-C live manager booking test'
}
});
const [managedHistory] = await pool.query(
`SELECT actor_id AS actorId, source, CAST(metadata AS CHAR) AS metadata
FROM qipai_order_status_history WHERE tenant_id = ? AND order_id = ?`,
[context.tenantId, managed.orderId]
);
assert.equal(String(managedHistory[0].actorId), adminId);
assert.equal(managedHistory[0].source, 'ADMIN');
assert.equal(String(JSON.parse(managedHistory[0].metadata).targetUserId), customerId);
const [managedAudit] = await pool.query(
`SELECT actor_id AS actorId, action, CAST(metadata AS CHAR) AS metadata
FROM qipai_audit_logs
WHERE tenant_id = ? AND trace_id = 'm08c-reserve-on-behalf'`,
[context.tenantId]
);
assert.equal(String(managedAudit[0].actorId), adminId);
assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF');
assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId);
const [foreignUser] = await pool.query(
`INSERT INTO qipai_users (tenant_id, user_type, status)
VALUES (?, 'CUSTOMER', 'ACTIVE')`,
[String(Number(context.tenantId) + 1)]
);
await assert.rejects(
() => repository.reserve({
tenantId: context.tenantId, userId: String(foreignUser.insertId), roomId,
startAt: new Date(managedStartAt.getTime() + 24 * 3600000),
endAt: new Date(managedEndAt.getTime() + 24 * 3600000),
pricingMode: 'HOURLY'
}),
(error) => error instanceof PricingError && error.code === 'USER_NOT_FOUND'
);
}
async function assertOrderStateMachine(pool, context) {
@@ -1947,6 +1998,8 @@ try {
'room price difference and manager time adjustment',
'configured cancellation fee quote',
'store-scoped on-behalf booking rejection',
'manager on-behalf booking history and audit attribution',
'cross-tenant on-behalf target user rejection',
'share token stored as SHA-256 only',
'default view and door permissions',
'renew permission denied by default',
+17 -1
View File
@@ -9,6 +9,7 @@ const token = signAccessToken({
}, secret, 900);
let called;
let customerCalled;
let noteCalled;
const authRepository = {
async validateSession() {
return {
@@ -44,7 +45,10 @@ const app = await buildApp({
return { orderId, adjustmentType: 'CHANGE_ROOM', amountDeltaCents: -500 };
},
async adjustTime() { throw new Error('not called'); },
async note() { throw new Error('not called'); },
async note(actor, orderId, note) {
noteCalled = { actor, orderId, note };
return { orderId, note };
},
async cancellationQuote() {
return { allowed: true, feeCents: 0, refundableCents: 3000 };
}
@@ -80,6 +84,18 @@ assert.equal(called.orderId, '31');
assert.equal(called.actor.traceId, 'm04c-renew-route');
assert.equal(called.body.pricingPolicy, 'LOCKED');
const managementNote = await app.inject({
method: 'POST',
url: '/app-api/management/orders/31/note',
headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08c-management-note' },
payload: { note: 'front desk confirmed member arrival' }
});
assert.equal(managementNote.statusCode, 200);
assert.equal(noteCalled.orderId, '31');
assert.equal(noteCalled.actor.source, 'ADMIN');
assert.equal(noteCalled.actor.traceId, 'm08c-management-note');
assert.equal(noteCalled.note, 'front desk confirmed member arrival');
const customerRenewed = await app.inject({
method: 'POST',
url: '/app-api/orders/31/renew',
+2 -1
View File
@@ -77,7 +77,7 @@ const app = await buildApp({
const listed = await app.inject({
method: 'GET',
url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT',
url: '/app-api/orders?page=2&pageSize=5&status=PENDING_PAYMENT&storeId=18',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(listed.statusCode, 200);
@@ -87,6 +87,7 @@ assert.equal(listInput.userId, '21');
assert.equal(listInput.page, 2);
assert.equal(listInput.pageSize, 5);
assert.equal(listInput.status, 'PENDING_PAYMENT');
assert.equal(listInput.storeId, '18');
const detail = await app.inject({
method: 'GET',
+15
View File
@@ -61,6 +61,21 @@ assert.equal(transitionInput.action, 'CONFIRM_PAYMENT');
assert.equal(transitionInput.actor.source, 'ADMIN');
assert.equal(transitionInput.actor.traceId, 'm04b-route-transition');
const managementTransition = await app.inject({
method: 'POST',
url: '/app-api/management/orders/31/actions',
headers: {
authorization: `Bearer ${token}`,
'x-trace-id': 'm08c-route-transition'
},
payload: { action: 'START', reason: 'front desk started the room' }
});
assert.equal(managementTransition.statusCode, 200);
assert.equal(transitionInput.orderId, '31');
assert.equal(transitionInput.action, 'START');
assert.equal(transitionInput.actor.source, 'ADMIN');
assert.equal(transitionInput.actor.traceId, 'm08c-route-transition');
const cancelled = await app.inject({
method: 'POST',
url: '/app-api/orders/31/cancel',
+37 -1
View File
@@ -20,6 +20,7 @@ const token = signAccessToken({
rv: 1
}, secret, 900);
let reserveInput;
let accessProfile = { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
const app = await buildApp({
pricing: {
jwtSecret: secret,
@@ -45,7 +46,7 @@ const app = await buildApp({
},
accessControl: {
async getAccessProfile() {
return { roles: ['CUSTOMER'], capabilities: [], storeIds: [] };
return accessProfile;
}
},
repository: {
@@ -98,6 +99,41 @@ assert.deepEqual(reserveInput.benefits, {
clientRequestId: 'benefit-route-0001'
});
const forbiddenOnBehalf = await app.inject({
method: 'POST',
url: '/app-api/management/orders/reserve-on-behalf',
headers: { authorization: `Bearer ${token}` },
payload: {
userId: '88', roomId: '11',
startAt: startAt.toISOString(), endAt: endAt.toISOString(), pricingMode: 'HOURLY'
}
});
assert.equal(forbiddenOnBehalf.statusCode, 403);
accessProfile = {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.write'],
storeIds: ['18']
};
const reservedOnBehalf = await app.inject({
method: 'POST',
url: '/app-api/management/orders/reserve-on-behalf',
headers: { authorization: `Bearer ${token}` },
payload: {
userId: '88', roomId: '11',
startAt: startAt.toISOString(), endAt: endAt.toISOString(), pricingMode: 'HOURLY'
}
});
assert.equal(reservedOnBehalf.statusCode, 201);
assert.equal(reserveInput.userId, '88');
assert.deepEqual(reserveInput.allowedStoreIds, ['18']);
assert.equal('benefits' in reserveInput, false);
assert.equal(reserveInput.actor.userId, '21');
assert.equal(reserveInput.actor.source, 'ADMIN');
assert.ok(reserveInput.actor.traceId);
assert.match(pricingSource, /tenant_id = \? AND id = \? AND status = 'ACTIVE'/);
assert.match(pricingSource, /ORDER_CREATED_ON_BEHALF/);
const unauthenticated = await app.inject({
method: 'POST',
url: '/app-api/pricing/quote',