PAYATHON 2026

如何在 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

正确的做法是在后端建立支付回调接口,由后端完成以下工作:

  1. 接收并解析 HPP 提交的 Form Data。
  2. 验证签名、金额、币种、订单号和支付状态。
  3. 以幂等方式更新 invoice。
  4. 返回 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 只需在用户跳转回来后,从后端读取已经验证并持久化的支付状态。

备注:内容仅供参考。