如何在 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 到网关页面。
具体字段、签名方式和回调参数,应以接入网关的文档为准。
推荐的整体流程
完整的支付流程通常是:
- Angular 向 Node.js 提交商品、订单编号等必要信息。
- Node.js 从数据库重新读取商品价格,并计算实际应付金额。
- Node.js 创建本地订单,将初始状态设为
pending。 - Node.js 使用保存在服务端的密钥创建网关支付会话。
- Angular 跳转到网关提供的支付页面。
- 用户完成支付后,网关通知 Node.js 的 webhook 地址。
- Node.js 验证通知签名,并向网关查询支付状态或核对通知内容。
- 确认支付成功后,将本地订单更新为
paid。 - 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 表单。
备注:内容仅供参考。
