整体流程
1
2
3
4
5
6
7
| 用户点击购买
→ 创建 Stripe Checkout Session
→ 跳转到 Stripe 支付页面
→ 用户完成支付
→ Stripe 发送 Webhook 到服务器
→ 服务器验证签名 + 发放积分
→ 用户跳回成功页面
|
创建 Checkout Session
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // src/app/(settings)/settings/billing/billing.actions.ts
export const createCheckoutSessionAction = actionClient
.inputSchema(createCheckoutSchema)
.action(async ({ parsedInput }) => {
const session = await requireVerifiedEmail();
const userId = session.user.id;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const checkoutSession = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price: parsedInput.priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?canceled=true`,
metadata: {
userId,
priceId: parsedInput.priceId,
},
});
return { url: checkoutSession.url };
});
|
前端跳转:
1
2
3
4
5
6
7
| const { execute } = useAction(createCheckoutSessionAction, {
onSuccess: ({ data }) => {
if (data?.url) {
window.location.href = data.url;
}
},
});
|
Webhook 处理
Stripe 支付完成后,会向你的服务器发送 Webhook 事件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| // src/app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
// 验证签名,防止伪造请求
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.CheckoutSession;
await handleCheckoutCompleted(session);
break;
}
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
await handleRefund(charge);
break;
}
}
return new Response("OK", { status: 200 });
}
async function handleCheckoutCompleted(session: Stripe.CheckoutSession) {
const userId = session.metadata?.userId;
const priceId = session.metadata?.priceId;
if (!userId || !priceId) return;
// 根据 priceId 查找对应的积分数量
const creditPlan = CREDIT_PLANS.find(p => p.priceId === priceId);
if (!creditPlan) return;
const db = getDB();
// 发放积分
await db.insert(creditTransactionTable).values({
id: `txn_${createId()}`,
userId,
amount: creditPlan.credits,
remainingAmount: creditPlan.credits,
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
description: `Purchased ${creditPlan.credits} credits`,
createdAt: new Date(),
updatedAt: new Date(),
});
// 更新用户积分缓存
await db
.update(userTable)
.set({ currentCredits: sql`currentCredits + ${creditPlan.credits}` })
.where(eq(userTable.id, userId));
}
|
幂等性处理
Stripe 的 Webhook 可能会重复发送(网络超时重试)。需要保证同一个事件只处理一次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| async function handleCheckoutCompleted(session: Stripe.CheckoutSession) {
const db = getDB();
// 检查是否已经处理过这个 session
const existing = await db.query.creditTransactionTable.findFirst({
where: eq(creditTransactionTable.stripeSessionId, session.id),
});
if (existing) {
console.log(`Session ${session.id} already processed, skipping`);
return;
}
// 正常处理...
await db.insert(creditTransactionTable).values({
id: `txn_${createId()}`,
stripeSessionId: session.id, // 记录 session ID
// ...
});
}
|
退款自动化
用户在 Stripe 后台申请退款后,Stripe 会发送 charge.refunded 事件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| async function handleRefund(charge: Stripe.Charge) {
const sessionId = charge.payment_intent as string;
// 找到对应的积分记录
const transaction = await db.query.creditTransactionTable.findFirst({
where: eq(creditTransactionTable.stripeSessionId, sessionId),
});
if (!transaction) return;
// 扣除积分(不能超过剩余积分)
const refundCredits = Math.min(
transaction.remainingAmount,
transaction.amount
);
await db
.update(creditTransactionTable)
.set({ remainingAmount: 0 })
.where(eq(creditTransactionTable.id, transaction.id));
// 更新用户积分缓存
await db
.update(userTable)
.set({
currentCredits: sql`MAX(0, currentCredits - ${refundCredits})`,
})
.where(eq(userTable.id, transaction.userId));
}
|
本地调试
本地调试 Webhook 需要用 Stripe CLI 转发事件:
1
2
3
4
5
6
7
8
9
10
11
| # 安装 Stripe CLI
brew install stripe/stripe-cli/stripe
# 登录
stripe login
# 转发 Webhook 到本地
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# 触发测试事件
stripe trigger checkout.session.completed
|
总结
Stripe 集成的关键点:
- 签名验证:每个 Webhook 都要验证
stripe-signature,防止伪造 - 幂等性:用
stripeSessionId 去重,防止重复发放积分 - 退款联动:监听
charge.refunded 事件,自动扣除积分 - 本地调试:用 Stripe CLI 转发 Webhook 到本地
参考资源