书籍目录 展开 / 收起

支付闭环:结账、回调验证与权益发放

先把最难观察的一段跑出来:同一笔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官方回调说明;你本机先做验证与发放演练,再按后半节启用测试结账;它不是截图里的真实经营后台。

Stripe官方Webhook接收端说明:事件通过HTTPS进入服务端,后续还需验证、去重与履约;页面返回并不等于权益已发放
Stripe官方Webhook接收端说明:事件通过HTTPS进入服务端,后续还需验证、去重与履约;页面返回并不等于权益已发放。

发送一条有效通知,再发送一次

另开终端,仍进入同一目录,新建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 按钮。点击按钮后发生的是:

  1. 浏览器向本机/create-checkout发请求,不提交价格、次数或购买者ID。
  2. 服务器读取配置价格,查回它确为美元900分、一次性且有效;不匹配就返回错误,不建会话。
  3. SDK用固定数量1、订单号、服务器教学用户、成功/取消地址创建Checkout,同一订单重试使用同一幂等键。
  4. 平台返回的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在本机小演示中被忽略,接入此类方式前还要增加结束等待和提示重新付款的界面,并实际测试失败流程。

Stripe官方Checkout界面分层示意:完整页面、嵌入式表单与Elements的托管边界不同;这是官方示例,非作者收款页面
Stripe官方Checkout界面分层示意:完整页面、嵌入式表单与Elements的托管边界不同;这是官方示例,非作者收款页面。

把“只发一次”保存下来

迁入PostgreSQL时,把“锁定本地订单、检查尚未发放、插入唯一发放记录、增加该用户余额、更新订单状态”放在同一事务中。以checkoutSessionId或本地订单号给发放记录加唯一约束,并锁定订单行,避免两台API同时增加次数。按事件ID去重可辅助排查,但不同事件也可能指向同一笔付款,因此订单的唯一发放约束仍要保留。

Stripe幂等请求解决你向平台重试创建请求的问题;数据库约束解决收到通知后重复发放的问题。二者放在各自位置。

把两行200输出和改坏签名得到的400保存在练习记录中。你已经能实际观察一条通知被验证、订单增加10次、重复通知保持10次;接入数据库后,再用两次并发投递重做相同实验。

登录后可保存阅读进度

下一篇:订阅生命周期:续费、取消、退款与支付失败登录保存进度