Menu

积分系统

积分系统是 NEXTY.DEV 支付系统的核心功能之一,它管理用户通过购买计划获得的积分,以及使用积分的情况。本文档详细说明积分系统的设计和使用方法。

积分系统概述

积分类型

系统支持两种类型的积分:

  1. 一次性积分 (One-time Credits)
  • 通过一次性支付获得
  • 不会过期
  • 使用后减少,不会自动补充
  1. 订阅积分 (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.');
  }
}

扣除策略

系统采用"优先扣除订阅积分"的策略:

  1. 先扣除订阅积分余额
  2. 如果订阅积分不足,再扣除一次性积分
  3. 确保两种积分都能被充分利用

积分撤销

一次性积分撤销

当用户退款时,撤销相应的一次性积分:

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: 年度订阅采用"按月分配"的策略:

  1. 订阅时立即分配第一个月的积分
  2. 之后每月在用户查询积分时,如果检测到达到分配积分时间,则分配一次积分
  3. 分配逻辑在 getUserBenefits 函数中自动执行

Q: 如果用户同时有订阅积分和一次性积分,扣除时优先使用哪个?

A: 系统优先扣除订阅积分,再扣除一次性积分。这样可以确保订阅积分得到充分利用。

Q: 订阅取消后,剩余的订阅积分会被撤销吗?

A: 是的,当订阅过期或取消时,系统会自动撤销剩余的订阅积分。

Q: 如何查询用户的积分历史?

A: 使用 getCreditLogs 函数查询用户的积分变动历史,支持分页查询。