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 { 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 { 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 {} async ensureCode(userId: string): Promise { 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 { 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 { 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('邀请关系不存在或已失效') } } }