perf: 优化订阅刷新逻辑

This commit is contained in:
richarjiang
2026-09-07 14:06:19 +08:00
parent 88cd8419c8
commit f5c7b7eaac
27 changed files with 3248 additions and 1060 deletions

View File

@@ -129,6 +129,8 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
timeSlot: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
updateMany: jest.fn(),
},
booking: {
findUnique: jest.fn(),
@@ -140,6 +142,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
bookingStatusHistory: {
create: jest.fn(),
},
@@ -1001,4 +1006,412 @@ describe('BookingService', () => {
)
})
})
describe('adminArrangeBooking', () => {
const MOCK_ADMIN_ID = 'admin-001'
const dto = {
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
}
const mockTrialCardType = {
...mockTimesCardType,
id: 'ct-trial-001',
name: '体验卡',
type: CardTypeCategory.TRIAL,
totalTimes: 1,
}
const mockTrialMembership = {
...mockActiveMembership,
id: 'mem-trial-001',
cardTypeId: mockTrialCardType.id,
remainingTimes: 1,
cardType: mockTrialCardType,
}
function stubArrangeSuccess(
tx: ReturnType<typeof buildTxMock>,
options?: {
membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockTrialMembership
slot?: typeof mockOpenSlot
existing?: typeof mockConfirmedBooking | null
},
) {
const membership = options?.membership ?? mockActiveMembership
const slot = options?.slot ?? mockOpenSlot
const existing = options?.existing ?? null
const arranged = {
...mockConfirmedBooking,
membershipId: membership.id,
status: BookingStatus.CONFIRMED,
confirmedAt: new Date(),
operatorId: MOCK_ADMIN_ID,
}
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.timeSlot.findUnique
.mockResolvedValueOnce(slot)
.mockResolvedValueOnce({ ...slot, bookedCount: slot.bookedCount + 1 })
tx.booking.findUnique.mockResolvedValue(existing)
tx.membership.findUnique.mockResolvedValue(membership)
tx.booking.create.mockResolvedValue(arranged)
tx.booking.update.mockResolvedValue(arranged)
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
tx.timeSlot.update.mockResolvedValue({ ...slot, bookedCount: slot.bookedCount + 1 })
tx.membership.update.mockResolvedValue({
...membership,
remainingTimes: membership.remainingTimes == null ? null : membership.remainingTimes - 1,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...arranged,
timeSlot: slot,
membership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
return arranged
}
it('creates a confirmed times-card booking and deducts one session', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx)
const result = await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
expect(tx.booking.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: MOCK_USER_ID,
timeSlotId: MOCK_SLOT_ID,
membershipId: MOCK_MEMBERSHIP_ID,
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 4, status: MembershipStatus.ACTIVE }),
}),
)
expect(tx.timeSlot.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: MOCK_SLOT_ID,
status: TimeSlotStatus.OPEN,
bookedCount: { lt: mockOpenSlot.capacity },
}),
data: { bookedCount: { increment: 1 } },
}),
)
expect(tx.bookingStatusHistory.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
toStatus: BookingStatus.CONFIRMED,
remark: '老师代为安排',
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(subscriptionMessageService.sendBookingConfirmedMessage).toHaveBeenCalled()
expect(subscriptionMessageService.sendAdminBookingCreatedMessage).not.toHaveBeenCalled()
expect(result.status).toBe(BookingStatus.CONFIRMED)
})
it('does not deduct remaining times for duration cards', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockDurationMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockDurationMembership.id,
})
expect(tx.membership.update).not.toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalled()
})
it('deducts a trial card session', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx, { membership: mockTrialMembership })
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockTrialMembership.id,
})
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 0,
status: MembershipStatus.USED_UP,
}),
}),
)
})
it('rejects when the times card has no remaining sessions', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('rejects when a duration card has expired', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockDurationMembership,
expireDate: new Date('2020-01-01'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.adminArrangeBooking(MOCK_ADMIN_ID, {
...dto,
membershipId: mockDurationMembership.id,
}),
).rejects.toThrow(BadRequestException)
})
it('rejects when the time slot is full', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockFullSlot)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.membership.findUnique).not.toHaveBeenCalled()
})
it('rejects duplicate active bookings for the same slot', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
ConflictException,
)
})
it('rejects arranging a past time slot', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue({
...mockOpenSlot,
date: new Date('2020-01-01T00:00:00Z'),
startTime: '09:00',
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
})
it('revives a cancelled booking instead of creating a new row', async () => {
const tx = buildTxMock()
const cancelled = {
...mockConfirmedBooking,
status: BookingStatus.CANCELLED,
}
stubArrangeSuccess(tx, { existing: cancelled })
await service.adminArrangeBooking(MOCK_ADMIN_ID, dto)
expect(tx.booking.create).not.toHaveBeenCalled()
expect(tx.booking.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: cancelled.id },
data: expect.objectContaining({
status: BookingStatus.CONFIRMED,
operatorId: MOCK_ADMIN_ID,
}),
}),
)
expect(tx.timeSlot.updateMany).toHaveBeenCalled()
expect(tx.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ remainingTimes: 4 }),
}),
)
})
it('rejects when the member does not exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
NotFoundException,
)
})
it('rejects when the membership belongs to another member', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockActiveMembership,
userId: 'other-user',
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
ForbiddenException,
)
})
it('rejects an expired times card even if remaining sessions exist', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue({
...mockActiveMembership,
expireDate: new Date('2020-01-01'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.timeSlot.updateMany).not.toHaveBeenCalled()
})
it('rejects when occupancy update races and the slot is already full', async () => {
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.timeSlot.updateMany.mockResolvedValue({ count: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(service.adminArrangeBooking(MOCK_ADMIN_ID, dto)).rejects.toThrow(
BadRequestException,
)
expect(tx.booking.create).not.toHaveBeenCalled()
})
it('reuses an existing slot when arranging by date and time', async () => {
const tx = buildTxMock()
stubArrangeSuccess(tx)
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
userId: MOCK_USER_ID,
membershipId: MOCK_MEMBERSHIP_ID,
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
})
expect(tx.timeSlot.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: {
date_startTime_endTime: {
date: new Date('2099-12-31T00:00:00.000Z'),
startTime: '09:00',
endTime: '10:00',
},
},
}),
)
expect(tx.timeSlot.create).not.toHaveBeenCalled()
expect(tx.booking.create).toHaveBeenCalled()
})
it('creates a manual slot when arranging a missing date and time', async () => {
const tx = buildTxMock()
const createdSlot = { ...mockOpenSlot, id: 'slot-manual-001', source: 'MANUAL' }
tx.timeSlot.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ ...createdSlot, bookedCount: 1 })
tx.timeSlot.create.mockResolvedValue(createdSlot)
tx.user.findUnique.mockResolvedValue({ id: MOCK_USER_ID })
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
tx.timeSlot.updateMany.mockResolvedValue({ count: 1 })
tx.booking.create.mockResolvedValue({
...mockConfirmedBooking,
timeSlotId: createdSlot.id,
status: BookingStatus.CONFIRMED,
})
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 4 })
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
timeSlotId: createdSlot.id,
status: BookingStatus.CONFIRMED,
timeSlot: createdSlot,
membership: mockActiveMembership,
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
studioService.getInfo.mockResolvedValue({
...mockStudioConfig,
name: 'FocusCore Pilates',
})
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
userId: MOCK_USER_ID,
membershipId: MOCK_MEMBERSHIP_ID,
date: '2099-12-31',
startTime: '09:00',
endTime: '10:00',
})
expect(tx.timeSlot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
startTime: '09:00',
endTime: '10:00',
source: 'MANUAL',
}),
}),
)
})
it('rejects custom slots whose end time is not after start time', async () => {
const tx = buildTxMock()
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
await expect(
service.adminArrangeBooking(MOCK_ADMIN_ID, {
userId: MOCK_USER_ID,
membershipId: MOCK_MEMBERSHIP_ID,
date: '2099-12-31',
startTime: '23:00',
endTime: '00:00',
}),
).rejects.toThrow(BadRequestException)
expect(tx.timeSlot.findUnique).not.toHaveBeenCalled()
expect(tx.timeSlot.create).not.toHaveBeenCalled()
})
})
})

View File

@@ -16,6 +16,7 @@ import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { BookingService } from './booking.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
@Controller()
export class BookingController {
@@ -92,6 +93,16 @@ export class BookingController {
)
}
@Post('admin/bookings')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
async arrangeBooking(
@CurrentUser('sub') operatorId: string,
@Body() dto: AdminArrangeBookingDto,
) {
return this.bookingService.adminArrangeBooking(operatorId, dto)
}
@Get('admin/teaching-schedule')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)

View File

@@ -5,11 +5,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common'
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
import { Booking, Membership, Prisma, TimeSlot, BookingStatusHistory } from '@prisma/client'
import {
BookingStatus,
CardTypeCategory,
DEFAULT_SLOT_CAPACITY,
MembershipStatus,
TimeSlotSource,
TimeSlotStatus,
type TeachingScheduleSlot,
} from '@mp-pilates/shared'
@@ -18,6 +20,7 @@ import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { AdminArrangeBookingDto } from './dto/admin-arrange-booking.dto'
import { InviteService } from '../invite/invite.service'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -48,6 +51,23 @@ function buildSlotStartMs(slotDate: Date, startTime: string): number {
return d.getTime()
}
function normalizeClock(time: string): string {
return time.slice(0, 5)
}
function clockToMinutes(time: string): number {
const [hours, minutes] = normalizeClock(time).split(':').map(Number)
return hours * 60 + minutes
}
function parseSlotDate(date: string): Date {
const parsed = new Date(`${date}T00:00:00.000Z`)
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('Invalid date')
}
return parsed
}
// ─── Service ───────────────────────────────────────────────────────────────
@Injectable()
@@ -273,6 +293,195 @@ export class BookingService {
return confirmedBooking
}
async adminArrangeBooking(
operatorId: string,
dto: AdminArrangeBookingDto,
): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => {
const timeSlot = await this.resolveArrangeSlot(tx, dto)
if (timeSlot.status !== TimeSlotStatus.OPEN) {
throw new BadRequestException(
`TimeSlot is not available (status: ${timeSlot.status})`,
)
}
if (Date.now() >= buildSlotStartMs(timeSlot.date, timeSlot.startTime)) {
throw new BadRequestException('Cannot arrange a past time slot')
}
const user = await tx.user.findUnique({
where: { id: dto.userId },
select: { id: true },
})
if (!user) {
throw new NotFoundException(`User ${dto.userId} not found`)
}
const existing = await tx.booking.findUnique({
where: {
userId_timeSlotId: {
userId: dto.userId,
timeSlotId: timeSlot.id,
},
},
})
if (existing && existing.status !== BookingStatus.CANCELLED) {
throw new ConflictException('Member already has a booking for this time slot')
}
const membership = await tx.membership.findUnique({
where: { id: dto.membershipId },
include: { cardType: true },
})
if (!membership) {
throw new NotFoundException(`Membership ${dto.membershipId} not found`)
}
if (membership.userId !== dto.userId) {
throw new ForbiddenException('This membership does not belong to the member')
}
if (membership.status !== MembershipStatus.ACTIVE) {
throw new BadRequestException(
`Membership is not active (status: ${membership.status})`,
)
}
if (membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired')
}
const cardType = membership.cardType
const isTimeBased =
cardType.type === CardTypeCategory.TIMES ||
cardType.type === CardTypeCategory.TRIAL
if (isTimeBased && (membership.remainingTimes ?? 0) <= 0) {
throw new BadRequestException('No remaining times on this membership')
}
const occupied = await tx.timeSlot.updateMany({
where: {
id: timeSlot.id,
status: TimeSlotStatus.OPEN,
bookedCount: { lt: timeSlot.capacity },
},
data: {
bookedCount: { increment: 1 },
},
})
if (occupied.count !== 1) {
throw new BadRequestException('Time slot is full')
}
const occupiedSlot = await tx.timeSlot.findUnique({ where: { id: timeSlot.id } })
if (occupiedSlot && occupiedSlot.bookedCount >= occupiedSlot.capacity) {
await tx.timeSlot.update({
where: { id: timeSlot.id },
data: { status: TimeSlotStatus.FULL },
})
}
const now = new Date()
const arranged = existing
? await tx.booking.update({
where: { id: existing.id },
data: {
membershipId: dto.membershipId,
status: BookingStatus.CONFIRMED,
cancelledAt: null,
confirmedAt: now,
completedAt: null,
operatorId,
},
})
: await tx.booking.create({
data: {
userId: dto.userId,
timeSlotId: timeSlot.id,
membershipId: dto.membershipId,
status: BookingStatus.CONFIRMED,
confirmedAt: now,
operatorId,
},
})
if (isTimeBased) {
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
await tx.membership.update({
where: { id: membership.id },
data: {
remainingTimes: newRemainingTimes,
status: newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
}
await tx.bookingStatusHistory.create({
data: {
bookingId: arranged.id,
fromStatus: existing?.status === BookingStatus.CANCELLED
? BookingStatus.CANCELLED
: null,
toStatus: BookingStatus.CONFIRMED,
operatorId,
remark: '老师代为安排',
},
})
return arranged
})
const arrangedBooking = await this.fetchBookingWithRelations(booking.id)
await this.trySendBookingConfirmedSubscriptionMessage(arrangedBooking)
return arrangedBooking
}
private async resolveArrangeSlot(
tx: Prisma.TransactionClient,
dto: AdminArrangeBookingDto,
): Promise<TimeSlot> {
if (dto.timeSlotId) {
const slot = await tx.timeSlot.findUnique({
where: { id: dto.timeSlotId },
})
if (!slot) {
throw new NotFoundException(`TimeSlot ${dto.timeSlotId} not found`)
}
return slot
}
if (!dto.date || !dto.startTime || !dto.endTime) {
throw new BadRequestException('timeSlotId or date+startTime+endTime is required')
}
const startTime = normalizeClock(dto.startTime)
const endTime = normalizeClock(dto.endTime)
if (clockToMinutes(endTime) <= clockToMinutes(startTime)) {
throw new BadRequestException('End time must be after start time')
}
const date = parseSlotDate(dto.date)
const existing = await tx.timeSlot.findUnique({
where: {
date_startTime_endTime: {
date,
startTime,
endTime,
},
},
})
if (existing) {
return existing
}
return tx.timeSlot.create({
data: {
date,
startTime,
endTime,
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
source: TimeSlotSource.MANUAL,
},
})
}
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
async completeBooking(

View File

@@ -0,0 +1,40 @@
import { Type } from 'class-transformer'
import {
IsDateString,
IsInt,
IsOptional,
IsUUID,
Matches,
Min,
ValidateIf,
} from 'class-validator'
export class AdminArrangeBookingDto {
@IsUUID()
userId!: string
@IsUUID()
membershipId!: string
@IsOptional()
@IsUUID()
timeSlotId?: string
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
@IsDateString()
date?: string
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
startTime?: string
@ValidateIf((dto: AdminArrangeBookingDto) => !dto.timeSlotId)
@Matches(/^\d{2}:\d{2}(:\d{2})?$/)
endTime?: string
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
capacity?: number
}