支付闭环:结账、回调验证与权益发放
先把最难观察的一段跑出来:同一笔9美元付款通知来两次,ClearCopy仍只增加10次额度。 本节提供一个可单独运行的本地演示。你不用付款账户,也不需要输入银行卡;Stripe SDK在本机生成教学签名,Fastify接收并核验,再发放一次。
先做无需账户的签名练习;后半节会启用同一个服务中的Sandbox结账入口,把真正返回的会话ID接回回调。这里是仅限本机的单用户演示,demo-user-a由服务器固定,不代表已经实现登录。它使用单进程内存保存订单,适合看清消息如何流动。最后会说明迁入PostgreSQL的位置;这个演示本身不供真实交易使用。准备Node.js 22及终端,在新的空目录中操作,别放进已有生产项目。
安装只供本次练习使用的依赖
新建并进入clearcopy-webhook-demo目录后,依次执行:
npm init -y
npm install fastify@5.12.7 stripe@23.1.0
然后新建server.mjs,复制下面的完整代码。whsec_local_demo_only是本地教学约定值,不连接任何支付账户:
import Fastify from 'fastify';
import Stripe from 'stripe';
const sandbox = process.env.CHECKOUT_MODE === 'sandbox';
const mockPort = sandbox ? null : process.env.STRIPE_MOCK_PORT;
const priceId = process.env.STRIPE_PRICE_10_CHECKS;
const key = sandbox ? process.env.STRIPE_SECRET_KEY
: 'sk_test_local_demo_only';
const secret = sandbox ? process.env.STRIPE_WEBHOOK_SECRET
: 'whsec_local_demo_only';
if (sandbox && (!key?.startsWith('sk_test_') ||
!secret?.startsWith('whsec_') || !priceId?.startsWith('price_'))) {
throw new Error('Configure sandbox key, price and webhook secret');
}
const stripe = new Stripe(key, mockPort ? {
host: '127.0.0.1', port: Number(mockPort), protocol: 'http'
} : {});
const origin = 'http://localhost:4242';
const order = {
id: process.env.DEMO_ORDER_ID || 'demo-order-001', userId: 'demo-user-a', amount: 900,
currency: 'usd', session: 'cs_test_demo_001',
fulfilled: false, credits: 0
};
const app = Fastify();
app.removeContentTypeParser('application/json');
app.addContentTypeParser('application/json',
{ parseAs: 'buffer' }, (request, body, done) => done(null, body));
app.get('/', async (request, reply) => reply.type('text/html').send(`
<h1>ClearCopy local single-user demo</h1>
<p>User: demo-user-a. One package: USD 9 for 10 checks.</p>
<button id="buy">Open sandbox Checkout</button><p id="status"></p>
<a href="/balance">View server balance</a>
<script>
document.querySelector('#buy').onclick = async function () {
this.disabled = true;
try {
const r = await fetch('/create-checkout', { method: 'POST' });
const data = await r.json();
if (!r.ok) throw new Error(data.error);
location.href = data.url;
} catch (e) {
document.querySelector('#status').textContent = e.message;
} finally { this.disabled = false; }
};
</script>`));
app.get('/balance', async () => ({ orderId: order.id, userId: order.userId,
fulfilled: order.fulfilled, credits: order.credits, session: order.session }));
app.get('/success', async () => ({ message: 'Read server balance; this page grants no credits.',
credits: order.credits }));
app.get('/cancel', async () => ({ message: 'Checkout canceled',
credits: order.credits }));
app.post('/create-checkout', async (request, reply) => {
if (!sandbox && !mockPort) {
return reply.code(409).send({ error: 'sandbox_not_configured' });
}
if (order.fulfilled) {
return reply.code(409).send({ error: 'demo_order_already_fulfilled' });
}
try {
const price = await stripe.prices.retrieve(priceId);
if (price.currency !== 'usd' || price.unit_amount !== 900 ||
price.recurring || !price.active) {
return reply.code(400).send({ error: 'expected_usd_9_one_time_price' });
}
const s = await stripe.checkout.sessions.create({
mode: 'payment', currency: order.currency,
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: order.id,
metadata: { order_id: order.id, user_id: order.userId },
success_url: origin + '/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: origin + '/cancel'
}, { idempotencyKey: 'checkout/' + order.id });
if (!s.url) throw new Error('missing_checkout_url');
// 真正创建的会话ID替换本地签名练习的固定值。
order.session = s.id;
return { id: s.id, url: s.url };
} catch {
return reply.code(502).send({ error: 'checkout_creation_failed' });
}
});
app.post('/webhook', async (request, reply) => {
let event;
try {
event = stripe.webhooks.constructEvent(
request.body, request.headers['stripe-signature'], secret);
} catch {
return reply.code(400).send({ error: 'invalid_signature' });
}
if (!['checkout.session.completed',
'checkout.session.async_payment_succeeded'].includes(event.type)) {
return { ignored: true };
}
let s = event.data.object;
if (s.id !== order.session) {
return reply.code(400).send({ error: 'order_mismatch' });
}
if (sandbox || mockPort) {
s = await stripe.checkout.sessions.retrieve(s.id,
{ expand: ['line_items'] });
const item = s.line_items?.data?.[0];
if (s.livemode || s.metadata?.order_id !== order.id ||
s.metadata?.user_id !== order.userId ||
s.client_reference_id !== order.id ||
s.line_items?.data?.length !== 1 ||
item?.price?.id !== priceId || item?.quantity !== 1) {
return reply.code(400).send({ error: 'order_mismatch' });
}
}
if (s.amount_total !== order.amount || s.currency !== order.currency) {
return reply.code(400).send({ error: 'order_mismatch' });
}
if (s.payment_status !== 'paid') {
return { waiting: true, credits: order.credits };
}
// 单进程同步转换;真实应用须同一数据库事务和唯一发放约束。
const alreadyFulfilled = order.fulfilled;
if (!alreadyFulfilled) {
order.credits += 10;
order.fulfilled = true;
}
return { credits: order.credits, alreadyFulfilled };
});
await app.listen({ port: 4242, host: '127.0.0.1' });
console.log('Demo listening on ' + origin);
代码中的STRIPE_MOCK_PORT只供本地HTTP模拟测试使用,普通练习与Sandbox联调不设置它;未启用Sandbox时不会向Stripe请求创建会话。运行node server.mjs,终端应显示监听地址。这里核对的金额900来自前一节的订单。配图展示的是Stripe官方回调说明;你本机先做验证与发放演练,再按后半节启用测试结账;它不是截图里的真实经营后台。
发送一条有效通知,再发送一次
另开终端,仍进入同一目录,新建send.mjs:
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_local_demo_only');
const payload = JSON.stringify({
id: 'evt_demo_001', type: 'checkout.session.completed',
data: { object: {
id: 'cs_test_demo_001', payment_status: 'paid',
amount_total: 900, currency: 'usd'
} }
});
const signature = stripe.webhooks.generateTestHeaderString({
payload, secret: 'whsec_local_demo_only'
});
for (let i = 0; i < 2; i++) {
const response = await fetch('http://127.0.0.1:4242/webhook', {
method: 'POST',
headers: { 'content-type': 'application/json',
'stripe-signature': signature },
body: payload
});
console.log(response.status, await response.json());
}
本文已用Node.js 22.22.1、Fastify 5.12.7与Stripe SDK 23.1.0在独立本地目录运行以下演练,未连接支付账户。执行node send.mjs,应得到:
200 { credits: 10, alreadyFulfilled: false }
200 { credits: 10, alreadyFulfilled: true }
第一行增加10次,第二行认出同一订单已发放。这就是本节要拿到的结果。重启server.mjs会丢掉内存,余额回到0,因此它还没有数据库持久性。
故意改坏签名,观察拒绝发生在哪里
在send.mjs中只将签名生成的secret改成whsec_wrong_demo,保留服务器里的原值。再次执行,应收到两次400和invalid_signature。请求没有走到增加额度的位置。把发送脚本改回原值,再重启服务恢复干净状态。
若连正确签名也失败,先检查服务器收到的body是否仍是Buffer,以及两处secret是否相同。Stripe回调文档明确要求用原始请求体核验;解析JSON后再JSON.stringify可能改变字节,不能拿重建后的字符串当原始内容。
若出现EADDRINUSE,4242端口已有服务;停止本练习上一次的进程再试。不要为了避开错误关闭已有真实业务进程。
从本地签名练习进入真正的Sandbox结账
现在不要再凭空寻找“已有的结账入口”,上面同一个server.mjs已给出POST /create-checkout和本地按钮。只有你具备自己的Stripe测试环境权限时才做这一段;主体或账户条件未确认的读者,保留刚才两行200与坏签名400,即完成无账户路径,不填假商户资料。
先停止本地默认服务。确认5-3已在同一Sandbox创建USD 9.00的一次性价格,并找到它真实的price_标识。安装Stripe CLI后,依次执行官方本地转发命令:
stripe login
stripe listen --forward-to http://localhost:4242/webhook
保持这个终端运行,确认CLI当前连接的是同一个Sandbox,不添加--live。从输出中取得该监听器的whsec_签名密钥;它与API密钥是两个值。Stripe密钥页面说明测试secret以sk_test_开头,不能把publishable key当服务端密钥。
在教学目录建立仅由自己读取、已加入Git忽略规则的.env.sandbox,填以下字段。等号后的大写词都是占位内容,必须换成对应Sandbox的真实测试值:
CHECKOUT_MODE=sandbox
DEMO_ORDER_ID=demo-order-001
STRIPE_SECRET_KEY=SANDBOX_SECRET_KEY_STARTING_SK_TEST
STRIPE_PRICE_10_CHECKS=SANDBOX_ONE_TIME_USD_9_PRICE_ID
STRIPE_WEBHOOK_SECRET=LOCAL_CLI_SIGNING_SECRET_STARTING_WHSEC
运行同一份服务器:
node --env-file=.env.sandbox server.mjs
打开http://localhost:4242,应看见本机单用户说明、9美元10次包和 Open sandbox Checkout 按钮。点击按钮后发生的是:
- 浏览器向本机
/create-checkout发请求,不提交价格、次数或购买者ID。 - 服务器读取配置价格,查回它确为美元900分、一次性且有效;不匹配就返回错误,不建会话。
- SDK用固定数量1、订单号、服务器教学用户、成功/取消地址创建Checkout,同一订单重试使用同一幂等键。
- 平台返回的
session.id立即写入order.session,替换最初cs_test_demo_001,再把session.url返回浏览器并跳转。
你应进入Stripe托管的测试付款页,而不是自己编的一张假卡片界面。再次核对商品名称、USD 9.00和测试环境。成功/取消地址都指回自己的本机服务;成功页只读取服务端余额,不根据地址栏中的session_id加次数。若回调稍晚,刷新/balance再看。
使用官方测试卡付款后,CLI应看到事件转发,本地/balance应显示真实的cs_test_会话和10次额度。回调会再次从Stripe取得该会话与商品,核对订单、教学用户、测试环境、美元金额与数量。若仍拿固定cs_test_demo_001发通知,服务应拒绝为order_mismatch,不是改掉比较来“放行”。具体成功/失败用例按5-6继续。
这台演示服务器一次只保留一笔内存订单。新的一次独立购买,先停止Node进程,在.env.sandbox把DEMO_ORDER_ID换成从未用过的新值,再执行上面的启动命令;重启会把该次演示余额归零。同一笔订单的网络重试或重复通知则保持ID不变,不要靠换ID绕过幂等保护。订单号只由服务端环境设置,浏览器不能提交或改写它;正式应用应改为服务端持久化的新订单,不以重启清空用户余额。
如果报checkout_creation_failed,先检查三个值是否属于同一Sandbox,以及价格是否存在;日志不输出完整密钥。这个本机版本只演示一笔订单:重启内存会清空,重复请求仍使用该教学订单的幂等键。下一轮新的购买必须生成新订单与新购买尝试,不能把固定订单号当正式多用户实现。
本文已用本地HTTP mock让真实Stripe SDK发送创建请求,验证价格、用户关联、返回ID、相同重试键及后续10次发放;没有连接真实Stripe账户,没有进行Sandbox卡付款。因此上面的托管页与CLI联调结果由你在自己的测试账户观察,不能把本地mock通过称为这一步已替你完成。
接到真正的测试结账时替换哪几处
Stripe履约说明要求服务端核对Checkout付款状态,处理重复调用。实际应用沿用同一过程,但数据不能由send.mjs决定:收到通过签名的事件后,用服务端凭据获取对应会话与商品明细,和本地订单的用户、价格、币种、数量及测试/正式环境关联一起核对。
这次选择的是单账户Snapshot教学事件,读取data.object;配置真实目标时选择匹配的事件格式与API版本。若使用Thin事件,就按其文档获取相关对象,不直接套用这份对象结构。
Checkout分层配图列出完整托管页面、嵌入表单和Elements等不同接法。本书先采用完整托管结账页:浏览器跳转付款,自己的Node服务继续负责订单核对与额度发放。图示用来定位各部分由谁托管,不代表本文已经建立了可付款页面。
本地接Stripe测试环境时,使用官方CLI本地转发步骤,执行stripe login后运行:
stripe listen --forward-to localhost:4242/webhook
它输出的whsec_值替换本地secret,并从环境变量加载;不再使用教学值。测试环境Checkout发出的通知才会到这里。成功页面只显示应用后台查到的余额,用户没返回页面时也必须能发放。
代码已经处理两条成功路径:checkout.session.completed到达但payment_status仍是unpaid时保留等待、不增加额度;checkout.session.async_payment_succeeded到达后重新取得会话,确认最终已付再执行同一发放。失败通知checkout.session.async_payment_failed在本机小演示中被忽略,接入此类方式前还要增加结束等待和提示重新付款的界面,并实际测试失败流程。
把“只发一次”保存下来
迁入PostgreSQL时,把“锁定本地订单、检查尚未发放、插入唯一发放记录、增加该用户余额、更新订单状态”放在同一事务中。以checkoutSessionId或本地订单号给发放记录加唯一约束,并锁定订单行,避免两台API同时增加次数。按事件ID去重可辅助排查,但不同事件也可能指向同一笔付款,因此订单的唯一发放约束仍要保留。
Stripe幂等请求解决你向平台重试创建请求的问题;数据库约束解决收到通知后重复发放的问题。二者放在各自位置。
把两行200输出和改坏签名得到的400保存在练习记录中。你已经能实际观察一条通知被验证、订单增加10次、重复通知保持10次;接入数据库后,再用两次并发投递重做相同实验。