PAYATHON 2026

如何在 MEAN stack 中实现 payment gateway 流程?

支付阿杰

明确结论

通常,Angular 应先调用自己的 Node.js 后端,再由 Node.js 与 payment gateway 通信:

Angular → Node.js → Payment Gateway

支付密钥、签名算法、订单金额校验和支付结果确认都必须留在服务端,不能交给 Angular。

网关一般有两种接入模式:

  • API 模式:Node.js 向网关发送 POST 请求,获取支付链接或支付会话,然后让 Angular 跳转。
  • 表单跳转模式:Node.js 生成订单参数和签名,Angular 创建隐藏表单并直接 POST 到网关页面。

具体字段、签名方式和回调参数,应以接入网关的文档为准。

推荐的整体流程

完整的支付流程通常是:

  1. Angular 向 Node.js 提交商品、订单编号等必要信息。
  2. Node.js 从数据库重新读取商品价格,并计算实际应付金额。
  3. Node.js 创建本地订单,将初始状态设为 pending。
  4. Node.js 使用保存在服务端的密钥创建网关支付会话。
  5. Angular 跳转到网关提供的支付页面。
  6. 用户完成支付后,网关通知 Node.js 的 webhook 地址。
  7. Node.js 验证通知签名,并向网关查询支付状态或核对通知内容。
  8. 确认支付成功后,将本地订单更新为 paid。
  9. Angular 查询订单状态并展示支付结果。

不能仅凭浏览器跳转到成功页面,就认定订单已经支付。这个跳转可以被伪造,也可能因网络中断而没有发生。

API 模式:由 Node.js 请求网关

下面假设网关提供“创建支付会话”接口,并返回 checkoutUrl。其中的接口地址和参数只是结构示例,需要替换为真实网关规定的内容。

Angular 发起支付

import { HttpClient } from '@angular/common/http';
import { Component } from '@angular/core';

@Component({
  selector: 'app-checkout',
  template: `<button (click)="pay()">立即支付</button>`
})
export class CheckoutComponent {
  constructor(private http: HttpClient) {}

  pay(): void {
    this.http.post<{ checkoutUrl: string }>('/api/payments', {
      productId: 'product-123',
      quantity: 1
    }).subscribe({
      next: ({ checkoutUrl }) => {
        window.location.assign(checkoutUrl);
      },
      error: (error) => {
        console.error('创建支付失败', error);
      }
    });
  }
}

前端可以提交 productId 和数量,但后端不能把前端传来的金额当作最终金额。

Node.js 创建支付会话

以下示例假设 Node.js 运行环境支持 fetch。如果不支持,可以改用网关官方 SDK 或合适的 HTTP 客户端。

import express from 'express';
import crypto from 'node:crypto';

const app = express();
app.use(express.json());

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

    if (!productId || !Number.isInteger(quantity) || quantity <= 0) {
      return res.status(400).json({ message: 'Invalid payment request' });
    }

    // 应从数据库查询,不要相信前端传入的价格
    const product = await findProductById(productId);

    if (!product) {
      return res.status(404).json({ message: 'Product not found' });
    }

    const amount = product.priceInMinorUnit * quantity;
    const orderId = crypto.randomUUID();

    await createOrder({
      orderId,
      productId,
      quantity,
      amount,
      currency: 'CNY',
      status: 'pending'
    });

    const gatewayResponse = await fetch(
      'https://gateway.example.com/api/payment-sessions',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${process.env.PAYMENT_GATEWAY_SECRET}`,
          'Idempotency-Key': orderId
        },
        body: JSON.stringify({
          merchantOrderId: orderId,
          amount,
          currency: 'CNY',
          successUrl: 'https://shop.example.com/payment/result',
          cancelUrl: 'https://shop.example.com/payment/cancelled',
          webhookUrl: 'https://api.example.com/webhooks/payment'
        })
      }
    );

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

    const paymentSession = await gatewayResponse.json();

    await saveGatewayReference(
      orderId,
      paymentSession.paymentId
    );

    res.json({
      orderId,
      checkoutUrl: paymentSession.checkoutUrl
    });
  } catch (error) {
    console.error(error);
    res.status(502).json({
      message: 'Unable to create payment session'
    });
  }
});

示例中的 findProductById、createOrder 和 saveGatewayReference 代表数据库操作,需要根据项目的数据层实现。

密钥应通过环境变量或专门的密钥管理服务注入:

PAYMENT_GATEWAY_SECRET=your-server-side-secret

不要将密钥写入 Angular 的 environment.ts,也不能通过接口返回给浏览器。Angular 打包后的代码对访问者可见。

表单跳转模式:让浏览器 POST 到网关

有些传统网关要求用户的浏览器通过 HTML form 向网关提交 POST 请求。这时不适合简单地让 Node.js 代替浏览器发送请求,因为网关返回的可能是需要展示给用户的支付页面。

可以采用下面的流程:

Angular → Node.js:请求支付参数
Node.js:创建订单并生成签名
Node.js → Angular:返回非敏感字段和签名
Angular → Gateway:提交隐藏表单

Node.js 返回已签名的字段:

app.post('/api/payments/form-data', async (req, res) => {
  const order = await createPendingOrder(req.body);

  const fields = {
    merchantId: process.env.MERCHANT_ID,
    orderId: order.id,
    amount: String(order.amount),
    currency: 'CNY',
    returnUrl: 'https://shop.example.com/payment/result',
    notifyUrl: 'https://api.example.com/webhooks/payment'
  };

  const signature = signPaymentFields(
    fields,
    process.env.PAYMENT_SIGNING_SECRET
  );

  res.json({
    action: 'https://gateway.example.com/pay',
    method: 'POST',
    fields: {
      ...fields,
      signature
    }
  });
});

Angular 收到数据后,创建并提交隐藏表单:

interface PaymentFormResponse {
  action: string;
  method: 'POST';
  fields: Record<string, string>;
}

submitPaymentForm(payment: PaymentFormResponse): void {
  const form = document.createElement('form');
  form.method = payment.method;
  form.action = payment.action;
  form.style.display = 'none';

  for (const [name, value] of Object.entries(payment.fields)) {
    const input = document.createElement('input');
    input.type = 'hidden';
    input.name = name;
    input.value = value;
    form.appendChild(input);
  }

  document.body.appendChild(form);
  form.submit();
}

签名只能由 Node.js 生成。Angular 可以拿到签名结果,但不能接触生成签名所用的密钥。

也不要让这段代码直接使用任意 action 地址。前端只应接受后端返回的数据,后端则应将目标地址固定为经过配置和验证的网关域名。

正确处理 webhook

支付网关通常会异步通知支付结果。Node.js 应提供一个公开的 HTTPS webhook 地址:

app.post('/webhooks/payment', async (req, res) => {
  try {
    const signature = req.get('x-gateway-signature');

    if (!signature || !verifyWebhookSignature(req.body, signature)) {
      return res.status(400).send('Invalid signature');
    }

    const event = req.body;

    if (event.type === 'payment.succeeded') {
      const order = await findOrderById(event.merchantOrderId);

      if (!order) {
        return res.status(404).send('Order not found');
      }

      if (
        event.amount !== order.amount ||
        event.currency !== order.currency
      ) {
        return res.status(400).send('Payment data mismatch');
      }

      // 应设计为幂等操作,重复通知不能重复发货
      await markOrderAsPaidOnce(
        order.id,
        event.paymentId
      );
    }

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

这只是通用结构。许多网关要求使用未经 JSON 解析的原始请求体计算 webhook 签名。如果接入的网关有这项要求,就需要为 webhook 路由保留 raw body,不能直接用普通的 express.json() 解析结果验签。

例如:

app.post(
  '/webhooks/payment',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody = req.body;
    const signature = req.get('x-gateway-signature');

    if (!verifyRawWebhook(rawBody, signature)) {
      return res.status(400).send('Invalid signature');
    }

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

    await processPaymentEvent(event);
    res.sendStatus(200);
  }
);

路由和中间件的注册顺序会影响原始请求体的获取,因此要按照所用网关 SDK 的要求配置。

需要特别注意的安全问题

不信任前端金额

用户可以修改 Angular 发出的任何请求。Node.js 必须根据 productId、优惠规则和数据库数据重新计算金额。

不在前端保存密钥

API secret、签名私钥和 merchant secret 只能保存在服务端。前端环境变量并不是真正的秘密,因为它们会被编译进浏览器代码。

验证支付结果

处理 webhook 时至少要核对:

  • 通知签名;
  • 本地订单编号;
  • 网关支付编号;
  • 金额和币种;
  • 商户身份;
  • 当前订单状态。

如果网关支持,还可以通过服务端查询接口再次确认支付状态。

保证幂等性

用户可能重复点击支付按钮,网关也可能多次发送同一条通知。创建支付请求时可以使用稳定的幂等键。更新订单时,应通过数据库事务或唯一约束,保证同一笔支付只处理一次。

区分返回页和支付通知

successUrl 主要用于改善用户体验,可靠的订单状态变更则应由 webhook 驱动。用户返回结果页时,Angular 应通过自己的后端查询订单状态:

this.http
  .get<{ status: string }>(`/api/orders/${orderId}`)
  .subscribe(({ status }) => {
    console.log('Current payment status:', status);
  });

在 MEAN stack 中,Angular 负责发起操作和页面跳转,Node.js 负责处理订单、金额、签名、密钥和支付结果确认。只有网关明确要求浏览器提交表单时,才让 Angular 向网关发送经过服务端签名的 POST 表单。

备注:内容仅供参考。