diff --git a/packages/app/src/components/ProfileMenu.vue b/packages/app/src/components/ProfileMenu.vue index 34c48dd..45c7734 100644 --- a/packages/app/src/components/ProfileMenu.vue +++ b/packages/app/src/components/ProfileMenu.vue @@ -49,6 +49,7 @@ const props = defineProps<{ requireAuth?: boolean activeMembershipCount?: number upcomingBookingCount?: number + inviteShareEligible?: boolean }>() const emit = defineEmits<{ @@ -81,6 +82,15 @@ const menuItems = computed(() => { badge: bookingBadge, requireAuth: true, }, + ...(props.inviteShareEligible + ? [{ + key: 'invite', + type: 'item' as const, + title: '邀请好友', + path: '/pages/profile/invite', + requireAuth: true, + }] + : []), { key: 'info', type: 'item', @@ -226,6 +236,34 @@ function handleTap(item: MenuItem) { } } + &--invite { + background: rgba(255, 122, 69, 0.12); + &::before { + content: ''; + width: 20rpx; + height: 20rpx; + border: 2.5rpx solid #ff7a45; + border-radius: 50%; + position: absolute; + top: 12rpx; + left: 14rpx; + box-sizing: border-box; + box-shadow: 16rpx 8rpx 0 -2rpx rgba(255, 122, 69, 0.95); + } + &::after { + content: ''; + position: absolute; + width: 22rpx; + height: 12rpx; + border: 2.5rpx solid #ff7a45; + border-top: none; + border-radius: 0 0 14rpx 14rpx; + left: 17rpx; + bottom: 13rpx; + box-sizing: border-box; + } + } + // 个人信息 — 人形(圆 + 肩弧) &--info { background: rgba($brand-color, 0.06); diff --git a/packages/app/src/pages.json b/packages/app/src/pages.json index 3bfe283..9af1ab6 100644 --- a/packages/app/src/pages.json +++ b/packages/app/src/pages.json @@ -51,6 +51,12 @@ "navigationStyle": "custom" } }, + { + "path": "pages/profile/invite", + "style": { + "navigationStyle": "custom" + } + }, { "path": "pages/teacher/detail", "style": { diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue index 24a34a5..1a3d9d1 100644 --- a/packages/app/src/pages/card/detail.vue +++ b/packages/app/src/pages/card/detail.vue @@ -332,8 +332,10 @@ async function doPurchase() { uni.showLoading({ title: '创建订单...' }) try { + const inviterId = uni.getStorageSync('invite_inviter_id') as string const result = await post('/payment/create-order', { cardTypeId: card.value.id, + inviterId: isTrial.value && inviterId ? inviterId : undefined, }) uni.hideLoading() diff --git a/packages/app/src/pages/profile/index.vue b/packages/app/src/pages/profile/index.vue index db4c14e..60c41ba 100644 --- a/packages/app/src/pages/profile/index.vue +++ b/packages/app/src/pages/profile/index.vue @@ -13,6 +13,7 @@ :require-auth="loggedIn" :active-membership-count="activeMembershipCount" :upcoming-booking-count="upcomingBookingCount" + :invite-share-eligible="!!user?.inviteShareEligible" @clear-cache="handleClearCache" @require-login="handleLogin" /> diff --git a/packages/app/src/pages/profile/invite.vue b/packages/app/src/pages/profile/invite.vue new file mode 100644 index 0000000..4d37f68 --- /dev/null +++ b/packages/app/src/pages/profile/invite.vue @@ -0,0 +1,504 @@ + + + + + + diff --git a/packages/app/src/stores/invite.ts b/packages/app/src/stores/invite.ts new file mode 100644 index 0000000..3ddd61d --- /dev/null +++ b/packages/app/src/stores/invite.ts @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { InviteActivitySummary } from '@mp-pilates/shared' +import { get } from '../utils/request' + +export const useInviteStore = defineStore('invite', () => { + const activity = ref(null) + const loading = ref(false) + + async function fetchActivity() { + loading.value = true + try { + activity.value = await get('/invite/activity') + return activity.value + } finally { + loading.value = false + } + } + + return { + activity, + loading, + fetchActivity, + } +}) + diff --git a/packages/app/src/stores/user.ts b/packages/app/src/stores/user.ts index 158867d..f0859c4 100644 --- a/packages/app/src/stores/user.ts +++ b/packages/app/src/stores/user.ts @@ -28,6 +28,7 @@ export const useUserStore = defineStore('user', () => { memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE), ) const hasValidMembership = computed(() => activeMemberships.value.length > 0) + const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible) // Actions async function login() { @@ -124,6 +125,7 @@ export const useUserStore = defineStore('user', () => { isAdmin, activeMemberships, hasValidMembership, + inviteShareEligible, login, loginWithSetup, fetchProfile, diff --git a/packages/app/src/utils/auth.ts b/packages/app/src/utils/auth.ts index 9972ba8..3e88803 100644 --- a/packages/app/src/utils/auth.ts +++ b/packages/app/src/utils/auth.ts @@ -54,6 +54,8 @@ export function getErrorMessage(err: unknown, fallback: string): string { } export async function wxLogin(): Promise { + const inviterId = uni.getStorageSync('invite_inviter_id') as string + await ensurePrivacyAuthorization() return new Promise((resolve, reject) => { @@ -72,8 +74,12 @@ export async function wxLogin(): Promise { // 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改 const result = await post('/auth/login', { code: loginRes.code, + inviterId: inviterId || undefined, }) uni.setStorageSync('token', result.token) + if (result.isNewUser && inviterId) { + uni.removeStorageSync('invite_inviter_id') + } resolve(result) } catch (err) { reject(err) diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 231627a..d338c8c 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -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 diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts index 4526eba..71f7aef 100644 --- a/packages/server/src/app.module.ts +++ b/packages/server/src/app.module.ts @@ -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], }) diff --git a/packages/server/src/auth/__tests__/auth.service.spec.ts b/packages/server/src/auth/__tests__/auth.service.spec.ts index 1230ee2..9aa2364 100644 --- a/packages/server/src/auth/__tests__/auth.service.spec.ts +++ b/packages/server/src/auth/__tests__/auth.service.spec.ts @@ -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 }, }) }) diff --git a/packages/server/src/auth/auth.controller.ts b/packages/server/src/auth/auth.controller.ts index d36ceff..daa8cd7 100644 --- a/packages/server/src/auth/auth.controller.ts +++ b/packages/server/src/auth/auth.controller.ts @@ -29,6 +29,7 @@ export class AuthController { loginDto.code, loginDto.nickname, loginDto.avatarUrl, + loginDto.inviterId, ) } diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index 4bac297..7ba2849 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -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], diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index 6ac3de0..b6c4d77 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -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 { 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) diff --git a/packages/server/src/auth/dto/login.dto.ts b/packages/server/src/auth/dto/login.dto.ts index d1da171..cbdc1f1 100644 --- a/packages/server/src/auth/dto/login.dto.ts +++ b/packages/server/src/auth/dto/login.dto.ts @@ -12,4 +12,8 @@ export class LoginDto { @IsString() @IsOptional() avatarUrl?: string + + @IsString() + @IsOptional() + inviterId?: string } diff --git a/packages/server/src/booking/__tests__/booking.service.spec.ts b/packages/server/src/booking/__tests__/booking.service.spec.ts index 0402ba3..8565c1e 100644 --- a/packages/server/src/booking/__tests__/booking.service.spec.ts +++ b/packages/server/src/booking/__tests__/booking.service.spec.ts @@ -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 let studioService: jest.Mocked 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 studioService = module.get(StudioService) as jest.Mocked 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', () => { diff --git a/packages/server/src/booking/booking.module.ts b/packages/server/src/booking/booking.module.ts index d450024..0e1358c 100644 --- a/packages/server/src/booking/booking.module.ts +++ b/packages/server/src/booking/booking.module.ts @@ -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], diff --git a/packages/server/src/booking/booking.service.ts b/packages/server/src/booking/booking.service.ts index bf5d9c2..49d7a8f 100644 --- a/packages/server/src/booking/booking.service.ts +++ b/packages/server/src/booking/booking.service.ts @@ -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 ────────────────────────────────────────────────────── diff --git a/packages/server/src/invite/invite.constants.ts b/packages/server/src/invite/invite.constants.ts new file mode 100644 index 0000000..7aedb6a --- /dev/null +++ b/packages/server/src/invite/invite.constants.ts @@ -0,0 +1,3 @@ +export const INVITE_REWARD_REQUIRED_COUNT = 3 +export const INVITE_REWARD_TIMES = 1 + diff --git a/packages/server/src/invite/invite.controller.ts b/packages/server/src/invite/invite.controller.ts new file mode 100644 index 0000000..b96e080 --- /dev/null +++ b/packages/server/src/invite/invite.controller.ts @@ -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) + } +} + diff --git a/packages/server/src/invite/invite.module.ts b/packages/server/src/invite/invite.module.ts new file mode 100644 index 0000000..430da58 --- /dev/null +++ b/packages/server/src/invite/invite.module.ts @@ -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 {} + diff --git a/packages/server/src/invite/invite.service.ts b/packages/server/src/invite/invite.service.ts new file mode 100644 index 0000000..7a2c7c0 --- /dev/null +++ b/packages/server/src/invite/invite.service.ts @@ -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 { + 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(), + }, + }) + } + + async recordQualifiedTrialBooking(bookingId: string): Promise { + 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 { + 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 { + 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 { + 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, + }, + }) + }) + } + } +} diff --git a/packages/server/src/payment/__tests__/payment.service.spec.ts b/packages/server/src/payment/__tests__/payment.service.spec.ts index 754bfb4..126cb03 100644 --- a/packages/server/src/payment/__tests__/payment.service.spec.ts +++ b/packages/server/src/payment/__tests__/payment.service.spec.ts @@ -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> = {}) => ({ 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') }) diff --git a/packages/server/src/payment/dto/create-order.dto.ts b/packages/server/src/payment/dto/create-order.dto.ts index a5ae122..a52b520 100644 --- a/packages/server/src/payment/dto/create-order.dto.ts +++ b/packages/server/src/payment/dto/create-order.dto.ts @@ -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 } diff --git a/packages/server/src/payment/payment.controller.ts b/packages/server/src/payment/payment.controller.ts index 9a091ba..9015a19 100644 --- a/packages/server/src/payment/payment.controller.ts +++ b/packages/server/src/payment/payment.controller.ts @@ -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) } /** diff --git a/packages/server/src/payment/payment.module.ts b/packages/server/src/payment/payment.module.ts index 16a3ac9..2f197ac 100644 --- a/packages/server/src/payment/payment.module.ts +++ b/packages/server/src/payment/payment.module.ts @@ -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], diff --git a/packages/server/src/payment/payment.service.ts b/packages/server/src/payment/payment.service.ts index 062075a..7b21d89 100644 --- a/packages/server/src/payment/payment.service.ts +++ b/packages/server/src/payment/payment.service.ts @@ -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 { + async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise { 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 ── diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts index e59d1c5..87cb420 100644 --- a/packages/server/src/user/user.service.ts +++ b/packages/server/src/user/user.service.ts @@ -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(), diff --git a/packages/shared/src/enums.ts b/packages/shared/src/enums.ts index 3274acb..5a698f9 100644 --- a/packages/shared/src/enums.ts +++ b/packages/shared/src/enums.ts @@ -59,6 +59,13 @@ export enum FlashSaleOrderStatus { EXPIRED = 'EXPIRED', } +// ===== Invite ===== +export enum InviteReferralStatus { + REGISTERED = 'REGISTERED', + TRIAL_PURCHASED = 'TRIAL_PURCHASED', + QUALIFIED = 'QUALIFIED', +} + // ===== Subscribe Message ===== export enum SubscriptionMessageScene { ORDER_PAID = 'ORDER_PAID', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 14cef22..24d9c15 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export { OrderStatus, FlashSaleStatus, FlashSaleOrderStatus, + InviteReferralStatus, SubscriptionMessageScene, } from './enums' @@ -72,6 +73,9 @@ export type { CreateFlashSaleDto, UpdateFlashSaleDto, FlashSalePurchaseResponse, + InviteActivityReferral, + InviteRewardGrantRecord, + InviteActivitySummary, SubscriptionMessageRequestResult, SubscriptionMessageRequestItem, SubscriptionMessageTemplate, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c86f510..a82097d 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -30,4 +30,9 @@ export type { UpdateFlashSaleDto, FlashSalePurchaseResponse, } from './flash-sale' +export type { + InviteActivityReferral, + InviteRewardGrantRecord, + InviteActivitySummary, +} from './invite' export { FlashSalePhase } from './flash-sale' diff --git a/packages/shared/src/types/invite.ts b/packages/shared/src/types/invite.ts new file mode 100644 index 0000000..4c5a878 --- /dev/null +++ b/packages/shared/src/types/invite.ts @@ -0,0 +1,36 @@ +import { InviteReferralStatus } from '../enums' + +export interface InviteActivityReferral { + readonly id: string + readonly inviteeId: string + readonly inviteeNickname: string + readonly inviteeAvatarUrl: string | null + readonly status: InviteReferralStatus + readonly invitedAt: string + readonly trialPurchasedAt: string | null + readonly qualifiedAt: string | null +} + +export interface InviteRewardGrantRecord { + readonly id: string + readonly membershipId: string | null + readonly qualifiedReferralCount: number + readonly rewardTimes: number + readonly grantedAt: string +} + +export interface InviteActivitySummary { + readonly inviterId: string + readonly canInvite: boolean + readonly sharePath: string + readonly rewardRuleInvitesRequired: number + readonly rewardRuleTimes: number + readonly qualifiedInviteCount: number + readonly rewardedTimes: number + readonly pendingRewardGrantCount: number + readonly pendingInviteCount: number + readonly currentCycleQualifiedCount: number + readonly nextRewardRemainingCount: number + readonly referrals: readonly InviteActivityReferral[] + readonly rewardGrants: readonly InviteRewardGrantRecord[] +} diff --git a/packages/shared/src/types/order.ts b/packages/shared/src/types/order.ts index d9be93b..277168e 100644 --- a/packages/shared/src/types/order.ts +++ b/packages/shared/src/types/order.ts @@ -26,6 +26,7 @@ export interface OrderWithDetails extends Order { export interface CreateOrderDto { readonly cardTypeId: string + readonly inviterId?: string } export interface PaymentParams { diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index fedf4c0..260c9b1 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -20,6 +20,7 @@ export interface UserProfileResponse { readonly avatarUrl: string | null readonly role: UserRole readonly activeMembershipCount: number + readonly inviteShareEligible: boolean readonly adminBookingSubscriptionCount: number readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig readonly createdAt: string