Docs/modules/支付系统

支付系统

NextShip 支持多种支付服务商,实现订阅、一次性购买和基于积分的计费功能。

支持的服务商

服务商功能
Stripe全球支付、订阅、客户门户
Creem替代服务商,功能相同

通过环境变量切换服务商:

PAYMENT_PROVIDER=stripe  # 或 "creem"

配置

Stripe

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx

Creem

CREEM_API_KEY=ck_test_xxx
CREEM_WEBHOOK_SECRET=xxx

支付类型

1. 订阅

套餐的周期性付款(免费版、专业版、企业版)。

// src/server/actions/subscription.ts
export async function createCheckoutSession(plan: string, interval: "monthly" | "yearly") {
  const session = await requireAuth();
 
  // 创建 Stripe/Creem 结账会话
  const checkoutSession = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${baseUrl}/billing?success=true`,
    cancel_url: `${baseUrl}/pricing`,
    metadata: { userId: session.user.id },
  });
 
  return { url: checkoutSession.url };
}

2. 积分购买(一次性)

购买用于 API 使用的积分套餐。

// 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 }],
    metadata: {
      userId: session.user.id,
      skuId: sku.id,
      credits: sku.credits.toString(),
      type: "credit_purchase",
    },
  });
 
  return { url: checkoutSession.url };
}

Webhook 处理

Webhook 用于将支付事件同步到数据库。

Stripe Webhooks

// src/app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;
 
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );
 
  switch (event.type) {
    case "checkout.session.completed":
      const session = event.data.object;
      if (session.metadata?.type === "credit_purchase") {
        // 为用户添加积分
        await addCredits(session.metadata.userId, Number(session.metadata.credits));
      } else {
        // 处理订阅
        await handleSubscriptionCreated(session);
      }
      break;
    case "customer.subscription.updated":
      await handleSubscriptionUpdate(event.data.object);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionDelete(event.data.object);
      break;
  }
 
  return new Response("OK");
}

本地测试

# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe
 
# 登录 Stripe
stripe login
 
# 将 webhook 转发到本地服务器
pnpm stripe:listen

积分系统(SKU)

积分套餐作为 SKU 在管理后台中管理。

SKU 数据结构

// src/lib/db/schema.ts
export const skus = pgTable("skus", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  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),
});

管理后台

可以在 /skus 管理页面创建/编辑 SKU:

  • 名称和描述
  • 积分数量
  • 价格(单位:分)
  • Stripe Price ID / Creem Product ID
  • 启用/禁用状态

客户门户

允许用户管理其订阅:

// src/server/actions/subscription.ts
export async function createPortalSession() {
  const session = await requireAuth();
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: session.user.stripeCustomerId,
    return_url: `${baseUrl}/billing`,
  });
 
  return { url: portalSession.url };
}

检查订阅状态

// src/lib/permissions.ts
export async function requireSubscription(plan: string) {
  const session = await requireAuth();
 
  const subscription = await db.query.subscriptions.findFirst({
    where: eq(subscriptions.userId, session.user.id),
  });
 
  if (subscription?.plan !== plan || subscription?.status !== "active") {
    throw new Error("Subscription required");
  }
 
  return session;
}

最佳实践

  • 始终验证 webhook 签名
  • 在数据库中存储客户 ID
  • 优雅地处理支付失败
  • 上线前使用测试模式进行测试
  • 使用幂等键进行重试