积分系统
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 页面查看其积分:
- 当前余额
- 购买历史
- 使用明细
- 购买更多积分
最佳实践
- 原子操作 - 余额更新使用数据库事务
- 审计追踪 - 始终记录带引用的交易
- 余额检查 - 在执行昂贵操作前验证余额
- 退款处理 - 实现管理员退款功能
- 低余额提醒 - 在积分不足时通知用户