From 22407a7ff93704ee266651246b4796940ce6d43b Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 9 Sep 2026 19:50:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(booking):=20=E5=85=81=E8=AE=B8=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98/=E8=80=81=E5=B8=88=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E6=8E=A5=E5=8F=A3=E5=8F=96=E6=B6=88=E4=BB=BB?= =?UTF-8?q?=E6=84=8F=E9=A2=84=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 cancelBooking 改为接收 actor({ id, isAdmin }),服务端按 JWT role 分流:普通成员仅能取消自己的预约,管理员跳过 owner check。 history 备注自动按 actor 选择「学员/管理员」前缀,审计可追溯。 客户端无感,仍调 PUT /booking/:id/cancel,前端零改动。 Co-Authored-By: Claude Code --- .../booking/__tests__/booking.service.spec.ts | 104 ++++++++++++++++-- .../server/src/booking/booking.controller.ts | 8 +- .../server/src/booking/booking.service.ts | 17 +-- 3 files changed, 110 insertions(+), 19 deletions(-) diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index d997e31..2bb076c 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -20,6 +20,10 @@ const MOCK_SLOT_ID = 'slot-001' const MOCK_MEMBERSHIP_ID = 'mem-001' const MOCK_BOOKING_ID = 'booking-001' +const MEMBER_ACTOR = { id: MOCK_USER_ID, isAdmin: false } +const MOCK_ADMIN_ID = 'admin-001' +const ADMIN_ACTOR = { id: MOCK_ADMIN_ID, isAdmin: true } + const mockTimesCardType = { id: 'ct-times-001', name: '10次卡', @@ -762,7 +766,7 @@ describe('BookingService', () => { ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.booking.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -813,7 +817,7 @@ describe('BookingService', () => { }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -846,7 +850,7 @@ describe('BookingService', () => { tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).not.toHaveBeenCalled() expect(result.refunded).toBe(false) @@ -875,7 +879,7 @@ describe('BookingService', () => { tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).not.toHaveBeenCalled() expect(result.refunded).toBe(false) @@ -897,7 +901,7 @@ describe('BookingService', () => { ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(result.refunded).toBe(false) // membership.update must NOT be called @@ -920,7 +924,7 @@ describe('BookingService', () => { ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) // slot was FULL → should be restored to OPEN expect(tx.timeSlot.update).toHaveBeenCalledWith( @@ -955,7 +959,7 @@ describe('BookingService', () => { ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) - const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID) + const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) expect(tx.membership.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -971,7 +975,7 @@ describe('BookingService', () => { it('throws NotFoundException when booking does not exist', async () => { ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null) - await expect(service.cancelBooking(MOCK_USER_ID, 'nonexistent')).rejects.toThrow( + await expect(service.cancelBooking(MEMBER_ACTOR, 'nonexistent')).rejects.toThrow( NotFoundException, ) }) @@ -980,7 +984,7 @@ describe('BookingService', () => { const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking) - await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( + await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow( ForbiddenException, ) }) @@ -994,10 +998,90 @@ describe('BookingService', () => { } ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking) - await expect(service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)).rejects.toThrow( + await expect(service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)).rejects.toThrow( BadRequestException, ) }) + + // ─── Admin actor branch ───────────────────────────────────────────────── + + it('admin actor can cancel another user\'s booking (skips owner check)', async () => { + const otherUserBooking = { + ...mockConfirmedBooking, + userId: 'other-user', + timeSlot: { ...futureSlot, bookedCount: 1 }, + membership: mockActiveMembership, + } + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking) + + const tx = buildTxMock() + tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) + tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + const result = await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID) + + expect(result.refunded).toBe(true) + expect(tx.booking.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: BookingStatus.CANCELLED }), + }), + ) + }) + + it('admin actor records 管理员 remark in bookingStatusHistory', async () => { + const otherUserBooking = { + ...mockConfirmedBooking, + userId: 'other-user', + timeSlot: { ...futureSlot, bookedCount: 1 }, + membership: mockActiveMembership, + } + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherUserBooking) + + const tx = buildTxMock() + tx.booking.update.mockResolvedValue({ ...otherUserBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) + tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await service.cancelBooking(ADMIN_ACTOR, MOCK_BOOKING_ID) + + expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + operatorId: MOCK_ADMIN_ID, + remark: '管理员取消预约(超时退款)', + }), + }), + ) + }) + + it('member actor records 学员 remark when cancelling own booking', async () => { + const ownBooking = { + ...mockConfirmedBooking, + timeSlot: { ...futureSlot, bookedCount: 1 }, + membership: mockActiveMembership, + } + ;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking) + + const tx = buildTxMock() + tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() }) + tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 }) + tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 }) + ;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx)) + + await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID) + + expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + operatorId: MOCK_USER_ID, + remark: '学员取消预约(超时退款)', + }), + }), + ) + }) }) // ─── getMyBookings ──────────────────────────────────────────────────────── diff --git a/packages/server/src/booking/booking.controller.ts b/packages/server/src/booking/booking.controller.ts index ee13921..cd91156 100644 --- a/packages/server/src/booking/booking.controller.ts +++ b/packages/server/src/booking/booking.controller.ts @@ -14,6 +14,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard' import { RolesGuard } from '../auth/roles.guard' import { Roles } from '../auth/roles.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator' +import { AuthenticatedUser } from '../auth/jwt.strategy' import { BookingService } from './booking.service' import { CreateBookingDto } from './dto/create-booking.dto' import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto' @@ -36,10 +37,13 @@ export class BookingController { @Put('booking/:id/cancel') @UseGuards(JwtAuthGuard) async cancelBooking( - @CurrentUser('sub') userId: string, + @CurrentUser() user: AuthenticatedUser, @Param('id') id: string, ) { - return this.bookingService.cancelBooking(userId, id) + return this.bookingService.cancelBooking( + { id: user.sub, isAdmin: user.role === UserRole.ADMIN }, + id, + ) } @Get('booking/my/activity') diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index 41f00f0..99ff900 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -487,7 +487,7 @@ export class BookingService { // ─── Cancel Booking ────────────────────────────────────────────────────── async cancelBooking( - userId: string, + actor: { id: string; isAdmin: boolean }, bookingId: string, ): Promise { const booking = await this.prisma.booking.findUnique({ @@ -495,17 +495,18 @@ export class BookingService { include: { timeSlot: true, membership: { include: { cardType: true } }, - review: { select: { rating: true } }, }, }) if (!booking) { throw new NotFoundException(`Booking ${bookingId} not found`) } - if (booking.userId !== userId) { + // Members can only cancel their own bookings; admins can cancel any. + if (!actor.isAdmin && booking.userId !== actor.id) { throw new ForbiddenException('This booking does not belong to you') } + const actorLabel = actor.isAdmin ? '管理员' : '学员' let refunded = false // PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted) @@ -521,8 +522,8 @@ export class BookingService { bookingId, fromStatus: BookingStatus.PENDING_CONFIRMATION, toStatus: BookingStatus.CANCELLED, - operatorId: userId, - remark: '学员取消预约(待确认状态)', + operatorId: actor.id, + remark: `${actorLabel}取消预约(待确认状态)`, }, }) }) @@ -602,8 +603,10 @@ export class BookingService { bookingId, fromStatus: BookingStatus.CONFIRMED, toStatus: BookingStatus.CANCELLED, - operatorId: userId, - remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)', + operatorId: actor.id, + remark: refunded + ? `${actorLabel}取消预约(超时退款)` + : `${actorLabel}取消预约(未超时不退款)`, }, })