feat: 支持同卡种续卡叠加并修复过期登录态
支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
203
packages/shared/src/membership-renewal.ts
Normal file
203
packages/shared/src/membership-renewal.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user