feat: 支持邀请好友功能
This commit is contained in:
@@ -3,7 +3,8 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client'
|
||||
import { randomInt } from 'crypto'
|
||||
import type { Prisma, InviteReferral, InviteRewardGrant } from '@prisma/client'
|
||||
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
@@ -70,50 +71,72 @@ export class InviteService {
|
||||
})
|
||||
}
|
||||
|
||||
async recordQualifiedTrialBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
membership: { include: { cardType: true } },
|
||||
},
|
||||
})
|
||||
// Legacy booking callbacks must never qualify trial purchases.
|
||||
async recordQualifiedTrialBooking(_bookingId: string): Promise<void> {}
|
||||
|
||||
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
|
||||
return
|
||||
async ensureCode(userId: string): Promise<string> {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } })
|
||||
if (user.inviteCode) return user.inviteCode
|
||||
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const code = Array.from({ length: 6 }, () => alphabet[randomInt(alphabet.length)]).join('')
|
||||
try {
|
||||
const updated = await this.prisma.user.updateMany({
|
||||
where: { id: userId, inviteCode: null }, data: { inviteCode: code },
|
||||
})
|
||||
if (updated.count) return code
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') throw error
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('邀请码生成繁忙,请重试')
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findFirst({
|
||||
where: {
|
||||
inviteeId: booking.userId,
|
||||
status: {
|
||||
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
|
||||
},
|
||||
qualifiedBookingId: null,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
async getCodeStatus(userId: string) {
|
||||
const inviteCode = await this.ensureCode(userId)
|
||||
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||
return { inviteCode, discountEligible: !!referral }
|
||||
}
|
||||
|
||||
if (!referral) {
|
||||
return
|
||||
async confirmCode(userId: string, code: string) {
|
||||
const inviter = await this.prisma.user.findUnique({ where: { inviteCode: code.toUpperCase() } })
|
||||
if (!inviter) throw new BadRequestException('邀请码不存在,请检查后重试')
|
||||
if (inviter.id === userId) throw new BadRequestException('不能使用自己的邀请码')
|
||||
let referral: InviteReferral | null
|
||||
try {
|
||||
referral = await this.prisma.inviteReferral.upsert({
|
||||
where: { inviteeId: userId }, update: {},
|
||||
create: { inviterId: inviter.id, inviteeId: userId },
|
||||
})
|
||||
} catch (error) {
|
||||
// MySQL upserts may race on the unique invitee key. Read the winner.
|
||||
if ((error as { code?: string }).code !== 'P2002') throw error
|
||||
referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||
if (!referral) throw error
|
||||
}
|
||||
if (referral.inviterId !== inviter.id) throw new BadRequestException('你已绑定其他好友的邀请码,已享有 95 折优惠')
|
||||
return this.getCodeStatus(userId)
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.update({
|
||||
where: { id: referral.id },
|
||||
data: {
|
||||
status: InviteReferralStatus.QUALIFIED,
|
||||
qualifiedBookingId: booking.id,
|
||||
qualifiedAt: booking.completedAt ?? new Date(),
|
||||
},
|
||||
async rewardPaidOrder(tx: Prisma.TransactionClient, order: { userId: string; inviteInviterId: string | null; purchasedCategory: string | null }, now: Date) {
|
||||
if (!order.inviteInviterId || !order.purchasedCategory || order.purchasedCategory === 'TRIAL') return
|
||||
const claimed = await tx.inviteReferral.updateMany({
|
||||
where: { inviteeId: order.userId, inviterId: order.inviteInviterId, status: { not: 'QUALIFIED' } },
|
||||
data: { status: 'QUALIFIED', qualifiedAt: now },
|
||||
})
|
||||
|
||||
await this.grantRewardsIfEligible(referral.inviterId)
|
||||
if (!claimed.count) return
|
||||
const membership = await tx.membership.create({ data: {
|
||||
userId: order.inviteInviterId, cardTypeId: 'invite-reward-card',
|
||||
remainingTimes: 1, totalTimes: 1, startDate: now,
|
||||
expireDate: new Date(now.getTime() + 365 * 86400000), status: 'ACTIVE',
|
||||
} })
|
||||
await tx.inviteRewardGrant.create({ data: {
|
||||
inviterId: order.inviteInviterId, membershipId: membership.id,
|
||||
qualifiedReferralCount: 1, rewardTimes: 1,
|
||||
} })
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -132,7 +155,7 @@ export class InviteService {
|
||||
orderBy: { grantedAt: 'desc' },
|
||||
})
|
||||
|
||||
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE)
|
||||
const canInvite = true
|
||||
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(
|
||||
@@ -144,7 +167,7 @@ export class InviteService {
|
||||
return {
|
||||
inviterId: userId,
|
||||
canInvite,
|
||||
sharePath: `/pages/profile/invite?inviterId=${userId}`,
|
||||
sharePath: `/pages/card/detail?showAll=1&inviteCode=${await this.ensureCode(userId)}`,
|
||||
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
|
||||
rewardRuleTimes: INVITE_REWARD_TIMES,
|
||||
qualifiedInviteCount,
|
||||
@@ -196,58 +219,4 @@ export class InviteService {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user