支付系统
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_xxxCreem
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
- 优雅地处理支付失败
- 上线前使用测试模式进行测试
- 使用幂等键进行重试