支付回调开发中的常见陷阱与高可用方案实践

司徒尚文 移动 阅读 2,072
赞 19 收藏
二维码
手机扫码查看
反馈

支付回调页面白屏?折腾半天发现是 history 模式惹的祸

上周上线一个新功能,用户在 H5 页面里用微信支付,支付完跳回我们的结果页。结果测试同事一脸懵地跑来问:“为啥支付成功了,回来却是个白屏?”我一开始以为是后端没返回数据,查了半天日志,结果发现根本没走到前端代码——页面直接 404 了。

支付回调开发中的常见陷阱与高可用方案实践

这不对啊,本地开发一切正常,怎么一上生产就挂?我立马打开 Safari 远程调试,一看 URL:https://jztheme.com/pay/success?out_trade_no=xxx&status=1。路径看起来没问题,但页面就是白屏。刷新一下,404。再手动输入首页地址,又能正常进。问题明显出在“从微信支付跳回来”这个环节。

排查过程:从 Nginx 到 Vue Router 的弯路

第一反应是 Nginx 配置问题。我们用的是 Vue SPA,所有非静态资源请求都该 fallback 到 index.html。我检查了配置:

location / {
  try_files $uri $uri/ /index.html;
}

看起来没问题啊。而且首页、其他页面都能正常访问,唯独 /pay/success 这个路由不行。难道是这个路径被什么中间件拦截了?我又翻了一遍后端网关配置,确认没有针对这个路径的特殊处理。

接着怀疑是不是微信支付回调时带了奇怪的 header 或 referer 导致被拦截。抓包看了一下,其实微信支付完成后的跳转就是一个普通的 GET 请求,除了 query 参数啥也没有。那问题肯定在前端。

突然想到:我们项目用的是 Vue Router 的 history 模式!而微信内置浏览器(尤其是旧版)对 history 模式的支持其实不太稳定。更关键的是——如果用户是从外部 App(比如微信)直接打开一个带路径的 URL,而服务器又没正确配置 fallback,就会 404。但我们的 Nginx 明明配了啊?

等等……我本地测试都是通过点击按钮跳转到 /pay/success,走的是前端路由,所以没问题。但微信支付完成后,是直接在浏览器地址栏输入完整 URL 跳转的,这时候如果服务器没把 /pay/success 映射到 index.html,就会 404。可 Nginx 配置明明写了 try_files 啊?

我登录服务器,手动 curl 一下:

curl -I https://jztheme.com/pay/success

返回 404。这就奇怪了。再仔细看 Nginx 配置,发现我们用了 CDN,而 CDN 缓存了 404 状态!因为第一次访问 /pay/success 时,CDN 回源发现是 404(可能因为当时 index.html 没生成好),就缓存了这个错误状态。后来即使 Nginx 配了 fallback,CDN 也直接返回 404,不回源了。

清掉 CDN 缓存,再试,果然好了?但等下,测试同事说还是偶尔白屏。这次不是 404,而是页面加载了,但 Vue 根本没渲染,只显示一个空的 <div id="app"></div>。这说明 HTML 返回了,但 JS 没执行?或者 Vue 没初始化?

核心问题:Vue 初始化时拿不到路由参数

我打开控制台,发现控制台报错:Cannot read property 'status' of undefined。原来是在 mounted 钩子里直接读取 this.$route.query.status,但有时候 $route 是空的?不可能啊,Vue Router 不会这样。

后来试了下发现:当用户从微信支付跳回时,URL 是完整的,但 Vue 应用初始化时,router 可能还没完全解析完路径。尤其是在某些低端安卓机或微信老版本里,DOMContentLoaded 触发时,window.location 虽然有值,但 Vue Router 的 currentRoute 还是初始状态(比如 /)。

这其实是个竞态问题。更稳妥的做法是 不要依赖组件挂载时的路由状态,而是监听路由变化。但支付结果页一般只进一次,监听好像也不太对。

另一个思路:干脆不用 Vue Router 来处理这个页面,直接在 index.html 里加一段原生 JS,读取 URL 参数,然后重定向到带 hash 的路径。比如把 https://jztheme.com/pay/success?status=1 转成 https://jztheme.com/#/pay/success?status=1。这样不管什么环境,都能确保前端路由生效。

但这样用户体验不好,URL 会变。而且产品经理明确要求不能有 hash。

最终方案:服务端兜底 + 前端双重保险

折腾半天,我决定双管齐下:

  • 服务端:确保所有路径都返回 index.html,且 CDN 不缓存 404(设置 cache-control)
  • 前端:在 Vue 实例创建前,先用原生 JS 检查 URL,如果发现是支付回调路径,就等 100ms 再初始化 Vue,给浏览器一点时间稳定路由状态

服务端这块我们已经搞定了。重点说前端。我在 main.js 里加了这么一段:

// main.js
const isPayCallback = window.location.pathname.startsWith('/pay/');

if (isPayCallback) {
  // 给浏览器一点时间,确保 location 完全 ready
  setTimeout(() => {
    initApp();
  }, 100);
} else {
  initApp();
}

function initApp() {
  new Vue({
    router,
    render: h => h(App)
  }).$mount('#app');
}

同时,在支付结果页组件里,不再只在 mounted 里处理逻辑,而是用 watch 监听 $route

// PaySuccess.vue
export default {
  name: 'PaySuccess',
  data() {
    return {
      status: null,
      loading: true
    };
  },
  watch: {
    '$route'(to) {
      this.handlePayResult(to.query);
    }
  },
  mounted() {
    // 兜底:万一 watch 没触发(比如首次进入)
    this.handlePayResult(this.$route.query);
  },
  methods: {
    handlePayResult(query) {
      if (this.loading && query.status !== undefined) {
        this.status = query.status;
        this.loading = false;
        // 调用接口上报 or 显示结果
        this.reportResult(query.out_trade_no, query.status);
      }
    },
    async reportResult(tradeNo, status) {
      try {
        await fetch('https://jztheme.com/api/pay/verify', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ tradeNo, status })
        });
      } catch (e) {
        console.error('上报失败', e);
        // 即使上报失败,也显示结果,避免用户困惑
      }
    }
  }
};

这样,无论是通过前端路由跳转,还是直接从微信打开 URL,都能正确拿到参数。100ms 的延迟在实际体验中几乎无感,但能有效避开某些浏览器的 timing bug。

踩坑提醒:这三点一定注意

  • CDN 缓存 404 是隐形杀手:SPA 的所有路径都要返回 200,且 CDN 不能缓存 404。建议在 Nginx 里加 error_page 404 =200 /index.html;,并设置 Cache-Control: no-cache 对动态路径
  • 别在 mounted 里直接读 query:尤其在可能从外部直接打开的页面,用 watch + mounted 双重保障更安全
  • 微信浏览器很任性:不同版本行为不一致,iOS 和安卓也有差异。真机测试必须覆盖主流机型和微信版本

改完之后,测试通过。虽然那个 100ms 的 hack 有点丑,但亲测有效。而且目前没发现副作用——正常用户点击跳转时,根本不会进这个延迟分支。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有办法让 Vue Router 在初始化时更可靠地获取初始路由?或者有没有更优雅的 timing 解决方案?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论