feat: 支持分享邀请好友功能
This commit is contained in:
@@ -49,6 +49,7 @@ const props = defineProps<{
|
|||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
activeMembershipCount?: number
|
activeMembershipCount?: number
|
||||||
upcomingBookingCount?: number
|
upcomingBookingCount?: number
|
||||||
|
inviteShareEligible?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -81,6 +82,15 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
badge: bookingBadge,
|
badge: bookingBadge,
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
|
...(props.inviteShareEligible
|
||||||
|
? [{
|
||||||
|
key: 'invite',
|
||||||
|
type: 'item' as const,
|
||||||
|
title: '邀请好友',
|
||||||
|
path: '/pages/profile/invite',
|
||||||
|
requireAuth: true,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
type: 'item',
|
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 {
|
&--info {
|
||||||
background: rgba($brand-color, 0.06);
|
background: rgba($brand-color, 0.06);
|
||||||
|
|||||||
@@ -51,6 +51,12 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/profile/invite",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/teacher/detail",
|
"path": "pages/teacher/detail",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -332,8 +332,10 @@ async function doPurchase() {
|
|||||||
uni.showLoading({ title: '创建订单...' })
|
uni.showLoading({ title: '创建订单...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||||
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
||||||
cardTypeId: card.value.id,
|
cardTypeId: card.value.id,
|
||||||
|
inviterId: isTrial.value && inviterId ? inviterId : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
:require-auth="loggedIn"
|
:require-auth="loggedIn"
|
||||||
:active-membership-count="activeMembershipCount"
|
:active-membership-count="activeMembershipCount"
|
||||||
:upcoming-booking-count="upcomingBookingCount"
|
:upcoming-booking-count="upcomingBookingCount"
|
||||||
|
:invite-share-eligible="!!user?.inviteShareEligible"
|
||||||
@clear-cache="handleClearCache"
|
@clear-cache="handleClearCache"
|
||||||
@require-login="handleLogin"
|
@require-login="handleLogin"
|
||||||
/>
|
/>
|
||||||
|
|||||||
504
packages/app/src/pages/profile/invite.vue
Normal file
504
packages/app/src/pages/profile/invite.vue
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<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 { 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'
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
26
packages/app/src/stores/invite.ts
Normal file
26
packages/app/src/stores/invite.ts
Normal file
@@ -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<InviteActivitySummary | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchActivity() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
activity.value = await get<InviteActivitySummary>('/invite/activity')
|
||||||
|
return activity.value
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
loading,
|
||||||
|
fetchActivity,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||||
)
|
)
|
||||||
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
|
||||||
|
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
async function login() {
|
async function login() {
|
||||||
@@ -124,6 +125,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
activeMemberships,
|
activeMemberships,
|
||||||
hasValidMembership,
|
hasValidMembership,
|
||||||
|
inviteShareEligible,
|
||||||
login,
|
login,
|
||||||
loginWithSetup,
|
loginWithSetup,
|
||||||
fetchProfile,
|
fetchProfile,
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export function getErrorMessage(err: unknown, fallback: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function wxLogin(): Promise<LoginResponse> {
|
export async function wxLogin(): Promise<LoginResponse> {
|
||||||
|
const inviterId = uni.getStorageSync('invite_inviter_id') as string
|
||||||
|
|
||||||
await ensurePrivacyAuthorization()
|
await ensurePrivacyAuthorization()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -72,8 +74,12 @@ export async function wxLogin(): Promise<LoginResponse> {
|
|||||||
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
// 新用户的昵称/头像由后端生成默认值,用户可在个人资料页修改
|
||||||
const result = await post<LoginResponse>('/auth/login', {
|
const result = await post<LoginResponse>('/auth/login', {
|
||||||
code: loginRes.code,
|
code: loginRes.code,
|
||||||
|
inviterId: inviterId || undefined,
|
||||||
})
|
})
|
||||||
uni.setStorageSync('token', result.token)
|
uni.setStorageSync('token', result.token)
|
||||||
|
if (result.isNewUser && inviterId) {
|
||||||
|
uni.removeStorageSync('invite_inviter_id')
|
||||||
|
}
|
||||||
resolve(result)
|
resolve(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err)
|
reject(err)
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ enum FlashSaleOrderStatus {
|
|||||||
EXPIRED
|
EXPIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum InviteReferralStatus {
|
||||||
|
REGISTERED
|
||||||
|
TRIAL_PURCHASED
|
||||||
|
QUALIFIED
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Models =====
|
// ===== Models =====
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -82,6 +88,9 @@ model User {
|
|||||||
orders Order[]
|
orders Order[]
|
||||||
flashSaleOrders FlashSaleOrder[]
|
flashSaleOrders FlashSaleOrder[]
|
||||||
subscriptionMessageConsents SubscriptionMessageConsent[]
|
subscriptionMessageConsents SubscriptionMessageConsent[]
|
||||||
|
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
|
||||||
|
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
|
||||||
|
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -147,6 +156,7 @@ model Membership {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
bookings Booking[]
|
bookings Booking[]
|
||||||
|
inviteRewardGrants InviteRewardGrant[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -206,6 +216,7 @@ model Booking {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||||
membership Membership @relation(fields: [membershipId], references: [id])
|
membership Membership @relation(fields: [membershipId], references: [id])
|
||||||
|
qualifiedInviteReferrals InviteReferral[]
|
||||||
|
|
||||||
statusHistory BookingStatusHistory[]
|
statusHistory BookingStatusHistory[]
|
||||||
|
|
||||||
@@ -246,12 +257,54 @@ model Order {
|
|||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||||
flashSaleOrder FlashSaleOrder?
|
flashSaleOrder FlashSaleOrder?
|
||||||
|
inviteReferrals InviteReferral[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("orders")
|
@@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 {
|
model StudioConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SchedulerModule } from './scheduler/scheduler.module'
|
|||||||
import { PaymentModule } from './payment/payment.module'
|
import { PaymentModule } from './payment/payment.module'
|
||||||
import { AdminModule } from './admin/admin.module'
|
import { AdminModule } from './admin/admin.module'
|
||||||
import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
||||||
|
import { InviteModule } from './invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -30,6 +31,7 @@ import { FlashSaleModule } from './flash-sale/flash-sale.module'
|
|||||||
PaymentModule,
|
PaymentModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
FlashSaleModule,
|
FlashSaleModule,
|
||||||
|
InviteModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { UserRole } from '@mp-pilates/shared'
|
|||||||
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
|
||||||
import { WechatService } from '../wechat.service'
|
import { WechatService } from '../wechat.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
|
||||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -46,6 +47,10 @@ const mockJwtService = {
|
|||||||
sign: jest.fn(),
|
sign: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockInviteService = {
|
||||||
|
bindInviterToUser: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
@@ -58,6 +63,7 @@ describe('AuthService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrismaService },
|
{ provide: PrismaService, useValue: mockPrismaService },
|
||||||
{ provide: WechatService, useValue: mockWechatService },
|
{ provide: WechatService, useValue: mockWechatService },
|
||||||
{ provide: JwtService, useValue: mockJwtService },
|
{ provide: JwtService, useValue: mockJwtService },
|
||||||
|
{ provide: InviteService, useValue: mockInviteService },
|
||||||
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
@@ -91,10 +97,20 @@ describe('AuthService', () => {
|
|||||||
where: { openid: OPENID },
|
where: { openid: OPENID },
|
||||||
})
|
})
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
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.user).toEqual(mockUser)
|
||||||
expect(result.isNewUser).toBe(true)
|
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 () => {
|
it('creates user with unionid when present', async () => {
|
||||||
@@ -110,7 +126,7 @@ describe('AuthService', () => {
|
|||||||
await authService.login(loginCode)
|
await authService.login(loginCode)
|
||||||
|
|
||||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||||
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
|
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class AuthController {
|
|||||||
loginDto.code,
|
loginDto.code,
|
||||||
loginDto.nickname,
|
loginDto.nickname,
|
||||||
loginDto.avatarUrl,
|
loginDto.avatarUrl,
|
||||||
|
loginDto.inviterId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { WechatService } from './wechat.service'
|
|||||||
import { JwtStrategy } from './jwt.strategy'
|
import { JwtStrategy } from './jwt.strategy'
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||||
import { RolesGuard } from './roles.guard'
|
import { RolesGuard } from './roles.guard'
|
||||||
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule,
|
PassportModule,
|
||||||
|
InviteModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { User } from '@prisma/client'
|
|||||||
import { UserRole } from '@mp-pilates/shared'
|
import { UserRole } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatService } from './wechat.service'
|
import { WechatService } from './wechat.service'
|
||||||
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
token: string
|
token: string
|
||||||
@@ -55,6 +56,7 @@ export class AuthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly wechatService: WechatService,
|
private readonly wechatService: WechatService,
|
||||||
|
private readonly inviteService: InviteService,
|
||||||
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ export class AuthService {
|
|||||||
code: string,
|
code: string,
|
||||||
nickname?: string,
|
nickname?: string,
|
||||||
avatarUrl?: string,
|
avatarUrl?: string,
|
||||||
|
inviterId?: string,
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
const { openid, unionid, sessionKey } =
|
const { openid, unionid, sessionKey } =
|
||||||
await this.wechatService.code2Session(code)
|
await this.wechatService.code2Session(code)
|
||||||
@@ -98,6 +101,10 @@ export class AuthService {
|
|||||||
|
|
||||||
sessionKeyStore.set(user.id, sessionKey)
|
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 payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||||
const token = this.jwtService.sign(payload)
|
const token = this.jwtService.sign(payload)
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,8 @@ export class LoginDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
inviterId?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PrismaService } from '../../prisma/prisma.service'
|
|||||||
import { MembershipService } from '../../membership/membership.service'
|
import { MembershipService } from '../../membership/membership.service'
|
||||||
import { StudioService } from '../../studio/studio.service'
|
import { StudioService } from '../../studio/studio.service'
|
||||||
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||||
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
|
||||||
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
// ─── Fixtures ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,6 +154,7 @@ describe('BookingService', () => {
|
|||||||
let prisma: jest.Mocked<PrismaService>
|
let prisma: jest.Mocked<PrismaService>
|
||||||
let studioService: jest.Mocked<StudioService>
|
let studioService: jest.Mocked<StudioService>
|
||||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
||||||
|
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -204,6 +206,12 @@ describe('BookingService', () => {
|
|||||||
sendAdminBookingCreatedMessage: jest.fn(),
|
sendAdminBookingCreatedMessage: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: InviteService,
|
||||||
|
useValue: {
|
||||||
|
recordQualifiedTrialBooking: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile()
|
}).compile()
|
||||||
|
|
||||||
@@ -211,6 +219,7 @@ describe('BookingService', () => {
|
|||||||
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
|
||||||
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
studioService = module.get(StudioService) as jest.Mocked<StudioService>
|
||||||
subscriptionMessageService = module.get(SubscriptionMessageService)
|
subscriptionMessageService = module.get(SubscriptionMessageService)
|
||||||
|
inviteService = module.get(InviteService)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => jest.clearAllMocks())
|
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 ────────────────────────────────────────────────────────
|
// ─── createBooking ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('createBooking', () => {
|
describe('createBooking', () => {
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { BookingService } from './booking.service'
|
|||||||
import { MembershipModule } from '../membership/membership.module'
|
import { MembershipModule } from '../membership/membership.module'
|
||||||
import { StudioModule } from '../studio/studio.module'
|
import { StudioModule } from '../studio/studio.module'
|
||||||
import { UserModule } from '../user/user.module'
|
import { UserModule } from '../user/user.module'
|
||||||
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MembershipModule, StudioModule, UserModule],
|
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
|
||||||
controllers: [BookingController],
|
controllers: [BookingController],
|
||||||
providers: [BookingService],
|
providers: [BookingService],
|
||||||
exports: [BookingService],
|
exports: [BookingService],
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MembershipService } from '../membership/membership.service'
|
|||||||
import { StudioService } from '../studio/studio.service'
|
import { StudioService } from '../studio/studio.service'
|
||||||
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto'
|
import { CreateBookingDto } from './dto/create-booking.dto'
|
||||||
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export class BookingService {
|
|||||||
private readonly membershipService: MembershipService,
|
private readonly membershipService: MembershipService,
|
||||||
private readonly studioService: StudioService,
|
private readonly studioService: StudioService,
|
||||||
private readonly subscriptionMessageService: SubscriptionMessageService,
|
private readonly subscriptionMessageService: SubscriptionMessageService,
|
||||||
|
private readonly inviteService: InviteService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── Create Booking ──────────────────────────────────────────────────────
|
// ─── Create Booking ──────────────────────────────────────────────────────
|
||||||
@@ -330,7 +332,11 @@ export class BookingService {
|
|||||||
return updated
|
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 ──────────────────────────────────────────────────────
|
// ─── Cancel Booking ──────────────────────────────────────────────────────
|
||||||
|
|||||||
3
packages/server/src/invite/invite.constants.ts
Normal file
3
packages/server/src/invite/invite.constants.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const INVITE_REWARD_REQUIRED_COUNT = 3
|
||||||
|
export const INVITE_REWARD_TIMES = 1
|
||||||
|
|
||||||
16
packages/server/src/invite/invite.controller.ts
Normal file
16
packages/server/src/invite/invite.controller.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
11
packages/server/src/invite/invite.module.ts
Normal file
11
packages/server/src/invite/invite.module.ts
Normal file
@@ -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 {}
|
||||||
|
|
||||||
253
packages/server/src/invite/invite.service.ts
Normal file
253
packages/server/src/invite/invite.service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<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: {
|
||||||
|
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<void> {
|
||||||
|
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<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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
|||||||
import { PaymentService } from '../payment.service'
|
import { PaymentService } from '../payment.service'
|
||||||
import { WechatPayService } from '../wechat-pay.service'
|
import { WechatPayService } from '../wechat-pay.service'
|
||||||
import { PrismaService } from '../../prisma/prisma.service'
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { InviteService } from '../../invite/invite.service'
|
||||||
|
|
||||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ const mockUser = {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockInviteService = {
|
||||||
|
validateInviterForTrialOrder: jest.fn(),
|
||||||
|
recordTrialOrderPaid: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||||
id: 'order-uuid-1',
|
id: 'order-uuid-1',
|
||||||
userId: mockUser.id,
|
userId: mockUser.id,
|
||||||
@@ -105,6 +111,7 @@ describe('PaymentService', () => {
|
|||||||
PaymentService,
|
PaymentService,
|
||||||
{ provide: PrismaService, useValue: prisma },
|
{ provide: PrismaService, useValue: prisma },
|
||||||
{ provide: WechatPayService, useValue: wechat },
|
{ provide: WechatPayService, useValue: wechat },
|
||||||
|
{ provide: InviteService, useValue: mockInviteService },
|
||||||
],
|
],
|
||||||
}).compile()
|
}).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 () => {
|
it('throws NotFoundException when cardType does not exist', async () => {
|
||||||
prisma.cardType.findUnique.mockResolvedValue(null)
|
prisma.cardType.findUnique.mockResolvedValue(null)
|
||||||
|
|
||||||
@@ -232,6 +249,7 @@ describe('PaymentService', () => {
|
|||||||
|
|
||||||
// membership.create was called
|
// membership.create was called
|
||||||
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
|
||||||
|
|
||||||
expect(result).toContain('SUCCESS')
|
expect(result).toContain('SUCCESS')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { IsUUID } from 'class-validator'
|
import { IsOptional, IsUUID } from 'class-validator'
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
cardTypeId!: string
|
cardTypeId!: string
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsOptional()
|
||||||
|
inviterId?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class PaymentController {
|
|||||||
@CurrentUser('sub') userId: string,
|
@CurrentUser('sub') userId: string,
|
||||||
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
|
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
|
||||||
) {
|
) {
|
||||||
return this.paymentService.createOrder(userId, dto.cardTypeId)
|
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { PrismaModule } from '../prisma/prisma.module'
|
|||||||
import { PaymentService } from './payment.service'
|
import { PaymentService } from './payment.service'
|
||||||
import { PaymentController } from './payment.controller'
|
import { PaymentController } from './payment.controller'
|
||||||
import { WechatPayService } from './wechat-pay.service'
|
import { WechatPayService } from './wechat-pay.service'
|
||||||
|
import { InviteModule } from '../invite/invite.module'
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, InviteModule],
|
||||||
controllers: [PaymentController],
|
controllers: [PaymentController],
|
||||||
providers: [PaymentService, WechatPayService],
|
providers: [PaymentService, WechatPayService],
|
||||||
exports: [PaymentService, WechatPayService],
|
exports: [PaymentService, WechatPayService],
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CardType, Order } from '@prisma/client'
|
|||||||
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
|
||||||
|
import { InviteService } from '../invite/invite.service'
|
||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
order: Order
|
order: Order
|
||||||
@@ -28,11 +29,12 @@ export class PaymentService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly wechatPayService: WechatPayService,
|
private readonly wechatPayService: WechatPayService,
|
||||||
|
private readonly inviteService: InviteService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─── User: create order ────────────────────────────────────────────────────
|
// ─── User: create order ────────────────────────────────────────────────────
|
||||||
|
|
||||||
async createOrder(userId: string, cardTypeId: string): Promise<CreateOrderResult> {
|
async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise<CreateOrderResult> {
|
||||||
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
|
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
|
||||||
|
|
||||||
if (!cardType) {
|
if (!cardType) {
|
||||||
@@ -47,6 +49,10 @@ export class PaymentService {
|
|||||||
throw new NotFoundException(`User ${userId} not found`)
|
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 orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
||||||
|
|
||||||
const order = await this.prisma.order.create({
|
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}`)
|
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
|
||||||
|
|
||||||
// ── Flash sale order: mark as PAID ──
|
// ── Flash sale order: mark as PAID ──
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export class UserService {
|
|||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
role: user.role as UserRole,
|
role: user.role as UserRole,
|
||||||
activeMembershipCount: user._count.memberships,
|
activeMembershipCount: user._count.memberships,
|
||||||
|
inviteShareEligible: user._count.memberships > 0,
|
||||||
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
|
||||||
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export enum FlashSaleOrderStatus {
|
|||||||
EXPIRED = 'EXPIRED',
|
EXPIRED = 'EXPIRED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Invite =====
|
||||||
|
export enum InviteReferralStatus {
|
||||||
|
REGISTERED = 'REGISTERED',
|
||||||
|
TRIAL_PURCHASED = 'TRIAL_PURCHASED',
|
||||||
|
QUALIFIED = 'QUALIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Subscribe Message =====
|
// ===== Subscribe Message =====
|
||||||
export enum SubscriptionMessageScene {
|
export enum SubscriptionMessageScene {
|
||||||
ORDER_PAID = 'ORDER_PAID',
|
ORDER_PAID = 'ORDER_PAID',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
OrderStatus,
|
OrderStatus,
|
||||||
FlashSaleStatus,
|
FlashSaleStatus,
|
||||||
FlashSaleOrderStatus,
|
FlashSaleOrderStatus,
|
||||||
|
InviteReferralStatus,
|
||||||
SubscriptionMessageScene,
|
SubscriptionMessageScene,
|
||||||
} from './enums'
|
} from './enums'
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ export type {
|
|||||||
CreateFlashSaleDto,
|
CreateFlashSaleDto,
|
||||||
UpdateFlashSaleDto,
|
UpdateFlashSaleDto,
|
||||||
FlashSalePurchaseResponse,
|
FlashSalePurchaseResponse,
|
||||||
|
InviteActivityReferral,
|
||||||
|
InviteRewardGrantRecord,
|
||||||
|
InviteActivitySummary,
|
||||||
SubscriptionMessageRequestResult,
|
SubscriptionMessageRequestResult,
|
||||||
SubscriptionMessageRequestItem,
|
SubscriptionMessageRequestItem,
|
||||||
SubscriptionMessageTemplate,
|
SubscriptionMessageTemplate,
|
||||||
|
|||||||
@@ -30,4 +30,9 @@ export type {
|
|||||||
UpdateFlashSaleDto,
|
UpdateFlashSaleDto,
|
||||||
FlashSalePurchaseResponse,
|
FlashSalePurchaseResponse,
|
||||||
} from './flash-sale'
|
} from './flash-sale'
|
||||||
|
export type {
|
||||||
|
InviteActivityReferral,
|
||||||
|
InviteRewardGrantRecord,
|
||||||
|
InviteActivitySummary,
|
||||||
|
} from './invite'
|
||||||
export { FlashSalePhase } from './flash-sale'
|
export { FlashSalePhase } from './flash-sale'
|
||||||
|
|||||||
36
packages/shared/src/types/invite.ts
Normal file
36
packages/shared/src/types/invite.ts
Normal file
@@ -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[]
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export interface OrderWithDetails extends Order {
|
|||||||
|
|
||||||
export interface CreateOrderDto {
|
export interface CreateOrderDto {
|
||||||
readonly cardTypeId: string
|
readonly cardTypeId: string
|
||||||
|
readonly inviterId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentParams {
|
export interface PaymentParams {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface UserProfileResponse {
|
|||||||
readonly avatarUrl: string | null
|
readonly avatarUrl: string | null
|
||||||
readonly role: UserRole
|
readonly role: UserRole
|
||||||
readonly activeMembershipCount: number
|
readonly activeMembershipCount: number
|
||||||
|
readonly inviteShareEligible: boolean
|
||||||
readonly adminBookingSubscriptionCount: number
|
readonly adminBookingSubscriptionCount: number
|
||||||
readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig
|
readonly subscriptionMessageTemplates: SubscriptionMessageTemplateConfig
|
||||||
readonly createdAt: string
|
readonly createdAt: string
|
||||||
|
|||||||
Reference in New Issue
Block a user