223 lines
8.2 KiB
TypeScript
223 lines
8.2 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common'
|
|
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'
|
|
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(),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Legacy booking callbacks must never qualify trial purchases.
|
|
async recordQualifiedTrialBooking(_bookingId: string): Promise<void> {}
|
|
|
|
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('邀请码生成繁忙,请重试')
|
|
}
|
|
|
|
async getCodeStatus(userId: string) {
|
|
const inviteCode = await this.ensureCode(userId)
|
|
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
|
return { inviteCode, discountEligible: !!referral }
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 },
|
|
})
|
|
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 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 = 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(
|
|
0,
|
|
qualifiedInviteCount - rewardGrants.length * INVITE_REWARD_REQUIRED_COUNT,
|
|
)
|
|
const currentCycleQualifiedCount = qualifiedInviteCount % INVITE_REWARD_REQUIRED_COUNT
|
|
|
|
return {
|
|
inviterId: userId,
|
|
canInvite,
|
|
sharePath: `/pages/card/detail?showAll=1&inviteCode=${await this.ensureCode(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('邀请关系不存在或已失效')
|
|
}
|
|
}
|
|
|
|
}
|