PAYATHON 2026

Cordova InAppBrowser 无法返回 3DS 支付数据

支付老李

结论

问题通常不在银行卡数据,而在 3DS 验证后的重定向链路。

非 3DS 支付可以直接进入商户返回页。3DS 支付会经过发卡行的跨域页面、iframe 或新窗口,验证完成后,再跳转到支付服务端配置的 success_url、failure_url 等地址。现有代码只监听 InAppBrowser 主窗口的 loadstop 事件,并模糊匹配 success、error、cancel,可能遇到以下问题:

  • 3DS 结果在另一个窗口中打开,原来的 inapp 实例收不到回调;
  • 回调页面没有加载完成,loadstop 不会触发;
  • 实际返回地址与代码预期不一致;
  • 回调页返回空内容、关闭连接,或使用应用无法处理的 URL,最终显示白屏;
  • executeScript() 因跨域、安全策略或页面卸载而不执行回调,导致 inapp.close() 始终无法执行。

正确的处理方式是:为支付请求配置明确且可以访问的 HTTPS 返回地址,在 loadstart 中精确拦截回调 URL,关闭 InAppBrowser,再由应用调用自己的服务端查询并核验订单状态。不要从 3DS 页面正文中提取支付结果,也不能仅凭 URL 中出现 success 就认定支付成功。

为什么普通支付正常,3DS 支付却失败

普通支付通常只有一条导航链路:

支付页 -> 商户成功页

3DS 支付可能经过多次跨域导航:

支付页
  -> 支付网关
  -> 发卡行 3DS 页面
  -> 支付网关处理页
  -> 商户 success_url / failure_url

部分 3DS 实现还会使用 window.open()、弹窗或 iframe。Cordova InAppBrowser 并不是完整的系统浏览器,它处理窗口、Cookie、跨域脚本和自定义 URL Scheme 的方式可能与系统浏览器不同。

当前实现还有几个明显的问题。

在 loadstop 中处理返回地址太晚

loadstop 只有在页面加载完成后才会触发。如果回调页面无法打开、TLS 配置异常、服务端提前关闭连接,或者返回的是应用自定义 Scheme,页面可能一直停在白屏,代码也无法进入成功分支。

支付返回地址更适合在 loadstart 中识别。导航一开始,应用就能立即拦截,不必等待返回页完成渲染。

executeScript() 没有必要,还可能阻断后续流程

成功分支会先执行:

inapp.executeScript({
  code: "document.documentElement.innerText"
}, function (html) {
  inapp.close();
  // ...
});

但后续流程并未使用 html。一旦脚本注入失败或回调没有执行,窗口就不会关闭,收据页面也无法打开。

支付结果应由服务端确认,不应依赖回调页面的正文。

URL 匹配范围太宽

下面的判断会匹配 URL 任意位置出现的单词:

url.match(/success/gi)

查询参数、路径甚至其他域名中只要包含 success,都可能被误判。error 和 cancel 也存在同样的问题。

代码应校验完整的域名、路径和必要参数。

3DS 验证可能打开第二个窗口

如果支付页通过 target="_blank" 或 window.open() 打开 3DS 页面,不同平台、不同版本的 InAppBrowser/WebView 可能有不同的处理方式。新页面可能脱离当前 inapp 对象,原实例便无法监听最终跳转。

应优先使用支付服务商支持的 同窗口重定向模式。如果无法控制支付页,需要检查 InAppBrowser 的 loadstart、loadstop、loaderror 和 message 日志,确认最终回调发生在哪个窗口。

推荐的回调设计

为支付流程准备固定的 HTTPS 返回地址,例如:

https://example.com/mobile-payment/return

创建支付请求时,把该地址配置为支付服务商要求的成功、失败或 3DS 返回地址。具体字段名取决于所使用的 Checkout.com 产品和 API 版本,可能是 success_url、failure_url、return_url,也可能是服务端支付会话指定的等价字段,应以当前集成接口为准。

返回页不需要输出支付数据,只需是一个能够正常访问的简单 HTTPS 页面。应用检测到该地址后,关闭 InAppBrowser,并使用本地保存的 order_id 向商户服务端查询支付状态。

完整流程如下:

InAppBrowser 检测到返回地址
  -> 关闭支付窗口
  -> App 将 order_id 发给商户服务端
  -> 商户服务端向支付平台查询支付状态
  -> 确认 paid/captured 后显示支付成功

支付平台的 webhook 也应由服务端处理。浏览器重定向只能说明用户回到了返回页,无法单独证明款项已经支付。

调整 InAppBrowser 代码

可以把回调识别移到 loadstart,并删除成功分支中的 executeScript():

var inapp = null;
var paymentFinished = false;

function payWebview(paymentUrl) {
  if (krms_config.debug) {
    window.open(paymentUrl, "_blank");
    return;
  }

  var callbackOrigin = "https://example.com";
  var callbackPath = "/mobile-payment/return";

  inapp = cordova.InAppBrowser.open(
    paymentUrl,
    "_blank",
    "location=no,footer=yes,footercolor=#000000," +
      "closebuttoncaption=X,closebuttoncolor=#ffffff," +
      "EnableViewPortScale=no,hidden=no"
  );

  function finishPayment(result, callbackUrl) {
    if (paymentFinished) {
      return;
    }

    paymentFinished = true;

    if (inapp) {
      inapp.close();
      inapp = null;
    }

    showLoader(false);

    if (result === "cancel") {
      return;
    }

    if (result === "error") {
      showAlert("支付未完成,请重试或检查订单状态。");
      return;
    }

    // 不把回调 URL 当成最终支付凭证。
    // 此请求应由服务端查询支付平台,或读取 webhook 已确认的订单状态。
    processAjax(
      "verifyPayment",
      "order_id=" +
        encodeURIComponent(getStorage("global_receipt_order_id"))
    );
  }

  inapp.addEventListener("loadstart", function (event) {
    var parsedUrl;

    try {
      parsedUrl = new URL(event.url);
    } catch (e) {
      return;
    }

    if (
      parsedUrl.origin !== callbackOrigin ||
      parsedUrl.pathname !== callbackPath
    ) {
      return;
    }

    var status = parsedUrl.searchParams.get("status");

    if (status === "cancel") {
      finishPayment("cancel", event.url);
    } else if (status === "error" || status === "failed") {
      finishPayment("error", event.url);
    } else {
      // 即使 URL 声称成功,仍然交给服务端核验。
      finishPayment("verify", event.url);
    }
  });

  inapp.addEventListener("loadstop", function () {
    if (!paymentFinished) {
      inapp.show();
      showLoader(false);
    }
  });

  inapp.addEventListener("loaderror", function (event) {
    showLoader(false);

    // 某些平台跳转到自定义 Scheme 时会同时触发 loaderror。
    // 如果使用 HTTPS 回调,一般应把这里视为真实的页面加载错误。
    console.error(
      "Payment page load error:",
      event.code,
      event.message,
      event.url
    );
  });

  inapp.addEventListener("exit", function () {
    inapp = null;
    showLoader(false);

    if (!paymentFinished) {
      // 用户主动关闭窗口时,可以查询一次订单状态,
      // 避免 3DS 已完成但最后一次重定向失败。
      processAjax(
        "verifyPayment",
        "order_id=" +
          encodeURIComponent(getStorage("global_receipt_order_id"))
      );
    }
  });
}

示例中的域名、路径和 status 参数必须替换为实际返回地址的格式。如果支付平台不会附带 status,命中返回路径后直接关闭窗口,并查询服务端订单状态即可。

服务端必须核验支付结果

应用当前保存了以下数据:

setStorage("global_receipt_order_id", data.details.order_id);
setStorage("global_receipt_amount_pay", data.details.total_amount);
setStorage("global_receipt_message", data.msg);

这些值可以用于恢复界面状态,但不能作为支付成功凭证。客户端存储可以被修改,回调 URL 也可能被伪造。

verifyPayment 对应的服务端逻辑至少需要:

  1. 根据登录用户和 order_id 查找本地订单;
  2. 使用服务端保存的支付 ID 查询支付平台,或读取已验证签名的 webhook 结果;
  3. 检查支付状态、订单号、金额和币种;
  4. 保证订单确认逻辑具备幂等性;
  5. 只有状态确实为已支付或已捕获时,才向应用返回成功。

客户端收到服务端确认后,再进入收据页面:

function handleVerifiedPayment(response) {
  if (!response || response.status !== "paid") {
    showAlert("支付结果仍在确认中,请稍后刷新订单状态。");
    return;
  }

  var options = {
    order_id: getStorage("global_receipt_order_id"),
    total_amount: getStorage("global_receipt_amount_pay"),
    message: getStorage("global_receipt_message")
  };

  onsenNavigator.pushPage("receipt.html", {
    animation: "slide",
    data: options
  });
}

返回地址也可以最终打开类似下面的链接:

myapp://payment/return?order_id=123

但这需要正确配置 iOS URL Scheme、Universal Links 或 Android App Links,并在 Cordova 应用恢复时处理链接。InAppBrowser 导航到自定义 Scheme 时,可能触发 loadstart,也可能触发 loaderror,不同平台的行为可能存在差异。

通常更稳妥的做法是先返回一个 HTTPS 页面,再由应用拦截该 HTTPS 地址。Universal Links/App Links 更适合需要从系统浏览器返回应用的场景。

无论采用哪种方式,都不要把支付卡信息、3DS 数据或敏感支付令牌放入可见的回调 URL。

排查时重点记录这些事件

临时加入以下日志,可以判断白屏出现在哪一步:

["beforeload", "loadstart", "loadstop", "loaderror", "exit", "message"]
  .forEach(function (eventName) {
    inapp.addEventListener(eventName, function (event) {
      console.log("[InAppBrowser]", eventName, event);
    });
  });

是否支持 beforeload 及其具体用法,取决于项目安装的 cordova-plugin-inappbrowser 版本,不能假定所有项目都可用。至少应记录:

  • 3DS 前后的完整导航地址;
  • 最后一次 loadstart 的 URL;
  • 是否出现 loaderror;
  • 返回地址是否在主 InAppBrowser 中打开;
  • 支付服务端实际配置的成功和失败 URL;
  • Android 与 iOS 的表现是否一致。

记录日志时,应隐藏支付令牌、会话 ID、银行卡信息和其他敏感查询参数。

还需要检查的配置

如果调整事件处理后仍然出现白屏,还需要确认:

  • 回调地址能够通过公网 HTTPS 正常访问;
  • TLS 证书链有效,证书没有过期,也不缺少中间证书;
  • 回调地址不存在无限重定向;
  • Cordova 的导航白名单和平台安全配置允许访问相关域名;
  • 3DS 页面没有依赖 WebView 禁止的第三方 Cookie;
  • 支付页面没有强制要求使用系统浏览器;
  • 支付服务商后台配置的返回地址与代码一致;
  • InAppBrowser 插件和平台 WebView 版本没有过旧。

核心修改是:拦截确定的返回地址,立即关闭 InAppBrowser,移除不必要的 executeScript(),再由服务端确认订单状态。 即使 3DS 最后一次跳转未能完成渲染,应用也不需要依赖白屏页面中的内容来完成订单。

备注:内容仅供参考。