积分系统
积分系统是 NEXTY.DEV 支付系统的核心功能之一,它管理用户通过购买计划获得的积分,以及使用积分的情况。本文档详细说明积分系统的设计和使用方法。
积分系统概述
积分类型
系统支持两种类型的积分:
- 一次性积分 (One-time Credits)
- 通过一次性支付获得
- 不会过期
- 使用后减少,不会自动补充
- 订阅积分 (Subscription Credits)
- 通过订阅计划获得
- 按月或按年分配
- 定期重置或补充
积分余额存储
积分余额存储在 usage 表中:
{
userId: string; // 用户 ID(唯一)
subscriptionCreditsBalance: number; // 订阅积分余额
oneTimeCreditsBalance: number; // 一次性积分余额
balanceJsonb: { // 余额详情(JSONB)
monthlyAllocationDetails?: { // 月度订阅详情
monthlyCredits: number;
relatedOrderId: string;
};
yearlyAllocationDetails?: { // 年度订阅详情
remainingMonths: number;
nextCreditDate: string;
monthlyCredits: number;
lastAllocatedMonth: string;
relatedOrderId: string;
};
};
}积分日志
所有积分变动都记录在 credit_logs 表中:
{
userId: string;
amount: number; // 变动金额(正数为增加,负数为减少)
oneTimeBalanceAfter: number; // 变动后一次性积分余额
subscriptionBalanceAfter: number; // 变动后订阅积分余额
type: string; // 变动类型
notes: string; // 备注
relatedOrderId?: string; // 关联订单 ID
createdAt: Date; // 创建时间
}积分授予
一次性积分授予
当用户购买一次性支付计划时,系统会授予一次性积分:
lib/payments/credit-manager.ts
export async function upgradeOneTimeCredits(
userId: string,
planId: string,
orderId: string
) {
// 1. 获取计划的权益配置
const plan = await db
.select({ benefitsJsonb: pricingPlansSchema.benefitsJsonb })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.id, planId))
.limit(1);
const creditsToGrant = plan.benefitsJsonb?.oneTimeCredits || 0;
if (creditsToGrant > 0) {
// 2. 使用事务更新积分余额
await db.transaction(async (tx) => {
// 更新或插入积分余额
const updatedUsage = await tx
.insert(usageSchema)
.values({
userId,
oneTimeCreditsBalance: creditsToGrant,
})
.onConflictDoUpdate({
target: usageSchema.userId,
set: {
oneTimeCreditsBalance: sql`${usageSchema.oneTimeCreditsBalance} + ${creditsToGrant}`,
},
})
.returning({
oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
});
// 3. 记录积分日志
await tx.insert(creditLogsSchema).values({
userId,
amount: creditsToGrant,
oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
type: 'one_time_purchase',
notes: 'One-time credit purchase',
relatedOrderId: orderId,
});
});
}
}订阅积分授予
月度订阅
月度订阅每次续费时重置积分:
lib/payments/credit-manager.ts
export async function upgradeSubscriptionCredits(
userId: string,
planId: string,
orderId: string,
currentPeriodStart: number
) {
const plan = await db
.select({
recurringInterval: pricingPlansSchema.recurringInterval,
benefitsJsonb: pricingPlansSchema.benefitsJsonb
})
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.id, planId))
.limit(1);
const monthlyCredits = plan.benefitsJsonb?.monthlyCredits || 0;
if (isMonthlyInterval(plan.recurringInterval) && monthlyCredits > 0) {
await db.transaction(async (tx) => {
// 更新订阅积分余额(重置为月度积分)
const updatedUsage = await tx
.insert(usageSchema)
.values({
userId,
subscriptionCreditsBalance: monthlyCredits,
balanceJsonb: {
monthlyAllocationDetails: {
monthlyCredits,
relatedOrderId: orderId,
}
},
})
.onConflictDoUpdate({
target: usageSchema.userId,
set: {
subscriptionCreditsBalance: monthlyCredits,
balanceJsonb: sql`coalesce(${usageSchema.balanceJsonb}, '{}'::jsonb) - 'monthlyAllocationDetails' || ${JSON.stringify(monthlyDetails)}::jsonb`,
},
})
.returning({
oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
});
// 记录积分日志
await tx.insert(creditLogsSchema).values({
userId,
amount: monthlyCredits,
oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
type: 'subscription_grant',
notes: 'Subscription credits granted/reset',
relatedOrderId: orderId,
});
});
}
}年度订阅
年度订阅采用按月分配的方式:
lib/payments/credit-manager.ts
if (isYearlyInterval(plan.recurringInterval) && benefits?.totalMonths && benefits?.monthlyCredits) {
await db.transaction(async (tx) => {
const startDate = new Date(currentPeriodStart);
const nextCreditDate = new Date(
startDate.getFullYear(),
startDate.getMonth() + 1,
startDate.getDate()
);
const yearlyDetails = {
yearlyAllocationDetails: {
remainingMonths: benefits.totalMonths - 1,
nextCreditDate: nextCreditDate.toISOString(),
monthlyCredits: benefits.monthlyCredits,
lastAllocatedMonth: `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}`,
relatedOrderId: orderId,
}
};
// 初始化年度订阅,立即分配第一个月的积分
const updatedUsage = await tx
.insert(usageSchema)
.values({
userId,
subscriptionCreditsBalance: benefits.monthlyCredits,
balanceJsonb: yearlyDetails,
})
.onConflictDoUpdate({
target: usageSchema.userId,
set: {
subscriptionCreditsBalance: benefits.monthlyCredits,
balanceJsonb: sql`coalesce(${usageSchema.balanceJsonb}, '{}'::jsonb) - 'yearlyAllocationDetails' || ${JSON.stringify(yearlyDetails)}::jsonb`,
}
})
.returning({
oneTimeBalanceAfter: usageSchema.oneTimeCreditsBalance,
subscriptionBalanceAfter: usageSchema.subscriptionCreditsBalance,
});
// 记录积分日志
await tx.insert(creditLogsSchema).values({
userId,
amount: benefits.monthlyCredits,
oneTimeBalanceAfter: updatedUsage[0].oneTimeBalanceAfter,
subscriptionBalanceAfter: updatedUsage[0].subscriptionBalanceAfter,
type: 'subscription_grant',
notes: 'Yearly plan initial credits granted',
relatedOrderId: orderId,
});
});
}年度订阅月度分配
年度订阅采用"按月分配"的策略,系统会在每月自动分配积分:
actions/usage/benefits.ts
async function processYearlySubscriptionCatchUp(userId: string) {
let shouldContinue = true;
while (shouldContinue) {
shouldContinue = await db.transaction(async (tx) => {
const usage = await tx
.select()
.from(usageSchema)
.where(eq(usageSchema.userId, userId))
.for('update');
const yearlyDetails = usage[0]?.balanceJsonb?.yearlyAllocationDetails;
// 检查是否需要分配积分
if (
!yearlyDetails ||
yearlyDetails.remainingMonths <= 0 ||
!yearlyDetails.nextCreditDate ||
new Date() < new Date(yearlyDetails.nextCreditDate)
) {
return false; // 不需要分配
}
// 分配本月积分
const creditsToAllocate = yearlyDetails.monthlyCredits;
const newRemainingMonths = yearlyDetails.remainingMonths - 1;
const nextCreditDate = new Date(yearlyDetails.nextCreditDate);
nextCreditDate.setMonth(nextCreditDate.getMonth() + 1);
// 更新积分余额
await tx.update(usageSchema)
.set({
subscriptionCreditsBalance: creditsToAllocate,
balanceJsonb: {
...usage[0].balanceJsonb,
yearlyAllocationDetails: {
...yearlyDetails,
remainingMonths: newRemainingMonths,
nextCreditDate: nextCreditDate.toISOString(),
lastAllocatedMonth: new Date().toISOString().slice(0, 7),
}
},
})
.where(eq(usageSchema.userId, userId));
// 记录积分日志
await tx.insert(creditLogsSchema).values({
userId,
amount: creditsToAllocate,
type: 'subscription_grant',
notes: 'Yearly subscription monthly credits allocated',
relatedOrderId: yearlyDetails.relatedOrderId,
createdAt: new Date(yearlyDetails.nextCreditDate), // 使用分配日期
});
return newRemainingMonths > 0; // 如果还有剩余月份,继续循环
});
}
}年度订阅按月重置积分没有使用定时任务,而是设计成在每次查询用户权益时自动检查并更新。这种做法相比定时任务处理,可以大幅减少数据库请求,提高性能。
积分使用
扣除积分
当用户使用功能消耗积分时,调用 deductCredits 函数:
actions/usage/deduct.ts
export async function deductCredits(
amountToDeduct: number,
notes: string
): Promise<ActionResult<DeductCreditsData | null>> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
if (amountToDeduct <= 0) {
return actionResponse.badRequest('Amount to deduct must be positive.');
}
try {
await db.transaction(async (tx) => {
// 锁定用户积分记录
const usage = await tx
.select({
oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
})
.from(usageSchema)
.where(eq(usageSchema.userId, user.id))
.for('update');
if (!usage[0]) {
throw new Error('INSUFFICIENT_CREDITS');
}
const totalCredits = usage[0].oneTimeCreditsBalance + usage[0].subscriptionCreditsBalance;
if (totalCredits < amountToDeduct) {
throw new Error('INSUFFICIENT_CREDITS');
}
// 优先扣除订阅积分,再扣除一次性积分
const deductedFromSub = Math.min(usage[0].subscriptionCreditsBalance, amountToDeduct);
const deductedFromOneTime = amountToDeduct - deductedFromSub;
const newSubBalance = usage[0].subscriptionCreditsBalance - deductedFromSub;
const newOneTimeBalance = usage[0].oneTimeCreditsBalance - deductedFromOneTime;
// 更新积分余额
await tx.update(usageSchema)
.set({
subscriptionCreditsBalance: newSubBalance,
oneTimeCreditsBalance: newOneTimeBalance,
})
.where(eq(usageSchema.userId, user.id));
// 记录积分日志
await tx.insert(creditLogsSchema)
.values({
userId: user.id,
amount: -amountToDeduct,
oneTimeBalanceAfter: newOneTimeBalance,
subscriptionBalanceAfter: newSubBalance,
type: 'feature_usage',
notes: notes,
});
});
// 返回更新后的用户权益
const updatedBenefits = await getUserBenefits(user.id);
return actionResponse.success({
message: 'Credits deducted successfully.',
updatedBenefits,
});
} catch (e: any) {
if (e.message === 'INSUFFICIENT_CREDITS') {
return actionResponse.badRequest('Insufficient credits.');
}
return actionResponse.error(e.message || 'An unexpected server error occurred.');
}
}扣除策略
系统采用"优先扣除订阅积分"的策略:
- 先扣除订阅积分余额
- 如果订阅积分不足,再扣除一次性积分
- 确保两种积分都能被充分利用
积分撤销
一次性积分撤销
当用户退款时,撤销相应的一次性积分:
lib/payments/credit-manager.ts
export async function revokeOneTimeCredits(
refundAmountCents: number,
originalOrder: Order,
refundOrderId: string
) {
const planId = originalOrder.planId;
const userId = originalOrder.userId;
// 仅处理全额退款
const isFullRefund = refundAmountCents === Math.round(parseFloat(originalOrder.amountTotal!) * 100);
if (isFullRefund) {
const plan = await db
.select({ benefitsJsonb: pricingPlansSchema.benefitsJsonb })
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.id, planId))
.limit(1);
const oneTimeToRevoke = plan.benefitsJsonb?.oneTimeCredits || 0;
if (oneTimeToRevoke > 0) {
await db.transaction(async (tx) => {
const usage = await tx
.select()
.from(usageSchema)
.where(eq(usageSchema.userId, userId))
.for('update');
if (!usage[0]) return;
const newOneTimeBalance = Math.max(0, usage[0].oneTimeCreditsBalance - oneTimeToRevoke);
const amountRevoked = usage[0].oneTimeCreditsBalance - newOneTimeBalance;
if (amountRevoked > 0) {
await tx.update(usageSchema)
.set({ oneTimeCreditsBalance: newOneTimeBalance })
.where(eq(usageSchema.userId, userId));
await tx.insert(creditLogsSchema).values({
userId,
amount: -amountRevoked,
oneTimeBalanceAfter: newOneTimeBalance,
subscriptionBalanceAfter: usage[0].subscriptionCreditsBalance,
type: 'refund_revoke',
notes: `Full refund for order ${originalOrder.id}.`,
relatedOrderId: originalOrder.id,
});
}
});
}
}
}订阅积分撤销
当订阅被退款或取消时,撤销订阅积分:
lib/payments/credit-manager.ts
export async function revokeSubscriptionCredits(originalOrder: Order) {
const planId = originalOrder.planId;
const userId = originalOrder.userId;
const ctx = await getSubscriptionRevokeContext(planId, userId);
if (!ctx) return;
if (ctx.subscriptionToRevoke > 0) {
await applySubscriptionCreditsRevocation({
userId,
amountToRevoke: ctx.subscriptionToRevoke,
clearMonthly: ctx.clearMonthly,
clearYearly: ctx.clearYearly,
logType: 'refund_revoke',
notes: `Full refund for subscription order ${originalOrder.id}.`,
relatedOrderId: originalOrder.id,
});
}
}订阅结束时的积分撤销
当订阅过期或取消时,撤销剩余的订阅积分:
lib/payments/credit-manager.ts
export async function revokeRemainingSubscriptionCreditsOnEnd(
provider: PaymentProvider,
subscriptionId: string,
userId: string,
metadata: any
) {
const usage = await db
.select({ subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance })
.from(usageSchema)
.where(eq(usageSchema.userId, userId))
.limit(1);
const amountToRevoke = usage[0]?.subscriptionCreditsBalance ?? 0;
if (amountToRevoke > 0) {
await applySubscriptionCreditsRevocation({
userId,
amountToRevoke,
clearMonthly: true,
clearYearly: true,
logType: 'subscription_ended_revoke',
notes: `${provider} subscription ${subscriptionId} ended; remaining credits revoked.`,
relatedOrderId: null,
});
}
}查询用户权益
获取用户权益
使用 getUserBenefits 函数查询用户的完整权益信息:
actions/usage/benefits.ts
export async function getUserBenefits(userId: string): Promise<UserBenefits> {
// 1. 查询积分余额
const usage = await db
.select({
subscriptionCreditsBalance: usageSchema.subscriptionCreditsBalance,
oneTimeCreditsBalance: usageSchema.oneTimeCreditsBalance,
balanceJsonb: usageSchema.balanceJsonb,
})
.from(usageSchema)
.where(eq(usageSchema.userId, userId));
// 2. 处理年度订阅的月度分配
if (usage[0]) {
await processYearlySubscriptionCatchUp(userId);
}
// 3. 查询订阅信息
const subscription = await fetchSubscriptionData(userId);
// 4. 构建权益对象
return {
activePlanId: subscription?.status === 'active' ? subscription.planId : null,
subscriptionStatus: subscription?.status,
currentPeriodEnd: subscription?.currentPeriodEnd,
nextCreditDate: usage[0]?.balanceJsonb?.yearlyAllocationDetails?.nextCreditDate,
totalAvailableCredits: (usage[0]?.subscriptionCreditsBalance || 0) + (usage[0]?.oneTimeCreditsBalance || 0),
subscriptionCreditsBalance: usage[0]?.subscriptionCreditsBalance || 0,
oneTimeCreditsBalance: usage[0]?.oneTimeCreditsBalance || 0,
};
}客户端查询
前端可以使用 getClientUserBenefits 函数:
actions/usage/benefits.ts
export async function getClientUserBenefits(): Promise<ActionResult<UserBenefits>> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
try {
const benefits = await getUserBenefits(user.id);
return actionResponse.success(benefits);
} catch (error: any) {
return actionResponse.error(error.message || 'Failed to fetch user benefits.');
}
}积分日志查询
查询用户的积分历史
actions/usage/logs.ts
export async function getCreditLogs({
pageIndex = 0,
pageSize = 20,
}: ListCreditLogsParams = {}): Promise<ListCreditLogsResult> {
const session = await getSession();
const user = session?.user;
if (!user) return actionResponse.unauthorized();
const logs = await db
.select()
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id))
.orderBy(desc(creditLogsSchema.createdAt))
.offset(pageIndex * pageSize)
.limit(pageSize);
const totalCount = await db
.select({ value: count() })
.from(creditLogsSchema)
.where(eq(creditLogsSchema.userId, user.id));
return actionResponse.success({
logs: logs || [],
count: totalCount[0]?.value ?? 0
});
}积分变动类型
系统支持以下积分变动类型:
one_time_purchase: 一次性购买获得积分subscription_grant: 订阅授予积分(月度重置或年度月度分配)feature_usage: 功能使用消耗积分refund_revoke: 退款撤销积分subscription_ended_revoke: 订阅结束撤销剩余积分
常见问题
Q: 年度订阅的积分是如何分配的?
A: 年度订阅采用"按月分配"的策略:
- 订阅时立即分配第一个月的积分
- 之后每月在用户查询积分时,如果检测到达到分配积分时间,则分配一次积分
- 分配逻辑在
getUserBenefits函数中自动执行
Q: 如果用户同时有订阅积分和一次性积分,扣除时优先使用哪个?
A: 系统优先扣除订阅积分,再扣除一次性积分。这样可以确保订阅积分得到充分利用。
Q: 订阅取消后,剩余的订阅积分会被撤销吗?
A: 是的,当订阅过期或取消时,系统会自动撤销剩余的订阅积分。
Q: 如何查询用户的积分历史?
A: 使用 getCreditLogs 函数查询用户的积分变动历史,支持分页查询。