PAYATHON 2026

部分iOS用户在Safari浏览器中无法唤起支付宝支付

支付小周

结论

这类问题通常不是因为”未安装支付宝”,而是 Safari 没有完成从 H5 页面到支付宝的顶层跳转。可以优先排查以下原因:

  1. 支付动作经过 fetch、定时器或多次异步回调后才执行,失去了 Safari 要求的用户点击上下文。
  2. 支付页面位于 iframe、弹窗或内嵌 WebView 中,顶层跳转受到限制。
  3. 支付页通过 window.open() 打开,被 Safari 的弹窗拦截机制阻止。
  4. 前端存在重复绑定、透明遮罩或按钮禁用状态等问题,导致用户点击”继续付款”后并未真正触发跳转。
  5. 支付表单、重定向地址或支付宝 URL 被 CSP、内容拦截器、代理网络等阻断。
  6. Safari、iOS 或支付宝客户端本身状态异常。

较稳妥的做法是:用户点击支付后,让当前顶层页面直接进入服务端支付入口。服务端调用支付宝接口,再将支付宝返回的支付表单原样输出并自动提交。不要通过 AJAX 获取支付 HTML,也不要自行拼接支付宝 URL Scheme。

推荐的支付跳转方式

用户点击按钮时,同步提交到本站的支付入口:

<form id="alipayForm" method="post" action="/payment/alipay/start">
  <input type="hidden" name="orderNo" value="ORDER_NO">
  <button type="submit">支付宝支付</button>
</form>

也可以同步跳转,但不能让浏览器直接决定金额、商品名称等可信交易参数:

document.querySelector('#payButton').addEventListener('click', function () {
  window.location.assign(
    '/payment/alipay/start?orderNo=' +
    encodeURIComponent(this.dataset.orderNo)
  );
});

服务端收到订单号后,需要重新查询订单,校验订单状态和金额,再调用支付宝手机网站支付接口。支付宝 SDK 返回支付表单后,直接将其作为 HTML 响应输出。

下面是示意代码,具体类名和方法以项目实际使用的支付宝 SDK 为准:

@PostMapping(
    value = "/payment/alipay/start",
    produces = "text/html;charset=UTF-8"
)
@ResponseBody
public String startAlipay(@RequestParam String orderNo) throws Exception {
    Order order = orderService.getPayableOrder(orderNo);

    AlipayTradeWapPayRequest request = new AlipayTradeWapPayRequest();
    request.setNotifyUrl(notifyUrl);
    request.setReturnUrl(returnUrl);

    AlipayTradeWapPayModel model = new AlipayTradeWapPayModel();
    model.setOutTradeNo(order.getOrderNo());
    model.setTotalAmount(order.getAmount().toPlainString());
    model.setSubject(order.getSubject());
    model.setProductCode("QUICK_WAP_WAY");

    request.setBizModel(model);

    // pageExecute 的具体重载形式取决于当前 SDK 版本。
    return alipayClient.pageExecute(request).getBody();
}

支付结果必须以服务端异步通知为准,不能只根据 return_url 页面判断是否支付成功。

避免 AJAX 获取支付表单

下面这种写法在 Safari 中更容易出现问题:

payButton.addEventListener('click', async function () {
  const response = await fetch('/payment/alipay/start', {
    method: 'POST'
  });

  const html = await response.text();
  document.body.innerHTML = html;
});

主要原因有:

  • 执行到 await 之后,代码已经离开最初的点击调用栈。
  • 通过 innerHTML 插入的 <script>,通常不会像普通页面加载时那样执行。
  • 支付流程可能停留在 iframe 或当前 DOM 环境中。
  • 后续调用 window.open() 时,Safari 可能认为它不是由用户主动触发的操作。

如果业务必须先异步创建订单,可以把流程拆成两步。第一步异步创建订单,成功后显示一个明确的”前往支付宝”按钮。用户第二次点击时,再同步提交支付表单。

let createdOrderNo = '';

async function createOrder() {
  const response = await fetch('/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      productId: 'PRODUCT_ID'
    })
  });

  if (!response.ok) {
    throw new Error('创建订单失败');
  }

  const result = await response.json();
  createdOrderNo = result.orderNo;
  document.querySelector('#continuePay').hidden = false;
}

document.querySelector('#continuePay').addEventListener('click', function () {
  if (!createdOrderNo) {
    return;
  }

  const form = document.createElement('form');
  form.method = 'post';
  form.action = '/payment/alipay/start';

  const input = document.createElement('input');
  input.type = 'hidden';
  input.name = 'orderNo';
  input.value = createdOrderNo;

  form.appendChild(input);
  document.body.appendChild(form);
  form.submit();
});

不要依赖异步回调中的 window.open()

下面这种写法可能被 Safari 拦截:

payButton.addEventListener('click', async function () {
  const result = await createPayment();
  window.open(result.payUrl, '_blank');
});

如果必须打开新窗口,可以在点击事件中同步创建窗口,等异步请求完成后再修改窗口地址:

payButton.addEventListener('click', function () {
  const paymentWindow = window.open('about:blank', '_blank');

  if (!paymentWindow) {
    showMessage('支付页面被 Safari 拦截,请允许该网站打开弹窗后重试');
    return;
  }

  createPayment()
    .then(function (result) {
      paymentWindow.location.replace(result.payUrl);
    })
    .catch(function () {
      paymentWindow.close();
      showMessage('支付请求失败,请稍后重试');
    });
});

不过,对于手机网站支付,仍然建议通过当前顶层页面提交支付表单,不要打开新窗口。

检查是否处于 iframe

先记录页面当前的运行环境:

const isInIframe = window.self !== window.top;

console.log({
  isInIframe,
  visibilityState: document.visibilityState,
  userAgent: navigator.userAgent
});

如果支付页面位于 iframe 中,应由顶层页面进入支付流程。跨域 iframe 无法直接操作 window.top.location,需要通过 postMessage 通知宿主页:

// iframe 内
window.parent.postMessage(
  {
    type: 'START_ALIPAY',
    orderNo: 'ORDER_NO'
  },
  'https://www.example.com'
);
// 顶层宿主页
window.addEventListener('message', function (event) {
  if (event.origin !== 'https://pay.example.com') {
    return;
  }

  if (event.data?.type === 'START_ALIPAY') {
    window.location.assign(
      '/payment/alipay/start?orderNo=' +
      encodeURIComponent(event.data.orderNo)
    );
  }
});

如果必须使用带 sandbox 的 iframe,还需要检查权限配置。是否配置 allow-top-navigation-by-user-activation、allow-popups 等权限,要根据实际跳转方式判断。支付页面最好直接脱离 iframe。

检查”继续付款”按钮是否真的执行

可以给按钮增加最少量的日志,用来区分”点击事件没有触发”和”事件已经执行,但跳转失败”:

payButton.addEventListener('click', function (event) {
  console.log('pay click', {
    time: Date.now(),
    isTrusted: event.isTrusted,
    disabled: payButton.disabled
  });
});

重点检查以下情况:

  • 按钮是否被透明元素或加载遮罩覆盖。
  • 页面是否为同一按钮注册了多个事件,其中某个事件调用了 preventDefault()。
  • <button> 是否位于表单中,却没有设置正确的 type。
  • 防重复支付逻辑是否过早将按钮永久禁用。
  • 前端异常是否导致支付事件没有成功绑定。
  • 触摸事件中是否错误调用了 stopPropagation()。
  • “继续付款”按钮是否仍在调用已经失效的旧支付地址。
  • 跳转前是否还在等待埋点、广告或统计请求完成。

统计上报不应阻塞支付:

payButton.addEventListener('click', function () {
  const data = JSON.stringify({
    event: 'alipay_start',
    orderNo: this.dataset.orderNo
  });

  navigator.sendBeacon('/analytics', data);

  window.location.assign(
    '/payment/alipay/start?orderNo=' +
    encodeURIComponent(this.dataset.orderNo)
  );
});

检查响应和安全策略

使用 Safari 的远程调试功能连接出现问题的设备,观察点击支付后的 Console 和 Network,重点确认:

  1. 支付入口请求是否已经发出。
  2. HTTP 状态码是否为 200 或预期的重定向状态。
  3. 响应内容是否确实为支付宝 SDK 返回的表单。
  4. 页面是否出现 JavaScript 异常。
  5. 控制台是否出现 CSP 拦截信息。
  6. 是否发生重定向循环、证书错误或跨域错误。
  7. 页面是否仍停留在 iframe 中。
  8. 是否存在多个订单请求或重复提交。

如果站点启用了 CSP,需要检查 form-action 是否允许支付表单提交到实际响应中指定的支付宝域名。不要凭经验将其放宽为 *,应根据浏览器控制台报告和实际支付地址设置精确的白名单。

还要确认:

  • 整个链路都使用有效的 HTTPS。
  • 页面中没有混合内容。
  • 中间代理、CDN 或网关没有改写支付宝返回的 HTML。
  • 支付宝返回的表单没有经过转义、JSON 包装或 DOM 清洗。
  • return_url 和 notify_url 使用的是服务端配置的可信地址。

问题设备的排查顺序

建议在同一台异常设备上按以下顺序测试:

  1. 关闭 Safari 无痕浏览后重试。
  2. 暂时停用内容拦截器、广告拦截器或相关 Safari 扩展。
  3. 检查 Safari 的”阻止弹出式窗口”设置。如果业务使用 window.open(),可以临时关闭该设置进行验证,但最终仍需要调整代码。
  4. 清除该站点的网站数据,然后重新登录。
  5. 关闭”请求桌面网站”后重试。
  6. 确认 Safari、iOS 和支付宝客户端均能正常运行,并尝试重启支付宝或手机。
  7. 在 Wi-Fi 和移动网络之间切换,排除 DNS、代理、VPN 或企业网络限制。
  8. 直接在 Safari 顶层页面打开支付入口,排除第三方 App 内嵌浏览器和 iframe 的影响。

如果停用扩展或允许弹窗后恢复,只能说明支付流程触发了客户端限制。修改用户设置不能作为长期解决办法,业务侧仍应改为顶层页面同步导航的标准支付流程。

建议补充的诊断信息

为了定位只在少量用户设备上出现的问题,可以在取得用户同意并完成脱敏的前提下记录:

const paymentDiagnostic = {
  time: new Date().toISOString(),
  pageUrl: location.origin + location.pathname,
  referrer: document.referrer,
  isTopWindow: window.self === window.top,
  visibilityState: document.visibilityState,
  userAgent: navigator.userAgent
};

服务端还应关联记录:

  • 脱敏后的订单号。
  • 支付入口的请求时间。
  • 是否成功调用支付宝接口。
  • 支付接口的返回状态。
  • 是否生成支付表单。
  • 异步通知是否到达。
  • 同一订单是否被重复发起。

不要记录支付宝账号、完整手机号、支付密钥、签名私钥,也不要保存完整支付请求中的敏感数据。

注意事项

  • 不要通过检测用户是否安装支付宝来判断订单状态。浏览器无法可靠且统一地完成这类检测。
  • 不要自行拼接 alipays:// 等客户端 Scheme,应优先使用支付宝手机网站支付接口和 SDK 返回的内容。
  • 不要在前端放置签名、私钥,也不要从前端调用支付接口。
  • 不要完全采用前端传入的金额。服务端必须根据订单重新计算并校验。
  • 不要只根据前端跳转结果认定支付失败。即使用户没有成功返回商户页面,服务端异步通知仍可能已经确认支付成功。
  • “部分用户复现”通常与 Safari 设置、扩展、页面嵌套方式和具体交互链路有关。应根据异常设备的远程调试记录定位,不能只通过 User-Agent 推断原因。

备注:内容仅供参考。