PAYATHON 2026

支付宝支付首次正常,二次拉起在 Safari 报网址无效

支付小周

结论

第二次无法拉起支付,通常不是因为”支付宝只能调用一次”,而是页面重复使用了第一次生成的跳转地址、表单或临时状态。也可能是用户返回页面后,页面仍被锁定在”正在支付”状态。

Safari 对格式错误、重复编码或已经失效的自定义协议地址更敏感,遇到这类地址时可能直接提示”网址无效”。Android 上没有反应,则常见于点击事件被拦截、页面状态没有恢复,或 WebView 没有处理外部协议。

用户每次点击支付时,都应先向服务端查询订单状态。如果订单尚未支付,由服务端重新生成支付请求,前端再通过用户的真实点击事件立即提交新表单或跳转到新地址。不要缓存或重复使用第一次生成的 alipays://、intent://、支付表单 HTML,也不要反复编解码支付 URL。

优先检查的问题

1. 是否重复使用了第一次的支付地址

如果页面初始化时只请求一次支付参数,后续点击始终使用同一个变量,第二次调用就可能异常:

let cachedPayUrl = '';

async function initPay() {
  cachedPayUrl = await requestPayUrl();
}

function pay() {
  window.location.href = cachedPayUrl;
}

支付地址中可能包含一次性参数、签名或编码后的回跳信息,不能默认长期有效。每次点击支付时,都应让服务端重新构造支付请求。

2. URL 是否被重复编码或解码

Safari 提示”网址无效”,经常是因为最终地址的格式已经损坏。例如:

// 错误:接口返回的地址已经编码,又被重复编码
location.href = encodeURIComponent(payUrl);

或者:

// 错误:整个支付 URL 被 decodeURIComponent,破坏了查询参数
location.href = decodeURIComponent(payUrl);

如果服务端返回的是完整 URL,前端通常可以直接使用:

window.location.assign(payUrl);

构造普通 HTTPS 查询参数时,只编码参数值:

const returnUrl = 'https://example.com/pay/return?source=h5';
const url =
  '/api/pay/create?return_url=' + encodeURIComponent(returnUrl);

不要对包含 ://、?、&、= 的完整 URL 再执行 encodeURIComponent()。

3. 返回页面后,按钮状态是否没有恢复

不少页面会用变量阻止重复提交:

let paying = false;

async function startPay() {
  if (paying) return;
  paying = true;

  const result = await createPayment();
  window.location.href = result.payUrl;
}

用户从支付宝返回时,浏览器可能通过前进后退缓存恢复原页面,JavaScript 上下文也可能继续保留。此时 paying 仍是 true,Android 上再次点击便会直接返回,看起来像是”没有反应”。

可以监听 pageshow,在页面恢复时重置状态:

let paying = false;
const payButton = document.querySelector('#pay-button');

function resetPayState() {
  paying = false;
  payButton.disabled = false;
  payButton.textContent = '立即支付';
}

window.addEventListener('pageshow', (event) => {
  // event.persisted 为 true 时,页面通常来自前进后退缓存
  resetPayState();
  refreshOrderStatus();
});

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    resetPayState();
    refreshOrderStatus();
  }
});

这里监听 pageshow 比只监听 load 更合适,因为从外部应用返回时,页面不一定会重新执行完整加载。

推荐的支付流程

订单状态、支付参数和签名由后端处理,前端只负责触发支付:

用户点击支付
    ↓
前端请求后端查询订单
    ↓
已支付 → 展示支付成功
未支付 → 后端重新生成支付宝支付请求
    ↓
前端立即提交新表单或跳转到新的 HTTPS 地址
    ↓
从支付宝返回后重新查询订单状态

不要只根据前端回跳参数判断支付结果。最终状态应以服务端收到的异步通知,或服务端主动查询到的支付宝交易状态为准。

前端实现示例

如果后端返回支付宝 WAP 支付表单 HTML,可以在用户每次点击时重新获取并提交:

<button id="pay-button" type="button">立即支付</button>
<div id="pay-form-container"></div>

<script>
  let paying = false;

  const payButton = document.querySelector('#pay-button');
  const container = document.querySelector('#pay-form-container');

  payButton.addEventListener('click', startPay);

  async function startPay() {
    if (paying) return;

    paying = true;
    payButton.disabled = true;
    payButton.textContent = '正在拉起支付…';

    try {
      const response = await fetch('/api/alipay/wap-pay', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          orderId: 'YOUR_ORDER_ID'
        })
      });

      if (!response.ok) {
        throw new Error(`创建支付请求失败:HTTP ${response.status}`);
      }

      const result = await response.json();

      if (result.paid) {
        window.location.replace('/pay/success');
        return;
      }

      if (!result.formHtml) {
        throw new Error('服务端未返回支付表单');
      }

      // 每次清除旧表单,避免提交上一次的参数。
      container.replaceChildren();
      container.innerHTML = result.formHtml;

      const form = container.querySelector('form');
      if (!form) {
        throw new Error('支付表单格式不正确');
      }

      form.submit();
    } catch (error) {
      console.error(error);
      paying = false;
      payButton.disabled = false;
      payButton.textContent = '重新支付';
    }
  }

  window.addEventListener('pageshow', () => {
    paying = false;
    payButton.disabled = false;
    payButton.textContent = '立即支付';
    checkOrderStatus();
  });

  async function checkOrderStatus() {
    try {
      const response = await fetch(
        '/api/orders/YOUR_ORDER_ID/status',
        { cache: 'no-store' }
      );
      const result = await response.json();

      if (result.paid) {
        window.location.replace('/pay/success');
      }
    } catch (error) {
      console.error('查询订单状态失败', error);
    }
  }
</script>

如果后端返回可跳转的 HTTPS 支付地址:

async function startPay() {
  if (paying) return;

  paying = true;

  try {
    const response = await fetch('/api/alipay/pay-url', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ orderId })
    });

    const result = await response.json();

    if (!result.payUrl || !/^https:\/\//i.test(result.payUrl)) {
      throw new Error('支付地址无效');
    }

    window.location.assign(result.payUrl);
  } catch (error) {
    paying = false;
    console.error(error);
  }
}

除非接入方案明确要求,否则不要在业务页面中自行拼接 alipays:// 或 intent:// 地址。

服务端需要处理的逻辑

服务端创建支付请求前,应先检查业务订单:

async function createAlipayPayment(orderId) {
  const order = await orderRepository.findById(orderId);

  if (!order) {
    throw new Error('订单不存在');
  }

  if (order.status === 'PAID') {
    return { paid: true };
  }

  if (order.status === 'CLOSED') {
    throw new Error('订单已关闭');
  }

  // 使用服务端保存的订单金额和标题,不信任前端传入的数据。
  const formHtml = await alipayClient.pageExecute(
    'alipay.trade.wap.pay',
    {
      bizContent: {
        out_trade_no: order.tradeNo,
        total_amount: order.amount,
        subject: order.subject,
        product_code: 'QUICK_WAP_WAY'
      },
      returnUrl: 'https://example.com/pay/return',
      notifyUrl: 'https://example.com/api/alipay/notify'
    },
    'POST'
  );

  return {
    paid: false,
    formHtml
  };
}

不同语言和 SDK 版本的方法签名、参数结构可能不同,具体写法应以当前使用的支付宝 SDK 文档为准。服务端需要重新执行支付请求的生成流程,不能把第一次生成的结果长期缓存在前端。

同一个 out_trade_no 能否再次发起支付,以及哪些业务字段必须保持一致,应根据实际接口返回结果处理。不要在未确认订单状态时,为每次点击随意创建新的业务订单号,否则可能产生重复订单,也会让支付状态难以对应。

Safari 专项排查

在 Safari 中复现问题时,可以记录第二次点击前的相关状态:

console.log({
  paying,
  visibilityState: document.visibilityState,
  payUrl,
  payUrlLength: payUrl?.length
});

检查以下内容:

  • 地址是否为空、undefined 或 [object Object]。
  • 地址前后是否带有空格、换行或额外引号。
  • https:// 是否被错误编码成 https%3A%2F%2F。
  • 自定义协议中的 % 是否构成了不完整的转义序列。
  • 第二次使用的地址是否与第一次完全相同。
  • 跳转是否由用户直接点击触发,而不是发生在延迟回调、定时器或页面自动跳转中。
  • 返回页面时是否命中了前进后退缓存。

如果必须使用外部 App 协议,调用最好直接发生在点击处理函数中。Safari 可能会限制异步操作完成后的自定义协议跳转:

// 可能被浏览器认为不是直接由用户操作触发
button.addEventListener('click', async () => {
  const schemeUrl = await fetchSchemeUrl();
  window.location.href = schemeUrl;
});

更稳妥的做法是先进入支付宝提供的 HTTPS 收银台,由收银台处理 App 拉起和降级流程。

Android 专项排查

Android 上点击没有反应时,先确认点击事件是否真的执行了:

button.addEventListener('click', () => {
  console.log('pay button clicked');
});

同时检查:

  • 按钮是否仍有 disabled 属性。
  • 页面上是否有透明遮罩层拦截点击。
  • paying、防抖或节流逻辑是否直接执行了 return。
  • Promise 是否一直没有结束。
  • 页面是否运行在 App 内置 WebView 中。
  • WebView 是否允许打开外部应用协议。

如果页面嵌在自有 Android WebView 中,只修改 H5 可能无法解决问题。宿主应用通常还要在 URL 拦截回调中识别外部协议,再通过系统 Intent 打开支付宝。具体实现取决于 WebView 容器和 Android 版本。测试时应区分普通 Chrome 和自有 WebView。

注意事项

  • 不要直接缓存支付表单、签名结果或自定义协议地址。
  • 用户返回支付页后,应重新查询订单状态并恢复按钮。
  • 防重复点击锁需要在支付失败、取消和返回页面等路径中解除。
  • 前端回跳不代表支付成功,最终结果以服务端通知或交易查询为准。
  • 金额、订单号和商品信息应由服务端读取,不能信任前端提交的值。
  • 分别测试 Safari、Chrome、微信内置浏览器和自有 WebView。它们对外部协议的限制并不完全一致。
  • 调试日志中不要完整输出签名、用户标识或敏感支付参数。

备注:内容仅供参考。