PAYATHON 2026

如何在本地开发环境中测试支付 callbacks?

支付老李

结论

当本地服务无法直接接收公网 callback(也常称 webhook)时,比较可靠的测试方式是把以下三种方法结合起来:

  1. 使用支付服务商提供的 sandbox 和 webhook 测试工具;
  2. 通过安全隧道,临时将本地端口映射为公网 HTTPS 地址;
  3. 保存真实或模拟的 callback 请求,在本地重复回放。

上线前还应准备一个可公开访问的测试环境,用来验证签名、HTTPS、重试、并发和网络异常。浏览器跳转结果不能作为最终支付状态,订单状态应以服务端 callback 或主动查询结果为准。

为什么本地地址收不到 callback

支付服务商发送 callback 时,会由其服务器主动请求你的接口,例如:

POST https://example.com/webhooks/payment

localhost、127.0.0.1 和大多数局域网地址只能从本机或内网访问,支付服务商无法通过互联网连接这些地址。问题也不只是有没有绑定域名。即使使用本机 IP,只要缺少公网路由、端口映射或有效的 HTTPS 配置,通常仍然无法接收请求。

推荐的测试流程

1. 先把处理逻辑与 HTTP 接口拆开

不要把订单更新逻辑全部塞进路由。可以将 callback 处理分成两层:

  • HTTP 层:读取请求、验证签名并快速返回响应;
  • 业务层:解析事件、更新订单并记录处理结果。

即使暂时无法接收公网 callback,也可以通过单元测试直接验证业务逻辑。

下面是一个简化的 Node.js 示例:

import express from "express";

const app = express();

// 具体支付平台可能要求使用原始请求体计算签名。
// 此处仅演示结构,不能直接替代平台规定的验签方式。
app.use(express.json());

async function processPaymentEvent(event) {
  if (!event.id || !event.type) {
    throw new Error("Invalid payment event");
  }

  switch (event.type) {
    case "payment.succeeded":
      // 根据平台交易号查找订单,并以幂等方式更新状态。
      console.log("Payment succeeded:", event.data);
      break;

    case "payment.failed":
      console.log("Payment failed:", event.data);
      break;

    default:
      console.log("Ignored event type:", event.type);
  }
}

app.post("/webhooks/payment", async (req, res) => {
  try {
    // 正式接入时,应先按照支付平台文档验证签名和时间戳。
    await processPaymentEvent(req.body);

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

app.listen(3000, () => {
  console.log("Listening on http://localhost:3000");
});

真实项目使用的事件字段、签名算法和成功响应格式,都必须以对应支付服务商的文档为准。

2. 用本地请求模拟 callback

可以通过 curl、Postman 或自动化测试工具向本地接口发送请求:

curl -i \
  -X POST http://localhost:3000/webhooks/payment \
  -H "Content-Type: application/json" \
  -H "X-Payment-Signature: test-signature" \
  -d '{
    "id": "evt_test_001",
    "type": "payment.succeeded",
    "data": {
      "transaction_id": "txn_test_001",
      "order_id": "order_123",
      "amount": 9900,
      "currency": "CNY"
    }
  }'

这种方式可以测试:

  • 参数解析和数据校验;
  • 不同交易状态的处理;
  • 未知事件类型;
  • 重复通知;
  • 错误响应;
  • 数据库更新失败;
  • 乱序通知。

不过,自行构造的请求不能完全证明真实平台的签名验证和请求格式没有问题,因此还要结合 sandbox 或公开测试环境进行验证。

3. 使用安全隧道接收真实测试 callback

可以使用 ngrok、Cloudflare Tunnel 等工具,为本地端口提供一个临时的公网 HTTPS 地址。工具安装并完成必要配置后,可以运行类似下面的命令:

ngrok http 3000

或者:

cloudflared tunnel --url http://localhost:3000

工具通常会返回一个公开地址,例如:

https://temporary-example.example

然后在支付服务商的测试后台,将 callback URL 配置为:

https://temporary-example.example/webhooks/payment

支付服务商发往这个公网地址的请求,就会被转发到本机的 localhost:3000。

免费或临时隧道的地址可能会在重启后变化,具体行为取决于工具和账户方案。隧道只应开放必要的端口与路径,调试完成后要及时关闭。

4. 优先使用服务商提供的 sandbox

如果支付服务商提供 sandbox、测试商户、测试卡号、事件触发器或 webhook 重发功能,应优先使用。这些工具可以验证自行模拟请求难以覆盖的内容:

  • 平台实际发送的请求头和请求体;
  • 签名生成与验签;
  • 事件名称和字段格式;
  • 平台如何判断响应成功;
  • 超时后的重试机制;
  • 同一事件被多次发送时的行为。

有些服务商还提供 CLI,可以把测试事件转发到本地地址。不同平台的命令和配置方式不同,应以其当前官方文档为准。

5. 在公开测试环境完成最终验证

本地隧道适合开发调试,但不能完全替代 staging 环境。正式上线前,应在独立测试环境中使用真实 HTTPS 域名验证完整流程:

支付平台 sandbox
        ↓
公网 HTTPS callback 地址
        ↓
验签与事件持久化
        ↓
订单状态更新
        ↓
日志、告警与主动查询

测试环境应尽量接近生产环境,包括反向代理、证书、数据库、队列和网络超时设置。

必须覆盖的异常场景

重复 callback

如果支付服务商没有及时收到成功响应,通常会再次发送通知。因此,同一个事件可能到达多次。

可以使用平台事件 ID 或交易 ID 做幂等控制:

CREATE UNIQUE INDEX payment_events_event_id_uq
ON payment_events(event_id);

处理事件时先登记事件。如果发生唯一键冲突,说明该事件已经接收过,此时不应重复发货、记账或增加余额。

只在内存中记录事件 ID 并不可靠。应用重启后记录会丢失,多实例部署时也无法共享这些记录。

callback 乱序

受网络延迟影响,退款事件、支付成功事件或其他状态通知可能不会按预期顺序到达。状态更新必须遵循明确的状态机,不能直接覆盖现有状态:

PENDING → PAID → REFUNDED
PENDING → FAILED

例如,订单已经进入 REFUNDED 状态后,不应因为迟到的 payment.succeeded 事件重新变成 PAID。允许哪些状态转换,需要结合支付平台的事件语义来设计。

超时和重试

callback 接口应尽快完成验签、基本校验和事件持久化,然后返回支付平台要求的成功响应。耗时操作可以交给后台任务处理:

app.post("/webhooks/payment", async (req, res) => {
  const event = req.body;

  await verifyAndStoreEvent(event, req.headers);
  res.sendStatus(200);

  // 实际项目中通常交给持久化队列,而不是直接启动未受控任务。
});

事件尚未可靠保存时不能返回成功,否则进程一旦崩溃,通知可能丢失。接口如果长时间不响应,平台又可能认为处理失败并触发重试。

callback 丢失

callback 不应是唯一的状态来源。系统还要支持根据平台交易号主动查询支付状态,并定期检查长时间停留在 PENDING 状态的订单。

更稳妥的确认流程是:

  1. 接收 callback;
  2. 验证签名和事件字段;
  3. 必要时调用支付平台的查询接口进行确认;
  4. 以幂等方式更新本地订单;
  5. 定期执行对账或状态补偿。

是否要在每次收到 callback 后主动查询,应根据平台的安全要求、接口限流和业务风险决定。

安全注意事项

callback 地址是公网接口,不能仅凭请求中写着”支付成功”就更新订单。至少要做到:

  • 严格按照支付平台文档验证签名;
  • 验证时间戳,并在平台支持时防止重放攻击;
  • 使用 HTTPS;
  • 校验商户号、订单号、币种和金额;
  • 不信任客户端提交的支付状态;
  • 保留事件 ID、接收时间和处理结果;
  • 不在日志中记录密钥、完整银行卡数据或其他敏感信息;
  • 分开管理 sandbox 与生产环境的密钥、数据库和 callback 地址。

还要留意请求体的解析方式。有些平台要求使用原始请求字节验证签名。如果 Web 框架先解析 JSON,再将其序列化,空格、字段顺序或编码可能发生变化,导致验签失败。遇到这种情况,应按照平台文档保留并使用 raw body。

实际可执行的组合方案

开发阶段可以这样安排:

  • 在单元测试中直接调用事件处理函数;
  • 使用 curl 或 Postman 测试异常输入和重复事件;
  • 通过安全隧道接收 sandbox 发出的真实 callback;
  • 保存脱敏后的测试请求,用于复现问题和自动化回归;
  • 上线前在 staging 环境测试 HTTPS、签名、重试和并发;
  • 在生产环境加入幂等控制、日志、告警、主动查询和对账机制。

这套组合可以减少开发过程对支付平台真实通知的依赖,同时验证完整的公网 callback 链路。

备注:内容仅供参考。