fix(booking): 允许管理员/老师通过同一接口取消任意预约
将 cancelBooking 改为接收 actor({ id, isAdmin }),服务端按 JWT role
分流:普通成员仅能取消自己的预约,管理员跳过 owner check。
history 备注自动按 actor 选择「学员/管理员」前缀,审计可追溯。
客户端无感,仍调 PUT /booking/:id/cancel,前端零改动。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ const MOCK_SLOT_ID = 'slot-001'
|
|||||||
const MOCK_MEMBERSHIP_ID = 'mem-001'
|
const MOCK_MEMBERSHIP_ID = 'mem-001'
|
||||||
const MOCK_BOOKING_ID = 'booking-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 = {
|
const mockTimesCardType = {
|
||||||
id: 'ct-times-001',
|
id: 'ct-times-001',
|
||||||
name: '10次卡',
|
name: '10次卡',
|
||||||
@@ -762,7 +766,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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(tx.booking.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -813,7 +817,7 @@ describe('BookingService', () => {
|
|||||||
})
|
})
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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(tx.membership.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -846,7 +850,7 @@ describe('BookingService', () => {
|
|||||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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(tx.membership.update).not.toHaveBeenCalled()
|
||||||
expect(result.refunded).toBe(false)
|
expect(result.refunded).toBe(false)
|
||||||
@@ -875,7 +879,7 @@ describe('BookingService', () => {
|
|||||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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(tx.membership.update).not.toHaveBeenCalled()
|
||||||
expect(result.refunded).toBe(false)
|
expect(result.refunded).toBe(false)
|
||||||
@@ -897,7 +901,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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)
|
expect(result.refunded).toBe(false)
|
||||||
// membership.update must NOT be called
|
// membership.update must NOT be called
|
||||||
@@ -920,7 +924,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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
|
// slot was FULL → should be restored to OPEN
|
||||||
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
expect(tx.timeSlot.update).toHaveBeenCalledWith(
|
||||||
@@ -955,7 +959,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(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(tx.membership.update).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -971,7 +975,7 @@ describe('BookingService', () => {
|
|||||||
it('throws NotFoundException when booking does not exist', async () => {
|
it('throws NotFoundException when booking does not exist', async () => {
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(null)
|
;(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,
|
NotFoundException,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -980,7 +984,7 @@ describe('BookingService', () => {
|
|||||||
const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership }
|
const otherBooking = { ...mockConfirmedBooking, userId: 'other-user', timeSlot: futureSlot, membership: mockActiveMembership }
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(otherBooking)
|
;(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,
|
ForbiddenException,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -994,10 +998,90 @@ describe('BookingService', () => {
|
|||||||
}
|
}
|
||||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(cancelledBooking)
|
;(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,
|
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 ────────────────────────────────────────────────────────
|
// ─── getMyBookings ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
|||||||
import { RolesGuard } from '../auth/roles.guard'
|
import { RolesGuard } from '../auth/roles.guard'
|
||||||
import { Roles } from '../auth/roles.decorator'
|
import { Roles } from '../auth/roles.decorator'
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||||
|
import { AuthenticatedUser } from '../auth/jwt.strategy'
|
||||||
import { BookingService } from './booking.service'
|
import { BookingService } from './booking.service'
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||||
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
|
||||||
@@ -36,10 +37,13 @@ export class BookingController {
|
|||||||
@Put('booking/:id/cancel')
|
@Put('booking/:id/cancel')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async cancelBooking(
|
async cancelBooking(
|
||||||
@CurrentUser('sub') userId: string,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@Param('id') id: string,
|
@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')
|
@Get('booking/my/activity')
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ export class BookingService {
|
|||||||
// ─── Cancel Booking ──────────────────────────────────────────────────────
|
// ─── Cancel Booking ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async cancelBooking(
|
async cancelBooking(
|
||||||
userId: string,
|
actor: { id: string; isAdmin: boolean },
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
): Promise<CancelBookingResult> {
|
): Promise<CancelBookingResult> {
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
@@ -495,17 +495,18 @@ export class BookingService {
|
|||||||
include: {
|
include: {
|
||||||
timeSlot: true,
|
timeSlot: true,
|
||||||
membership: { include: { cardType: true } },
|
membership: { include: { cardType: true } },
|
||||||
review: { select: { rating: true } },
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException(`Booking ${bookingId} not found`)
|
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')
|
throw new ForbiddenException('This booking does not belong to you')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actorLabel = actor.isAdmin ? '管理员' : '学员'
|
||||||
let refunded = false
|
let refunded = false
|
||||||
|
|
||||||
// PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted)
|
// PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted)
|
||||||
@@ -521,8 +522,8 @@ export class BookingService {
|
|||||||
bookingId,
|
bookingId,
|
||||||
fromStatus: BookingStatus.PENDING_CONFIRMATION,
|
fromStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||||
toStatus: BookingStatus.CANCELLED,
|
toStatus: BookingStatus.CANCELLED,
|
||||||
operatorId: userId,
|
operatorId: actor.id,
|
||||||
remark: '学员取消预约(待确认状态)',
|
remark: `${actorLabel}取消预约(待确认状态)`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -602,8 +603,10 @@ export class BookingService {
|
|||||||
bookingId,
|
bookingId,
|
||||||
fromStatus: BookingStatus.CONFIRMED,
|
fromStatus: BookingStatus.CONFIRMED,
|
||||||
toStatus: BookingStatus.CANCELLED,
|
toStatus: BookingStatus.CANCELLED,
|
||||||
operatorId: userId,
|
operatorId: actor.id,
|
||||||
remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)',
|
remark: refunded
|
||||||
|
? `${actorLabel}取消预约(超时退款)`
|
||||||
|
: `${actorLabel}取消预约(未超时不退款)`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user