01MVP 标识01MVP
包文档基础设施服务Credits

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 - 用户 ID
  • params.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 - 用户 ID
  • params.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 - 用户 ID
  • amount: number - 需要的积分数量

返回: Promise<boolean> - 是否有足够积分

const hasEnough = await creditService.hasEnoughCredits('user_123', 50);
if (!hasEnough) {
  throw new Error('积分不足');
}

getTransactions(userId, options?)

获取用户的交易历史记录。

参数:

  • userId: string - 用户 ID
  • options?: 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)

相关文档

许可证

MIT