如何在 Angular application 中接收 HTTP POST callback
结论
Angular 不能直接接收 HPP 发来的 HTTP POST callback。
Angular Router 中的 /callback 只是浏览器端路由,用于 SPA 页面导航,并不是监听 HTTP 请求的服务器接口。直接访问 /callback 时,浏览器发送的是 GET 请求,因此 Angular 页面可以正常打开。HPP 回调发送的则是 POST /callback。请求会先到达 Web 服务器,而服务器没有对应的 POST handler,所以返回:
Cannot POST /callback
正确的做法是在后端建立支付回调接口,由后端完成以下工作:
- 接收并解析 HPP 提交的 Form Data。
- 验证签名、金额、币种、订单号和支付状态。
- 以幂等方式更新 invoice。
- 返回
303重定向,让浏览器跳转到 Angular 的/financial页面。
Angular 页面只负责查询和展示支付结果,不应直接处理或信任支付回调中的数据。
为什么 queryParams 读不到 Form Data
下面的代码读取的是 URL 查询参数:
this.route.queryParams.subscribe(params => {
console.log(params);
});
例如,它可以读取:
/callback?invoice=INV-1001&status=paid
但 HPP 提交的 Form Data 位于 HTTP 请求体中:
POST /callback HTTP/1.1
Content-Type: application/x-www-form-urlencoded
invoice=INV-1001&status=paid
只有服务器返回 Angular 应用后,Angular Router 才会运行。它无法监听服务器端收到的 POST 请求,也不能通过 ActivatedRoute.queryParams 读取原始 POST body。
推荐的处理流程
支付通知地址和 Angular 页面地址应分开设置:
HPP
└─ POST /api/payments/callback
├─ 验证 PSP 签名
├─ 查询本地 invoice
├─ 更新支付状态
└─ 303 Redirect /financial?payment=opaque-reference
Angular /financial
└─ 调用后端查询最新 invoice 状态
如果 PSP 支持分别配置两个地址,建议使用:
notifyUrl或webhookUrl:由 PSP 服务器向后端发送支付通知。returnUrl:付款结束后,将客户的浏览器带回 Angular 页面。
这两类请求的用途不同。服务器通知用于可靠地确认付款,浏览器返回只负责页面跳转。客户可能在付款后关闭页面,浏览器返回也可能被伪造,因此不能仅凭用户访问了 /financial 就把 invoice 标记为已付款。
后端实现示例
以下示例使用 Node.js 和 Express。具体字段名、签名算法及 PSP 要求的响应内容,应以对应 PSP 的接口文档为准。
import express from 'express';
const app = express();
// HPP 使用 application/x-www-form-urlencoded 提交 Form Data 时需要此中间件
app.use(express.urlencoded({ extended: false }));
app.post('/api/payments/callback', async (req, res) => {
try {
const {
invoiceNumber,
amount,
currency,
transactionId,
paymentStatus,
signature
} = req.body;
// 必须按照 PSP 文档规定的算法验证签名。
// 如果 PSP 要求使用原始请求体验签,需要保留 raw body,
// 不能直接使用重新序列化后的 req.body。
const signatureIsValid = verifyPspSignature(req.body, signature);
if (!signatureIsValid) {
return res.status(400).send('Invalid signature');
}
// 不要直接相信 callback 中的金额和 invoice 信息。
const invoice = await findInvoiceByNumber(invoiceNumber);
if (!invoice) {
return res.status(404).send('Invoice not found');
}
if (
invoice.amount !== amount ||
invoice.currency !== currency
) {
return res.status(400).send('Payment details do not match');
}
// callback 可能被重复发送,更新操作必须具有幂等性。
await recordPaymentOnce({
invoiceId: invoice.id,
transactionId,
paymentStatus,
callbackPayload: req.body
});
// HPP 的 POST 完成后,让浏览器以 GET 请求打开 Angular 页面。
return res.redirect(
303,
`/financial?invoice=${encodeURIComponent(invoiceNumber)}`
);
} catch (error) {
console.error('Payment callback failed', error);
return res.status(500).send('Callback processing failed');
}
});
示例中的这些函数需要根据实际数据库和 PSP 协议实现:
verifyPspSignature(...)
findInvoiceByNumber(...)
recordPaymentOnce(...)
303 See Other 适合这个场景。它明确要求浏览器在重定向后使用 GET 请求访问 /financial,避免浏览器继续向 Angular 路由提交原来的 POST body。
Angular 页面如何读取最终状态
重定向到 /financial 后,Angular 可以读取非敏感的查询参数,再向自己的后端查询可信的支付状态:
ngOnInit(): void {
this.route.queryParamMap.subscribe(params => {
const invoiceNumber = params.get('invoice');
if (!invoiceNumber) {
return;
}
this.invoiceService.getInvoice(invoiceNumber).subscribe(invoice => {
this.invoice = invoice;
});
});
}
服务调用示例:
getInvoice(invoiceNumber: string) {
return this.http.get<Invoice>(
`/api/invoices/${encodeURIComponent(invoiceNumber)}`
);
}
更稳妥的做法是避免在 URL 中暴露可枚举的 invoice number。可以改用后端生成的随机、短期有效引用值,或者根据当前登录用户从后端获取最近一次支付结果。
如果前端和后端部署在同一个域名
生产环境可以通过反向代理按路径分流:
/api/* -> 后端服务
其他路径 -> Angular index.html
例如:
POST /api/payments/callback -> Express、ASP.NET、Spring 等后端
GET /financial -> Angular index.html
Angular 开发服务器可以配置 proxy,但 proxy 只是把请求转发给真正的后端,并不能让 Angular component 获得处理 POST body 的能力。
服务器还应只对 Angular 页面导航执行 SPA fallback,不能把所有请求方法都无条件重写到 index.html。支付 callback 的 POST 必须优先匹配后端接口。
如果 PSP 只能回调 /callback
如果 PSP 的 callback URL 已经固定为 /callback,仍应由服务器处理该路径:
app.post('/callback', async (req, res) => {
// 验证和保存支付结果
// ...
res.redirect(303, '/financial');
});
Angular 中可以继续保留 /callback 的 GET 路由,但它和服务器端的 POST /callback 是两种不同的请求:
GET /callback -> 返回 Angular 应用
POST /callback -> 由后端处理支付数据
同一路径能否正确区分这两种请求,取决于 Web 服务器、反向代理和后端路由的配置。
需要特别注意的安全问题
- 必须验证 PSP 提供的签名、MAC 或其他认证信息,不能仅根据
paymentStatus=success更新 invoice。 - 应使用本地保存的订单金额、币种和 invoice 信息核对回调数据。
- 应使用 PSP 的唯一
transactionId建立唯一约束,防止重复 callback 导致重复入账。 - 只接受 PSP 文档规定的 HTTP method 和
Content-Type。 - callback 必须使用 HTTPS。
- 不要把完整支付结果、签名或敏感客户信息放入重定向 URL。
- 不要在前端保存 PSP 密钥,也不要在前端实现需要密钥的验签逻辑。
- 如果后端启用了 CSRF 防护,可以针对经过签名验证的 PSP callback 设置精确例外,但不能关闭整个应用的 CSRF 防护。
- 有些 PSP 使用
application/json,另一些使用application/x-www-form-urlencoded或multipart/form-data。后端的解析方式必须与实际Content-Type一致。 - 如果 PSP 的验签规则要求使用原始字节流,应在 body parser 修改请求体之前保存 raw body。
- 后端向 PSP 返回成功响应后,PSP 是否继续跟随重定向,取决于其回调模式。如果是纯服务器 webhook,通常应直接返回 PSP 要求的
2xx响应,再由独立的returnUrl完成浏览器跳转。
因此,callback component 不需要监听 POST。支付确认应由后端完成,Angular 只需在用户跳转回来后,从后端读取已经验证并持久化的支付状态。
备注:内容仅供参考。
