PAYATHON 2026

使用 web redirection method 集成 payment gateway

支付阿杰

明确结论

不要把 WebView 跳转到 success URL 视为支付成功。更可靠的处理方式是:

  1. 支付前,由服务端创建唯一订单,并将状态设为 PENDING。
  2. 主要通过支付网关发送且经过验签的 webhook 确认结果。
  3. App 只查询自身服务端保存的订单状态,不能根据页面跳转直接把订单改为成功。
  4. 所有通知和状态更新都要支持幂等处理。
  5. 如果支付网关没有查询 API,也不能保证 webhook 重试,就无法在技术上做到完全可靠。此时只能增加 UNKNOWN 状态,并配合延迟对账和人工核查。

重定向用于改善用户体验,webhook 用于确认支付结果,两者不能互相替代。

为什么 success URL 不能证明支付成功

支付页面的跳转可能受到多种情况影响:

  • 用户付款后可能立即断网或关闭 WebView。
  • App 可能被系统终止,因而观察不到重定向。
  • 用户可以手动访问或重放 success URL。
  • WebView 检测到 URL,只能说明发生了导航,不能证明资金已经到账。
  • App 上报“支付成功”的请求可能被伪造。
  • 随机 token 可以防止猜测和错误关联,但不能证明支付网关已经收款。

所以,即使 App 捕获到了重定向,也只能说明“支付流程已经返回”。接下来仍要向业务服务端查询最终状态。

推荐流程

1. 服务端创建支付订单

App 发起支付时,服务端先创建内部订单:

order_id: ord_20260831_xxx
amount: 100.00
currency: CNY
status: PENDING
provider_reference: ...
return_token_hash: ...

然后,服务端调用支付 API 或生成支付 URL,再把 URL 返回给 App。

金额、币种、商品和用户信息都必须以服务端记录为准,不能信任 App 后续传回的数据。

2. App 打开支付页面

App 可以使用系统浏览器、Custom Tabs、ASWebAuthenticationSession,也可以使用支付方明确支持的 WebView。

如果支付方没有明确说明支持嵌入式 WebView,通常应优先使用系统浏览器。部分银行验证、3-D Secure、Cookie 和外部 App 跳转在 WebView 中可能无法正常工作。

3. 重定向只触发状态查询

支付流程结束后,网关将用户重定向到类似地址:

https://merchant.example.com/payment/return?order=ord_20260831_xxx&token=...

该页面可以通过 Universal Link、App Link 或自定义 scheme 返回 App。此时,App 不应直接显示“支付成功”,而应显示“正在确认支付”,并查询业务服务端:

GET /api/payments/ord_20260831_xxx/status

4. 服务端通过 webhook 确认支付

支付网关向服务端发送 webhook 后,服务端应执行以下操作:

  1. 验证支付方签名或消息认证信息。
  2. 校验商户号、订单号、金额和币种。
  3. 检查该通知事件是否已经处理。
  4. 在数据库事务中更新订单。
  5. 尽快返回成功响应,让支付方停止重试。
  6. 将耗时的发货、积分或通知操作放入异步任务。

可以采用以下状态模型:

CREATED -> PENDING -> PAID
                   -> FAILED
                   -> EXPIRED
                   -> UNKNOWN

状态通常只能单向推进。例如,订单一旦进入 PAID 状态,就不应因为迟到的失败通知而改回 FAILED。

服务端示例

下面是一个简化的 Node.js/Express 示例。签名算法、header 名称和 webhook 字段必须以支付方文档为准。

import express from "express";
import crypto from "node:crypto";

const app = express();

// webhook 验签通常需要原始请求体
app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const rawBody = req.body;
    const signature = req.get("X-Payment-Signature");

    if (!verifyWebhookSignature(rawBody, signature)) {
      return res.sendStatus(401);
    }

    let event;

    try {
      event = JSON.parse(rawBody.toString("utf8"));
    } catch {
      return res.sendStatus(400);
    }

    try {
      await database.transaction(async (tx) => {
        // event.id 应替换为支付方提供的唯一通知 ID
        const alreadyProcessed = await tx.webhookEvents.exists(event.id);

        if (alreadyProcessed) {
          return;
        }

        const order = await tx.orders.findForUpdate(event.order_id);

        if (!order) {
          throw new Error("Unknown order");
        }

        if (
          event.status !== "success" ||
          event.amount !== order.amount ||
          event.currency !== order.currency
        ) {
          throw new Error("Webhook data does not match the order");
        }

        await tx.orders.markPaid(order.id, {
          providerReference: event.payment_id,
          paidAt: event.paid_at
        });

        await tx.webhookEvents.insert({
          eventId: event.id,
          orderId: order.id
        });

        await tx.outbox.insert({
          type: "PAYMENT_CONFIRMED",
          orderId: order.id
        });
      });

      return res.sendStatus(200);
    } catch (error) {
      console.error("Payment webhook processing failed", error);

      // 返回非 2xx,以便支持重试的支付方再次投递
      return res.sendStatus(500);
    }
  }
);

function verifyWebhookSignature(rawBody, receivedSignature) {
  if (!receivedSignature) {
    return false;
  }

  const expectedSignature = crypto
    .createHmac("sha256", process.env.PAYMENT_WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");

  const expected = Buffer.from(expectedSignature, "utf8");
  const received = Buffer.from(receivedSignature, "utf8");

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}

其中的 X-Payment-Signature、HMAC-SHA256 和事件字段都只是示例。如果供应商使用证书、公钥签名、固定 IP、Basic Auth 或其他认证方式,必须严格按照其协议实现,不能自行假定算法。

React Native 端示例

App 捕获回跳后,只查询自身服务端,不直接确认付款:

async function waitForPaymentResult(orderId: string) {
  const deadline = Date.now() + 60_000;

  while (Date.now() < deadline) {
    const response = await fetch(
      `https://api.example.com/payments/${encodeURIComponent(orderId)}/status`,
      {
        headers: {
          Authorization: `Bearer ${accessToken}`
        }
      }
    );

    if (!response.ok) {
      throw new Error(`Status request failed: ${response.status}`);
    }

    const payment = await response.json();

    if (payment.status === "PAID") {
      return payment;
    }

    if (["FAILED", "EXPIRED"].includes(payment.status)) {
      throw new Error(`Payment ended with status: ${payment.status}`);
    }

    await new Promise(resolve => setTimeout(resolve, 2000));
  }

  return {
    status: "PENDING",
    message: "支付结果仍在确认中,可稍后在订单页面查看"
  };
}

轮询超时并不表示支付失败。超时后应继续保留 PENDING 或 UNKNOWN 状态,让用户稍后重新进入订单页面查看。

如果基础设施支持,也可以通过 WebSocket 或 Server-Sent Events 通知 App。这只能减少轮询,最终状态仍以服务端保存的支付结果为准。

网络中断时如何恢复

需要分别处理三类中断。

App 与服务端之间中断

影响不大。订单状态保存在服务端,App 恢复网络后重新查询即可。

用户浏览器重定向失败

这也不应影响支付确认。只要 webhook 能到达服务端,订单仍会更新为 PAID。用户再次打开订单页面时,就能看到支付结果。

支付网关向 webhook 投递失败

能否恢复取决于支付方是否支持自动重试,需要确认以下事项:

  • 非 2xx 响应是否会触发重试。
  • 重试持续多久,采用什么退避策略。
  • 每次重试是否携带相同的事件 ID。
  • 通知是否可能乱序或重复。
  • 商户能否在后台下载交易记录或对账文件。

如果支付方提供查询 API,服务端应定时查询长时间处于 PENDING 状态的订单。但题目中的支付方没有查询 API,因此无法主动拉取最终状态,只能依赖 webhook、对账文件或人工查询。

没有查询 API 时的现实限制

如果供应商只发送一次 webhook,并且投递失败后不再重试,商户就无法单方面实现完全可靠的支付确认。success URL 也不能弥补这一缺陷,因为客户端回跳同样可能丢失或被伪造。

此时至少应要求供应商提供以下一种能力:

  • 可重试且带签名的 webhook。
  • 可以按商户订单号查询交易状态的 API。
  • 可下载的日结对账文件。
  • 商户后台中的交易查询和导出功能。
  • 由支付方签名的重定向结果。

如果重定向参数经过支付方私钥签名,服务端可以在验证签名后确认结果。普通随机 token 或商户自行生成的 token 没有这种证明能力。

对于无法自动确认的订单,应保留 UNKNOWN 状态,不要自动标记为失败,也不要让用户立即重复付款。可以向用户显示:

支付结果暂未确认,请稍后刷新订单。若已扣款,请勿重复支付。

还需要注意的事项

  • 每次支付尝试都要生成唯一的 payment_attempt_id,不能只依赖业务订单号。
  • 创建支付请求时使用幂等键,防止网络重试造成多次扣款。
  • webhook 必须使用 HTTPS,并验证签名、时间戳和重放窗口。
  • 不要只依赖来源 IP。IP 可能变化,而且 IP 校验不能代替签名。
  • 金额应使用整数形式的最小货币单位,例如分,避免浮点数比较错误。
  • webhook 可能早于或晚于页面重定向到达,代码必须允许两者以任意顺序发生。
  • 发货、充值等业务动作,必须由服务端将订单从 PENDING 成功变更为 PAID 后触发。
  • webhook 重复投递时,不能重复发货或重复记账。
  • 日志应保存支付方交易号、事件 ID、订单号和处理结果,但不能记录银行卡信息、密钥或完整的敏感请求。
  • 对长时间未确认的订单设置告警和对账任务。

客户端重定向只能说明用户已经返回。只有服务端收到并验证支付方提供的可信通知后,才能确认付款成功。

备注:内容仅供参考。