diff --git a/docs/invite-marketing.md b/docs/invite-marketing.md
new file mode 100644
index 0000000..7f8ac0e
--- /dev/null
+++ b/docs/invite-marketing.md
@@ -0,0 +1,32 @@
+# 邀请好友领课时
+
+个人中心为所有登录用户展示 6 位邀请码;首次访问 `/invite/code` 时惰性生成,字符避开 0、1、I、O。数据库唯一索引保证唯一,条件更新和冲突重试保证并发安全,无需回填存量用户。
+
+分享入口使用微信 `button open-type="share"`,分享落地为 `/pages/card/detail?showAll=1&inviteCode=XXXXXX`。落地弹窗自动填码,未领取的列表价格标注「领取后 95 折」。用户确认后绑定一次,禁止自邀和更换邀请人;绑定后体验卡、次卡、期限卡及限时购卡均按当前售价享 95 折,按分四舍五入,后续购买自动享受。订单金额只由服务端计算。
+
+每位好友首次支付非体验卡后,邀请人获得独立的 1 次赠课会员卡,365 天有效;邀请人无需先持有会员卡。赠课卡隐藏于商品列表,可从「我的会员卡」预约。体验卡购买与上课均不发奖。每人只奖励一次,续购不重复奖励。
+
+订单记录下单时的邀请人和卡片类别。支付到账、购卡权益、邀请资格更新、赠课及奖励记录处于同一数据库事务。条件更新防止并发回调、同一好友多笔订单重复发奖。历史无归因快照的订单不补发奖励;历史已 QUALIFIED 的邀请保留原资格,不重复奖励。
+
+## 发布
+
+先备份数据库,并在服务端运行 `pnpm exec prisma migrate deploy`,应用 `20260908000000_invite_codes`。该迁移新增可空邀请码、唯一索引、订单归因字段及隐藏赠课卡类型,不更新用户历史数据。
+
+然后运行 `pnpm prisma:generate`,从仓库根目录运行 `pnpm build:shared`、`pnpm build:server`、`pnpm build:app`。先发布服务端,再发布小程序。无需新增环境变量。已于 2026-09-08 22:29(北京时间)执行生产迁移并发布后台;小程序尚未发布。
+
+## 真机验收
+
+用两个微信账号验证:个人中心复制和好友分享;分享卡片落地列表与自动填码;未登录确认领取;重启后保留服务端优惠;原价和 95 折金额一致;体验卡支付不赠课;次卡/期限卡首次支付赠课到账;后续购买不再赠课;赠课可预约。还应验证支付失败、取消和重复回调。微信分享与真实微信支付仍需在部署后进行真机验证。
+
+## 验证限制
+
+邀请、支付和限时购卡价格测试通过。已在 390 × 844 的 H5 预览中检查个人中心入口和自动填码落地弹窗;H5 预览不代替微信真机验收。全量测试中的旧 SchedulerService 测试缺少 FlashSaleService mock,8 项失败,与本次修改无关。`pnpm lint` 因仓库未安装 ESLint 无法运行。
+
+## 本次后台发布记录
+
+- 目标:`129.204.155.94`,目录 `/usr/local/web/mp-pilates-server`,PM2 服务 `mp-pilates-server`,端口 3008。
+- 使用服务器现有 Node 22.16.0 和 Prisma 5.22.0;现有部署脚本中的 Node 22.17.1 路径不适用于当前服务器,因此按步骤发布构建,未修改线上环境配置及证书。
+- 数据库迁移状态正常,新增 3 个字段及独立隐藏赠课卡核验通过。
+- 公网 `/api/health` 和 `/api/membership/card-types` 返回 200;商品列表不包含赠课卡;未登录访问 `/api/invite/code` 返回预期的 401。
+- 旧构建和 Prisma schema 已归档在服务器 `/usr/local/web/releases/mp-pilates-invite-20260908/previous-build.tgz`,新构建归档为同目录 `release.tgz`。
+- 本次只进行发布及只读核验,未制造测试用户、邀请关系或支付订单。微信真机分享与支付仍待小程序发布后联调。
diff --git a/packages/app/src/components/CardShop.vue b/packages/app/src/components/CardShop.vue
index ee6dccb..ef54524 100644
--- a/packages/app/src/components/CardShop.vue
+++ b/packages/app/src/components/CardShop.vue
@@ -58,7 +58,7 @@
课时
- ¥{{ formatPrice(card.price) }}
+ ¥{{ formatPrice(invite.price(card.price)) }}
+ 95 折
›
@@ -82,6 +83,7 @@
+
diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue
index 66629d4..c7f8597 100644
--- a/packages/app/src/pages/card/detail.vue
+++ b/packages/app/src/pages/card/detail.vue
@@ -5,6 +5,21 @@
:style="{ paddingTop: navBarHeight }"
>
+
+ {{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }}
+ {{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ›' }}
+
+
+
+ A GIFT FROM YOUR FRIEND
+ 朋友送你一份 95 折礼遇
+ 确认邀请码后,购卡自动减免 5%。体验卡也可享受。
+
+ {{ inviteError }}
+ 每人仅绑定一位好友。首次成功购买非体验卡,好友获得 1 节免费课;体验卡不触发赠课。
+
+
+
@@ -64,10 +79,11 @@
{{ cardAccessLabel(c) }}
- ¥{{ formatPrice(c.price) }}
- {{ getSavingsLabel(c) }}
+ ¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }}
+ 领取后 95 折
+ {{ getSavingsLabel(c) }}
- {{ getUnitPriceLabel(c) }}
+ {{ getUnitPriceLabel(c) }}
@@ -129,7 +145,7 @@
¥
- {{ formatPrice(cardData.price) }}
+ {{ formatPrice(invite.price(cardData.price)) }}
{{ isRenewal ? '续卡金额' : '实付金额' }}
- ¥{{ formatPrice(cardData.price) }}
+ ¥{{ formatPrice(invite.price(cardData.price)) }}
{{ bottomPriceHint }}
{{ cardData.name }}
- ¥{{ formatPrice(cardData.price) }}
+ ¥{{ formatPrice(invite.price(cardData.price)) }}
{{ cardAccessLabel(cardData) }}
@@ -319,8 +335,9 @@
-
diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue
index 6edce38..67851df 100644
--- a/packages/app/src/pages/profile/membership.vue
+++ b/packages/app/src/pages/profile/membership.vue
@@ -170,7 +170,7 @@ function goStore() {
}
function canRenewMembership(m: MembershipWithCardType): boolean {
- return m.cardType.type !== CardTypeCategory.TRIAL
+ return m.cardType.isActive && m.cardType.type !== CardTypeCategory.TRIAL
}
function goRenew(m: MembershipWithCardType) {
diff --git a/packages/app/src/stores/invite.ts b/packages/app/src/stores/invite.ts
index 3ddd61d..e8d1912 100644
--- a/packages/app/src/stores/invite.ts
+++ b/packages/app/src/stores/invite.ts
@@ -1,26 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
-import type { InviteActivitySummary } from '@mp-pilates/shared'
-import { get } from '../utils/request'
+import { get, post } from '../utils/request'
export const useInviteStore = defineStore('invite', () => {
- const activity = ref(null)
- const loading = ref(false)
-
- async function fetchActivity() {
- loading.value = true
+ const code = ref('')
+ const eligible = ref(false)
+ const pendingCode = ref('')
+ let revision = 0
+ let confirming = false
+ async function refresh() {
+ if (confirming) return
+ const requestRevision = ++revision
+ const result = await get<{ inviteCode: string; discountEligible: boolean }>('/invite/code')
+ if (requestRevision !== revision) return
+ code.value = result.inviteCode
+ eligible.value = result.discountEligible
+ }
+ async function confirm(value: string) {
+ const requestRevision = ++revision
+ confirming = true
try {
- activity.value = await get('/invite/activity')
- return activity.value
+ const result = await post<{ inviteCode: string; discountEligible: boolean }>('/invite/confirm', { code: value })
+ if (requestRevision !== revision) throw new Error('登录状态已变化,请重试')
+ code.value = result.inviteCode
+ eligible.value = result.discountEligible
+ pendingCode.value = ''
} finally {
- loading.value = false
+ confirming = false
}
}
-
- return {
- activity,
- loading,
- fetchActivity,
+ function reset() {
+ revision++
+ code.value = ''
+ eligible.value = false
+ pendingCode.value = ''
}
+ function price(value: number) { return eligible.value ? Math.round(value * 95 / 100) : value }
+ return { code, eligible, pendingCode, refresh, confirm, reset, price }
})
-
diff --git a/packages/app/src/stores/user.ts b/packages/app/src/stores/user.ts
index c23521a..cff2ba2 100644
--- a/packages/app/src/stores/user.ts
+++ b/packages/app/src/stores/user.ts
@@ -1,3 +1,4 @@
+import { useInviteStore } from './invite'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
@@ -46,6 +47,7 @@ export const useUserStore = defineStore('user', () => {
const result = await wxLogin()
token.value = result.token
user.value = result.user
+ await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(result.user)
return { user: result.user, isNewUser: result.isNewUser }
} catch (err) {
@@ -72,6 +74,7 @@ export const useUserStore = defineStore('user', () => {
if (!isLoggedIn()) return
try {
user.value = await get('/user/profile')
+ await useInviteStore().refresh().catch(() => {})
syncSubscriptionTemplates(user.value)
return user.value
} catch (err) {
@@ -120,6 +123,7 @@ export const useUserStore = defineStore('user', () => {
function clearSession() {
token.value = ''
+ useInviteStore().reset()
user.value = null
stats.value = null
memberships.value = []
diff --git a/packages/server/prisma/migrations/20260908000000_invite_codes/migration.sql b/packages/server/prisma/migrations/20260908000000_invite_codes/migration.sql
new file mode 100644
index 0000000..3422d9c
--- /dev/null
+++ b/packages/server/prisma/migrations/20260908000000_invite_codes/migration.sql
@@ -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());
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index d927e93..221764d 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -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")
diff --git a/packages/server/src/flash-sale/__tests__/flash-sale-invite.spec.ts b/packages/server/src/flash-sale/__tests__/flash-sale-invite.spec.ts
new file mode 100644
index 0000000..80e2e5e
--- /dev/null
+++ b/packages/server/src/flash-sale/__tests__/flash-sale-invite.spec.ts
@@ -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 }))
+ })
+})
diff --git a/packages/server/src/flash-sale/flash-sale.service.ts b/packages/server/src/flash-sale/flash-sale.service.ts
index 6ecc219..5d455b5 100644
--- a/packages/server/src/flash-sale/flash-sale.service.ts
+++ b/packages/server/src/flash-sale/flash-sale.service.ts
@@ -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,
},
diff --git a/packages/server/src/invite/__tests__/invite.service.spec.ts b/packages/server/src/invite/__tests__/invite.service.spec.ts
new file mode 100644
index 0000000..da527e3
--- /dev/null
+++ b/packages/server/src/invite/__tests__/invite.service.spec.ts
@@ -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)
+ })
+})
diff --git a/packages/server/src/invite/invite.constants.ts b/packages/server/src/invite/invite.constants.ts
index 7aedb6a..a9c15aa 100644
--- a/packages/server/src/invite/invite.constants.ts
+++ b/packages/server/src/invite/invite.constants.ts
@@ -1,3 +1,3 @@
-export const INVITE_REWARD_REQUIRED_COUNT = 3
+export const INVITE_REWARD_REQUIRED_COUNT = 1
export const INVITE_REWARD_TIMES = 1
diff --git a/packages/server/src/invite/invite.controller.ts b/packages/server/src/invite/invite.controller.ts
index b96e080..b8bf20f 100644
--- a/packages/server/src/invite/invite.controller.ts
+++ b/packages/server/src/invite/invite.controller.ts
@@ -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)
diff --git a/packages/server/src/invite/invite.service.ts b/packages/server/src/invite/invite.service.ts
index 7a2c7c0..b4f76cc 100644
--- a/packages/server/src/invite/invite.service.ts
+++ b/packages/server/src/invite/invite.service.ts
@@ -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 {
- 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 {}
- if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
- return
+ 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('邀请码生成繁忙,请重试')
+ }
- 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 {
- 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 {
- 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 4ff9f5d..b3c9898 100644
--- a/packages/server/src/payment/__tests__/payment.service.spec.ts
+++ b/packages/server/src/payment/__tests__/payment.service.spec.ts
@@ -41,6 +41,7 @@ const mockUser = {
const mockInviteService = {
validateInviterForTrialOrder: jest.fn(),
recordTrialOrderPaid: jest.fn(),
+ rewardPaidOrder: jest.fn(),
}
const buildMockOrder = (overrides: Partial> = {}) => ({
@@ -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) => 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)
diff --git a/packages/server/src/payment/payment.service.ts b/packages/server/src/payment/payment.service.ts
index 613dd3c..fbf9d9e 100644
--- a/packages/server/src/payment/payment.service.ts
+++ b/packages/server/src/payment/payment.service.ts
@@ -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)