feat: 支持同卡种续卡叠加并修复过期登录态

支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-07 14:04:11 +08:00
parent 801e2f47da
commit 88cd8419c8
22 changed files with 1370 additions and 123 deletions

View File

@@ -47,7 +47,10 @@
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ card.name }}</text>
<view class="card-name-row">
<text class="card-name">{{ card.name }}</text>
<text v-if="isRenewable(card)" class="renew-tag"></text>
</view>
<text class="card-validity">有效期 {{ card.durationDays }} </text>
</view>
<view class="info-bottom">
@@ -80,15 +83,32 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { computed, ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatPrice, getCardCoverClass } from '../utils/format'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
const hasLoaded = ref(false)
const renewableCardTypeIds = computed(() => {
const ids = new Set<string>()
for (const membership of userStore.memberships) {
if (membership.cardType.type !== CardTypeCategory.TRIAL) {
ids.add(membership.cardTypeId)
}
}
return ids
})
function isRenewable(card: CardType): boolean {
return card.type !== CardTypeCategory.TRIAL && renewableCardTypeIds.value.has(card.id)
}
async function fetchCardTypes() {
// Stale-While-Revalidate: only show skeleton on first load
// Subsequent refreshes silently update data in background
@@ -250,7 +270,15 @@ function goToAllCards() {
gap: 16rpx;
}
.card-name-row {
display: flex;
align-items: center;
gap: 10rpx;
min-width: 0;
}
.card-name {
min-width: 0;
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
@@ -261,6 +289,18 @@ function goToAllCards() {
line-height: 1.2;
}
.renew-tag {
flex-shrink: 0;
font-size: 18rpx;
font-weight: 700;
color: #5f7a6e;
background: rgba(143, 168, 154, 0.2);
padding: 2rpx 10rpx;
border-radius: 8rpx;
line-height: 1.4;
letter-spacing: 1rpx;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;

View File

@@ -33,8 +33,8 @@
</view>
<!-- Running low: thin accent strip -->
<view v-if="isRunningLow" class="renew-strip" @tap="scrollToCardShop">
<text class="renew-strip-text">仅剩 {{ lowestRemainingTimes }} · 续卡保持节奏</text>
<view v-if="renewStripText" class="renew-strip" @tap="handleRenew">
<text class="renew-strip-text">{{ renewStripText }}</text>
<text class="renew-strip-arrow"></text>
</view>
</template>
@@ -43,7 +43,7 @@
<view
v-else
class="entry-pill pill-expired"
@tap="scrollToCardShop"
@tap="handleRenew"
>
<view class="pill-dot dot-expired" />
<text class="pill-label">会员卡已到期</text>
@@ -91,6 +91,15 @@ function scrollToCardShop() {
emit('scroll-to-card-shop')
}
function handleRenew() {
const cardTypeId = userStore.renewalHint?.cardTypeId
if (cardTypeId) {
uni.navigateTo({ url: `/pages/card/detail?id=${cardTypeId}` })
return
}
scrollToCardShop()
}
const activeMembershipLabel = computed(() => {
const active = userStore.activeMemberships
if (!active.length) return ''
@@ -105,24 +114,19 @@ const activeMembershipLabel = computed(() => {
return `${cardName} · 剩余 ${daysLeft}`
})
const isRunningLow = computed(() => {
return userStore.activeMemberships.some(
(m) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= 2,
)
})
const lowestRemainingTimes = computed(() => {
const timesCards = userStore.activeMemberships.filter(
(m) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= 2,
)
if (!timesCards.length) return 0
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
const renewStripText = computed(() => {
const hint = userStore.renewalHint
if (!hint || !userStore.hasValidMembership) return ''
if (hint.kind === 'times_low') {
return `仅剩 ${hint.remainingTimes ?? 0} 次 · 续卡保持节奏`
}
if (hint.kind === 'days_low') {
return `还剩 ${hint.daysLeft ?? 0} 天到期 · 续卡不中断`
}
if (hint.kind === 'trial_low') {
return '体验课将尽 · 选购会员卡'
}
return ''
})
</script>
@@ -254,8 +258,8 @@ const lowestRemainingTimes = computed(() => {
}
.action-renew {
background: #e0e0e0;
.pill-action-text { color: #555; }
background: #e8efe9;
.pill-action-text { color: #5f7a6e; }
}
/* ── Renew strip (running low) ── */
@@ -266,21 +270,21 @@ const lowestRemainingTimes = computed(() => {
gap: 8rpx;
margin-top: 12rpx;
padding: 14rpx 24rpx;
background: linear-gradient(135deg, #FF6B35, #FF8E53);
background: linear-gradient(135deg, #8fa89a, #a8c0b4);
border-radius: 24rpx;
}
.renew-strip-text {
font-size: 22rpx;
font-weight: 500;
color: #fff;
color: #fffcfa;
letter-spacing: 0.5rpx;
}
.renew-strip-arrow {
font-size: 28rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
color: rgba(255, 252, 250, 0.82);
line-height: 1;
}
</style>

View File

@@ -178,12 +178,12 @@
</view>
</view>
<!-- Progress bar -->
<view v-if="detailMembership.remainingTimes !== null && detailMembership.cardType?.totalTimes" class="mship-progress-wrap">
<view v-if="detailMembership.remainingTimes !== null && getMembershipTotalTimes(detailMembership)" class="mship-progress-wrap">
<view class="mship-progress-bar">
<view class="mship-progress-fill" :style="{ width: getMembershipProgressWidth(detailMembership) }" />
</view>
<text class="mship-progress-label">
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ detailMembership.cardType.totalTimes }}
已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ getMembershipTotalTimes(detailMembership) }}
</text>
</view>
<!-- Date info -->
@@ -313,7 +313,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary, UserMembership } from '../../stores/admin'
import type { CardType } from '@mp-pilates/shared'

View File

@@ -1,6 +1,6 @@
<template>
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="购买会员卡" show-back />
<view class="card-detail-page" :class="{ 'page--renew': isRenewal }" :style="{ paddingTop: navBarHeight }">
<CustomNavBar :title="isRenewal ? '续卡' : '购买会员卡'" show-back />
<!-- Loading state -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-header" />
@@ -54,7 +54,10 @@
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ c.name }}</text>
<view class="card-name-row">
<text class="card-name">{{ c.name }}</text>
<text v-if="isOwnedForRenew(c)" class="renew-tag"></text>
</view>
<text class="card-validity">有效期 {{ c.durationDays }} </text>
</view>
<view class="info-bottom">
@@ -85,7 +88,7 @@
<!-- Card content (single card mode) -->
<template v-else>
<!-- Hero section -->
<view class="card-hero" :class="cardData.coverUrl ? 'hero--custom' : heroClass">
<view class="card-hero" :class="[cardData.coverUrl ? 'hero--custom' : heroClass, isRenewal ? 'hero--renew' : '']">
<!-- Cover image background -->
<image
v-if="cardData.coverUrl"
@@ -99,9 +102,15 @@
<view class="hero-deco hero-deco--2" />
</template>
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
<view class="hero-badge-row">
<view class="hero-badge">
<text class="hero-badge-text">{{ typeLabel }}</text>
</view>
<view v-if="isRenewal" class="hero-badge hero-badge--renew">
<text class="hero-badge-text">续卡</text>
</view>
</view>
<text v-if="isRenewal" class="hero-kicker">把练习节奏接下去</text>
<text class="hero-name">{{ cardData.name }}</text>
<view class="hero-price-row">
<text class="hero-currency">¥</text>
@@ -144,15 +153,49 @@
<text class="desc-content">{{ cardData.description }}</text>
</view>
<view v-if="renewalPreview" class="renew-compare">
<view class="renew-compare-head">
<text class="renew-compare-kicker">续卡对照</text>
<text class="renew-compare-status">{{ renewalPreview.statusLabel }}</text>
</view>
<view class="renew-compare-body">
<view class="renew-col">
<text class="renew-col-label">当前</text>
<text class="renew-col-value">{{ renewalPreview.currentHighlight }}</text>
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
<text class="renew-col-date">{{ renewalPreview.currentExpire }}</text>
</view>
<view class="renew-arrow">
<view class="renew-arrow-line" />
<view class="renew-arrow-diamond" />
<view class="renew-arrow-line" />
</view>
<view class="renew-col renew-col--after">
<text class="renew-col-label">续后</text>
<text class="renew-col-value">{{ renewalPreview.afterHighlight }}</text>
<text class="renew-col-unit">{{ renewalPreview.highlightUnit }}</text>
<text class="renew-col-date">{{ renewalPreview.afterExpire }}</text>
</view>
</view>
<view class="renew-compare-foot">
<text class="renew-compare-add">{{ renewalPreview.addedLabel }}</text>
<text v-if="renewalPreview.forfeitNote" class="renew-compare-note">{{ renewalPreview.forfeitNote }}</text>
</view>
</view>
<!-- Features list -->
<view class="features-card">
<view class="section-header">
<view class="section-dot" />
<text class="section-title">购买须知</text>
<text class="section-title">{{ isRenewal ? '续卡须知' : '购买须知' }}</text>
</view>
<view class="feature-item">
<text class="feature-dot"></text>
<text class="feature-text">购买后立即生效有效期 {{ cardData.durationDays }} </text>
<text class="feature-text">{{ isRenewal ? `续卡后有效期接续,再延长 ${cardData.durationDays}` : `购买后立即生效,有效期 ${cardData.durationDays}` }}</text>
</view>
<view v-if="cardData.totalTimes" class="feature-item">
<text class="feature-dot"></text>
@@ -186,17 +229,17 @@
</view>
<!-- Bottom action bar -->
<view class="bottom-bar">
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary">
<text class="summary-label">实付金额</text>
<text class="summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text>
<text class="summary-price">¥{{ formatPrice(cardData.price) }}</text>
</view>
<view
class="buy-btn"
:class="{ 'buy-btn--loading': buying }"
:class="{ 'buy-btn--loading': buying, 'buy-btn--renew': isRenewal }"
@tap="handleBuy"
>
<text class="buy-btn-text">{{ buying ? '支付中...' : '立即购买' }}</text>
<text class="buy-btn-text">{{ buying ? '支付中...' : (isRenewal ? '确认续卡' : '立即购买') }}</text>
</view>
</view>
</template>
@@ -206,10 +249,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, pickRenewalTarget, getMembershipDaysLeft } from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { get, post } from '../../utils/request'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import { requestOrderPaidSubscriptionMessage } from '../../utils/wechat-subscription'
@@ -261,6 +304,78 @@ const unitPrice = computed(() => {
const cardData = computed<CardType>(() => card.value as CardType)
const renewalTarget = computed(() => {
if (!card.value || card.value.type === CardTypeCategory.TRIAL) return null
return pickRenewalTarget(userStore.memberships, card.value.id)
})
const isRenewal = computed(() => !!renewalTarget.value)
const grantPreview = computed(() => {
if (!card.value || !renewalTarget.value) return null
return computeMembershipGrant({
existing: renewalTarget.value,
cardType: card.value,
now: new Date(),
})
})
function formatStudioDate(value: Date | string): string {
const date = typeof value === 'string' ? new Date(value) : value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
const renewalPreview = computed(() => {
const target = renewalTarget.value
const grant = grantPreview.value
const currentCard = card.value
if (!target || !grant || !currentCard) return null
const isTimes = grant.remainingTimes !== null
const isExpired =
target.status === MembershipStatus.EXPIRED ||
new Date(target.expireDate).getTime() <= Date.now()
const statusLabel =
target.status === MembershipStatus.USED_UP
? '次数已用完'
: isExpired
? '已到期'
: '使用中'
return {
isTimes,
isExpired,
statusLabel,
currentHighlight: isTimes ? String(target.remainingTimes ?? 0) : String(Math.max(0, getMembershipDaysLeft(target.expireDate))),
afterHighlight: isTimes ? String(grant.remainingTimes ?? 0) : String(Math.max(0, getMembershipDaysLeft(grant.expireDate))),
highlightUnit: isTimes ? '次剩余' : '天有效',
currentExpire: formatStudioDate(target.expireDate),
afterExpire: formatStudioDate(grant.expireDate),
addedLabel: isTimes
? `本次续入 +${currentCard.totalTimes ?? 0} 次 · +${currentCard.durationDays}`
: `本次续入 +${currentCard.durationDays}`,
forfeitNote: isExpired && isTimes && (target.remainingTimes ?? 0) > 0
? '到期余次不结转,按新购次数起算'
: '',
}
})
const renewalPreviewText = computed(() => {
const preview = renewalPreview.value
if (!preview) return ''
if (preview.isTimes) {
return `续卡后剩余 ${preview.afterHighlight} 次,有效期至 ${preview.afterExpire}`
}
return `续卡后有效期至 ${preview.afterExpire}`
})
function isOwnedForRenew(target: CardType): boolean {
return target.type !== CardTypeCategory.TRIAL && !!pickRenewalTarget(userStore.memberships, target.id)
}
// ─── Data loading ─────────────────────────────────────────
async function loadCard() {
loading.value = true
@@ -324,8 +439,10 @@ async function handleBuy() {
}
uni.showModal({
title: '确认购买',
content: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}`,
title: isRenewal.value ? '确认续卡' : '确认购买',
content: isRenewal.value
? `确认续卡「${card.value.name}」,${renewalPreviewText.value},实付 ¥${formatPrice(card.value.price)}`
: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}`,
confirmText: '确认支付',
success: async (res) => {
if (!res.confirm) return
@@ -364,7 +481,7 @@ async function doPurchase() {
// Payment succeeded — refresh memberships then navigate
await requestOrderPaidSubscriptionMessage().catch(() => undefined)
uni.showToast({ title: '购买成功!', icon: 'success' })
uni.showToast({ title: isRenewal.value ? '续卡成功!' : '购买成功!', icon: 'success' })
await userStore.fetchMemberships()
setTimeout(() => {
uni.navigateTo({ url: '/pages/profile/membership' })
@@ -391,6 +508,9 @@ onMounted(() => {
isTrial.value = options.trial === '1'
showAll.value = options.showAll === '1'
loadCard()
if (userStore.loggedIn) {
userStore.fetchMemberships()
}
})
</script>
@@ -401,6 +521,10 @@ onMounted(() => {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.page--renew {
background: #f4f1eb;
}
/* ── Loading ─────────────────────────────────────────── */
.loading-wrap {
padding: 0 0 32rpx;
@@ -473,19 +597,23 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
background: linear-gradient(160deg, #f3e9de 0%, #e6d5c4 100%);
}
&.hero--duration {
background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
background: linear-gradient(160deg, #eee6ea 0%, #dccfd6 100%);
}
&.hero--trial {
background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
background: linear-gradient(160deg, #e4eee9 0%, #c9dbd3 100%);
}
&.hero--custom {
background: #333;
background: #5c534c;
}
&.hero--renew {
background: linear-gradient(165deg, #e4ede8 0%, #f3ebe3 52%, #e8dcd0 100%);
}
}
@@ -501,7 +629,7 @@ onMounted(() => {
.hero-deco {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.38);
pointer-events: none;
&--1 {
@@ -519,26 +647,57 @@ onMounted(() => {
}
}
.hero--renew .hero-deco--1 {
background: rgba(143, 168, 154, 0.22);
}
.hero--renew .hero-deco--2 {
background: rgba(255, 252, 248, 0.45);
}
.hero-badge-row {
display: flex;
align-items: center;
gap: 12rpx;
z-index: 1;
}
.hero-badge {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
background: rgba(74, 64, 53, 0.1);
border: 1rpx solid rgba(74, 64, 53, 0.15);
background: rgba(255, 252, 248, 0.45);
border: 1rpx solid rgba(95, 122, 110, 0.18);
z-index: 1;
}
.hero-badge--renew {
background: rgba(143, 168, 154, 0.22);
border-color: rgba(95, 122, 110, 0.22);
.hero-badge-text {
color: #5f7a6e;
}
}
.hero-badge-text {
font-size: 22rpx;
color: $brand-color;
color: #5c534c;
font-weight: 600;
letter-spacing: 1rpx;
letter-spacing: 2rpx;
}
.hero-kicker {
font-size: 24rpx;
color: #7a8f85;
letter-spacing: 4rpx;
z-index: 1;
}
.hero-name {
font-size: 48rpx;
font-weight: 800;
color: $brand-color;
color: #4f5d56;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -553,20 +712,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
color: rgba(74, 64, 53, 0.7);
color: #7a8f85;
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
color: $brand-color;
color: #4f5d56;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
color: rgba(74, 64, 53, 0.4);
color: rgba(92, 83, 76, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -592,7 +751,7 @@ onMounted(() => {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
background: $primary-dark;
background: #8fa89a;
flex-shrink: 0;
}
@@ -633,13 +792,13 @@ onMounted(() => {
.cell-value {
font-size: 44rpx;
font-weight: 800;
color: #1a1a1a;
color: #4f5d56;
line-height: 1.1;
}
.cell-label {
font-size: 22rpx;
color: #999;
color: #9a9088;
}
/* ── Description card ────────────────────────────────── */
@@ -656,6 +815,128 @@ onMounted(() => {
line-height: 1.75;
}
.renew-compare {
background: linear-gradient(180deg, #fffcfa 0%, #f3eee6 100%);
border-radius: 28rpx;
padding: 32rpx 28rpx 28rpx;
box-shadow: 0 10rpx 32rpx rgba(95, 122, 110, 0.08);
border: 1rpx solid rgba(143, 168, 154, 0.28);
}
.renew-compare-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28rpx;
}
.renew-compare-kicker {
font-size: 22rpx;
font-weight: 600;
letter-spacing: 4rpx;
color: #7a8f85;
}
.renew-compare-status {
font-size: 20rpx;
color: #f7faf8;
background: #8fa89a;
padding: 6rpx 16rpx;
border-radius: 20rpx;
letter-spacing: 1rpx;
}
.renew-compare-body {
display: flex;
align-items: center;
justify-content: space-between;
}
.renew-col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.renew-col-label {
font-size: 20rpx;
color: #a3988e;
letter-spacing: 2rpx;
margin-bottom: 8rpx;
}
.renew-col-value {
font-size: 72rpx;
font-weight: 800;
color: #8a7e74;
line-height: 1;
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
}
.renew-col--after .renew-col-value {
color: #5f7a6e;
}
.renew-col-unit {
font-size: 22rpx;
color: #8a7e74;
margin-top: 8rpx;
}
.renew-col-date {
font-size: 22rpx;
color: #a3988e;
margin-top: 12rpx;
letter-spacing: 1rpx;
}
.renew-arrow {
width: auto;
padding: 0 8rpx 28rpx;
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.renew-arrow-line {
width: 28rpx;
height: 2rpx;
background: rgba(143, 168, 154, 0.45);
}
.renew-arrow-diamond {
width: 14rpx;
height: 14rpx;
background: #8fa89a;
transform: rotate(45deg);
}
.renew-compare-foot {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1rpx dashed rgba(143, 168, 154, 0.4);
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
.renew-compare-add {
font-size: 24rpx;
font-weight: 600;
color: #5f7a6e;
letter-spacing: 1rpx;
}
.renew-compare-note {
font-size: 20rpx;
color: #a3988e;
}
/* ── Features card ───────────────────────────────────── */
.features-card {
background: #fff;
@@ -676,14 +957,14 @@ onMounted(() => {
.feature-dot {
font-size: 26rpx;
color: $primary-dark;
color: #8fa89a;
line-height: 1.65;
flex-shrink: 0;
}
.feature-text {
font-size: 26rpx;
color: #555;
color: #6f675f;
line-height: 1.65;
}
@@ -693,14 +974,19 @@ onMounted(() => {
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1rpx solid #f0ece8;
background: rgba(255, 252, 250, 0.96);
border-top: 1rpx solid #efe8df;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
box-shadow: 0 -8rpx 28rpx rgba(95, 122, 110, 0.06);
}
.bottom-bar--renew {
background: #fffcfa;
border-top-color: rgba(143, 168, 154, 0.22);
}
.price-summary {
@@ -711,24 +997,24 @@ onMounted(() => {
.summary-label {
font-size: 22rpx;
color: #999;
color: #9a9088;
}
.summary-price {
font-size: 40rpx;
font-weight: 800;
color: $primary-dark;
color: #5f7a6e;
}
.buy-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
background: #6e8b7e;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
box-shadow: 0 8rpx 24rpx rgba(95, 122, 110, 0.22);
&:active {
opacity: 0.85;
@@ -737,13 +1023,17 @@ onMounted(() => {
&--loading {
opacity: 0.6;
}
&--renew {
background: linear-gradient(135deg, #6e8b7e 0%, #8fa89a 100%);
}
}
.buy-btn-text {
font-size: 32rpx;
font-size: 30rpx;
font-weight: 700;
color: $primary-dark;
letter-spacing: 2rpx;
color: #fffcfa;
letter-spacing: 4rpx;
}
/* ── All cards list ────────────────────────────────────── */
@@ -849,6 +1139,25 @@ onMounted(() => {
line-height: 1.2;
}
.card-name-row {
display: flex;
align-items: center;
gap: 10rpx;
min-width: 0;
}
.renew-tag {
flex-shrink: 0;
font-size: 18rpx;
font-weight: 700;
color: #5f7a6e;
background: rgba(143, 168, 154, 0.2);
padding: 2rpx 10rpx;
border-radius: 8rpx;
line-height: 1.4;
letter-spacing: 1rpx;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;

View File

@@ -63,7 +63,7 @@
<view v-if="m.remainingTimes !== null" class="mc-center">
<text class="mc-big-num">{{ m.remainingTimes }}</text>
<text class="mc-big-unit">次剩余</text>
<view v-if="m.cardType.totalTimes" class="mc-progress">
<view v-if="getMembershipTotalTimes(m)" class="mc-progress">
<view class="mc-progress-track">
<view
class="mc-progress-fill"
@@ -71,7 +71,7 @@
/>
</view>
<text class="mc-progress-label">
已用 {{ getMembershipUsedTimes(m) }} {{ m.cardType.totalTimes }}
已用 {{ getMembershipUsedTimes(m) }} {{ getMembershipTotalTimes(m) }}
</text>
</view>
</view>
@@ -94,6 +94,10 @@
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view v-if="canRenewMembership(m)" class="mc-renew" @tap.stop="goRenew(m)">
<text class="mc-renew-text">续卡</text>
</view>
</view>
</view>
@@ -133,6 +137,10 @@
<text class="mc-date-value">{{ m.expireDate.slice(0, 10) }}</text>
</view>
</view>
<view v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap.stop="goRenew(m)">
<text class="mc-renew-text">续同款</text>
</view>
</view>
</view>
</view>
@@ -153,7 +161,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
@@ -215,9 +223,18 @@ async function onRefresh() {
}
function goStore() {
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
function canRenewMembership(m: MembershipWithCardType): boolean {
return m.cardType.type !== CardTypeCategory.TRIAL
}
function goRenew(m: MembershipWithCardType) {
uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
}
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
@@ -571,6 +588,32 @@ onMounted(() => {
z-index: 1;
}
.mc-renew {
z-index: 1;
align-self: stretch;
height: 64rpx;
border-radius: 32rpx;
background: rgba(255, 252, 248, 0.42);
border: 1rpx solid rgba(143, 168, 154, 0.4);
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.85; }
}
.mc-renew--inactive {
background: rgba(143, 168, 154, 0.1);
border-color: rgba(143, 168, 154, 0.28);
}
.mc-renew-text {
font-size: 24rpx;
font-weight: 600;
color: #5f7a6e;
letter-spacing: 4rpx;
}
/* ── FAB ──────────────────────────────────── */
.fab {
position: fixed;

View File

@@ -5,8 +5,13 @@ import type {
UserStatsResponse,
MembershipWithCardType,
} from '@mp-pilates/shared'
import { UserRole, MembershipStatus } from '@mp-pilates/shared'
import {
UserRole,
MembershipStatus,
getMembershipRenewalHint,
} from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
import { setUnauthorizedHandler } from '../utils/session'
import { get, put } from '../utils/request'
import { ROUTES } from '../utils/routes'
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
@@ -32,6 +37,7 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions
@@ -110,15 +116,21 @@ export const useUserStore = defineStore('user', () => {
}
}
function logout() {
authLogout()
resetSubscriptionMessageTemplateCache()
function clearSession() {
token.value = ''
user.value = null
stats.value = null
memberships.value = []
resetSubscriptionMessageTemplateCache()
}
function logout() {
authLogout()
clearSession()
}
setUnauthorizedHandler(clearSession)
return {
user,
stats,
@@ -129,6 +141,7 @@ export const useUserStore = defineStore('user', () => {
isAdmin,
activeMemberships,
hasValidMembership,
renewalHint,
inviteShareEligible,
login,
loginWithSetup,

View File

@@ -4,6 +4,7 @@ import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
readonly remainingTimes: number | null
readonly totalTimes?: number | null
readonly cardType: { readonly totalTimes: number | null }
}
@@ -84,17 +85,24 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
return 'gradient--times'
}
/** 会员卡累计购入次数(续卡后优先用卡上的快照) */
export function getMembershipTotalTimes(membership: MembershipLike): number | null {
return membership.totalTimes ?? membership.cardType.totalTimes ?? null
}
/** 会员卡进度百分比(剩余 / 总次数clamp 到 0~100% */
export function getMembershipProgressWidth(membership: MembershipLike): string {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
const totalTimes = getMembershipTotalTimes(membership)
if (membership.remainingTimes === null || !totalTimes) return '0%'
const pct = (membership.remainingTimes / totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数(不低于 0防止管理员调高剩余次数导致负值 */
export function getMembershipUsedTimes(membership: MembershipLike): number {
if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
const totalTimes = getMembershipTotalTimes(membership)
if (membership.remainingTimes === null || !totalTimes) return 0
return Math.max(0, totalTimes - membership.remainingTimes)
}
/** 格式化倒计时HH:MM:SS */

View File

@@ -1,4 +1,5 @@
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
import { notifyUnauthorized } from './session'
// 统一使用线上服务地址
const BASE_URL = 'https://focus.richarjiang.com/api'
@@ -25,8 +26,9 @@ export function request<T>(options: RequestOptions): Promise<T> {
},
success: (res) => {
if (res.statusCode === 401) {
uni.removeStorageSync('token')
uni.showToast({ title: '请重新登录', icon: 'none' })
if (!options.url.startsWith('/auth/login')) {
notifyUnauthorized()
}
reject(new Error('Unauthorized'))
return
}

View File

@@ -0,0 +1,22 @@
const UNAUTHORIZED_TOAST_GAP_MS = 2000
let unauthorizedHandler: (() => void) | null = null
let lastUnauthorizedAt = 0
export function setUnauthorizedHandler(handler: () => void): void {
unauthorizedHandler = handler
}
/** Clear persisted token and in-memory session after the server rejects auth. */
export function notifyUnauthorized(): void {
const now = Date.now()
const shouldToast = now - lastUnauthorizedAt > UNAUTHORIZED_TOAST_GAP_MS
lastUnauthorizedAt = now
uni.removeStorageSync('token')
unauthorizedHandler?.()
if (shouldToast) {
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
}
}

View File

@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
-- AlterTable
ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
-- CreateIndex
CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
-- CreateIndex
CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
-- AddForeignKey
ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- Backfill purchased-times snapshot for existing times/trial cards
UPDATE `memberships` `m`
INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
SET `m`.`total_times` = `ct`.`total_times`
WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;

View File

@@ -147,6 +147,7 @@ model Membership {
userId String @map("user_id")
cardTypeId String @map("card_type_id")
remainingTimes Int? @map("remaining_times")
totalTimes Int? @map("total_times")
startDate DateTime @map("start_date")
expireDate DateTime @map("expire_date")
status MembershipStatus @default(ACTIVE)
@@ -156,9 +157,11 @@ model Membership {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[]
orders Order[]
inviteRewardGrants InviteRewardGrant[]
@@index([userId])
@@index([userId, cardTypeId])
@@index([status])
@@map("memberships")
}
@@ -245,6 +248,7 @@ model Order {
id String @id @default(uuid())
userId String @map("user_id")
cardTypeId String @map("card_type_id")
membershipId String? @map("membership_id")
orderNo String @unique @map("order_no")
amount Decimal @db.Decimal(10, 0)
status OrderStatus @default(PENDING)
@@ -256,11 +260,13 @@ model Order {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@index([status])
@@index([membershipId])
@@map("orders")
}

View File

@@ -0,0 +1,295 @@
import {
CardTypeCategory,
MembershipStatus,
RENEWAL_DAYS_THRESHOLD,
RENEWAL_TIMES_THRESHOLD,
computeMembershipGrant,
getMembershipRenewalHint,
pickRenewalTarget,
} from '@mp-pilates/shared'
import type { RenewalHintMembership } from '@mp-pilates/shared'
const now = new Date('2026-06-01T00:00:00Z')
const timesCard = {
type: CardTypeCategory.TIMES,
totalTimes: 10,
durationDays: 90,
}
const durationCard = {
type: CardTypeCategory.DURATION,
totalTimes: null,
durationDays: 30,
}
const trialCard = {
type: CardTypeCategory.TRIAL,
totalTimes: 1,
durationDays: 7,
}
function membership(overrides: Partial<RenewalHintMembership> & { cardTypeId: string }): RenewalHintMembership {
return {
remainingTimes: null,
expireDate: '2026-12-01T00:00:00.000Z',
status: MembershipStatus.ACTIVE,
cardType: { type: CardTypeCategory.TIMES },
...overrides,
}
}
describe('computeMembershipGrant', () => {
it('creates a fresh grant when there is no existing membership', () => {
const result = computeMembershipGrant({ existing: null, cardType: timesCard, now })
expect(result.isRenewal).toBe(false)
expect(result.remainingTimes).toBe(10)
expect(result.totalTimes).toBe(10)
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
})
it('always treats TRIAL as a new grant even if an old trial exists', () => {
const result = computeMembershipGrant({
existing: {
remainingTimes: 0,
totalTimes: 1,
expireDate: '2026-08-01T00:00:00.000Z',
status: MembershipStatus.ACTIVE,
},
cardType: trialCard,
now,
})
expect(result.isRenewal).toBe(false)
expect(result.remainingTimes).toBe(1)
expect(result.totalTimes).toBe(1)
})
it('stacks times and extends expireDate for an active TIMES card', () => {
const expireDate = new Date('2026-09-01T00:00:00Z')
const result = computeMembershipGrant({
existing: {
remainingTimes: 3,
totalTimes: 10,
expireDate,
status: MembershipStatus.ACTIVE,
},
cardType: timesCard,
now,
})
expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBe(13)
expect(result.totalTimes).toBe(20)
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
})
it('does not inherit leftover times when the TIMES card is expired', () => {
const result = computeMembershipGrant({
existing: {
remainingTimes: 4,
totalTimes: 10,
expireDate: '2026-01-01T00:00:00.000Z',
status: MembershipStatus.EXPIRED,
},
cardType: timesCard,
now,
})
expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBe(10)
expect(result.totalTimes).toBe(10)
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
})
it('extends a still-active DURATION card from its current expireDate', () => {
const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({
existing: {
remainingTimes: null,
totalTimes: null,
expireDate,
status: MembershipStatus.ACTIVE,
},
cardType: durationCard,
now,
})
expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBeNull()
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
})
it('starts an expired DURATION card from now', () => {
const result = computeMembershipGrant({
existing: {
remainingTimes: null,
totalTimes: null,
expireDate: '2026-01-01T00:00:00.000Z',
status: MembershipStatus.EXPIRED,
},
cardType: durationCard,
now,
})
expect(result.isRenewal).toBe(true)
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
})
})
describe('getMembershipRenewalHint', () => {
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
const hint = getMembershipRenewalHint(
[
membership({
cardTypeId: 'duration-1',
remainingTimes: null,
expireDate: new Date(now.getTime() + 3 * 86_400_000).toISOString(),
cardType: { type: CardTypeCategory.DURATION },
}),
membership({
cardTypeId: 'times-1',
remainingTimes: RENEWAL_TIMES_THRESHOLD,
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
cardType: { type: CardTypeCategory.TIMES },
}),
],
now,
)
expect(hint).toMatchObject({
kind: 'times_low',
cardTypeId: 'times-1',
remainingTimes: RENEWAL_TIMES_THRESHOLD,
})
})
it('returns days_low when a DURATION card is within the threshold', () => {
const hint = getMembershipRenewalHint(
[
membership({
cardTypeId: 'duration-1',
remainingTimes: null,
expireDate: new Date(now.getTime() + RENEWAL_DAYS_THRESHOLD * 86_400_000).toISOString(),
cardType: { type: CardTypeCategory.DURATION },
}),
],
now,
)
expect(hint).toMatchObject({
kind: 'days_low',
cardTypeId: 'duration-1',
daysLeft: RENEWAL_DAYS_THRESHOLD,
})
})
it('does not recommend renewing a TRIAL card when times run low', () => {
const hint = getMembershipRenewalHint(
[
membership({
cardTypeId: 'trial-1',
remainingTimes: 1,
expireDate: new Date(now.getTime() + 5 * 86_400_000).toISOString(),
cardType: { type: CardTypeCategory.TRIAL },
}),
],
now,
)
expect(hint).toMatchObject({
kind: 'trial_low',
cardTypeId: null,
remainingTimes: 1,
})
})
it('recommends the most recently expired non-trial card', () => {
const hint = getMembershipRenewalHint(
[
membership({
cardTypeId: 'times-old',
remainingTimes: 0,
expireDate: '2026-01-01T00:00:00.000Z',
status: MembershipStatus.EXPIRED,
cardType: { type: CardTypeCategory.TIMES },
}),
membership({
cardTypeId: 'duration-latest',
remainingTimes: null,
expireDate: '2026-05-01T00:00:00.000Z',
status: MembershipStatus.EXPIRED,
cardType: { type: CardTypeCategory.DURATION },
}),
membership({
cardTypeId: 'trial-1',
remainingTimes: 0,
expireDate: '2026-05-15T00:00:00.000Z',
status: MembershipStatus.USED_UP,
cardType: { type: CardTypeCategory.TRIAL },
}),
],
now,
)
expect(hint).toMatchObject({
kind: 'expired',
cardTypeId: 'duration-latest',
})
})
it('returns null when active cards are healthy', () => {
const hint = getMembershipRenewalHint(
[
membership({
cardTypeId: 'times-1',
remainingTimes: 8,
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
}),
],
now,
)
expect(hint).toBeNull()
})
})
describe('pickRenewalTarget', () => {
it('picks the latest expiring membership of the same card type', () => {
const target = pickRenewalTarget(
[
membership({
cardTypeId: 'times-1',
expireDate: '2026-01-01T00:00:00.000Z',
}),
membership({
cardTypeId: 'times-1',
expireDate: '2026-08-01T00:00:00.000Z',
}),
membership({
cardTypeId: 'duration-1',
expireDate: '2026-12-01T00:00:00.000Z',
cardType: { type: CardTypeCategory.DURATION },
}),
],
'times-1',
)
expect(target?.expireDate).toBe('2026-08-01T00:00:00.000Z')
})
it('does not pick a TRIAL membership as a renewal target', () => {
const target = pickRenewalTarget(
[
membership({
cardTypeId: 'trial-1',
cardType: { type: CardTypeCategory.TRIAL },
}),
],
'trial-1',
)
expect(target).toBeNull()
})
})

View File

@@ -16,6 +16,7 @@ const mockTimesCardType = {
price: 150000,
originalPrice: null,
description: null,
coverUrl: null,
isActive: true,
sortOrder: 0,
createdAt: new Date('2024-01-01T00:00:00Z'),
@@ -31,6 +32,7 @@ const mockDurationCardType = {
price: 80000,
originalPrice: null,
description: null,
coverUrl: null,
isActive: true,
sortOrder: 1,
createdAt: new Date('2024-01-01T00:00:00Z'),
@@ -48,6 +50,7 @@ const mockActiveMembership = {
userId: 'user-001',
cardTypeId: mockTimesCardType.id,
remainingTimes: 5,
totalTimes: 10,
startDate: new Date('2024-01-01T00:00:00Z'),
expireDate: new Date('2099-12-31T00:00:00Z'),
status: MembershipStatus.ACTIVE,
@@ -61,6 +64,7 @@ const mockDurationMembership = {
id: 'mem-duration-001',
cardTypeId: mockDurationCardType.id,
remainingTimes: null,
totalTimes: null,
cardType: mockDurationCardType,
}
@@ -77,6 +81,7 @@ const mockPrismaService = {
findMany: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
}
@@ -300,6 +305,160 @@ describe('MembershipService', () => {
})
})
// ─── grantPurchasedCard ────────────────────────────────────────────────
describe('grantPurchasedCard()', () => {
const now = new Date('2026-06-01T00:00:00Z')
const tx = mockPrismaService as unknown as Parameters<MembershipService['grantPurchasedCard']>[0]
it('creates a new membership when the user has no card of that type', async () => {
mockPrismaService.membership.findFirst.mockResolvedValue(null)
const created = { ...mockActiveMembership, remainingTimes: 10, totalTimes: 10 }
mockPrismaService.membership.create.mockResolvedValue(created)
const result = await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: 'user-001',
cardTypeId: mockTimesCardType.id,
remainingTimes: 10,
totalTimes: 10,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
expect(result.remainingTimes).toBe(10)
})
it('stacks remaining times and extends expireDate for an active TIMES card', async () => {
const existing = {
...mockActiveMembership,
remainingTimes: 3,
totalTimes: 10,
expireDate: new Date('2026-09-01T00:00:00Z'),
}
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.update.mockResolvedValue({
...existing,
remainingTimes: 13,
totalTimes: 20,
})
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: existing.id },
data: expect.objectContaining({
remainingTimes: 13,
totalTimes: 20,
status: MembershipStatus.ACTIVE,
}),
}),
)
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
expireDate: Date
}
expect(updateData.expireDate.getTime()).toBe(
existing.expireDate.getTime() + mockTimesCardType.durationDays * 86_400_000,
)
expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
})
it('extends a DURATION card from the current expireDate when still active', async () => {
const existing = {
...mockDurationMembership,
expireDate: new Date('2026-07-01T00:00:00Z'),
}
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.update.mockResolvedValue(existing)
await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: null,
totalTimes: null,
status: MembershipStatus.ACTIVE,
}),
}),
)
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
expireDate: Date
}
expect(updateData.expireDate.getTime()).toBe(
existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
)
})
it('does not carry leftover times when renewing an expired TIMES card', async () => {
const existing = {
...mockActiveMembership,
remainingTimes: 4,
totalTimes: 10,
status: MembershipStatus.EXPIRED,
expireDate: new Date('2026-01-01T00:00:00Z'),
}
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.update.mockResolvedValue({
...existing,
remainingTimes: 10,
status: MembershipStatus.ACTIVE,
})
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: 10,
totalTimes: 10,
status: MembershipStatus.ACTIVE,
}),
}),
)
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
expireDate: Date
}
expect(updateData.expireDate.getTime()).toBe(
now.getTime() + mockTimesCardType.durationDays * 86_400_000,
)
})
it('always creates a new membership for TRIAL cards', async () => {
const trialCardType = {
...mockTimesCardType,
id: 'ct-trial-001',
type: CardTypeCategory.TRIAL,
totalTimes: 1,
durationDays: 7,
}
mockPrismaService.membership.create.mockResolvedValue({
...mockActiveMembership,
cardTypeId: trialCardType.id,
remainingTimes: 1,
totalTimes: 1,
})
await service.grantPurchasedCard(tx, 'user-001', trialCardType, now)
expect(mockPrismaService.membership.findFirst).not.toHaveBeenCalled()
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
cardTypeId: trialCardType.id,
remainingTimes: 1,
totalTimes: 1,
}),
}),
)
})
})
// ─── createCardType ────────────────────────────────────────────────────
describe('createCardType()', () => {

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { CardType, Membership } from '@prisma/client'
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
import { CardType, Membership, Prisma } from '@prisma/client'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
@@ -119,6 +119,55 @@ export class MembershipService {
return { ...updated, cardType: { ...updated.cardType } }
}
async grantPurchasedCard(
tx: Prisma.TransactionClient,
userId: string,
cardType: Pick<CardType, 'id' | 'type' | 'totalTimes' | 'durationDays'>,
now = new Date(),
): Promise<Membership> {
const existing =
cardType.type === CardTypeCategory.TRIAL
? null
: await tx.membership.findFirst({
where: { userId, cardTypeId: cardType.id },
orderBy: { expireDate: 'desc' },
})
const grant = computeMembershipGrant({
existing,
cardType: {
type: cardType.type,
totalTimes: cardType.totalTimes,
durationDays: cardType.durationDays,
},
now,
})
if (!grant.isRenewal || !existing) {
return tx.membership.create({
data: {
userId,
cardTypeId: cardType.id,
startDate: now,
expireDate: grant.expireDate,
remainingTimes: grant.remainingTimes,
totalTimes: grant.totalTimes,
status: MembershipStatus.ACTIVE,
},
})
}
return tx.membership.update({
where: { id: existing.id },
data: {
remainingTimes: grant.remainingTimes,
totalTimes: grant.totalTimes,
expireDate: grant.expireDate,
status: MembershipStatus.ACTIVE,
},
})
}
// ─── Admin ─────────────────────────────────────────────────────────────────
async getAllCardTypes(): Promise<CardType[]> {

View File

@@ -6,6 +6,7 @@ import { PaymentService } from '../payment.service'
import { WechatPayService } from '../wechat-pay.service'
import { PrismaService } from '../../prisma/prisma.service'
import { InviteService } from '../../invite/invite.service'
import { MembershipService } from '../../membership/membership.service'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -19,6 +20,7 @@ const mockCardType = {
type: 'TIMES',
originalPrice: null,
description: null,
coverUrl: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
@@ -82,6 +84,11 @@ function buildPrismaMock() {
},
membership: {
create: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
flashSaleOrder: {
updateMany: jest.fn(),
},
$transaction: jest.fn(),
}
@@ -109,6 +116,7 @@ describe('PaymentService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentService,
MembershipService,
{ provide: PrismaService, useValue: prisma },
{ provide: WechatPayService, useValue: wechat },
{ provide: InviteService, useValue: mockInviteService },
@@ -226,31 +234,33 @@ describe('PaymentService', () => {
})
prisma.order.findUnique.mockResolvedValue(pendingOrder)
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
prisma.$transaction.mockResolvedValue([])
prisma.membership.findFirst.mockResolvedValue(null)
prisma.membership.create.mockResolvedValue({
id: 'mem-new-1',
userId: pendingOrder.userId,
cardTypeId: pendingOrder.cardTypeId,
remainingTimes: mockCardType.totalTimes,
totalTimes: mockCardType.totalTimes,
status: MembershipStatus.ACTIVE,
})
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
})
it('marks order as PAID and creates membership on valid callback', async () => {
it('marks order as PAID and grants a new membership on valid callback', async () => {
const result = await service.handleWxNotify(headers, successBody)
// $transaction called once with an array of two operations
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]]
expect(transactionOps).toHaveLength(2)
// order.update was called with PAID status and transaction id
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: OrderStatus.PAID,
wxTransactionId: successBody.transaction_id,
membershipId: 'mem-new-1',
}),
}),
)
// membership.create was called
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
expect(result).toContain('SUCCESS')
})
@@ -275,7 +285,7 @@ describe('PaymentService', () => {
const expectedExpireMs =
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2)
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
})
@@ -285,14 +295,15 @@ describe('PaymentService', () => {
expect(prisma.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: mockCardType.totalTimes, // 10
remainingTimes: mockCardType.totalTimes,
totalTimes: mockCardType.totalTimes,
}),
}),
)
})
it('creates membership with null remainingTimes for duration-based cardType', async () => {
const durationCardType = { ...mockCardType, totalTimes: null }
const durationCardType = { ...mockCardType, totalTimes: null, type: 'DURATION' }
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
await service.handleWxNotify(headers, successBody)
@@ -301,6 +312,46 @@ describe('PaymentService', () => {
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: null,
totalTimes: null,
}),
}),
)
})
it('renews an existing same-type membership instead of creating another', async () => {
const existingMembership = {
id: 'mem-existing-1',
userId: pendingOrder.userId,
cardTypeId: pendingOrder.cardTypeId,
remainingTimes: 2,
totalTimes: 10,
expireDate: new Date('2099-01-01T00:00:00Z'),
status: MembershipStatus.ACTIVE,
}
prisma.membership.findFirst.mockResolvedValue(existingMembership)
prisma.membership.update.mockResolvedValue({
...existingMembership,
remainingTimes: 12,
totalTimes: 20,
})
await service.handleWxNotify(headers, successBody)
expect(prisma.membership.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'mem-existing-1' },
data: expect.objectContaining({
remainingTimes: 12,
totalTimes: 20,
status: MembershipStatus.ACTIVE,
}),
}),
)
expect(prisma.membership.create).not.toHaveBeenCalled()
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
membershipId: 'mem-existing-1',
}),
}),
)

View File

@@ -4,9 +4,10 @@ import { PaymentService } from './payment.service'
import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { InviteModule } from '../invite/invite.module'
import { MembershipModule } from '../membership/membership.module'
@Module({
imports: [PrismaModule, InviteModule],
imports: [PrismaModule, InviteModule, MembershipModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService, WechatPayService],

View File

@@ -5,10 +5,11 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
import { MembershipService } from '../membership/membership.service'
export interface CreateOrderResult {
order: Order
@@ -30,6 +31,7 @@ export class PaymentService {
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
private readonly inviteService: InviteService,
private readonly membershipService: MembershipService,
) {}
// ─── User: create order ────────────────────────────────────────────────────
@@ -118,32 +120,29 @@ export class PaymentService {
}
const now = new Date()
const expireDate = new Date(now.getTime() + cardType.durationDays * 86_400_000)
await this.prisma.$transaction([
this.prisma.order.update({
await this.prisma.$transaction(async (tx) => {
const membership = await this.membershipService.grantPurchasedCard(
tx,
existingOrder.userId,
cardType,
now,
)
await tx.order.update({
where: { id: existingOrder.id },
data: {
status: OrderStatus.PAID,
wxTransactionId: notification.wxTransactionId,
paidAt: now,
membershipId: membership.id,
},
}),
this.prisma.membership.create({
data: {
userId: existingOrder.userId,
cardTypeId: existingOrder.cardTypeId,
startDate: now,
expireDate,
remainingTimes: cardType.totalTimes ?? null,
status: MembershipStatus.ACTIVE,
},
}),
])
})
})
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──
if (existingOrder.flashSaleId) {

View File

@@ -440,7 +440,7 @@ export class UserService {
}
return this.prisma.membership.create({
data: { userId, ...data },
data: { userId, totalTimes: dto.remainingTimes ?? null, ...data },
include: { cardType: true },
})
}

View File

@@ -30,6 +30,27 @@ export {
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
} from './constants'
export {
RENEWAL_TIMES_THRESHOLD,
RENEWAL_DAYS_THRESHOLD,
MEMBERSHIP_DAY_MS,
addDurationDays,
getMembershipDaysLeft,
canRenewCardType,
computeMembershipGrant,
pickRenewalTarget,
getMembershipRenewalHint,
} from './membership-renewal'
export type {
MembershipGrantExisting,
MembershipGrantCardType,
MembershipGrantResult,
MembershipRenewalHintKind,
MembershipRenewalHint,
RenewalHintMembership,
} from './membership-renewal'
// Types
export type {
User,

View File

@@ -0,0 +1,203 @@
import { CardTypeCategory, MembershipStatus } from './enums'
export const RENEWAL_TIMES_THRESHOLD = 2
export const RENEWAL_DAYS_THRESHOLD = 7
export const MEMBERSHIP_DAY_MS = 86_400_000
export interface MembershipGrantExisting {
readonly remainingTimes: number | null
readonly totalTimes: number | null
readonly expireDate: Date | string
readonly status: string
}
export interface MembershipGrantCardType {
readonly type: string
readonly totalTimes: number | null
readonly durationDays: number
}
export interface MembershipGrantResult {
readonly remainingTimes: number | null
readonly expireDate: Date
readonly totalTimes: number | null
readonly isRenewal: boolean
}
export type MembershipRenewalHintKind = 'times_low' | 'days_low' | 'trial_low' | 'expired'
export interface MembershipRenewalHint {
readonly kind: MembershipRenewalHintKind
readonly cardTypeId: string | null
readonly remainingTimes: number | null
readonly daysLeft: number | null
}
export interface RenewalHintMembership {
readonly cardTypeId: string
readonly remainingTimes: number | null
readonly expireDate: Date | string
readonly status: MembershipStatus
readonly cardType: {
readonly type: CardTypeCategory
}
}
export function addDurationDays(from: Date, durationDays: number): Date {
return new Date(from.getTime() + durationDays * MEMBERSHIP_DAY_MS)
}
export function getMembershipDaysLeft(expireDate: Date | string, now: Date = new Date()): number {
return Math.ceil((new Date(expireDate).getTime() - now.getTime()) / MEMBERSHIP_DAY_MS)
}
export function canRenewCardType(type: CardTypeCategory): boolean {
return type !== CardTypeCategory.TRIAL
}
export function computeMembershipGrant(params: {
readonly existing: MembershipGrantExisting | null
readonly cardType: MembershipGrantCardType
readonly now: Date
}): MembershipGrantResult {
const { existing, cardType, now } = params
if (!existing || cardType.type === CardTypeCategory.TRIAL) {
return {
remainingTimes: cardType.totalTimes ?? null,
expireDate: addDurationDays(now, cardType.durationDays),
totalTimes: cardType.totalTimes ?? null,
isRenewal: false,
}
}
const existingExpire = new Date(existing.expireDate)
const isExpired =
existing.status === MembershipStatus.EXPIRED || existingExpire.getTime() <= now.getTime()
const baseDate = existingExpire.getTime() > now.getTime() ? existingExpire : now
const expireDate = addDurationDays(baseDate, cardType.durationDays)
if (cardType.totalTimes == null) {
return {
remainingTimes: null,
expireDate,
totalTimes: null,
isRenewal: true,
}
}
if (isExpired) {
return {
remainingTimes: cardType.totalTimes,
expireDate: addDurationDays(now, cardType.durationDays),
totalTimes: cardType.totalTimes,
isRenewal: true,
}
}
return {
remainingTimes: (existing.remainingTimes ?? 0) + cardType.totalTimes,
expireDate,
totalTimes: (existing.totalTimes ?? existing.remainingTimes ?? 0) + cardType.totalTimes,
isRenewal: true,
}
}
export function pickRenewalTarget<T extends RenewalHintMembership>(
memberships: readonly T[],
cardTypeId: string,
): T | null {
const matches = memberships
.filter((m) => m.cardTypeId === cardTypeId && canRenewCardType(m.cardType.type))
.slice()
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
return matches[0] ?? null
}
function isUsableMembership(membership: RenewalHintMembership, now: Date): boolean {
if (membership.status !== MembershipStatus.ACTIVE) return false
if (new Date(membership.expireDate).getTime() <= now.getTime()) return false
return membership.remainingTimes === null || membership.remainingTimes > 0
}
export function getMembershipRenewalHint(
memberships: readonly RenewalHintMembership[],
now: Date = new Date(),
): MembershipRenewalHint | null {
const usable = memberships.filter((m) => isUsableMembership(m, now))
if (usable.length > 0) {
const lowTimes = usable.filter(
(m) =>
m.cardType.type === CardTypeCategory.TIMES &&
m.remainingTimes !== null &&
m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
)
if (lowTimes.length > 0) {
const target = lowTimes.reduce((lowest, current) =>
(current.remainingTimes ?? 0) < (lowest.remainingTimes ?? 0) ? current : lowest,
)
return {
kind: 'times_low',
cardTypeId: target.cardTypeId,
remainingTimes: target.remainingTimes,
daysLeft: getMembershipDaysLeft(target.expireDate, now),
}
}
const lowDays = usable.filter(
(m) =>
m.cardType.type === CardTypeCategory.DURATION &&
getMembershipDaysLeft(m.expireDate, now) <= RENEWAL_DAYS_THRESHOLD,
)
if (lowDays.length > 0) {
const target = lowDays.reduce((lowest, current) => {
const currentDays = getMembershipDaysLeft(current.expireDate, now)
const lowestDays = getMembershipDaysLeft(lowest.expireDate, now)
return currentDays < lowestDays ? current : lowest
})
return {
kind: 'days_low',
cardTypeId: target.cardTypeId,
remainingTimes: null,
daysLeft: getMembershipDaysLeft(target.expireDate, now),
}
}
const lowTrial = usable.filter(
(m) =>
m.cardType.type === CardTypeCategory.TRIAL &&
m.remainingTimes !== null &&
m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
)
if (lowTrial.length > 0) {
const target = lowTrial.reduce((lowest, current) =>
(current.remainingTimes ?? 0) < (lowest.remainingTimes ?? 0) ? current : lowest,
)
return {
kind: 'trial_low',
cardTypeId: null,
remainingTimes: target.remainingTimes,
daysLeft: getMembershipDaysLeft(target.expireDate, now),
}
}
return null
}
if (memberships.length === 0) return null
const renewable = memberships
.filter((m) => canRenewCardType(m.cardType.type))
.slice()
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
const recommended = renewable[0]
return {
kind: 'expired',
cardTypeId: recommended?.cardTypeId ?? null,
remainingTimes: recommended?.remainingTimes ?? null,
daysLeft: recommended ? getMembershipDaysLeft(recommended.expireDate, now) : null,
}
}

View File

@@ -6,6 +6,7 @@ export interface Membership {
readonly userId: string
readonly cardTypeId: string
readonly remainingTimes: number | null
readonly totalTimes: number | null
readonly startDate: string
readonly expireDate: string
readonly status: MembershipStatus

View File

@@ -4,6 +4,7 @@ export interface Order {
readonly id: string
readonly userId: string
readonly cardTypeId: string
readonly membershipId: string | null
readonly orderNo: string
readonly amount: number
readonly status: OrderStatus