diff --git a/backend/src/orders/order-management-repository.ts b/backend/src/orders/order-management-repository.ts index 5420e01..622efed 100644 --- a/backend/src/orders/order-management-repository.ts +++ b/backend/src/orders/order-management-repository.ts @@ -83,6 +83,14 @@ export class OrderManagementRepository { }); } + async customerRenew(actor: OrderActor, orderId: string, input: { + endAt: Date; pricingPolicy: PricingPolicy; reason: string; + }) { + return this.renewForActor(actor, orderId, input, async (connection, order) => { + await this.assertOwner(connection, actor.tenantId, order.id, actor.userId); + }); + } + async changeRoom(actor: OrderActor, orderId: string, input: { roomId: string; reason: string; }) { @@ -124,6 +132,17 @@ export class OrderManagementRepository { }); } + async customerChangeRoom(actor: OrderActor, orderId: string, input: { + roomId: string; reason: string; + }) { + return this.changeRoomForActor(actor, orderId, input, async (connection, order, target) => { + await this.assertOwner(connection, actor.tenantId, order.id, actor.userId); + if (target.storeId !== order.storeId) { + throw new OrderManagementError('ORDER_ROOM_CHANGE_STORE_INVALID'); + } + }); + } + async adjustTime(actor: OrderActor, orderId: string, input: { startAt?: Date; endAt?: Date; reason: string; }) { @@ -220,6 +239,93 @@ export class OrderManagementRepository { }; } + private async renewForActor( + actor: OrderActor, orderId: string, input: { + endAt: Date; pricingPolicy: PricingPolicy; reason: string; + }, + authorize: (connection: PoolConnection, order: OrderRow) => Promise + ) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId); + await authorize(connection, order); + const duplicate = await this.duplicate(connection, actor, orderId); + if (duplicate) return this.duplicateResult(orderId, duplicate); + if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { + throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID'); + } + if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID'); + await this.lockRooms(connection, actor.tenantId, [order.roomId]); + await this.assertAvailable( + connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId + ); + const unitPrice = await this.resolveUnitPrice( + connection, actor.tenantId, order, input.pricingPolicy + ); + const amountDeltaCents = Math.ceil( + (input.endAt.getTime() - order.endAt.getTime()) / 3600000 + ) * unitPrice; + await connection.execute( + `UPDATE qipai_orders + SET end_at = ?, total_amount_cents = total_amount_cents + ?, + adjustment_amount_cents = adjustment_amount_cents + ? + WHERE tenant_id = ? AND id = ?`, + [input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations + SET ends_at = ?, expires_at = GREATEST(expires_at, ?) + WHERE tenant_id = ? AND order_id = ?`, + [input.endAt, input.endAt, actor.tenantId, orderId] + ); + return this.record(connection, actor, order, 'RENEW', input.reason, { + endAt: order.endAt + }, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents); + }); + } + + private async changeRoomForActor( + actor: OrderActor, orderId: string, input: { roomId: string; reason: string }, + authorize: ( + connection: PoolConnection, order: OrderRow, target: RoomRow + ) => Promise + ) { + return this.transaction(async (connection) => { + const order = await this.loadOrder(connection, actor.tenantId, orderId); + const duplicate = await this.duplicate(connection, actor, orderId); + if (duplicate) return this.duplicateResult(orderId, duplicate); + if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) { + throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID'); + } + if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED'); + await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]); + const target = await this.loadRoom(connection, actor.tenantId, input.roomId); + await authorize(connection, order, target); + await this.assertAvailable( + connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId + ); + const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId); + const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000); + const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents); + await connection.execute( + `UPDATE qipai_orders + SET store_id = ?, room_id = ?, + total_amount_cents = GREATEST(0, total_amount_cents + ?), + adjustment_amount_cents = adjustment_amount_cents + ? + WHERE tenant_id = ? AND id = ?`, + [target.storeId, target.id, amountDeltaCents, amountDeltaCents, + actor.tenantId, orderId] + ); + await connection.execute( + `UPDATE qipai_room_reservations SET room_id = ? + WHERE tenant_id = ? AND order_id = ?`, + [target.id, actor.tenantId, orderId] + ); + return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, { + storeId: order.storeId, roomId: order.roomId + }, { storeId: target.storeId, roomId: target.id }, amountDeltaCents); + }); + } + private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) { const [rows] = await connection.execute( `SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status, diff --git a/backend/src/routes/order-management.ts b/backend/src/routes/order-management.ts index 13b419b..afaa073 100644 --- a/backend/src/routes/order-management.ts +++ b/backend/src/routes/order-management.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import type { AuthRepository } from '../auth/auth-repository.js'; import { authenticateAccessToken } from '../auth/authenticate.js'; @@ -27,7 +27,8 @@ const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict(); export interface OrderManagementRouteOptions { repository: Pick; + 'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote' + | 'customerRenew' | 'customerChangeRoom'>; authRepository: Pick; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise }; jwtSecret: string; @@ -36,6 +37,34 @@ export interface OrderManagementRouteOptions { export async function registerOrderManagementRoutes( app: FastifyInstance, options: OrderManagementRouteOptions ) { + app.post('/app-api/orders/:orderId/renew', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = renewSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success || !body.success) return invalid(reply, request.traceId); + const actor = customerActor(auth, request); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.customerRenew(actor, params.data.orderId, body.data), + traceId: request.traceId + })); + }); + + app.post('/app-api/orders/:orderId/change-room', async (request, reply) => { + const auth = await authenticate(request.headers.authorization, options); + const params = paramsSchema.safeParse(request.params); + const body = roomSchema.safeParse(request.body); + if (!auth) return unauthorized(reply, request.traceId); + if (!params.success || !body.success) return invalid(reply, request.traceId); + const actor = customerActor(auth, request); + return handle(reply, request.traceId, async () => ({ + code: 0, + data: await options.repository.customerChangeRoom(actor, params.data.orderId, body.data), + traceId: request.traceId + })); + }); + app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => { const auth = await authenticate(request.headers.authorization, options); const params = paramsSchema.safeParse(request.params); @@ -82,6 +111,17 @@ export async function registerOrderManagementRoutes( } } +function customerActor( + auth: { tenantId: string; userId: string; access: AccessProfile }, + request: FastifyRequest +): OrderActor { + return { + tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER', + source: 'APP', traceId: request.traceId, ip: request.ip, + userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access + }; +} + async function authenticate( authorization: string | undefined, options: OrderManagementRouteOptions ) { diff --git a/backend/tests/order-management.test.mjs b/backend/tests/order-management.test.mjs index 46af7ab..0ede0f1 100644 --- a/backend/tests/order-management.test.mjs +++ b/backend/tests/order-management.test.mjs @@ -8,6 +8,7 @@ const token = signAccessToken({ tid: '7', aid: '9', rv: 1 }, secret, 900); let called; +let customerCalled; const authRepository = { async validateSession() { return { @@ -33,7 +34,15 @@ const app = await buildApp({ called = { actor, orderId, body }; return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1200 }; }, + async customerRenew(actor, orderId, body) { + customerCalled = { action: 'renew', actor, orderId, body }; + return { orderId, adjustmentType: 'RENEW', amountDeltaCents: 1800 }; + }, async changeRoom() { throw new Error('not called'); }, + async customerChangeRoom(actor, orderId, body) { + customerCalled = { action: 'changeRoom', actor, orderId, body }; + return { orderId, adjustmentType: 'CHANGE_ROOM', amountDeltaCents: -500 }; + }, async adjustTime() { throw new Error('not called'); }, async note() { throw new Error('not called'); }, async cancellationQuote() { @@ -71,6 +80,37 @@ assert.equal(called.orderId, '31'); assert.equal(called.actor.traceId, 'm04c-renew-route'); assert.equal(called.body.pricingPolicy, 'LOCKED'); +const customerRenewed = await app.inject({ + method: 'POST', + url: '/app-api/orders/31/renew', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-customer-renew' }, + payload: { + endAt: new Date(Date.now() + 10800000).toISOString(), + pricingPolicy: 'CURRENT', + reason: 'miniapp renew' + } +}); +assert.equal(customerRenewed.statusCode, 200); +assert.equal(customerCalled.action, 'renew'); +assert.equal(customerCalled.actor.source, 'APP'); +assert.equal(customerCalled.actor.traceId, 'm08a-customer-renew'); +assert.equal(customerCalled.body.pricingPolicy, 'CURRENT'); + +const customerChanged = await app.inject({ + method: 'POST', + url: '/app-api/orders/31/change-room', + headers: { authorization: `Bearer ${token}`, 'x-trace-id': 'm08a-customer-change-room' }, + payload: { + roomId: '32', + reason: 'miniapp change room' + } +}); +assert.equal(customerChanged.statusCode, 200); +assert.equal(customerCalled.action, 'changeRoom'); +assert.equal(customerCalled.actor.source, 'APP'); +assert.equal(customerCalled.orderId, '31'); +assert.equal(customerCalled.body.roomId, '32'); + const quote = await app.inject({ method: 'GET', url: '/app-api/orders/31/cancellation-quote', diff --git a/miniapp/pages/orders/detail.js b/miniapp/pages/orders/detail.js index 976efbc..6e34358 100644 --- a/miniapp/pages/orders/detail.js +++ b/miniapp/pages/orders/detail.js @@ -9,6 +9,11 @@ Page({ order: null, history: [], cancellation: null, + renewMinutes: 60, + changeRoomId: '', + shareTtlMinutes: 30, + shareToken: '', + sharePermissions: ['VIEW_ROOM', 'OPEN_DOOR'], }, onLoad(options) { @@ -79,6 +84,81 @@ Page({ }) }, + onRenewMinutesInput(event) { + this.setData({ renewMinutes: Number(event.detail.value || 0) }) + }, + + onChangeRoomInput(event) { + this.setData({ changeRoomId: String(event.detail.value || '').trim() }) + }, + + onShareTtlInput(event) { + this.setData({ shareTtlMinutes: Number(event.detail.value || 0) }) + }, + + async renewOrder() { + if (!this.data.orderId || !this.data.order) return + const minutes = Number(this.data.renewMinutes || 0) + if (!Number.isFinite(minutes) || minutes < 15) { + this.setData({ errorMessage: '续费时长至少 15 分钟' }) + return + } + const currentEnd = new Date(this.data.order.endAt) + const nextEnd = new Date(currentEnd.getTime() + minutes * 60000) + await this.withOrderRequest(async () => { + const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/renew`, { + method: 'POST', + data: { + endAt: nextEnd.toISOString(), + pricingPolicy: 'CURRENT', + reason: `顾客小程序续费 ${minutes} 分钟`, + }, + }) + await this.loadOrder(this.data.orderId) + this.setData({ + successMessage: `续费已提交,补差 ${cents(response.data.amountDeltaCents)}`, + }) + }) + }, + + async changeRoom() { + if (!this.data.orderId || !this.data.changeRoomId) { + this.setData({ errorMessage: '请输入目标房间 ID' }) + return + } + await this.withOrderRequest(async () => { + const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/change-room`, { + method: 'POST', + data: { + roomId: this.data.changeRoomId, + reason: '顾客小程序换房', + }, + }) + await this.loadOrder(this.data.orderId) + this.setData({ + successMessage: `换房已提交,差价 ${cents(response.data.amountDeltaCents)}`, + }) + }) + }, + + async createShare() { + if (!this.data.orderId) return + const ttlMinutes = Math.min(Math.max(Number(this.data.shareTtlMinutes || 30), 5), 1440) + await this.withOrderRequest(async () => { + const response = await request(`/orders/${encodeURIComponent(this.data.orderId)}/shares`, { + method: 'POST', + data: { + permissions: this.data.sharePermissions, + ttlMinutes, + }, + }) + this.setData({ + shareToken: response.data.token, + successMessage: `分享口令已生成,${response.data.expiresInMinutes} 分钟内有效`, + }) + }) + }, + async withOrderRequest(work) { this.setData({ loading: true, errorMessage: '', successMessage: '' }) try { diff --git a/miniapp/pages/orders/detail.wxml b/miniapp/pages/orders/detail.wxml index 146fb09..185d216 100644 --- a/miniapp/pages/orders/detail.wxml +++ b/miniapp/pages/orders/detail.wxml @@ -5,7 +5,7 @@ {{successMessage}} - {{order.storeName}} · {{order.roomName}} + {{order.storeName}} / {{order.roomName}} 订单号:{{order.orderNo}} 房间:{{order.roomNo}} 开始:{{order.startText}} @@ -26,6 +26,34 @@ + + 续费 + + 分钟 + + + + + + + 换房 + + 目标房间 ID + + + + + + + 分享 + + 有效分钟 + + + + {{shareToken}} + + 取消测算 是否允许:{{cancellation.allowed ? '允许' : '不允许'}} @@ -34,7 +62,7 @@ 免费取消分钟线:{{cancellation.cutoffMinutes}} - 状态历史 + 状态历史 {{item.action}}:{{item.fromStatus || '-'}} → {{item.toStatus}} {{item.createdText}} diff --git a/miniapp/pages/orders/detail.wxss b/miniapp/pages/orders/detail.wxss index 012bbf0..52bd339 100644 --- a/miniapp/pages/orders/detail.wxss +++ b/miniapp/pages/orders/detail.wxss @@ -59,6 +59,41 @@ flex: 1 1 220rpx; } +.field { + display: flex; + align-items: center; + gap: 16rpx; + margin: 20rpx 0; +} + +.field text { + width: 180rpx; + color: #475467; +} + +.field input { + flex: 1; + min-height: 72rpx; + padding: 0 20rpx; + border: 1rpx solid #d0d5dd; + border-radius: 8rpx; + background: #ffffff; +} + +.token { + margin-top: 16rpx; + padding: 16rpx; + word-break: break-all; + border-radius: 8rpx; + background: #f2f4f7; + color: #344054; + font-size: 24rpx; +} + +.history-title { + margin-top: 28rpx; +} + .success { margin: 16rpx 0; color: #17823b; diff --git a/scripts/check-miniapp-m08-a.mjs b/scripts/check-miniapp-m08-a.mjs index 33aa9bd..a3aeef0 100644 --- a/scripts/check-miniapp-m08-a.mjs +++ b/scripts/check-miniapp-m08-a.mjs @@ -51,7 +51,10 @@ for (const route of [ '/history', '/cancellation-quote', '/cancel', - '/open-door' + '/open-door', + '/renew', + '/change-room', + '/shares' ]) { assert.match(orders, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); }