feat: 支持秒杀活动

This commit is contained in:
richarjiang
2026-04-09 10:24:44 +08:00
parent 23bdd05811
commit 74551085e3
29 changed files with 3521 additions and 760 deletions

View File

@@ -51,6 +51,18 @@ enum OrderStatus {
REFUNDED
}
enum FlashSaleStatus {
DRAFT
ACTIVE
ENDED
}
enum FlashSaleOrderStatus {
RESERVED
PAID
EXPIRED
}
// ===== Models =====
model User {
@@ -67,6 +79,7 @@ model User {
memberships Membership[]
bookings Booking[]
orders Order[]
flashSaleOrders FlashSaleOrder[]
@@map("users")
}
@@ -87,6 +100,7 @@ model CardType {
memberships Membership[]
orders Order[]
flashSales FlashSale[]
@@map("card_types")
}
@@ -197,11 +211,13 @@ model Order {
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")
paidAt DateTime? @map("paid_at")
flashSaleId String? @map("flash_sale_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
flashSaleOrder FlashSaleOrder?
@@index([userId])
@@index([status])
@@ -223,3 +239,48 @@ model StudioConfig {
@@map("studio_config")
}
model FlashSale {
id String @id @default(uuid())
cardTypeId String @map("card_type_id")
title String
originalPrice Decimal @map("original_price") @db.Decimal(10, 0)
flashPrice Decimal @map("flash_price") @db.Decimal(10, 0)
totalStock Int @map("total_stock")
soldCount Int @default(0) @map("sold_count")
startTime DateTime @map("start_time")
endTime DateTime @map("end_time")
status FlashSaleStatus @default(DRAFT)
description String? @db.Text
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cardType CardType @relation(fields: [cardTypeId], references: [id])
orders FlashSaleOrder[]
@@index([status, startTime, endTime])
@@map("flash_sales")
}
model FlashSaleOrder {
id String @id @default(uuid())
flashSaleId String @map("flash_sale_id")
userId String @map("user_id")
orderId String? @unique @map("order_id")
status FlashSaleOrderStatus @default(RESERVED)
reservedAt DateTime @default(now()) @map("reserved_at")
paidAt DateTime? @map("paid_at")
expiredAt DateTime? @map("expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
flashSale FlashSale @relation(fields: [flashSaleId], references: [id])
user User @relation(fields: [userId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
@@unique([flashSaleId, userId])
@@index([userId])
@@index([status])
@@map("flash_sale_orders")
}

View File

@@ -11,6 +11,7 @@ import { BookingModule } from './booking/booking.module'
import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
@Module({
imports: [
@@ -28,6 +29,7 @@ import { AdminModule } from './admin/admin.module'
SchedulerModule,
PaymentModule,
AdminModule,
FlashSaleModule,
],
controllers: [AppController],
})

View File

@@ -0,0 +1,35 @@
import { IsUUID, IsString, IsInt, IsDateString, IsOptional, Min, IsNumber } from 'class-validator'
export class CreateFlashSaleDto {
@IsUUID()
cardTypeId!: string
@IsString()
title!: string
@IsNumber()
@Min(1)
originalPrice!: number
@IsNumber()
@Min(1)
flashPrice!: number
@IsInt()
@Min(1)
totalStock!: number
@IsDateString()
startTime!: string
@IsDateString()
endTime!: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -0,0 +1,43 @@
import { IsString, IsInt, IsDateString, IsOptional, Min, IsNumber, IsEnum } from 'class-validator'
import { FlashSaleStatus } from '@mp-pilates/shared'
export class UpdateFlashSaleDto {
@IsOptional()
@IsString()
title?: string
@IsOptional()
@IsNumber()
@Min(1)
originalPrice?: number
@IsOptional()
@IsNumber()
@Min(1)
flashPrice?: number
@IsOptional()
@IsInt()
@Min(1)
totalStock?: number
@IsOptional()
@IsDateString()
startTime?: string
@IsOptional()
@IsDateString()
endTime?: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsEnum(FlashSaleStatus)
status?: FlashSaleStatus
@IsOptional()
@IsInt()
sortOrder?: number
}

View File

@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
UseGuards,
ValidationPipe,
} from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator'
import { FlashSaleService } from './flash-sale.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
@Controller('admin/flash-sales')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class FlashSaleAdminController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** POST /admin/flash-sales — create */
@Post()
create(
@Body(new ValidationPipe({ whitelist: true })) dto: CreateFlashSaleDto,
) {
return this.flashSaleService.createFlashSale(dto)
}
/** GET /admin/flash-sales — list (paginated) */
@Get()
list(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.flashSaleService.getAdminFlashSales(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
)
}
/** GET /admin/flash-sales/:id — detail */
@Get(':id')
detail(@Param('id') id: string) {
return this.flashSaleService.getFlashSaleDetail(id)
}
/** PUT /admin/flash-sales/:id — update */
@Put(':id')
update(
@Param('id') id: string,
@Body(new ValidationPipe({ whitelist: true })) dto: UpdateFlashSaleDto,
) {
return this.flashSaleService.updateFlashSale(id, dto)
}
/** DELETE /admin/flash-sales/:id — delete (DRAFT only) */
@Delete(':id')
remove(@Param('id') id: string) {
return this.flashSaleService.deleteFlashSale(id)
}
}

View File

@@ -0,0 +1,40 @@
import {
Controller,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { FlashSaleService } from './flash-sale.service'
@Controller('flash-sales')
export class FlashSaleController {
constructor(private readonly flashSaleService: FlashSaleService) {}
/** GET /flash-sales — list active/upcoming (public) */
@Get()
getActiveFlashSales() {
return this.flashSaleService.getActiveFlashSales()
}
/** GET /flash-sales/:id — detail (optionally authenticated) */
@Get(':id')
getFlashSaleDetail(
@Param('id') id: string,
@CurrentUser('sub') userId?: string,
) {
return this.flashSaleService.getFlashSaleDetail(id, userId)
}
/** POST /flash-sales/:id/purchase — requires auth */
@Post(':id/purchase')
@UseGuards(JwtAuthGuard)
purchase(
@Param('id') flashSaleId: string,
@CurrentUser('sub') userId: string,
) {
return this.flashSaleService.purchase(flashSaleId, userId)
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { PrismaModule } from '../prisma/prisma.module'
import { PaymentModule } from '../payment/payment.module'
import { FlashSaleService } from './flash-sale.service'
import { FlashSaleController } from './flash-sale.controller'
import { FlashSaleAdminController } from './flash-sale-admin.controller'
@Module({
imports: [PrismaModule, PaymentModule],
controllers: [FlashSaleController, FlashSaleAdminController],
providers: [FlashSaleService],
exports: [FlashSaleService],
})
export class FlashSaleModule {}

View File

@@ -0,0 +1,409 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common'
import { Prisma } from '@prisma/client'
import {
FlashSaleStatus,
FlashSaleOrderStatus,
MembershipStatus,
OrderStatus,
} from '@mp-pilates/shared'
import { FlashSalePhase } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from '../payment/wechat-pay.service'
import { CreateFlashSaleDto } from './dto/create-flash-sale.dto'
import { UpdateFlashSaleDto } from './dto/update-flash-sale.dto'
// ── Helpers ─────────────────────────────────────────────────
function computePhase(sale: {
startTime: Date
endTime: Date
soldCount: number
totalStock: number
status: string
}): FlashSalePhase {
if (sale.status === FlashSaleStatus.ENDED) return FlashSalePhase.ENDED
const now = new Date()
if (now < sale.startTime) return FlashSalePhase.UPCOMING
if (now > sale.endTime) return FlashSalePhase.ENDED
if (sale.soldCount >= sale.totalStock) return FlashSalePhase.SOLD_OUT
return FlashSalePhase.ONGOING
}
function toNumber(val: Prisma.Decimal | number): number {
return typeof val === 'number' ? val : Number(val)
}
// ── Service ─────────────────────────────────────────────────
@Injectable()
export class FlashSaleService {
private readonly logger = new Logger(FlashSaleService.name)
constructor(
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
) {}
// ═══════════════════════════════════════════════════════════
// USER: List active/upcoming flash sales
// ═══════════════════════════════════════════════════════════
async getActiveFlashSales() {
const sales = await this.prisma.flashSale.findMany({
where: {
status: FlashSaleStatus.ACTIVE,
endTime: { gt: new Date() },
},
include: {
cardType: {
select: { name: true, type: true, totalTimes: true, durationDays: true },
},
},
orderBy: [{ sortOrder: 'asc' }, { startTime: 'asc' }],
})
return sales.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
remainingStock: s.totalStock - s.soldCount,
cardType: s.cardType,
}))
}
// ═══════════════════════════════════════════════════════════
// USER: Get detail (with participation check)
// ═══════════════════════════════════════════════════════════
async getFlashSaleDetail(id: string, userId?: string) {
const sale = await this.prisma.flashSale.findUnique({
where: { id },
include: {
cardType: {
select: {
name: true,
type: true,
totalTimes: true,
durationDays: true,
description: true,
},
},
},
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
let hasParticipated = false
let userOrderStatus: FlashSaleOrderStatus | null = null
if (userId) {
const existing = await this.prisma.flashSaleOrder.findUnique({
where: { flashSaleId_userId: { flashSaleId: id, userId } },
})
if (existing && existing.status !== FlashSaleOrderStatus.EXPIRED) {
hasParticipated = true
userOrderStatus = existing.status as FlashSaleOrderStatus
}
}
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
remainingStock: sale.totalStock - sale.soldCount,
cardType: { ...sale.cardType },
hasParticipated,
userOrderStatus,
serverTime: new Date().toISOString(),
}
}
// ═══════════════════════════════════════════════════════════
// PURCHASE — Atomic stock deduction
// ═══════════════════════════════════════════════════════════
async purchase(flashSaleId: string, userId: string) {
// ① Pre-validate (fast-fail before transaction)
const user = await this.prisma.user.findUnique({ where: { id: userId } })
if (!user) throw new NotFoundException('用户不存在')
if (!user.phone) throw new BadRequestException('请先授权手机号后再参与秒杀')
const sale = await this.prisma.flashSale.findUnique({
where: { id: flashSaleId },
include: { cardType: true },
})
if (!sale) throw new NotFoundException('秒杀活动不存在')
if (sale.status !== FlashSaleStatus.ACTIVE) {
throw new BadRequestException('秒杀活动未上线')
}
const now = new Date()
if (now < sale.startTime) throw new BadRequestException('秒杀尚未开始')
if (now > sale.endTime) throw new BadRequestException('秒杀已结束')
// ② Atomic transaction: reserve stock + create FlashSaleOrder + create Order
let result: { order: { id: string; orderNo: string; amount: Prisma.Decimal }; flashSaleOrderId: string }
try {
result = await this.prisma.$transaction(async (tx) => {
// ②-a: CAS optimistic lock stock deduction
const updated = await tx.flashSale.updateMany({
where: {
id: flashSaleId,
soldCount: { lt: sale.totalStock },
},
data: {
soldCount: { increment: 1 },
},
})
if (updated.count === 0) {
throw new BadRequestException('手慢了,已售罄')
}
// ②-b: Create Order with flash sale price
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await tx.order.create({
data: {
userId,
cardTypeId: sale.cardTypeId,
orderNo,
amount: sale.flashPrice,
status: OrderStatus.PENDING,
flashSaleId,
},
})
// ②-c: Create FlashSaleOrder (unique constraint prevents duplicate)
const flashSaleOrder = await tx.flashSaleOrder.create({
data: {
flashSaleId,
userId,
orderId: order.id,
status: FlashSaleOrderStatus.RESERVED,
},
})
return {
order: { id: order.id, orderNo: order.orderNo, amount: order.amount },
flashSaleOrderId: flashSaleOrder.id,
}
})
} catch (err) {
// Handle unique constraint violation (user already participated)
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new ConflictException('您已参与过此秒杀活动')
}
throw err
}
// ③ Create WeChat unified order (outside transaction — network call)
const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
openid: user.openid,
description: `秒杀-${sale.title}`,
})
return {
flashSaleOrderId: result.flashSaleOrderId,
order: {
id: result.order.id,
orderNo: result.order.orderNo,
amount: toNumber(result.order.amount),
},
paymentParams,
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Create flash sale
// ═══════════════════════════════════════════════════════════
async createFlashSale(dto: CreateFlashSaleDto) {
const cardType = await this.prisma.cardType.findUnique({
where: { id: dto.cardTypeId },
})
if (!cardType) throw new NotFoundException('卡种不存在')
const startTime = new Date(dto.startTime)
const endTime = new Date(dto.endTime)
if (endTime <= startTime) {
throw new BadRequestException('结束时间必须晚于开始时间')
}
const sale = await this.prisma.flashSale.create({
data: {
cardTypeId: dto.cardTypeId,
title: dto.title,
originalPrice: dto.originalPrice,
flashPrice: dto.flashPrice,
totalStock: dto.totalStock,
startTime,
endTime,
description: dto.description ?? null,
sortOrder: dto.sortOrder ?? 0,
status: FlashSaleStatus.DRAFT,
},
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Update flash sale
// ═══════════════════════════════════════════════════════════
async updateFlashSale(id: string, dto: UpdateFlashSaleDto) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
const data: Record<string, unknown> = {}
if (dto.title !== undefined) data.title = dto.title
if (dto.originalPrice !== undefined) data.originalPrice = dto.originalPrice
if (dto.flashPrice !== undefined) data.flashPrice = dto.flashPrice
if (dto.totalStock !== undefined) {
if (dto.totalStock < existing.soldCount) {
throw new BadRequestException('库存不能小于已售数量')
}
data.totalStock = dto.totalStock
}
if (dto.startTime !== undefined) data.startTime = new Date(dto.startTime)
if (dto.endTime !== undefined) data.endTime = new Date(dto.endTime)
if (dto.description !== undefined) data.description = dto.description
if (dto.status !== undefined) data.status = dto.status
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder
const sale = await this.prisma.flashSale.update({
where: { id },
data,
include: {
cardType: { select: { name: true, type: true } },
},
})
return {
...sale,
originalPrice: toNumber(sale.originalPrice),
flashPrice: toNumber(sale.flashPrice),
phase: computePhase(sale),
cardType: { ...sale.cardType },
}
}
// ═══════════════════════════════════════════════════════════
// ADMIN: Delete flash sale (only DRAFT)
// ═══════════════════════════════════════════════════════════
async deleteFlashSale(id: string) {
const existing = await this.prisma.flashSale.findUnique({ where: { id } })
if (!existing) throw new NotFoundException('秒杀活动不存在')
if (existing.soldCount > 0) {
throw new BadRequestException('已有用户参与,无法删除,请结束活动')
}
await this.prisma.flashSale.delete({ where: { id } })
return { deleted: true }
}
// ═══════════════════════════════════════════════════════════
// ADMIN: List all flash sales (paginated)
// ═══════════════════════════════════════════════════════════
async getAdminFlashSales(page = 1, limit = 20) {
const skip = (page - 1) * limit
const [data, total] = await Promise.all([
this.prisma.flashSale.findMany({
include: {
cardType: { select: { name: true, type: true } },
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
this.prisma.flashSale.count(),
])
return {
data: data.map((s) => ({
...s,
originalPrice: toNumber(s.originalPrice),
flashPrice: toNumber(s.flashPrice),
phase: computePhase(s),
cardType: s.cardType,
})),
total,
page,
limit,
}
}
// ═══════════════════════════════════════════════════════════
// SCHEDULER: Expire unpaid reservations (release stock)
// ═══════════════════════════════════════════════════════════
async expireUnpaidReservations(expireMinutes = 15): Promise<number> {
const cutoff = new Date(Date.now() - expireMinutes * 60_000)
const expiredOrders = await this.prisma.flashSaleOrder.findMany({
where: {
status: FlashSaleOrderStatus.RESERVED,
reservedAt: { lt: cutoff },
},
})
if (expiredOrders.length === 0) return 0
// Group by flashSaleId to batch stock release
const stockDecrements = new Map<string, number>()
const orderIds: string[] = []
const flashSaleOrderIds: string[] = []
for (const fo of expiredOrders) {
flashSaleOrderIds.push(fo.id)
stockDecrements.set(fo.flashSaleId, (stockDecrements.get(fo.flashSaleId) ?? 0) + 1)
if (fo.orderId) orderIds.push(fo.orderId)
}
try {
await this.prisma.$transaction([
// Batch mark all as expired
this.prisma.flashSaleOrder.updateMany({
where: { id: { in: flashSaleOrderIds } },
data: { status: FlashSaleOrderStatus.EXPIRED, expiredAt: new Date() },
}),
// Release stock per flash sale
...Array.from(stockDecrements.entries()).map(([flashSaleId, count]) =>
this.prisma.flashSale.update({
where: { id: flashSaleId },
data: { soldCount: { decrement: count } },
}),
),
// Cancel associated payment orders
...(orderIds.length > 0
? [
this.prisma.order.updateMany({
where: { id: { in: orderIds } },
data: { status: OrderStatus.REFUNDED },
}),
]
: []),
])
} catch (err) {
this.logger.error('Failed to batch-expire flash sale orders', err)
return 0
}
return expiredOrders.length
}
}

View File

@@ -8,6 +8,6 @@ import { WechatPayService } from './wechat-pay.service'
imports: [PrismaModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService],
exports: [PaymentService, WechatPayService],
})
export class PaymentModule {}

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
@@ -136,6 +136,22 @@ export class PaymentService {
])
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──
if (existingOrder.flashSaleId) {
await this.prisma.flashSaleOrder.updateMany({
where: {
orderId: existingOrder.id,
status: FlashSaleOrderStatus.RESERVED,
},
data: {
status: FlashSaleOrderStatus.PAID,
paidAt: now,
},
})
this.logger.log(`Flash sale order marked PAID for orderNo=${notification.orderNo}`)
}
return this.buildSuccessXml()
}

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common'
import { ScheduleModule } from '@nestjs/schedule'
import { TimeSlotModule } from '../time-slot/time-slot.module'
import { FlashSaleModule } from '../flash-sale/flash-sale.module'
import { SchedulerService } from './scheduler.service'
@Module({
imports: [
ScheduleModule.forRoot(),
TimeSlotModule,
FlashSaleModule,
],
providers: [SchedulerService],
})

View File

@@ -1,12 +1,16 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron } from '@nestjs/schedule'
import { SlotGeneratorService } from '../time-slot/slot-generator.service'
import { FlashSaleService } from '../flash-sale/flash-sale.service'
@Injectable()
export class SchedulerService {
private readonly logger = new Logger(SchedulerService.name)
constructor(private readonly slotGenerator: SlotGeneratorService) {}
constructor(
private readonly slotGenerator: SlotGeneratorService,
private readonly flashSaleService: FlashSaleService,
) {}
/** 02:00 daily — generate slots 14 days ahead from week templates */
@Cron('0 2 * * *')
@@ -51,4 +55,17 @@ export class SchedulerService {
this.logger.error('[handleCompleteBookings] Failed to complete bookings', err)
}
}
/** Every 5 min — expire unpaid flash sale reservations older than 15min */
@Cron('*/5 * * * *')
async handleExpireFlashSaleReservations(): Promise<void> {
try {
const count = await this.flashSaleService.expireUnpaidReservations(15)
if (count > 0) {
this.logger.log(`[handleExpireFlashSaleReservations] Expired ${count} unpaid reservations`)
}
} catch (err) {
this.logger.error('[handleExpireFlashSaleReservations] Failed', err)
}
}
}