Docs/modules/积分系统

积分系统

NextShip 包含一个积分/代币系统,用于按量计费,通常用于 AI API 访问、文件存储限制或任何计量功能。

功能特性

  • 每用户积分余额
  • 交易历史记录
  • 基于 SKU 的积分套餐
  • 通过 Stripe/Creem 购买
  • 带日志的使用扣减

数据库结构

用户积分

// src/lib/db/schema.ts
export const userCredits = pgTable("user_credits", {
  id: text("id").primaryKey(),
  userId: text("user_id").references(() => users.id).unique(),
  balance: integer("balance").default(0),
  updatedAt: timestamp("updated_at").defaultNow(),
});

积分交易

export const creditTransactions = pgTable("credit_transactions", {
  id: text("id").primaryKey(),
  userId: text("user_id").references(() => users.id),
  amount: integer("amount").notNull(),        // 正数 = 增加,负数 = 扣减
  type: text("type").notNull(),               // "purchase"、"usage"、"refund"、"admin"
  description: text("description"),
  referenceId: text("reference_id"),          // 支付 ID、使用日志 ID 等
  createdAt: timestamp("created_at").defaultNow(),
});

积分套餐(SKU)

export const skus = pgTable("skus", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  description: text("description"),
  credits: integer("credits").notNull(),
  price: integer("price").notNull(),          // 单位:分
  currency: text("currency").default("usd"),
  stripePriceId: text("stripe_price_id"),
  creemProductId: text("creem_product_id"),
  isActive: boolean("is_active").default(true),
  createdAt: timestamp("created_at").defaultNow(),
});

服务端操作

获取用户余额

// src/server/actions/credits.ts
export async function getUserCredits() {
  const session = await requireAuth();
 
  const credits = await db.query.userCredits.findFirst({
    where: eq(userCredits.userId, session.user.id),
  });
 
  return { balance: credits?.balance ?? 0 };
}

增加积分

export async function addCredits(
  userId: string,
  amount: number,
  options?: {
    type?: string;
    description?: string;
    referenceId?: string;
  }
) {
  const { type = "admin", description, referenceId } = options ?? {};
 
  // 更新或创建余额
  await db
    .insert(userCredits)
    .values({
      id: crypto.randomUUID(),
      userId,
      balance: amount,
    })
    .onConflictDoUpdate({
      target: userCredits.userId,
      set: {
        balance: sql`${userCredits.balance} + ${amount}`,
        updatedAt: new Date(),
      },
    });
 
  // 记录交易
  await db.insert(creditTransactions).values({
    id: crypto.randomUUID(),
    userId,
    amount,
    type,
    description,
    referenceId,
  });
 
  // 获取新余额
  const credits = await db.query.userCredits.findFirst({
    where: eq(userCredits.userId, userId),
  });
 
  return { newBalance: credits?.balance ?? 0 };
}

扣减积分

export async function deductCredits(
  userId: string,
  amount: number,
  options?: {
    description?: string;
    referenceId?: string;
  }
) {
  const { description, referenceId } = options ?? {};
 
  // 检查余额
  const credits = await db.query.userCredits.findFirst({
    where: eq(userCredits.userId, userId),
  });
 
  if (!credits || credits.balance < amount) {
    throw new Error("Insufficient credits");
  }
 
  // 扣减
  await db
    .update(userCredits)
    .set({
      balance: sql`${userCredits.balance} - ${amount}`,
      updatedAt: new Date(),
    })
    .where(eq(userCredits.userId, userId));
 
  // 记录交易
  await db.insert(creditTransactions).values({
    id: crypto.randomUUID(),
    userId,
    amount: -amount,
    type: "usage",
    description,
    referenceId,
  });
 
  return { newBalance: credits.balance - amount };
}

获取交易历史

export async function getCreditTransactions(params: {
  page: number;
  limit: number;
}) {
  const session = await requireAuth();
 
  const transactions = await db.query.creditTransactions.findMany({
    where: eq(creditTransactions.userId, session.user.id),
    orderBy: desc(creditTransactions.createdAt),
    limit: params.limit,
    offset: (params.page - 1) * params.limit,
  });
 
  return { items: transactions };
}

购买积分

可用套餐

// src/server/actions/skus.ts
export async function getActiveSkus() {
  const skuList = await db.query.skus.findMany({
    where: eq(skus.isActive, true),
    orderBy: asc(skus.price),
  });
 
  return { data: skuList };
}

创建结账会话

// src/server/actions/credit-purchase.ts
export async function createCreditCheckoutSession(params: {
  skuId: string;
  successUrl: string;
  cancelUrl: string;
}) {
  const session = await requireAuth();
  const sku = await getSkuById(params.skuId);
 
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: sku.stripePriceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: {
      userId: session.user.id,
      skuId: sku.id,
      credits: sku.credits.toString(),
      type: "credit_purchase",
    },
  });
 
  return { url: checkoutSession.url };
}

Webhook 处理

// 在 Stripe webhook 处理器中
case "checkout.session.completed":
  const session = event.data.object;
  if (session.metadata?.type === "credit_purchase") {
    await addCredits(
      session.metadata.userId,
      Number(session.metadata.credits),
      {
        type: "purchase",
        description: `购买积分套餐`,
        referenceId: session.id,
      }
    );
  }
  break;

使用示例

AI 网关集成

// src/app/api/v1/chat/completions/route.ts
export async function POST(req: Request) {
  // ... 验证 API 密钥,获取 userId
 
  // 检查余额
  const { balance } = await getUserCredits(userId);
  if (balance <= 0) {
    return Response.json({ error: "Insufficient credits" }, { status: 402 });
  }
 
  // 转发到 AI 服务商
  const response = await forwardToProvider(body);
 
  // 根据使用量计算成本
  const cost = calculateCost(body.model, response.usage);
 
  // 扣减积分
  await deductCredits(userId, cost, {
    description: `API 调用:${body.model}`,
    referenceId: response.id,
  });
 
  return Response.json(response);
}

管理后台

SKU 管理页面(/skus)允许管理员:

  • 创建积分套餐
  • 设置定价
  • 配置 Stripe/Creem 产品 ID
  • 启用/禁用套餐

积分页面

用户可以在 /credits 页面查看其积分:

  • 当前余额
  • 购买历史
  • 使用明细
  • 购买更多积分

最佳实践

  1. 原子操作 - 余额更新使用数据库事务
  2. 审计追踪 - 始终记录带引用的交易
  3. 余额检查 - 在执行昂贵操作前验证余额
  4. 退款处理 - 实现管理员退款功能
  5. 低余额提醒 - 在积分不足时通知用户