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

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