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

32
docs/invite-marketing.md Normal file
View File

@@ -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 mock8 项失败,与本次修改无关。`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`
- 本次只进行发布及只读核验,未制造测试用户、邀请关系或支付订单。微信真机分享与支付仍待小程序发布后联调。

View File

@@ -58,7 +58,7 @@
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
<text class="price-current">¥{{ formatPrice(invite.price(card.price)) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
@@ -69,6 +69,7 @@
</view>
</view>
<text v-if="invite.eligible" class="renew-tag">95 </text>
<!-- Arrow -->
<text class="card-arrow"></text>
</view>
@@ -82,6 +83,7 @@
</template>
<script setup lang="ts">
import { useInviteStore } from '../stores/invite'
import { computed, ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
@@ -89,6 +91,7 @@ import { get } from '../utils/request'
import { formatPrice, getCardCoverClass } from '../utils/format'
import { useUserStore } from '../stores/user'
const invite = useInviteStore()
const userStore = useUserStore()
const cardTypes = ref<CardType[]>([])
const loading = ref(false)

View File

@@ -52,8 +52,9 @@
<view class="price-area">
<view class="flash-price-row">
<text class="flash-currency">¥</text>
<text class="flash-price">{{ formatPrice(sale.flashPrice) }}</text>
<text class="flash-price">{{ formatPrice(invite.price(sale.flashPrice)) }}</text>
</view>
<text v-if="invite.eligible" class="original-price">好友 95 </text>
<text class="original-price">¥{{ formatPrice(sale.originalPrice) }}</text>
</view>
@@ -78,6 +79,8 @@
</template>
<script setup lang="ts">
import { useInviteStore } from "../stores/invite"
const invite = useInviteStore()
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { FlashSalePhase } from '@mp-pilates/shared'
import type { FlashSaleListItem } from '@mp-pilates/shared'

View File

@@ -0,0 +1,57 @@
<template>
<view class="invite-entry">
<view class="invite-heading" @tap="open">
<view><text class="eyebrow">好友享 95 · 一起变好</text><text class="entry-title">邀请好友领课时 <text></text></text></view>
<view class="gift"><text class="gift-number">1</text><text>节免费课</text></view>
</view>
<view class="code-line" @tap="copy"><text>{{ invite.code ? '我的邀请码' : '登录领取专属邀请码' }}</text><text class="code">{{ invite.code }}</text><text v-if="invite.code">复制</text></view>
<view v-if="visible" class="veil" @tap="visible = false" @touchmove.stop.prevent>
<view class="sheet" @tap.stop>
<button class="close" @tap="visible = false" aria-label="关闭">×</button>
<text class="eyebrow">A LITTLE GIFT, SHARED</text>
<text class="headline">好朋友好好练</text>
<text class="subtitle">把你的练习日常分享给在意的人</text>
<view class="benefits"><view><text class="benefit-num">1<text> </text></text><text>你得免费课</text></view><view><text class="benefit-num">95<text> </text></text><text>好友购卡优惠</text></view></view>
<view class="ticket" @tap="copy"><text>你的专属邀请码 · 点击复制</text><text class="ticket-code">{{ invite.code }}</text></view>
<text class="rules">好友确认邀请码体验卡次卡和期限卡均享 95 每位好友首次成功购买非体验卡你得 1 节免费课购买或完成体验课不触发赠课</text>
<text class="rules">赠课自动存入我的会员卡365 天内可预约每位好友仅绑定一位邀请人</text>
<button class="share" open-type="share" :disabled="!invite.code">分享给微信好友</button>
<text class="footnote">送朋友一份心意也给自己一次练习</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInviteStore } from '../stores/invite'
import { useUserStore } from '../stores/user'
import { getErrorMessage } from '../utils/auth'
const invite = useInviteStore()
const user = useUserStore()
const visible = ref(false)
async function open() {
try {
if (!user.loggedIn) await user.login()
await invite.refresh()
visible.value = true
} catch (err) { uni.showToast({ title: getErrorMessage(err, '暂时无法获取邀请码,请重试'), icon: 'none' }) }
}
function copy() {
if (!invite.code) { open(); return }
uni.setClipboardData({ data: invite.code })
}
</script>
<style scoped lang="scss">
.invite-entry { margin: 24rpx 32rpx; padding: 28rpx; background: #eaf0e7; border: 1rpx solid #d9e3d4; border-radius: 26rpx; color: #435b4c; }
.invite-heading { display: flex; justify-content: space-between; align-items: center; }
.eyebrow { display: block; font-size: 19rpx; letter-spacing: 3rpx; color: #788976; }
.entry-title { display: block; font-size: 29rpx; margin-top: 12rpx; font-weight: 500; }
.gift { display: flex; flex-direction: column; align-items: center; font-size: 18rpx; }.gift-number { font-family: Georgia, serif; font-size: 56rpx; line-height: 1; }
.code-line { display: flex; align-items: center; gap: 18rpx; border-top: 1rpx solid #d2dece; padding-top: 20rpx; margin-top: 22rpx; font-size: 21rpx; }.code { flex: 1; letter-spacing: 4rpx; font-family: monospace; font-size: 27rpx; }
.veil { position: fixed; inset: 0; z-index: 400; background: rgba(32,43,35,.48); display: flex; align-items: center; padding: 30rpx; }
.sheet { position: relative; width: 100%; max-height: 85vh; overflow-y: auto; box-sizing: border-box; padding: 48rpx 34rpx 32rpx; border-radius: 32rpx; background: #fcfaf5; }.close { position: absolute; right: 18rpx; top: 12rpx; margin: 0; background: transparent; color: #7b8477; font-size: 36rpx; }.close::after,.share::after { border: none; }
.headline { display: block; margin-top: 20rpx; font-family: 'Songti SC',serif; font-size: 46rpx; }.subtitle { display: block; margin-top: 12rpx; font-size: 23rpx; color: #87907f; }
.benefits { display: flex; margin: 30rpx 0; padding: 25rpx 0; background: #eaf0e5; border-radius: 22rpx; }.benefits>view { flex: 1; text-align: center; font-size: 24rpx; }.benefits>view+view { border-left: 1rpx solid #cbd8c5; }.benefit-num { display: block; font: 66rpx Georgia,serif; margin-bottom: 10rpx; }.benefit-num text { font-size: 26rpx; }
.ticket { padding: 22rpx; text-align: center; border: 1rpx dashed #b7c5af; border-radius: 16rpx; font-size: 21rpx; color: #7a8772; }.ticket-code { display: block; margin-top: 12rpx; font: 42rpx monospace; letter-spacing: 10rpx; color: #435b4c; }
.rules { display: block; margin-top: 18rpx; font-size: 21rpx; line-height: 1.8; color: #7d8076; }.share { margin-top: 26rpx; border-radius: 999rpx; background: #526e58; color: #fff; font-size: 27rpx; line-height: 86rpx; }.footnote { display: block; text-align: center; margin-top: 16rpx; font-size: 19rpx; color: #93978a; }
</style>

View File

@@ -5,6 +5,21 @@
:style="{ paddingTop: navBarHeight }"
>
<CustomNavBar :title="pageTitle" show-back />
<view class="invite-banner" @tap="inviteVisible = true; inviteInput = invite.pendingCode">
<text>{{ invite.eligible ? '好友礼遇 · 已享 95 折' : '好友礼遇 · 领取 95 折购卡优惠' }}</text>
<text class="invite-banner-note">{{ invite.eligible ? '体验卡、次卡、期限卡均适用' : '填写邀请码,和朋友一起开始练习 ' }}</text>
</view>
<view v-if="inviteVisible" class="purchase-sheet-layer" @touchmove.stop.prevent>
<view class="purchase-sheet">
<text class="sheet-kicker">A GIFT FROM YOUR FRIEND</text>
<text class="sheet-title">朋友送你一份 95 折礼遇</text>
<text class="invite-banner-note">确认邀请码后购卡自动减免 5%体验卡也可享受</text>
<input v-model="inviteInput" class="invite-input" maxlength="6" placeholder="填写 6 位好友邀请码" :disabled="inviteBusy" />
<text v-if="inviteError" class="invite-error">{{ inviteError }}</text>
<text class="sheet-note-text">每人仅绑定一位好友首次成功购买非体验卡好友获得 1 节免费课体验卡不触发赠课</text>
<view class="sheet-actions"><button class="sheet-cancel" :disabled="inviteBusy" @tap="inviteVisible = false">稍后再说</button><button class="sheet-confirm" :disabled="inviteBusy || inviteInput.length !== 6" @tap="confirmInvite">{{ inviteBusy ? '领取中' : '确认领取' }}</button></view>
</view>
</view>
<view v-if="loading" class="loading-wrap">
<view class="skeleton-pass" />
@@ -64,10 +79,11 @@
<text class="card-benefit">{{ cardAccessLabel(c) }}</text>
<view class="card-price-row">
<view class="price-stack">
<text class="card-price">¥{{ formatPrice(c.price) }}</text>
<text v-if="getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
<text class="card-price">¥{{ formatPrice(invite.pendingCode && !invite.eligible ? Math.round(c.price * 95 / 100) : invite.price(c.price)) }}</text>
<text v-if="invite.pendingCode && !invite.eligible" class="save-tag">领取后 95 </text>
<text v-if="(!invite.pendingCode || invite.eligible) && getSavingsLabel(c)" class="save-tag">{{ getSavingsLabel(c) }}</text>
</view>
<text class="card-unit">{{ getUnitPriceLabel(c) }}</text>
<text v-if="!invite.pendingCode || invite.eligible" class="card-unit">{{ getUnitPriceLabel(c) }}</text>
</view>
</view>
@@ -129,7 +145,7 @@
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(cardData.price) }}</text>
<text class="hero-price">{{ formatPrice(invite.price(cardData.price)) }}</text>
<text
v-if="cardData.originalPrice && cardData.originalPrice > cardData.price"
class="hero-original"
@@ -239,7 +255,7 @@
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary">
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
<text class="price-summary-value">¥{{ formatPrice(cardData.price) }}</text>
<text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
<text class="price-summary-hint">{{ bottomPriceHint }}</text>
</view>
<button
@@ -270,7 +286,7 @@
<text class="sheet-card-type">{{ typeLabel }}</text>
<text class="sheet-card-name">{{ cardData.name }}</text>
</view>
<text class="sheet-card-price">¥{{ formatPrice(cardData.price) }}</text>
<text class="sheet-card-price">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
</view>
<view class="sheet-card-meta">
<text>{{ cardAccessLabel(cardData) }}</text>
@@ -319,8 +335,9 @@
</template>
<script setup lang="ts">
import { useInviteStore } from '../../stores/invite'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onLoad, onShow } from '@dcloudio/uni-app'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import {
CardTypeCategory,
@@ -348,6 +365,30 @@ interface MyOrderStatusResponse {
type PaymentConfirmationResult = 'paid' | 'pending' | 'unavailable' | 'unauthorized'
type PaymentStatusIssue = 'none' | 'order-check' | 'membership-sync' | 'reauthentication'
const invite = useInviteStore()
const inviteVisible = ref(false)
const inviteInput = ref('')
const inviteBusy = ref(false)
const inviteError = ref('')
onLoad((options) => {
if (options?.inviteCode) {
invite.pendingCode = options.inviteCode.toUpperCase()
inviteInput.value = invite.pendingCode
inviteVisible.value = true
}
})
async function confirmInvite() {
if (inviteBusy.value) return
inviteBusy.value = true
inviteError.value = ''
try {
if (!userStore.loggedIn) await userStore.login()
await invite.confirm(inviteInput.value.trim().toUpperCase())
inviteVisible.value = false
uni.showToast({ title: '95 折优惠已领取', icon: 'success' })
} catch (err) { inviteError.value = getErrorMessage(err, '领取失败,请重试') }
finally { inviteBusy.value = false }
}
const userStore = useUserStore()
const navBarHeight = ref('64px')
const cardId = ref('')
@@ -578,7 +619,7 @@ function cardAccessLabel(cardType: CardType): string {
function getUnitPrice(cardType: CardType): string {
const divisor = cardType.totalTimes === null ? cardType.durationDays : cardType.totalTimes
if (!divisor) return '-'
return '¥' + String(Math.round(cardType.price / divisor / 100))
return '¥' + String(Math.round(invite.price(cardType.price) / divisor / 100))
}
function getUnitPriceLabel(cardType: CardType): string {
@@ -586,6 +627,7 @@ function getUnitPriceLabel(cardType: CardType): string {
}
function getSavingsLabel(cardType: CardType): string {
if (invite.eligible) return '好友价 · 95 折'
if (!cardType.originalPrice || cardType.originalPrice <= cardType.price) return ''
return '省 ¥' + formatPrice(cardType.originalPrice - cardType.price)
}
@@ -698,6 +740,7 @@ async function preparePurchaseConfirmation() {
if (!card.value || isPurchaseBusy.value) return
purchasePreparing.value = true
try {
await invite.refresh()
const refreshed = await refreshMembershipContext()
if (!userStore.loggedIn) {
showLoginPrompt()
@@ -708,6 +751,8 @@ async function preparePurchaseConfirmation() {
return
}
purchaseConfirmVisible.value = true
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
} finally {
purchasePreparing.value = false
}
@@ -824,10 +869,8 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
try {
const inviterId = uni.getStorageSync('invite_inviter_id') as string
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
inviterId: isTrialCard.value && inviterId ? inviterId : undefined,
})
uni.hideLoading()
@@ -872,6 +915,7 @@ onMounted(() => {
})
onShow(() => {
if (userStore.loggedIn) void invite.refresh().catch(() => {})
if (!paymentConfirming.value && !paymentPending.value) {
void refreshMembershipContext()
}
@@ -883,6 +927,11 @@ onUnmounted(() => {
</script>
<style lang="scss" scoped>
.invite-banner { margin: 24rpx 32rpx 0; padding: 24rpx; border-radius: 20rpx; background: #eaf0e5; color: #526e58; font-size: 27rpx; }
.invite-banner-note { display: block; margin-top: 10rpx; color: #7b8974; font-size: 22rpx; line-height: 1.6; }
.invite-input { margin: 26rpx 0; padding: 24rpx; border: 1rpx dashed #a9b99e; border-radius: 16rpx; font: 34rpx monospace; letter-spacing: 6rpx; height: 55rpx; text-align: center; }
.invite-error { display: block; color: #ad604c; font-size: 23rpx; margin-bottom: 16rpx; }
.card-detail-page {
min-height: 100vh;
box-sizing: border-box;

View File

@@ -40,7 +40,8 @@
<!-- Price row -->
<view class="hero-price-row">
<text class="hero-currency">¥</text>
<text class="hero-price">{{ formatPrice(detail.flashPrice) }}</text>
<text v-if="invite.eligible" class="hero-discount-text">好友 95 </text>
<text class="hero-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
<view class="hero-original-wrap">
<text class="hero-original-label">原价</text>
<text class="hero-original">¥{{ formatPrice(detail.originalPrice) }}</text>
@@ -49,7 +50,7 @@
<!-- Discount badge -->
<view class="hero-discount-badge">
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - detail.flashPrice) }}</text>
<text class="hero-discount-text">立省 ¥{{ formatPrice(detail.originalPrice - invite.price(detail.flashPrice)) }}</text>
</view>
<!-- Countdown -->
@@ -186,7 +187,7 @@
<text class="bar-price-label">秒杀价</text>
<view class="bar-price-row">
<text class="bar-currency">¥</text>
<text class="bar-price">{{ formatPrice(detail.flashPrice) }}</text>
<text class="bar-price">{{ formatPrice(invite.price(detail.flashPrice)) }}</text>
</view>
</view>
<view
@@ -202,6 +203,8 @@
</template>
<script setup lang="ts">
import { useInviteStore } from "../../stores/invite"
const invite = useInviteStore()
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
FlashSalePhase,
@@ -295,7 +298,7 @@ const actionBtnText = computed(() => {
if (!userStore.loggedIn) return '登录后参与'
if (!userStore.user?.phone) return '授权手机号后参与'
if (buying.value) return '抢购中...'
return `¥${formatPrice(d.flashPrice)} 立即抢购`
return `¥${formatPrice(invite.price(d.flashPrice))} 立即抢购`
})
const actionBtnClass = computed(() => {
@@ -360,10 +363,15 @@ async function handleAction() {
return
}
try { await invite.refresh() } catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
return
}
// Confirm purchase
uni.showModal({
title: '确认抢购',
content: `确认以 ¥${formatPrice(detail.value.flashPrice)} 抢购「${detail.value.title}」?`,
content: `确认以 ¥${formatPrice(invite.price(detail.value.flashPrice))} 抢购「${detail.value.title}」?`,
confirmText: '确认抢购',
success: async (res) => {
if (res.confirm) {

View File

@@ -8,6 +8,8 @@
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
<InviteCard />
<!-- Menu section: always visible -->
<ProfileMenu
:is-admin="isAdmin"
@@ -29,6 +31,8 @@
</template>
<script setup lang="ts">
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { ref, computed, onMounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
@@ -40,6 +44,7 @@ import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
const invite = useInviteStore()
const userStore = useUserStore()
const bookingStore = useBookingStore()
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
@@ -61,8 +66,8 @@ const upcomingBookingCount = computed(
// ─── 微信分享 ───────────────────────────────────────────────
onShareAppMessage(() => {
return {
title: '我的普拉提会所,记录每一次进步',
path: '/pages/profile/index',
title: invite.code ? '送你 95 折购卡礼,一起练普拉提' : '一起练普拉提',
path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index',
imageUrl: '',
}
})
@@ -82,6 +87,7 @@ onShow(async () => {
activityRefreshKey.value += 1
if (loggedIn.value) {
await Promise.all([
invite.refresh().catch(() => {}),
userStore.fetchProfile(),
userStore.fetchStats(),
userStore.fetchMemberships(),

View File

@@ -1,504 +1,14 @@
<template>
<view class="invite-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="邀请好友" show-back />
<scroll-view class="invite-scroll" scroll-y>
<view class="hero-card">
<view class="hero-glow hero-glow--one" />
<view class="hero-glow hero-glow--two" />
<text class="hero-badge">会员专享裂变活动</text>
<text class="hero-title"> 3 位好友体验并核销</text>
<text class="hero-subtitle">好友购买体验课并完成上课后会员卡立即奖励 1 节正课次数</text>
<view class="hero-stats">
<view class="hero-stat">
<text class="hero-stat-value">{{ summary?.qualifiedInviteCount ?? 0 }}</text>
<text class="hero-stat-label">已完成邀请</text>
</view>
<view class="hero-stat hero-stat--accent">
<text class="hero-stat-value">{{ summary?.rewardedTimes ?? 0 }}</text>
<text class="hero-stat-label">已得奖励</text>
</view>
<view class="hero-stat">
<text class="hero-stat-value">{{ summary?.nextRewardRemainingCount ?? 3 }}</text>
<text class="hero-stat-label">距下次奖励</text>
</view>
</view>
<view class="progress-shell">
<view class="progress-track">
<view class="progress-fill" :style="{ width: progressWidth }" />
</view>
<text class="progress-caption">本轮进度 {{ summary?.currentCycleQualifiedCount ?? 0 }}/{{ summary?.rewardRuleInvitesRequired ?? 3 }}</text>
</view>
<button class="share-btn" open-type="share">
立即邀请好友
</button>
<text class="share-hint">分享后新用户登录并购买体验课即可自动绑定邀请关系</text>
</view>
<view class="steps-card">
<text class="section-title">活动规则</text>
<view v-for="item in ruleSteps" :key="item.title" class="step-item">
<view class="step-index">{{ item.index }}</view>
<view class="step-body">
<text class="step-title">{{ item.title }}</text>
<text class="step-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view class="referrals-card">
<view class="section-head">
<text class="section-title">邀请进度</text>
<text class="section-meta">待完成 {{ summary?.pendingInviteCount ?? 0 }} </text>
</view>
<view v-if="summary?.referrals?.length" class="referral-list">
<view v-for="item in summary.referrals" :key="item.id" class="referral-item">
<image v-if="item.inviteeAvatarUrl" class="referral-avatar" :src="item.inviteeAvatarUrl" mode="aspectFill" />
<view v-else class="referral-avatar referral-avatar--placeholder"></view>
<view class="referral-main">
<text class="referral-name">{{ item.inviteeNickname || '新好友' }}</text>
<text class="referral-time">邀请于 {{ formatDateTime(item.invitedAt) }}</text>
</view>
<text class="referral-status" :class="statusClass(item.status)">{{ statusLabel(item.status) }}</text>
</view>
</view>
<view v-else class="empty-block">
<text class="empty-title">还没有邀请记录</text>
<text class="empty-desc">先分享给 3 位好友完成一次体验闭环就会在这里点亮进度</text>
</view>
</view>
<view class="reward-card">
<view class="section-head">
<text class="section-title">奖励记录</text>
<text class="section-meta">累计 {{ summary?.rewardedTimes ?? 0 }} </text>
</view>
<view v-if="summary?.rewardGrants?.length" class="reward-list">
<view v-for="item in summary.rewardGrants" :key="item.id" class="reward-item">
<text class="reward-item-title">完成 {{ item.qualifiedReferralCount }} 位好友核销</text>
<text class="reward-item-time">{{ formatDateTime(item.grantedAt) }}</text>
<text class="reward-item-tag">+{{ item.rewardTimes }} </text>
</view>
</view>
<view v-else class="empty-block empty-block--warm">
<text class="empty-title">还未获得奖励</text>
<text class="empty-desc"> 3 位好友完成体验核销系统自动增加 1 节真实会员课次</text>
</view>
</view>
<view class="bottom-space" />
</scroll-view>
</view>
</template>
<template><view :style="{ paddingTop: navBarHeight }"><CustomNavBar title="邀请好友" show-back /><InviteCard /></view></template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad, onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { InviteReferralStatus } from '@mp-pilates/shared'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { formatDateTime } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const inviteStore = useInviteStore()
const userStore = useUserStore()
const navBarHeight = ref('64px')
const summary = computed(() => inviteStore.activity)
const progressWidth = computed(() => {
const current = summary.value?.currentCycleQualifiedCount ?? 0
const total = summary.value?.rewardRuleInvitesRequired ?? 3
return `${Math.min(100, (current / total) * 100)}%`
})
const ruleSteps = [
{ index: '01', title: '分享活动页', desc: '会员用户把活动页转发给微信好友或朋友圈。' },
{ index: '02', title: '好友购买体验课', desc: '新好友通过你的分享进入,并成功购买体验课。' },
{ index: '03', title: '体验课完成核销', desc: '好友到店体验并被老师核销后,这次邀请记为有效。' },
{ index: '04', title: '满 3 人自动加课', desc: '每累计 3 位有效邀请,系统自动给你的会员卡增加 1 节。' },
]
onLoad((query) => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
const inviterId = typeof query?.inviterId === 'string' ? query.inviterId : ''
if (inviterId) {
uni.setStorageSync('invite_inviter_id', inviterId)
}
})
onShow(async () => {
if (!userStore.loggedIn) {
return
}
await Promise.all([
userStore.fetchProfile(),
inviteStore.fetchActivity(),
])
})
onShareAppMessage(() => ({
title: '邀 3 位好友体验核销,立得 1 节会员正课',
path: summary.value?.sharePath || `/pages/profile/invite?inviterId=${userStore.user?.id || ''}`,
imageUrl: '',
}))
onShareTimeline(() => ({
title: '邀 3 位好友体验核销,立得 1 节会员正课',
query: `inviterId=${userStore.user?.id || ''}`,
}))
function statusLabel(status: InviteReferralStatus): string {
const map: Record<InviteReferralStatus, string> = {
[InviteReferralStatus.REGISTERED]: '已注册',
[InviteReferralStatus.TRIAL_PURCHASED]: '已购体验课',
[InviteReferralStatus.QUALIFIED]: '已完成核销',
}
return map[status]
}
function statusClass(status: InviteReferralStatus): string {
if (status === InviteReferralStatus.QUALIFIED) return 'referral-status--done'
if (status === InviteReferralStatus.TRIAL_PURCHASED) return 'referral-status--paid'
return 'referral-status--registered'
}
const invite = useInviteStore()
const user = useUserStore()
const navBarHeight = `${getSystemLayout().navBarHeight}px`
onShow(() => { if (user.loggedIn) invite.refresh().catch(() => {}) })
onShareAppMessage(() => ({ title: '送你 95 折购卡礼,一起练普拉提', path: invite.code ? `/pages/card/detail?showAll=1&inviteCode=${invite.code}` : '/pages/home/index' }))
</script>
<style lang="scss" scoped>
.invite-page {
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(255, 142, 83, 0.28), transparent 34%),
radial-gradient(circle at top right, rgba(255, 214, 102, 0.34), transparent 26%),
linear-gradient(180deg, #fff5db 0%, #ffe7ea 30%, #fef7ff 100%);
}
.invite-scroll {
height: 100vh;
padding: 24rpx;
box-sizing: border-box;
}
.hero-card,
.steps-card,
.referrals-card,
.reward-card {
position: relative;
overflow: hidden;
border-radius: 36rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 18rpx 50rpx rgba(157, 70, 42, 0.08);
}
.hero-card {
background: linear-gradient(135deg, #ff7a45 0%, #ff4d6d 48%, #ffb347 100%);
color: #fff;
}
.hero-glow {
position: absolute;
border-radius: 50%;
opacity: 0.28;
background: rgba(255, 255, 255, 0.5);
}
.hero-glow--one {
width: 260rpx;
height: 260rpx;
top: -90rpx;
right: -40rpx;
}
.hero-glow--two {
width: 180rpx;
height: 180rpx;
bottom: -50rpx;
left: -40rpx;
}
.hero-badge {
display: inline-flex;
align-self: flex-start;
padding: 10rpx 18rpx;
background: rgba(255, 255, 255, 0.18);
border-radius: 999rpx;
font-size: 22rpx;
margin-bottom: 18rpx;
}
.hero-title {
display: block;
font-size: 52rpx;
font-weight: 700;
line-height: 1.18;
}
.hero-subtitle {
display: block;
margin-top: 18rpx;
font-size: 26rpx;
line-height: 1.7;
color: rgba(255, 255, 255, 0.92);
}
.hero-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
margin-top: 28rpx;
}
.hero-stat {
padding: 24rpx 18rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(10rpx);
}
.hero-stat--accent {
background: rgba(75, 16, 16, 0.22);
}
.hero-stat-value {
display: block;
font-size: 46rpx;
font-weight: 700;
}
.hero-stat-label {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.86);
}
.progress-shell {
margin-top: 28rpx;
}
.progress-track {
height: 20rpx;
border-radius: 999rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.25);
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #fff7ad 0%, #ffffff 100%);
}
.progress-caption {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.88);
}
.share-btn {
margin-top: 28rpx;
height: 96rpx;
line-height: 96rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 700;
color: #ff5a3c;
background: linear-gradient(90deg, #fff7e4 0%, #ffffff 100%);
border: none;
}
.share-btn::after {
border: none;
}
.share-hint {
display: block;
margin-top: 14rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.82);
}
.steps-card {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 247, 234, 0.92));
}
.section-title {
display: block;
font-size: 32rpx;
font-weight: 700;
color: #30201a;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.section-meta {
font-size: 22rpx;
color: #9b6b55;
}
.step-item {
display: flex;
gap: 18rpx;
align-items: flex-start;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(214, 171, 134, 0.2);
}
.step-item:last-child {
border-bottom: none;
}
.step-index {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
text-align: center;
line-height: 64rpx;
font-size: 24rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #ff8f5a 0%, #ff4d6d 100%);
}
.step-body {
flex: 1;
}
.step-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #36231d;
}
.step-desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
line-height: 1.7;
color: #7b5d52;
}
.referrals-card {
background: linear-gradient(180deg, #ffffff 0%, #fff7fb 100%);
}
.reward-card {
background: linear-gradient(180deg, #fffdf5 0%, #fff2dc 100%);
}
.referral-item,
.reward-item {
display: flex;
align-items: center;
padding: 22rpx 0;
border-bottom: 1rpx solid rgba(221, 196, 177, 0.35);
}
.referral-item:last-child,
.reward-item:last-child {
border-bottom: none;
}
.referral-avatar {
width: 78rpx;
height: 78rpx;
border-radius: 50%;
margin-right: 18rpx;
background: #ffd9c8;
}
.referral-avatar--placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: #ff6f3c;
}
.referral-main {
flex: 1;
}
.referral-name,
.reward-item-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #31211a;
}
.referral-time,
.reward-item-time {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #8b6d62;
}
.referral-status,
.reward-item-tag {
padding: 12rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
}
.referral-status--registered {
color: #9c5e2f;
background: #fff0de;
}
.referral-status--paid {
color: #c44f1f;
background: #ffe0d1;
}
.referral-status--done,
.reward-item-tag {
color: #0f7a53;
background: #dff7ea;
}
.empty-block {
padding: 36rpx 0 10rpx;
text-align: center;
}
.empty-block--warm {
padding-bottom: 0;
}
.empty-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #5f4337;
}
.empty-desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.8;
color: #9c7d70;
}
.bottom-space {
height: 48rpx;
}
</style>

View File

@@ -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) {

View File

@@ -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<InviteActivitySummary | null>(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<InviteActivitySummary>('/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 }
})

View File

@@ -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<UserProfileResponse>('/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 = []

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)