feat: 支持分享邀请好友功能
This commit is contained in:
@@ -63,6 +63,12 @@ enum FlashSaleOrderStatus {
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum InviteReferralStatus {
|
||||
REGISTERED
|
||||
TRIAL_PURCHASED
|
||||
QUALIFIED
|
||||
}
|
||||
|
||||
// ===== Models =====
|
||||
|
||||
model User {
|
||||
@@ -82,6 +88,9 @@ model User {
|
||||
orders Order[]
|
||||
flashSaleOrders FlashSaleOrder[]
|
||||
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -147,6 +156,7 @@ model Membership {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
bookings Booking[]
|
||||
inviteRewardGrants InviteRewardGrant[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@ -206,6 +216,7 @@ model Booking {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||
membership Membership @relation(fields: [membershipId], references: [id])
|
||||
qualifiedInviteReferrals InviteReferral[]
|
||||
|
||||
statusHistory BookingStatusHistory[]
|
||||
|
||||
@@ -246,12 +257,54 @@ model Order {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
flashSaleOrder FlashSaleOrder?
|
||||
inviteReferrals InviteReferral[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model InviteReferral {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
inviteeId String @unique @map("invitee_id")
|
||||
status InviteReferralStatus @default(REGISTERED)
|
||||
trialOrderId String? @unique @map("trial_order_id")
|
||||
qualifiedBookingId String? @unique @map("qualified_booking_id")
|
||||
invitedAt DateTime @default(now()) @map("invited_at")
|
||||
trialPurchasedAt DateTime? @map("trial_purchased_at")
|
||||
qualifiedAt DateTime? @map("qualified_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
inviter User @relation("InviteReferralInviter", fields: [inviterId], references: [id])
|
||||
invitee User @relation("InviteReferralInvitee", fields: [inviteeId], references: [id])
|
||||
trialOrder Order? @relation(fields: [trialOrderId], references: [id])
|
||||
qualifiedBooking Booking? @relation(fields: [qualifiedBookingId], references: [id])
|
||||
|
||||
@@unique([inviterId, inviteeId])
|
||||
@@index([inviterId, status])
|
||||
@@index([status])
|
||||
@@map("invite_referrals")
|
||||
}
|
||||
|
||||
model InviteRewardGrant {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
membershipId String? @map("membership_id")
|
||||
qualifiedReferralCount Int @map("qualified_referral_count")
|
||||
rewardTimes Int @default(1) @map("reward_times")
|
||||
grantedAt DateTime @default(now()) @map("granted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||
|
||||
@@index([inviterId, grantedAt])
|
||||
@@map("invite_reward_grants")
|
||||
}
|
||||
|
||||
model StudioConfig {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
import { InviteModule } from './invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,6 +31,7 @@ import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
||||
PaymentModule,
|
||||
AdminModule,
|
||||
FlashSaleModule,
|
||||
InviteModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserRole } from '@mp-pilates/shared'
|
||||
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||
import { WechatService } from '../wechat.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,6 +47,10 @@ const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
}
|
||||
|
||||
const mockInviteService = {
|
||||
bindInviterToUser: jest.fn(),
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AuthService', () => {
|
||||
@@ -58,6 +63,7 @@ describe('AuthService', () => {
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
{ provide: WechatService, useValue: mockWechatService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: InviteService, useValue: mockInviteService },
|
||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||
],
|
||||
}).compile()
|
||||
@@ -91,10 +97,20 @@ describe('AuthService', () => {
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, nickname: TEST_NICKNAME },
|
||||
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||
})
|
||||
expect(result.user).toEqual(mockUser)
|
||||
expect(result.isNewUser).toBe(true)
|
||||
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
|
||||
})
|
||||
|
||||
it('binds inviter for new users when inviterId is present', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||
mockPrismaService.user.create.mockResolvedValue(mockUser)
|
||||
|
||||
await authService.login(loginCode, undefined, undefined, 'inviter-001')
|
||||
|
||||
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, 'inviter-001')
|
||||
})
|
||||
|
||||
it('creates user with unionid when present', async () => {
|
||||
@@ -110,7 +126,7 @@ describe('AuthService', () => {
|
||||
await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
|
||||
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export class AuthController {
|
||||
loginDto.code,
|
||||
loginDto.nickname,
|
||||
loginDto.avatarUrl,
|
||||
loginDto.inviterId,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { WechatService } from './wechat.service'
|
||||
import { JwtStrategy } from './jwt.strategy'
|
||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||
import { RolesGuard } from './roles.guard'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
InviteModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { User } from '@prisma/client'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatService } from './wechat.service'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
@@ -55,6 +56,7 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly wechatService: WechatService,
|
||||
private readonly inviteService: InviteService,
|
||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||
) {}
|
||||
|
||||
@@ -62,6 +64,7 @@ export class AuthService {
|
||||
code: string,
|
||||
nickname?: string,
|
||||
avatarUrl?: string,
|
||||
inviterId?: string,
|
||||
): Promise<LoginResult> {
|
||||
const { openid, unionid, sessionKey } =
|
||||
await this.wechatService.code2Session(code)
|
||||
@@ -98,6 +101,10 @@ export class AuthService {
|
||||
|
||||
sessionKeyStore.set(user.id, sessionKey)
|
||||
|
||||
if (isNewUser) {
|
||||
await this.inviteService.bindInviterToUser(user.id, inviterId)
|
||||
}
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
|
||||
|
||||
@@ -12,4 +12,8 @@ export class LoginDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
avatarUrl?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
inviterId?: string
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { MembershipService } from '../../membership/membership.service'
|
||||
import { StudioService } from '../../studio/studio.service'
|
||||
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
|
||||
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -153,6 +154,7 @@ describe('BookingService', () => {
|
||||
let prisma: jest.Mocked<PrismaService>
|
||||
let studioService: jest.Mocked<StudioService>
|
||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
||||
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -204,6 +206,12 @@ describe('BookingService', () => {
|
||||
sendAdminBookingCreatedMessage: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: InviteService,
|
||||
useValue: {
|
||||
recordQualifiedTrialBooking: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile()
|
||||
|
||||
@@ -211,6 +219,7 @@ describe('BookingService', () => {
|
||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||
subscriptionMessageService = module.get(SubscriptionMessageService)
|
||||
inviteService = module.get(InviteService)
|
||||
})
|
||||
|
||||
afterEach(() => jest.clearAllMocks())
|
||||
@@ -262,6 +271,44 @@ describe('BookingService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeBooking', () => {
|
||||
it('records qualified trial booking after completion', async () => {
|
||||
const tx = buildTxMock({
|
||||
bookingStatusHistory: { create: jest.fn() },
|
||||
})
|
||||
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.COMPLETED,
|
||||
completedAt: new Date('2099-12-31T11:00:00Z'),
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
status: BookingStatus.COMPLETED,
|
||||
completedAt: new Date('2099-12-31T11:00:00Z'),
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: {
|
||||
...mockActiveMembership,
|
||||
cardType: {
|
||||
...mockTimesCardType,
|
||||
type: CardTypeCategory.TRIAL,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── createBooking ────────────────────────────────────────────────────────
|
||||
|
||||
describe('createBooking', () => {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { BookingService } from './booking.service'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { StudioModule } from '../studio/studio.module'
|
||||
import { UserModule } from '../user/user.module'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [MembershipModule, StudioModule, UserModule],
|
||||
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
|
||||
controllers: [BookingController],
|
||||
providers: [BookingService],
|
||||
exports: [BookingService],
|
||||
|
||||
@@ -12,6 +12,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 { InviteService } from '../invite/invite.service'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -50,6 +51,7 @@ export class BookingService {
|
||||
private readonly membershipService: MembershipService,
|
||||
private readonly studioService: StudioService,
|
||||
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||
private readonly inviteService: InviteService,
|
||||
) {}
|
||||
|
||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
||||
@@ -330,7 +332,11 @@ export class BookingService {
|
||||
return updated
|
||||
})
|
||||
|
||||
return this.fetchBookingWithRelations(booking.id)
|
||||
const result = await this.fetchBookingWithRelations(booking.id)
|
||||
if (toStatus === BookingStatus.COMPLETED) {
|
||||
await this.inviteService.recordQualifiedTrialBooking(result.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Cancel Booking ──────────────────────────────────────────────────────
|
||||
|
||||
3
packages/server/src/invite/invite.constants.ts
Normal file
3
packages/server/src/invite/invite.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const INVITE_REWARD_REQUIRED_COUNT = 3
|
||||
export const INVITE_REWARD_TIMES = 1
|
||||
|
||||
16
packages/server/src/invite/invite.controller.ts
Normal file
16
packages/server/src/invite/invite.controller.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { InviteService } from './invite.service'
|
||||
|
||||
@Controller('invite')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InviteController {
|
||||
constructor(private readonly inviteService: InviteService) {}
|
||||
|
||||
@Get('activity')
|
||||
getActivity(@CurrentUser('sub') userId: string) {
|
||||
return this.inviteService.getInviteActivitySummary(userId)
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/server/src/invite/invite.module.ts
Normal file
11
packages/server/src/invite/invite.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { InviteController } from './invite.controller'
|
||||
import { InviteService } from './invite.service'
|
||||
|
||||
@Module({
|
||||
controllers: [InviteController],
|
||||
providers: [InviteService],
|
||||
exports: [InviteService],
|
||||
})
|
||||
export class InviteModule {}
|
||||
|
||||
253
packages/server/src/invite/invite.service.ts
Normal file
253
packages/server/src/invite/invite.service.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client'
|
||||
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import {
|
||||
INVITE_REWARD_REQUIRED_COUNT,
|
||||
INVITE_REWARD_TIMES,
|
||||
} from './invite.constants'
|
||||
|
||||
@Injectable()
|
||||
export class InviteService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private isTrialCardType(type: string): boolean {
|
||||
return type === 'TRIAL'
|
||||
}
|
||||
|
||||
async bindInviterToUser(inviteeId: string, inviterId?: string | null): Promise<void> {
|
||||
if (!inviterId || inviterId === inviteeId) {
|
||||
return
|
||||
}
|
||||
|
||||
const [inviter, inviteeMembershipCount, existingReferral] = await Promise.all([
|
||||
this.prisma.user.findUnique({ where: { id: inviterId }, select: { id: true } }),
|
||||
this.prisma.membership.count({ where: { userId: inviteeId } }),
|
||||
this.prisma.inviteReferral.findUnique({ where: { inviteeId } }),
|
||||
])
|
||||
|
||||
if (!inviter || inviteeMembershipCount > 0 || existingReferral) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.create({
|
||||
data: {
|
||||
inviterId,
|
||||
inviteeId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async recordTrialOrderPaid(orderId: string): Promise<void> {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
cardType: true,
|
||||
user: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!order || order.status !== OrderStatus.PAID || !this.isTrialCardType(order.cardType.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.updateMany({
|
||||
where: {
|
||||
inviteeId: order.user.id,
|
||||
status: InviteReferralStatus.REGISTERED,
|
||||
trialOrderId: null,
|
||||
},
|
||||
data: {
|
||||
status: InviteReferralStatus.TRIAL_PURCHASED,
|
||||
trialOrderId: order.id,
|
||||
trialPurchasedAt: order.paidAt ?? new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async recordQualifiedTrialBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
membership: { include: { cardType: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
|
||||
return
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findFirst({
|
||||
where: {
|
||||
inviteeId: booking.userId,
|
||||
status: {
|
||||
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
|
||||
},
|
||||
qualifiedBookingId: null,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
if (!referral) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.update({
|
||||
where: { id: referral.id },
|
||||
data: {
|
||||
status: InviteReferralStatus.QUALIFIED,
|
||||
qualifiedBookingId: booking.id,
|
||||
qualifiedAt: booking.completedAt ?? new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await this.grantRewardsIfEligible(referral.inviterId)
|
||||
}
|
||||
|
||||
async getInviteActivitySummary(userId: string): Promise<InviteActivitySummary> {
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
|
||||
})
|
||||
const referrals = await this.prisma.inviteReferral.findMany({
|
||||
where: { inviterId: userId },
|
||||
include: {
|
||||
invitee: {
|
||||
select: {
|
||||
id: true,
|
||||
nickname: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const rewardGrants = await this.prisma.inviteRewardGrant.findMany({
|
||||
where: { inviterId: userId },
|
||||
orderBy: { grantedAt: 'desc' },
|
||||
})
|
||||
|
||||
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE)
|
||||
const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length
|
||||
const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0)
|
||||
const pendingRewardGrantCount = Math.max(
|
||||
0,
|
||||
qualifiedInviteCount - rewardGrants.length * INVITE_REWARD_REQUIRED_COUNT,
|
||||
)
|
||||
const currentCycleQualifiedCount = qualifiedInviteCount % INVITE_REWARD_REQUIRED_COUNT
|
||||
|
||||
return {
|
||||
inviterId: userId,
|
||||
canInvite,
|
||||
sharePath: `/pages/profile/invite?inviterId=${userId}`,
|
||||
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
|
||||
rewardRuleTimes: INVITE_REWARD_TIMES,
|
||||
qualifiedInviteCount,
|
||||
rewardedTimes,
|
||||
pendingRewardGrantCount,
|
||||
pendingInviteCount: referrals.filter((item: InviteReferral) => item.status !== InviteReferralStatus.QUALIFIED).length,
|
||||
currentCycleQualifiedCount,
|
||||
nextRewardRemainingCount: currentCycleQualifiedCount === 0
|
||||
? INVITE_REWARD_REQUIRED_COUNT
|
||||
: INVITE_REWARD_REQUIRED_COUNT - currentCycleQualifiedCount,
|
||||
referrals: referrals.map((item: InviteReferral & { invitee: { nickname: string; avatarUrl: string | null } }) => ({
|
||||
id: item.id,
|
||||
inviteeId: item.inviteeId,
|
||||
inviteeNickname: item.invitee.nickname,
|
||||
inviteeAvatarUrl: item.invitee.avatarUrl,
|
||||
status: item.status as InviteReferralStatus,
|
||||
invitedAt: item.invitedAt.toISOString(),
|
||||
trialPurchasedAt: item.trialPurchasedAt?.toISOString() ?? null,
|
||||
qualifiedAt: item.qualifiedAt?.toISOString() ?? null,
|
||||
})),
|
||||
rewardGrants: rewardGrants.map((item: InviteRewardGrant) => ({
|
||||
id: item.id,
|
||||
membershipId: item.membershipId,
|
||||
qualifiedReferralCount: item.qualifiedReferralCount,
|
||||
rewardTimes: item.rewardTimes,
|
||||
grantedAt: item.grantedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async validateInviterForTrialOrder(userId: string, inviterId?: string): Promise<void> {
|
||||
if (!inviterId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (inviterId === userId) {
|
||||
throw new BadRequestException('不能邀请自己购买体验课')
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findFirst({
|
||||
where: {
|
||||
inviterId,
|
||||
inviteeId: userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (!referral) {
|
||||
throw new NotFoundException('邀请关系不存在或已失效')
|
||||
}
|
||||
}
|
||||
|
||||
private async grantRewardsIfEligible(inviterId: string): Promise<void> {
|
||||
const [qualifiedCount, rewardGrantCount] = await Promise.all([
|
||||
this.prisma.inviteReferral.count({
|
||||
where: {
|
||||
inviterId,
|
||||
status: InviteReferralStatus.QUALIFIED,
|
||||
},
|
||||
}),
|
||||
this.prisma.inviteRewardGrant.count({ where: { inviterId } }),
|
||||
])
|
||||
|
||||
const shouldGrantCount = Math.floor(qualifiedCount / INVITE_REWARD_REQUIRED_COUNT)
|
||||
const missingGrantCount = shouldGrantCount - rewardGrantCount
|
||||
|
||||
if (missingGrantCount <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let index = 0; index < missingGrantCount; index += 1) {
|
||||
const targetQualifiedCount = (rewardGrantCount + index + 1) * INVITE_REWARD_REQUIRED_COUNT
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const membership = await tx.membership.findFirst({
|
||||
where: {
|
||||
userId: inviterId,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
orderBy: [{ expireDate: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
if (!membership) {
|
||||
throw new BadRequestException('邀请人当前没有有效会员卡,无法发放奖励')
|
||||
}
|
||||
|
||||
await tx.membership.update({
|
||||
where: { id: membership.id },
|
||||
data: {
|
||||
remainingTimes: membership.remainingTimes === null
|
||||
? null
|
||||
: membership.remainingTimes + INVITE_REWARD_TIMES,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
|
||||
await tx.inviteRewardGrant.create({
|
||||
data: {
|
||||
inviterId,
|
||||
membershipId: membership.id,
|
||||
qualifiedReferralCount: targetQualifiedCount,
|
||||
rewardTimes: INVITE_REWARD_TIMES,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||
import { PaymentService } from '../payment.service'
|
||||
import { WechatPayService } from '../wechat-pay.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +36,11 @@ const mockUser = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const mockInviteService = {
|
||||
validateInviterForTrialOrder: jest.fn(),
|
||||
recordTrialOrderPaid: jest.fn(),
|
||||
}
|
||||
|
||||
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||
id: 'order-uuid-1',
|
||||
userId: mockUser.id,
|
||||
@@ -105,6 +111,7 @@ describe('PaymentService', () => {
|
||||
PaymentService,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: WechatPayService, useValue: wechat },
|
||||
{ provide: InviteService, useValue: mockInviteService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
@@ -161,6 +168,16 @@ describe('PaymentService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('validates inviter relationship for trial card orders', async () => {
|
||||
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type: 'TRIAL' })
|
||||
prisma.user.findUnique.mockResolvedValue(mockUser)
|
||||
prisma.order.create.mockResolvedValue(buildMockOrder())
|
||||
|
||||
await service.createOrder(mockUser.id, mockCardType.id, 'inviter-001')
|
||||
|
||||
expect(mockInviteService.validateInviterForTrialOrder).toHaveBeenCalledWith(mockUser.id, 'inviter-001')
|
||||
})
|
||||
|
||||
it('throws NotFoundException when cardType does not exist', async () => {
|
||||
prisma.cardType.findUnique.mockResolvedValue(null)
|
||||
|
||||
@@ -232,6 +249,7 @@ describe('PaymentService', () => {
|
||||
|
||||
// membership.create was called
|
||||
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
|
||||
|
||||
expect(result).toContain('SUCCESS')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { IsUUID } from 'class-validator'
|
||||
import { IsOptional, IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
inviterId?: string
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class PaymentController {
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
|
||||
) {
|
||||
return this.paymentService.createOrder(userId, dto.cardTypeId)
|
||||
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,9 +3,10 @@ import { PrismaModule } from '../prisma/prisma.module'
|
||||
import { PaymentService } from './payment.service'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, InviteModule],
|
||||
controllers: [PaymentController],
|
||||
providers: [PaymentService, WechatPayService],
|
||||
exports: [PaymentService, WechatPayService],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CardType, Order } from '@prisma/client'
|
||||
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
||||
import { InviteService } from '../invite/invite.service'
|
||||
|
||||
export interface CreateOrderResult {
|
||||
order: Order
|
||||
@@ -28,11 +29,12 @@ export class PaymentService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wechatPayService: WechatPayService,
|
||||
private readonly inviteService: InviteService,
|
||||
) {}
|
||||
|
||||
// ─── User: create order ────────────────────────────────────────────────────
|
||||
|
||||
async createOrder(userId: string, cardTypeId: string): Promise<CreateOrderResult> {
|
||||
async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise<CreateOrderResult> {
|
||||
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
|
||||
|
||||
if (!cardType) {
|
||||
@@ -47,6 +49,10 @@ export class PaymentService {
|
||||
throw new NotFoundException(`User ${userId} not found`)
|
||||
}
|
||||
|
||||
if (cardType.type === 'TRIAL') {
|
||||
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
|
||||
}
|
||||
|
||||
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
@@ -135,6 +141,8 @@ export class PaymentService {
|
||||
}),
|
||||
])
|
||||
|
||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||
|
||||
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
|
||||
|
||||
// ── Flash sale order: mark as PAID ──
|
||||
|
||||
@@ -71,6 +71,7 @@ export class UserService {
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role as UserRole,
|
||||
activeMembershipCount: user._count.memberships,
|
||||
inviteShareEligible: user._count.memberships > 0,
|
||||
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
|
||||
Reference in New Issue
Block a user