PAYATHON 2026

Angular/NodeJS 如何向第三方支付网关 POST 并获取响应

支付小周

结论

需要重新梳理支付流程。Option 4.2 不适合作为最终方案,因为后端 fetch() 和浏览器跳转是两次彼此独立的请求。前一次 POST 使用的表单、会话状态和响应上下文,不会自动“转交”给浏览器。

支付流程应拆成两条链路:

  1. 浏览器通过标准 HTML form POST 进入支付页面。也可以由后端调用网关的“创建支付会话”API,再把支付 URL 返回给浏览器。
  2. 支付网关通过 webhook/server callback 直接把最终结果通知后端。用户返回网站后,Angular 再向后端查询订单状态。

确认支付结果应以 webhook 为准。success 和 cancel 页面只负责界面跳转,不能单独作为入账凭证。

为什么现有方案拿不到响应

标准 form POST 本质上是一次页面导航。浏览器收到支付网关返回的 HTML 后,会直接显示支付页面,不会把响应交给 Angular 的 HttpClient。

Angular 和 jQuery 发起的 AJAX 请求受浏览器同源策略限制。只有支付网关明确允许你的域名,并返回正确的 CORS 响应头,前端 JavaScript 才能读取响应。客户端无法靠设置 crossDomain 或请求头绕过 CORS。

Node.js 的 fetch() 不受浏览器 CORS 限制,但它发起的是服务器到服务器的请求,无法替用户建立完整的浏览器支付会话。网关可能依赖这些信息:

  • application/x-www-form-urlencoded 表单格式
  • 网关设置的 Cookie
  • 浏览器重定向过程
  • CSRF token 或一次性支付 token
  • JavaScript 在支付页面中初始化的状态

另外,fetch() 默认跟随重定向。newData.url 通常只是重定向后的最终地址,并不表示浏览器可以脱离原始 POST、Cookie 和 token,再单独访问这个地址。

window.location.replace()、location.href 和普通链接只能发起 GET,不能携带原来的 POST body。这就是 Option 4.3 最终打开空白页面的原因。

推荐的支付流程

方案一:使用网关的“创建支付会话”API

如果支付网关提供后端 API,优先使用这种方式:

  1. Angular 把商品或订单标识发送给后端。
  2. 后端重新读取商品价格,并在数据库中创建 pending 订单。
  3. 后端使用密钥调用支付网关的创建支付接口。
  4. 网关返回 checkoutUrl、session ID 或 payment ID。
  5. 后端保存这些标识,把 checkoutUrl 返回给 Angular。
  6. Angular 使用 window.location.assign(checkoutUrl) 跳转。
  7. 网关通过 webhook 通知后端付款结果。
  8. 用户返回网站后,Angular 查询后端的订单状态。
// Angular
this.http.post<{ checkoutUrl: string }>('/api/payments/start', {
  orderId: this.orderId
}).subscribe({
  next: ({ checkoutUrl }) => {
    window.location.assign(checkoutUrl);
  },
  error: () => {
    this.message = '暂时无法发起支付,请稍后重试';
  }
});

Node.js 应按照支付网关文档规定的格式调用接口。下面只是结构示例,具体 URL、字段和认证方式必须以网关文档为准:

app.post('/api/payments/start', async (req, res, next) => {
  try {
    const order = await loadOrder(req.body.orderId);

    if (!order || order.userId !== req.user.id) {
      return res.status(404).json({ message: 'Order not found' });
    }

    const gatewayResponse = await fetch(
      'https://gateway.example.com/api/payment-sessions',
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.PAYMENT_API_KEY}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          reference: order.id,
          amount: order.amount,
          currency: order.currency,
          returnUrl: `https://example.com/payment/return?orderId=${encodeURIComponent(order.id)}`,
          cancelUrl: `https://example.com/payment/cancel?orderId=${encodeURIComponent(order.id)}`,
          webhookUrl: 'https://api.example.com/webhooks/payment'
        })
      }
    );

    if (!gatewayResponse.ok) {
      throw new Error(`Gateway returned ${gatewayResponse.status}`);
    }

    const payment = await gatewayResponse.json();

    await saveGatewayPaymentId(order.id, payment.id);

    res.json({
      checkoutUrl: payment.checkoutUrl
    });
  } catch (error) {
    next(error);
  }
});

只有网关明确提供这类 API,才能假设响应中包含 id 或 checkoutUrl。

方案二:网关只接受 HTML form POST

如果网关文档要求浏览器向 checkout 地址提交表单,就应继续使用标准 form POST。跨域页面导航不受 AJAX CORS 限制。

后端可以先创建订单并计算签名,再返回一个自动提交的 HTML 页面:

app.post('/api/payments/start', async (req, res, next) => {
  try {
    const order = await createPendingOrder(req.user.id, req.body.productId);
    const signature = createGatewaySignature(order);

    res.type('html').send(`
      <!doctype html>
      <html lang="zh-CN">
        <head>
          <meta charset="utf-8">
          <title>正在跳转到支付页面</title>
        </head>
        <body>
          <form id="payment-form"
                method="post"
                action="https://test-wallet.example.com/checkout/">
            <input type="hidden" name="order_id" value="${escapeHtml(order.id)}">
            <input type="hidden" name="amount" value="${escapeHtml(order.amount)}">
            <input type="hidden" name="currency" value="${escapeHtml(order.currency)}">
            <input type="hidden" name="signature" value="${escapeHtml(signature)}">
            <input type="hidden" name="return_url"
                   value="https://example.com/payment/return">
            <input type="hidden" name="cancel_url"
                   value="https://example.com/payment/cancel">
          </form>
          <script>
            document.getElementById('payment-form').submit();
          </script>
        </body>
      </html>
    `);
  } catch (error) {
    next(error);
  }
});

这个接口需要通过普通 form 或页面导航访问。不能先用 Angular HttpClient 请求,再期待浏览器自动显示接口返回的页面。例如:

<form method="post" action="/api/payments/start">
  <input type="hidden" name="productId" value="PRODUCT_ID">
  <button type="submit">Pay with card</button>
</form>

隐藏字段名称、签名规则和回调地址都要采用支付网关规定的格式。API 密钥和签名密钥不能放进 Angular。

如何可靠接收支付结果

需要在支付网关后台或创建支付请求时配置 webhook。付款完成后,网关会从自己的服务器向你的后端发送请求:

app.post(
  '/webhooks/payment',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    try {
      const signature = req.get('X-Payment-Signature');

      if (!verifyGatewaySignature(req.body, signature)) {
        return res.sendStatus(401);
      }

      const event = JSON.parse(req.body.toString('utf8'));

      if (event.type === 'payment.succeeded') {
        await markOrderPaidIdempotently(
          event.data.reference,
          event.data.paymentId
        );
      } else if (event.type === 'payment.failed') {
        await markOrderFailedIdempotently(event.data.reference);
      }

      res.sendStatus(200);
    } catch (error) {
      res.sendStatus(500);
    }
  }
);

X-Payment-Signature、事件结构和验签算法只是占位名称,必须换成网关实际使用的协议。部分网关发送 application/x-www-form-urlencoded 数据,此时也要调整解析方式。

很多验签算法需要原始请求体。因此,webhook 路由的 express.raw() 必须放在全局 express.json() 之前,或者单独为该路由保留 raw body。

返回页面应该做什么

用户回到 /payment/return 后,不能直接根据 URL 显示“付款成功”。返回地址中的参数可能被用户修改,而且用户可能在 webhook 到达前就已经返回。

Angular 应向后端查询订单状态:

this.http.get<{ status: string }>(
  `/api/orders/${encodeURIComponent(orderId)}/payment-status`
).subscribe(({ status }) => {
  if (status === 'paid') {
    this.message = '支付成功';
  } else if (status === 'failed') {
    this.message = '支付失败';
  } else {
    this.message = '支付结果确认中,请稍候';
  }
});

如果状态仍是 pending,可以短时间轮询,也可以让后端主动调用网关的支付查询 API。不能因为用户进入了 /success,就把订单标记为已支付。

关于 307 redirect

307 表示浏览器应在新地址继续使用原来的请求方法和请求体。它只适用于网关确实要求相同的 POST body,并且字段格式完全匹配的情况。

307 不能代替“创建支付会话”流程,也不会自动携带后端 fetch() 得到的 Cookie、token 或响应状态。尤其不要采用下面的流程:

  1. 后端先 POST 到网关。
  2. fetch() 跟随网关重定向。
  3. 取出最终 URL。
  4. 再让浏览器以 POST 或 GET 访问最终 URL。

这种做法会让两个不同的客户端分别参与同一次支付流程,可能导致 token 已消费、Cookie 丢失或请求字段缺失。Option 4.2 出现 “Your request cannot be processed”,很可能就与这些问题有关,并非只是少了一次 FormData。

其他注意事项

  • 金额、币种和商品价格必须由后端计算,不能信任 Angular 提交的金额。
  • 每笔订单应使用唯一 reference。webhook 更新操作要具备幂等性,避免重复通知造成重复发货。
  • 只有经过验签的 webhook 或网关查询 API 才能改变订单的最终支付状态。
  • 不要在 URL 查询字符串中放置密钥、银行卡信息或其他敏感数据。
  • 应对网关返回的跳转地址设置域名白名单,避免开放重定向漏洞。
  • 用户可能关闭页面、遇到网络中断,或者一直没有返回网站。支付确认逻辑不能依赖浏览器返回。
  • webhook 应使用公网可访问的 HTTPS 地址,并按网关要求及时返回成功状态码。
  • 不要混用 async 和 subscribe()。Observable 可以直接订阅,也可以通过 firstValueFrom() 转成 Promise。

标准 form POST 可以保留,但支付结果必须通过 webhook 或后端支付查询接口获取。如果网关提供正式的“创建支付会话并返回 checkout URL”API,应优先使用该流程,不要继续围绕 Option 4.2 转发 fetch() 得到的最终 URL。

备注:内容仅供参考。