PAYATHON 2026

如何在 Node.js Web 应用中集成支付网关?

支付阿杰

结论

Node.js 应用通常不会直接处理银行卡信息,而是通过支付网关的官方 SDK 或 HTTP API 收款。对初学者来说,使用支付服务商提供的托管收银台通常更稳妥,基本流程如下:

  1. 服务端创建支付订单或支付会话。
  2. 浏览器跳转到支付网关的收银台。
  3. 用户在收银台完成付款。
  4. 支付网关通过 Webhook 通知服务端。
  5. 服务端验证通知,并更新本地订单状态。

不能根据前端的跳转结果判断支付成功。最终状态应以通过签名验证的 Webhook 为准,也可以由服务端主动查询支付网关确认。

集成前需要准备什么

先根据业务所在国家或地区、结算币种和需要支持的支付方式选择支付网关。常见服务商有 Stripe、PayPal,以及主要面向特定市场的微信支付和支付宝等。

创建商户账号后,一般可以获得:

  • 测试环境和生产环境
  • 服务端密钥,例如 SECRET_KEY
  • 前端可以公开使用的密钥,例如 PUBLISHABLE_KEY
  • Webhook 签名密钥
  • 官方 Node.js SDK 或 REST API 文档

各服务商的开户流程、实名审核、费率、退款规则和 API 参数都有差异,具体要求应查看对应的官方文档:

下面用 Express 和 Stripe 托管收银台说明整个流程。示例代码只展示通用结构,实际接入前仍要核对当前版本的官方 SDK 文档。

安装依赖

npm install express stripe dotenv

在 .env 中保存测试环境密钥:

PORT=3000
APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

不要将 .env 提交到 Git,也不要在浏览器代码中使用 STRIPE_SECRET_KEY。

创建本地订单

用户点击“支付”后,服务端应先在自己的数据库中创建订单。例如:

id: order_20260901_001
userId: user_123
amount: 9900
currency: cny
status: pending

金额最好用最小货币单位保存。在人民币场景中,9900 表示 99.00 元。实际支付金额不要使用 JavaScript 浮点数计算,以免出现精度问题。

商品价格必须由服务端根据商品或订单数据计算,不能直接采用前端传来的金额。前端可以提交商品 ID、数量或订单 ID,但服务端必须重新确认最终应付金额。

创建支付会话

下面的接口接收本地订单 ID,并创建 Stripe Checkout Session:

import 'dotenv/config';
import express from 'express';
import Stripe from 'stripe';

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Webhook 路由必须放在 express.json() 之前。
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  handleStripeWebhook
);

app.use(express.json());

app.post('/api/payments/checkout', async (req, res) => {
  try {
    const { orderId } = req.body;

    // 实际项目中应从数据库读取订单,并验证订单属于当前登录用户。
    const order = {
      id: orderId,
      amount: 9900,
      currency: 'cny',
      productName: '示例商品',
      status: 'pending'
    };

    if (!order.id || order.status !== 'pending') {
      return res.status(400).json({
        error: '订单不存在或当前状态不允许支付'
      });
    }

    const session = await stripe.checkout.sessions.create(
      {
        mode: 'payment',
        line_items: [
          {
            price_data: {
              currency: order.currency,
              product_data: {
                name: order.productName
              },
              unit_amount: order.amount
            },
            quantity: 1
          }
        ],
        metadata: {
          orderId: order.id
        },
        success_url:
          `${process.env.APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
        cancel_url:
          `${process.env.APP_URL}/payment/cancel?order_id=${encodeURIComponent(order.id)}`
      },
      {
        idempotencyKey: `checkout:${order.id}`
      }
    );

    // 应将 session.id 保存到对应的本地订单中。
    res.json({
      checkoutUrl: session.url
    });
  } catch (error) {
    console.error('Failed to create checkout session:', error);
    res.status(500).json({
      error: '无法创建支付会话'
    });
  }
});

浏览器收到 checkoutUrl 后,跳转到该地址即可:

async function startPayment(orderId) {
  const response = await fetch('/api/payments/checkout', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ orderId })
  });

  const result = await response.json();

  if (!response.ok) {
    throw new Error(result.error || '创建支付失败');
  }

  window.location.assign(result.checkoutUrl);
}

生产环境中的支付创建接口还需要处理身份认证、订单归属验证、库存检查和访问频率限制。

接收并验证 Webhook

付款完成后,支付网关会向预先配置的 Webhook 地址发送事件。服务端必须先验证签名,再处理事件:

async function handleStripeWebhook(req, res) {
  const signature = req.headers['stripe-signature'];

  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (error) {
    console.error('Invalid webhook signature:', error.message);
    return res.status(400).send('Invalid signature');
  }

  try {
    if (event.type === 'checkout.session.completed') {
      const session = event.data.object;
      const orderId = session.metadata?.orderId;

      if (!orderId) {
        throw new Error('Webhook 中缺少 orderId');
      }

      /*
       * 在数据库事务中完成以下操作:
       *
       * 1. 查询本地订单并锁定记录;
       * 2. 检查订单是否已经支付;
       * 3. 核对 session.amount_total、session.currency 等信息;
       * 4. 将订单更新为 paid;
       * 5. 保存 event.id 和支付网关交易号。
       *
       * event.id 应建立唯一约束,防止重复处理。
       */
    }

    res.json({ received: true });
  } catch (error) {
    console.error('Failed to process webhook:', error);
    res.status(500).send('Webhook processing failed');
  }
}

app.listen(process.env.PORT || 3000, () => {
  console.log(`Server is running on port ${process.env.PORT || 3000}`);
});

签名验证需要使用原始请求体,因此 Webhook 路由使用:

express.raw({ type: 'application/json' })

如果请求先经过 express.json(),请求体可能已经被转换成对象,导致签名验证失败。这是 Express 支付集成中常见的问题。

本地开发时,支付服务商通常会提供 CLI 或测试工具,把 Webhook 转发到本机。相关命令可能随服务商和工具版本变化,请以当前官方文档为准。

正确处理支付状态

本地订单至少可以包含以下状态:

pending
processing
paid
failed
cancelled
refunded
partially_refunded

订单状态不能只有“成功”和“失败”。部分支付方式需要异步确认,也可能出现授权成功但尚未完成扣款的情况。

支付流程还必须保证幂等性:

  • 用户对同一个订单重复点击支付时,不应产生多笔有效扣款。
  • 同一个 Webhook 事件重复到达时,不应重复发货或重复增加余额。
  • 如果数据库更新失败,支付网关可能再次发送通知。
  • 服务端应保存网关事件 ID,并为其设置唯一约束。
  • 发货、充值等操作应与订单状态变更一起放入可靠的事务或任务流程。

Webhook 返回 2xx,表示事件已经成功接收并处理。如果暂时无法完成处理,应返回非 2xx 状态,让支付网关按自身规则重试。

成功页面不能作为付款凭证

success_url 只是用户完成收银台操作后,浏览器跳转到的地址,不能用来证明付款成功。原因包括:

  • 用户可能关闭页面,浏览器没有完成跳转。
  • 跳转地址可能被手动访问。
  • 部分支付方式需要等待异步确认。
  • 浏览器虽然跳转成功,但数据库可能尚未完成更新。

成功页面应使用 session_id 或本地订单 ID 调用自己的服务端查询接口,再显示“正在确认”“支付成功”或“支付失败”。发货、开通会员、账户充值等操作必须由可信的服务端流程触发。

上线前检查

正式上线前,至少检查以下事项:

  • 全站已经使用 HTTPS。
  • 测试密钥已经换成生产密钥。
  • 测试环境和生产环境使用不同的 Webhook 密钥。
  • 密钥保存在环境变量或密钥管理服务中。
  • 日志不会记录银行卡信息、密钥或完整的个人敏感信息。
  • 金额、币种和商户订单号都经过服务端核对。
  • 支付、退款和 Webhook 处理都具备幂等性。
  • 已经测试支付成功、失败、取消、超时、重复通知和退款等情况。
  • Webhook 接口可以从公网访问,所有事件都必须验证签名。
  • 已配置告警,可以及时发现验签失败和订单状态不同步。
  • 已了解所选支付网关的结算周期、退款规则、争议处理流程和合规要求。

除非业务确有需要,否则不要自行采集或保存银行卡号、有效期和安全码。使用支付网关的托管收银台或官方安全组件,可以减少需要自行承担的安全与合规工作。

备注:内容仅供参考。