feat: 支持邀请好友功能

This commit is contained in:
richarjiang
2026-09-08 22:31:39 +08:00
parent 5a4d2c7a1b
commit 30ebc1c344
21 changed files with 445 additions and 632 deletions

View File

@@ -0,0 +1,4 @@
ALTER TABLE `users` ADD COLUMN `invite_code` VARCHAR(6) NULL;
CREATE UNIQUE INDEX `users_invite_code_key` ON `users` (`invite_code`);
ALTER TABLE `orders` ADD COLUMN `invite_inviter_id` VARCHAR(191) NULL, ADD COLUMN `purchased_category` VARCHAR(191) NULL;
INSERT INTO `card_types` (`id`, `name`, `type`, `total_times`, `duration_days`, `price`, `is_active`, `sort_order`, `updated_at`) VALUES ('invite-reward-card', '邀请好友赠课', 'TIMES', 1, 365, 0, false, 9999, NOW());

View File

@@ -73,6 +73,7 @@ enum InviteReferralStatus {
model User {
id String @id @default(uuid())
inviteCode String? @unique @map("invite_code") @db.VarChar(6)
openid String @unique
unionid String?
phone String?
@@ -254,6 +255,8 @@ model Order {
cardTypeId String @map("card_type_id")
membershipId String? @map("membership_id")
orderNo String @unique @map("order_no")
inviteInviterId String? @map("invite_inviter_id")
purchasedCategory String? @map("purchased_category")
amount Decimal @db.Decimal(10, 0)
status OrderStatus @default(PENDING)
wxTransactionId String? @map("wx_transaction_id")

View File

@@ -0,0 +1,25 @@
import { FlashSaleService } from '../flash-sale.service'
import { PrismaService } from '../../prisma/prisma.service'
import { WechatPayService } from '../../payment/wechat-pay.service'
describe('Flash sale referral pricing', () => {
it.each([true, false])('snapshots referral and category with eligible=%s', async (eligible) => {
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue({ id: 'buyer', phone: '123', openid: 'openid' }) },
flashSale: {
findUnique: jest.fn().mockResolvedValue({ id: 'sale', title: 'sale', cardTypeId: 'card', cardType: { type: 'TIMES' }, status: 'ACTIVE', startTime: new Date(0), endTime: new Date(Date.now() + 60000), totalStock: 10, flashPrice: 999 }),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
inviteReferral: { findUnique: jest.fn().mockResolvedValue(eligible ? { inviterId: 'friend' } : null) },
order: { create: jest.fn().mockImplementation(({ data }) => ({ ...data, id: 'order' })) },
flashSaleOrder: { create: jest.fn().mockResolvedValue({ id: 'reserved' }) },
$transaction: jest.fn(),
}
prisma.$transaction.mockImplementation((fn) => fn(prisma))
const pay = { createUnifiedOrder: jest.fn().mockResolvedValue({}) }
const service = new FlashSaleService(prisma as unknown as PrismaService, pay as unknown as WechatPayService)
await service.purchase('sale', 'buyer')
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: eligible ? 949 : 999, inviteInviterId: eligible ? 'friend' : undefined, purchasedCategory: 'TIMES' }) })
expect(pay.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: eligible ? 949 : 999 }))
})
})

View File

@@ -167,12 +167,15 @@ export class FlashSaleService {
// ②-b: Create Order with flash sale price
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const referral = await tx.inviteReferral.findUnique({ where: { inviteeId: userId } })
const order = await tx.order.create({
data: {
userId,
cardTypeId: sale.cardTypeId,
orderNo,
amount: sale.flashPrice,
amount: referral ? Math.round(Number(sale.flashPrice) * 95 / 100) : sale.flashPrice,
inviteInviterId: referral?.inviterId,
purchasedCategory: sale.cardType.type,
status: OrderStatus.PENDING,
flashSaleId,
},

View File

@@ -0,0 +1,71 @@
import { InviteService } from '../invite.service'
import { PrismaService } from '../../prisma/prisma.service'
import type { Prisma } from '@prisma/client'
describe('InviteService marketing rules', () => {
function setup() {
const prisma = {
user: { findUniqueOrThrow: jest.fn(), updateMany: jest.fn(), findUnique: jest.fn() },
inviteReferral: { upsert: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
membership: { create: jest.fn() }, inviteRewardGrant: { create: jest.fn() },
}
return { prisma, service: new InviteService(prisma as unknown as PrismaService) }
}
it('lazily reuses existing short codes without writing', async () => {
const { prisma, service } = setup()
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'ABC234' })
expect(await service.ensureCode('user')).toBe('ABC234')
expect(prisma.user.updateMany).not.toHaveBeenCalled()
})
it('retries code collisions and only writes if still missing', async () => {
const { prisma, service } = setup()
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: null })
prisma.user.updateMany.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ count: 1 })
expect(await service.ensureCode('user')).toMatch(/^[23456789A-HJ-NP-Z]{6}$/)
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2)
expect(prisma.user.updateMany.mock.calls[0][0].where).toEqual({ id: 'user', inviteCode: null })
})
it('reads the winning code when concurrent generation already filled it', async () => {
const { prisma, service } = setup()
prisma.user.findUniqueOrThrow.mockResolvedValueOnce({ inviteCode: null }).mockResolvedValueOnce({ inviteCode: 'ABC234' })
prisma.user.updateMany.mockResolvedValue({ count: 0 })
expect(await service.ensureCode('user')).toBe('ABC234')
})
it('rejects invalid, self and changed inviter codes', async () => {
const { prisma, service } = setup()
prisma.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'self' }).mockResolvedValueOnce({ id: 'friend' })
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('不存在')
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('自己')
prisma.inviteReferral.upsert.mockResolvedValue({ inviterId: 'original' })
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('其他好友')
})
it('accepts repeat confirmation and recovers a concurrent binding to the same inviter', async () => {
const { prisma, service } = setup()
prisma.user.findUnique.mockResolvedValue({ id: 'friend' })
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'XYZ234' })
prisma.inviteReferral.upsert.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ inviterId: 'friend' })
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
})
it.each(['TRIAL', null])('does not reward category %s', async (purchasedCategory) => {
const { prisma, service } = setup()
await service.rewardPaidOrder(prisma as unknown as Prisma.TransactionClient, { userId: 'user', inviteInviterId: 'friend', purchasedCategory }, new Date())
expect(prisma.inviteReferral.updateMany).not.toHaveBeenCalled()
await service.recordQualifiedTrialBooking('booking')
expect(prisma.membership.create).not.toHaveBeenCalled()
})
it.each(['TIMES', 'DURATION'])('grants one usable class for %s even without an existing membership', async (purchasedCategory) => {
const { prisma, service } = setup()
prisma.inviteReferral.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 })
prisma.membership.create.mockResolvedValue({ id: 'reward' })
const order = { userId: 'user', inviteInviterId: 'friend', purchasedCategory }
const tx = prisma as unknown as Prisma.TransactionClient
await service.rewardPaidOrder(tx, order, new Date())
await service.rewardPaidOrder(tx, order, new Date())
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(prisma.membership.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'friend', remainingTimes: 1, totalTimes: 1, status: 'ACTIVE' }) })
expect(prisma.inviteRewardGrant.create).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,3 +1,3 @@
export const INVITE_REWARD_REQUIRED_COUNT = 3
export const INVITE_REWARD_REQUIRED_COUNT = 1
export const INVITE_REWARD_TIMES = 1

View File

@@ -1,13 +1,30 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { IsString, Matches } from 'class-validator'
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { InviteService } from './invite.service'
class ConfirmInviteDto {
@IsString()
@Matches(/^[23456789A-HJ-NP-Z]{6}$/i)
code!: string
}
@Controller('invite')
@UseGuards(JwtAuthGuard)
export class InviteController {
constructor(private readonly inviteService: InviteService) {}
@Get('code')
getCode(@CurrentUser('sub') userId: string) {
return this.inviteService.getCodeStatus(userId)
}
@Post('confirm')
confirm(@CurrentUser('sub') userId: string, @Body() body: ConfirmInviteDto) {
return this.inviteService.confirmCode(userId, body.code)
}
@Get('activity')
getActivity(@CurrentUser('sub') userId: string) {
return this.inviteService.getInviteActivitySummary(userId)

View File

@@ -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,
},
})
})
}
}
}

View File

@@ -41,6 +41,7 @@ const mockUser = {
const mockInviteService = {
validateInviterForTrialOrder: jest.fn(),
recordTrialOrderPaid: jest.fn(),
rewardPaidOrder: jest.fn(),
}
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
@@ -69,6 +70,7 @@ const mockPaymentParams = {
function buildPrismaMock() {
return {
inviteReferral: { findUnique: jest.fn().mockResolvedValue(null) },
cardType: {
findUnique: jest.fn(),
},
@@ -76,6 +78,7 @@ function buildPrismaMock() {
findUnique: jest.fn(),
},
order: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
@@ -131,6 +134,16 @@ describe('PaymentService', () => {
// ─── createOrder ────────────────────────────────────────────────────────────
describe('createOrder', () => {
it.each(['TRIAL', 'TIMES', 'DURATION'])('applies 95 percent server-side to %s and snapshots attribution', async (type) => {
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type, price: new Decimal(999) })
prisma.user.findUnique.mockResolvedValue(mockUser)
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
prisma.order.create.mockResolvedValue(buildMockOrder({ amount: new Decimal(949) }))
await service.createOrder(mockUser.id, mockCardType.id)
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: 949, inviteInviterId: 'friend', purchasedCategory: type }) })
expect(wechat.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: 949 }))
})
it('creates a PENDING order with correct amount and formatted orderNo', async () => {
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.user.findUnique.mockResolvedValue(mockUser)
@@ -146,7 +159,7 @@ describe('PaymentService', () => {
data: expect.objectContaining({
userId: mockUser.id,
cardTypeId: mockCardType.id,
amount: mockCardType.price,
amount: Number(mockCardType.price),
status: OrderStatus.PENDING,
}),
}),
@@ -246,6 +259,18 @@ describe('PaymentService', () => {
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
})
it('does not grant membership or referral rewards when another callback claimed the order', async () => {
prisma.order.updateMany.mockResolvedValue({ count: 0 })
await service.handleWxNotify(headers, successBody)
expect(prisma.membership.create).not.toHaveBeenCalled()
expect(mockInviteService.rewardPaidOrder).not.toHaveBeenCalled()
})
it('propagates reward issuance failure out of the payment transaction', async () => {
mockInviteService.rewardPaidOrder.mockRejectedValueOnce(new Error('reward failed'))
await expect(service.handleWxNotify(headers, successBody)).rejects.toThrow('reward failed')
})
it('marks order as PAID and grants a new membership on valid callback', async () => {
const result = await service.handleWxNotify(headers, successBody)

View File

@@ -55,6 +55,8 @@ export class PaymentService {
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
}
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
const amount = referral ? Math.round(Number(cardType.price) * 95 / 100) : Number(cardType.price)
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await this.prisma.order.create({
@@ -62,14 +64,16 @@ export class PaymentService {
userId,
cardTypeId,
orderNo,
amount: cardType.price,
amount,
inviteInviterId: referral?.inviterId,
purchasedCategory: cardType.type,
status: OrderStatus.PENDING,
},
})
const paymentParams = await this.wechatPayService.createUnifiedOrder({
orderNo,
amount: Number(cardType.price),
amount,
openid: user.openid,
description: cardType.name,
})
@@ -122,6 +126,11 @@ export class PaymentService {
const now = new Date()
await this.prisma.$transaction(async (tx) => {
const claimed = await tx.order.updateMany({
where: { id: existingOrder.id, status: OrderStatus.PENDING },
data: { status: OrderStatus.PAID },
})
if (!claimed.count) return
const membership = await this.membershipService.grantPurchasedCard(
tx,
existingOrder.userId,
@@ -138,6 +147,7 @@ export class PaymentService {
membershipId: membership.id,
},
})
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
})
await this.inviteService.recordTrialOrderPaid(existingOrder.id)