Credits
积分管理系统,支持余额追踪和交易历史记录
@mono/credits
积分管理系统,用于追踪用户余额和交易历史。提供类型安全的操作、原子事务和完整的交易记录,适用于基于使用量的计费场景。
特性
- 余额管理 - 实时追踪用户积分余额
- 交易记录 - 完整的交易历史和审计日志
- 多种交易类型 - 支持购买、消费、退款、奖励、调整
- 原子事务 - 防止竞态条件,确保数据一致性
- 类型安全 - 完整的 TypeScript 类型支持
- Prisma 集成 - 与数据库无缝集成
安装
pnpm add @mono/credits快速开始
import { PrismaClient } from '@prisma/client';
import { CreditService } from '@mono/credits';
const prisma = new PrismaClient();
const creditService = new CreditService(prisma);
// 获取用户余额
const balance = await creditService.getBalance('user_123');
console.log(`当前余额: ${balance} 积分`);
// 添加积分
await creditService.addCredits({
userId: 'user_123',
amount: 100,
type: 'purchase',
orderId: 'order_456',
description: '购买 100 积分',
});
// 消费积分
const result = await creditService.consumeCredits({
userId: 'user_123',
amount: 10,
description: 'AI 对话使用',
metadata: { model: 'gpt-4', tokens: 1000 },
});
if (result.success) {
console.log(`新余额: ${result.newBalance}`);
} else {
console.error(`错误: ${result.error}`);
}API 参考
CreditService
积分服务类,提供所有积分管理功能。
constructor(prisma)
创建积分服务实例。
参数:
prisma: PrismaClient - Prisma 客户端实例
const creditService = new CreditService(prisma);getBalance(userId)
获取用户当前积分余额。
参数:
userId: string - 用户 ID
返回: Promise<number> - 当前余额
const balance = await creditService.getBalance('user_123');addCredits(params)
为用户添加积分。
参数:
params.userId: string - 用户 IDparams.amount: number - 积分数量(必须为正数)params.type: 'purchase' | 'bonus' | 'refund' | 'adjustment' - 交易类型params.orderId?: string - 关联订单 ID(可选)params.description?: string - 交易描述(可选)params.metadata?: Record<string, unknown> - 额外元数据(可选)
返回: Promise<CreditTransaction> - 创建的交易记录
await creditService.addCredits({
userId: 'user_123',
amount: 100,
type: 'purchase',
orderId: 'order_456',
description: '购买积分套餐',
metadata: { package: 'starter' },
});consumeCredits(params)
消费用户积分,自动检查余额是否充足。
参数:
params.userId: string - 用户 IDparams.amount: number - 消费数量(必须为正数)params.description?: string - 消费描述(可选)params.metadata?: Record<string, unknown> - 额外元数据(可选)
返回: Promise<ConsumeCreditsResult>
success: boolean - 是否成功newBalance: number - 新余额transactionId?: string - 交易 ID(成功时)error?: string - 错误信息(失败时)
const result = await creditService.consumeCredits({
userId: 'user_123',
amount: 10,
description: 'AI 图片生成',
metadata: { model: 'dall-e-3', size: '1024x1024' },
});hasEnoughCredits(userId, amount)
检查用户是否有足够的积分。
参数:
userId: string - 用户 IDamount: number - 需要的积分数量
返回: Promise<boolean> - 是否有足够积分
const hasEnough = await creditService.hasEnoughCredits('user_123', 50);
if (!hasEnough) {
throw new Error('积分不足');
}getTransactions(userId, options?)
获取用户的交易历史记录。
参数:
userId: string - 用户 IDoptions?: GetTransactionsOptions - 查询选项limit?: number - 返回数量限制(默认 50)offset?: number - 偏移量(默认 0)type?: CreditTransactionType - 筛选交易类型
返回: Promise<CreditTransaction[]> - 交易记录列表
// 获取最近 20 条消费记录
const transactions = await creditService.getTransactions('user_123', {
limit: 20,
type: 'consumption',
});getStatus(userId)
获取用户积分状态的完整概览。
参数:
userId: string - 用户 ID
返回: Promise<CreditStatus>
balance: number - 当前余额totalPurchased: number - 累计购买/获得totalConsumed: number - 累计消费
const status = await creditService.getStatus('user_123');
console.log(`余额: ${status.balance}`);
console.log(`累计购买: ${status.totalPurchased}`);
console.log(`累计消费: ${status.totalConsumed}`);交易类型
purchase- 通过支付购买的积分consumption- 使用功能消费的积分refund- 退款返还的积分bonus- 奖励赠送的积分adjustment- 管理员手动调整
实战示例
示例 1: 支付成功后添加积分
// app/api/webhooks/payment/route.ts
import { creditService } from '@/lib/credits';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const payload = await req.json();
// 验证 webhook 签名
// ...
if (payload.status === 'paid') {
// 根据订单金额计算积分
const credits = calculateCredits(payload.amount);
// 添加积分
await creditService.addCredits({
userId: payload.userId,
amount: credits,
type: 'purchase',
orderId: payload.orderId,
description: `购买 ${credits} 积分`,
metadata: {
amount: payload.amount,
currency: payload.currency,
paymentMethod: payload.method,
},
});
console.log(`用户 ${payload.userId} 获得 ${credits} 积分`);
}
return NextResponse.json({ received: true });
}
function calculateCredits(amount: number): number {
// 1 元 = 10 积分
return Math.floor(amount * 10);
}示例 2: AI 服务消费积分
// app/api/ai/chat/route.ts
import { creditService } from '@/lib/credits';
import { auth } from '@mono/auth';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { message, model = 'gpt-4' } = await req.json();
// 计算所需积分
const requiredCredits = model === 'gpt-4' ? 10 : 5;
// 检查余额
const hasEnough = await creditService.hasEnoughCredits(
session.user.id,
requiredCredits
);
if (!hasEnough) {
return NextResponse.json(
{ error: '积分不足,请先充值' },
{ status: 402 }
);
}
// 调用 AI API
const response = await callAI(message, model);
// 消费积分
const result = await creditService.consumeCredits({
userId: session.user.id,
amount: requiredCredits,
description: `AI 对话 (${model})`,
metadata: {
model,
messageLength: message.length,
responseLength: response.length,
},
});
if (!result.success) {
console.error('积分消费失败:', result.error);
}
return NextResponse.json({
response,
creditsUsed: requiredCredits,
remainingCredits: result.newBalance,
});
}示例 3: 用户积分仪表板
'use client';
import { useEffect, useState } from 'react';
import { Card, Button } from '@mono/ui';
interface CreditStatus {
balance: number;
totalPurchased: number;
totalConsumed: number;
}
interface Transaction {
id: string;
type: string;
amount: number;
description: string;
createdAt: Date;
}
export function CreditsDashboard() {
const [status, setStatus] = useState<CreditStatus | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadCreditsData();
}, []);
const loadCreditsData = async () => {
try {
const [statusRes, transactionsRes] = await Promise.all([
fetch('/api/credits/status'),
fetch('/api/credits/transactions?limit=10'),
]);
const statusData = await statusRes.json();
const transactionsData = await transactionsRes.json();
setStatus(statusData);
setTransactions(transactionsData);
} catch (error) {
console.error('加载积分数据失败:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return <div>加载中...</div>;
}
return (
<div className="space-y-6">
<Card>
<Card.Header>
<Card.Title>我的积分</Card.Title>
</Card.Header>
<Card.Content>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">当前余额</p>
<p className="text-3xl font-bold">{status?.balance}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">累计获得</p>
<p className="text-2xl font-semibold">{status?.totalPurchased}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">累计消费</p>
<p className="text-2xl font-semibold">{status?.totalConsumed}</p>
</div>
</div>
<Button className="mt-4" onClick={() => window.location.href = '/pricing'}>
购买积分
</Button>
</Card.Content>
</Card>
<Card>
<Card.Header>
<Card.Title>交易记录</Card.Title>
</Card.Header>
<Card.Content>
<div className="space-y-2">
{transactions.map((tx) => (
<div key={tx.id} className="flex justify-between items-center py-2 border-b">
<div>
<p className="font-medium">{tx.description}</p>
<p className="text-sm text-muted-foreground">
{new Date(tx.createdAt).toLocaleString('zh-CN')}
</p>
</div>
<div className={`font-semibold ${
Number(tx.amount) > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{Number(tx.amount) > 0 ? '+' : ''}{tx.amount}
</div>
</div>
))}
</div>
</Card.Content>
</Card>
</div>
);
}示例 4: 积分状态 API 路由
// app/api/credits/status/route.ts
import { creditService } from '@/lib/credits';
import { auth } from '@mono/auth';
import { NextResponse } from 'next/server';
export async function GET(req: Request) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const status = await creditService.getStatus(session.user.id);
return NextResponse.json(status);
}// app/api/credits/transactions/route.ts
import { creditService } from '@/lib/credits';
import { auth } from '@mono/auth';
import { NextResponse } from 'next/server';
export async function GET(req: Request) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const limit = Number(searchParams.get('limit')) || 50;
const offset = Number(searchParams.get('offset')) || 0;
const type = searchParams.get('type') as any;
const transactions = await creditService.getTransactions(
session.user.id,
{ limit, offset, type }
);
return NextResponse.json(transactions);
}示例 5: 管理员奖励积分
// app/api/admin/credits/bonus/route.ts
import { creditService } from '@/lib/credits';
import { auth } from '@mono/auth';
import { can, Action, Subject } from '@mono/permissions';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
const session = await auth.api.getSession({ headers: req.headers });
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 检查管理员权限
if (!can(session.user, Action.MANAGE, Subject.ALL)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { userId, amount, reason } = await req.json();
// 验证参数
if (!userId || !amount || amount <= 0) {
return NextResponse.json(
{ error: '无效的参数' },
{ status: 400 }
);
}
// 添加奖励积分
await creditService.addCredits({
userId,
amount,
type: 'bonus',
description: `管理员奖励: ${reason || '无'}`,
metadata: {
adminId: session.user.id,
adminName: session.user.name,
reason,
},
});
return NextResponse.json({
success: true,
message: `已为用户 ${userId} 添加 ${amount} 积分`,
});
}示例 6: 积分不足时的优雅处理
'use client';
import { useState } from 'react';
import { Button, Dialog, Alert } from '@mono/ui';
export function AIFeature() {
const [showInsufficientDialog, setShowInsufficientDialog] = useState(false);
const [credits, setCredits] = useState(0);
const handleUseFeature = async () => {
try {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: '...' }),
});
if (response.status === 402) {
// 积分不足
const data = await response.json();
setCredits(data.currentBalance || 0);
setShowInsufficientDialog(true);
return;
}
const result = await response.json();
// 处理成功结果
} catch (error) {
console.error('功能使用失败:', error);
}
};
return (
<>
<Button onClick={handleUseFeature}>
使用 AI 功能 (消耗 10 积分)
</Button>
<Dialog open={showInsufficientDialog} onOpenChange={setShowInsufficientDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>积分不足</Dialog.Title>
<Dialog.Description>
您当前有 {credits} 积分,使用此功能需要 10 积分。
</Dialog.Description>
</Dialog.Header>
<div className="space-y-4">
<Alert>
<Alert.Title>提示</Alert.Title>
<Alert.Description>
购买积分套餐可享受更优惠的价格!
</Alert.Description>
</Alert>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowInsufficientDialog(false)}
>
取消
</Button>
<Button onClick={() => window.location.href = '/pricing'}>
立即充值
</Button>
</div>
</div>
</Dialog.Content>
</Dialog>
</>
);
}集成指南
1. 数据库设置
积分系统需要以下数据库模型(已包含在 mono-web 中):
model User {
id String @id @default(cuid())
creditBalance Decimal @default(0) @db.Decimal(10, 2)
creditTransactions CreditTransaction[]
// ... 其他字段
}
model CreditTransaction {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // purchase, consumption, refund, bonus, adjustment
amount Decimal @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2)
orderId String?
description String?
metadata Json?
createdAt DateTime @default(now())
@@index([userId])
@@index([userId, type])
@@index([createdAt])
}2. 初始化服务
// lib/credits.ts
import { prisma } from '@/lib/database';
import { CreditService } from '@mono/credits';
export const creditService = new CreditService(prisma);3. 与支付系统集成
// lib/payments/webhook-handler.ts
import { creditService } from '@/lib/credits';
export async function handlePaymentSuccess(payment: Payment) {
// 根据支付金额计算积分
const credits = calculateCreditsFromAmount(payment.amount);
// 添加积分
await creditService.addCredits({
userId: payment.userId,
amount: credits,
type: 'purchase',
orderId: payment.orderId,
description: `购买 ${credits} 积分`,
metadata: {
paymentId: payment.id,
amount: payment.amount,
currency: payment.currency,
},
});
}
function calculateCreditsFromAmount(amount: number): number {
// 定义积分套餐
const packages = [
{ amount: 9.9, credits: 100 },
{ amount: 49.9, credits: 600 }, // 20% 额外奖励
{ amount: 99.9, credits: 1500 }, // 50% 额外奖励
];
const pkg = packages.find(p => p.amount === amount);
return pkg?.credits || Math.floor(amount * 10);
}4. 与 AI 服务集成
// lib/ai/with-credits.ts
import { creditService } from '@/lib/credits';
export async function withCreditsCheck<T>(
userId: string,
requiredCredits: number,
operation: () => Promise<T>
): Promise<T> {
// 检查余额
const hasEnough = await creditService.hasEnoughCredits(userId, requiredCredits);
if (!hasEnough) {
throw new Error('INSUFFICIENT_CREDITS');
}
// 执行操作
const result = await operation();
// 消费积分
await creditService.consumeCredits({
userId,
amount: requiredCredits,
description: '使用 AI 服务',
});
return result;
}
// 使用示例
const response = await withCreditsCheck(
userId,
10,
() => callAIAPI(prompt)
);最佳实践
1. 原子性保证
积分操作使用数据库事务,确保原子性:
// ✅ 正确:使用 consumeCredits 自动处理事务
const result = await creditService.consumeCredits({
userId,
amount: 10,
description: '使用功能',
});
// ❌ 错误:手动检查和扣除可能导致竞态条件
const balance = await creditService.getBalance(userId);
if (balance >= 10) {
// 这里可能被其他请求抢先消费
await creditService.addCredits({
userId,
amount: -10, // 错误:addCredits 不接受负数
type: 'consumption',
});
}2. 错误处理
始终检查 consumeCredits 的返回结果:
const result = await creditService.consumeCredits({
userId,
amount: 10,
description: '使用功能',
});
if (!result.success) {
if (result.error === 'Insufficient credits') {
// 引导用户充值
return { error: 'INSUFFICIENT_CREDITS', balance: result.newBalance };
}
// 其他错误
throw new Error(result.error);
}
// 成功,继续处理
console.log(`交易 ID: ${result.transactionId}`);3. 元数据记录
为交易添加详细的元数据,便于审计和分析:
await creditService.consumeCredits({
userId,
amount: 10,
description: 'AI 图片生成',
metadata: {
feature: 'image-generation',
model: 'dall-e-3',
size: '1024x1024',
quality: 'hd',
timestamp: new Date().toISOString(),
ip: req.headers.get('x-forwarded-for'),
},
});4. 积分套餐设计
设计合理的积分套餐,鼓励用户购买更多:
const CREDIT_PACKAGES = [
{
id: 'starter',
name: '入门套餐',
credits: 100,
price: 9.9,
bonus: 0,
},
{
id: 'popular',
name: '热门套餐',
credits: 500,
price: 49.9,
bonus: 100, // 20% 额外奖励
badge: '最划算',
},
{
id: 'pro',
name: '专业套餐',
credits: 1000,
price: 99.9,
bonus: 500, // 50% 额外奖励
},
];5. 余额预警
实现余额不足预警机制:
export async function checkLowBalance(userId: string): Promise<boolean> {
const balance = await creditService.getBalance(userId);
const LOW_BALANCE_THRESHOLD = 10;
if (balance < LOW_BALANCE_THRESHOLD) {
// 发送通知
await sendNotification(userId, {
type: 'low_balance',
message: `您的积分余额不足 ${balance},请及时充值`,
});
return true;
}
return false;
}故障排查
问题:积分扣除失败但功能已执行
原因: 先执行功能再扣除积分,中间出现错误。
解决方案: 先扣除积分,再执行功能:
// ✅ 正确顺序
const result = await creditService.consumeCredits({
userId,
amount: 10,
description: '使用功能',
});
if (!result.success) {
throw new Error('积分不足');
}
// 积分已扣除,执行功能
const output = await executeFeature();问题:并发请求导致余额异常
原因: 多个请求同时消费积分,没有使用事务。
解决方案: consumeCredits 已内置事务保护,确保使用它而不是手动操作:
// ✅ 使用 consumeCredits,自动处理并发
await creditService.consumeCredits({ userId, amount: 10 });问题:交易记录查询慢
原因: 缺少数据库索引。
解决方案: 确保 CreditTransaction 表有正确的索引:
model CreditTransaction {
// ...
@@index([userId])
@@index([userId, type])
@@index([createdAt])
}问题:积分余额精度问题
原因: 使用 JavaScript Number 类型处理金额。
解决方案: 数据库使用 Decimal 类型,服务已正确处理:
creditBalance Decimal @default(0) @db.Decimal(10, 2)相关文档
- @mono/payment 包 - 支付系统集成
- @mono/auth 包 - 用户认证
- @mono/permissions 包 - 权限控制
- Prisma 文档 - 数据库操作
许可证
MIT