← 返回博客
Abstract illustration of a secure mobile checkout and payment flow

PWA 支付接入:真正不一样的只有三件事

先给结论

PWA 收款和普通网站收款是同一件事。 没有 PWA 专用的支付 SDK,不需要另开商户号,也没有商店抽成——你现有的网关接入(Stripe、Adyen、PayPal、本地 PSP,或者托管收银页)原样可用。

真正变了的是结算流程所处的运行环境:已安装的 PWA 以 standalone 模式启动、前面挡着一个 service worker、而且存储上下文常常和浏览器标签页里的同一个站点不完全一样。所有「PWA 特有」的支付 bug,追到底都是这三件事之一。本文讲的是这些,不讲怎么挑网关。

第零条规则:service worker 不许碰结算

大多数通用 service worker 模板默认就激进缓存。放在店铺上,这是一台资金事故生成器。

把界限明确划出来:

路由

策略

应用外壳、CSS、JS、字体、Logo

cache-first,缓存名带版本

分类页与商品内容

network-first,缓存兜底

价格、库存、购物车内容

network-only

结算、支付、订单确认

network-only,并且完全排除在 fetch 处理之外

网关域名与支付 iframe

一律不拦截

最干净的写法是在 fetch 处理器开头直接短路:

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  // 支付相关流量一律不介入,交给网络本身处理
  if (url.origin !== self.location.origin) return;
  if (/^\/(checkout|payment|cart|orders|api\/(cart|checkout|pay))/.test(url.pathname)) return;
  if (event.request.method !== 'GET') return;
  event.respondWith(handleGet(event.request));
});

这段里有两个细节和路径清单同样重要。提前 return 意味着请求交回浏览器默认处理,而不是返回一份缓存副本;跳过非 GET 请求意味着任何打向支付接口的 POST 都不会被缓存或重试队列重放——重放一次支付 POST 就是一次重复扣款。

跳转问题,这才是真正的坑

多数市场的银行卡支付都要离开你的域名一次:3-D Secure 验证、跳银行 App、本地钱包、托管收银页。在浏览器标签页里这一跳几乎无感;在已安装的 PWA 里,它就是出事的地方。

三种要提前防的失败形态:

用户跳出了 standalone 模式,然后没回来。 在部分 Android 配置下,跳转到第三方域名会打开 Custom Tab 或系统默认浏览器。支付确实完成了——但发生在另一个上下文里,而你的会话在原来那个里。解法是:回跳 URL 指向你自己的域名,订单状态以服务端为准,在回来时重新读取,而不是指望页面内的 JavaScript 状态能熬过这一来一回。

跨跳转丢会话/丢状态。 存储分区和 ITP 类限制意味着你在跳转前塞进 sessionStorage 的东西,回来后可能已经不在了。把订单号带在回跳 URL 里、从服务端重建状态,不要只依赖客户端存储。

确认页被打开了两次,或者一次都没打开。 用户会刷新、会把 App 切后台、会在跳转途中断网。确认页必须幂等,而且只凭一个订单 ID 冷启动打开也要能正常工作。

如果网关支持,页内或 iframe 形式的支付流程能完全避开这一跳,在 PWA 里是更稳的选择。优先用它,把跳转流程留作兜底,并且专门测一遍。

网页上的原生支付面板

两类 API 能在不离开网页的前提下调起系统支付面板:

  • Payment Request API——浏览器级别的支付面板,带已保存的卡和地址。各浏览器、各市场的支持度参差不齐,所以它只能作为渐进增强叠加在一个能正常工作的表单之上,绝不能是唯一路径。
  • Google Pay / Apple Pay 网页版——两者在 PWA 里都可用。Apple Pay 需要做域名验证(在 /.well-known/ 下放置验证文件),且只在 Safari 及 Safari 内核的 Web 视图中运行,其中包括已安装的 iOS PWA;Google Pay 在 Android Chrome 上可用,含 standalone 模式。

运行时检测能力,解析成功再显示钱包按钮,同时始终保留可达的普通银行卡表单。一个静默失败的钱包按钮,损失的转化比它带来的多。

服务端规则:在 PWA 里没有商量余地

这个环境会放大普通错误,所以几条常规实践变成了硬性要求:

  1. 每次支付调用都带幂等键。 移动网络不稳就一定会有重试。幂等键要在结算开始时生成,而不是每次尝试各生成一个——否则同一笔购买的重试会变成第二次扣款。
  2. 订单状态以 webhook 为准。 浏览器可能永远到不了你的确认页(App 被切后台、断网、跳转被吞)。发货必须由网关的服务端回调驱动,页面只负责展示服务端已经知道的结果。
  3. 扣款前在服务端重新校验价格与库存。 在一个带缓存外壳的应用里,你必须假设客户端展示的东西可能是旧的。真正作数的价格,是服务端在扣款时刻算出来的那个。
  4. 校验 webhook 签名,并按「至少一次投递」处理。 记录已处理的事件 ID,重复的直接忽略。

离线行为:要诚实,不要耍聪明

「用 Background Sync 把支付排队」这个念头要压住。用户以为成功、实际只是进了队列的订单,比一次明确的失败更糟——价格会变、库存会空,扣款在几分钟后落地时没有任何人在看着。

诚实的做法:

  • 在进入支付步骤前就检测离线,并直说。
  • 保住购物车(登录用户存服务端),并且只承诺这一件事。
  • 直接禁用支付按钮,而不是让它失败进一个含糊的中间态。
  • 恢复连接后,重新拉取购物车、重新校验价格与库存,再放开支付。

Background Sync 适合保存草稿购物车或提交评价,不适合钱

上线前该跑的一遍测试

这些必须在真机上、以已安装的 PWA 形态跑,不能在桌面浏览器标签页里跑——其中好几条在别处根本复现不出来:

  • 从桌面图标启动完整走一次银行卡支付,含 3-D Secure 验证,确认回来后仍在 standalone 模式且订单状态正确。
  • 在跳转过程中杀掉 App,再从图标重新打开,确认订单收敛到唯一且正确的状态。
  • 在支付这一步打开飞行模式,确认提示清晰、支付按钮被禁用。
  • 会话进行中发布新版 service worker,确认没有任何结算路由是从旧缓存里出来的。
  • 在慢网下连点两次支付,确认只产生一笔扣款(这是幂等键的验收项)。
  • 用订单 URL 冷启动打开确认页(无任何客户端状态),确认渲染正常。
  • 在 iOS 上把上面整套再跑一遍——已安装上下文的差异在 iOS 上最大。

它在整个实施里的位置

支付是最后接的一环,也是最先出事的一环,所以顺序很重要:先把 manifest 写对让应用能装上,再按内容类型定好缓存策略,最后才接结算,并把支付路由显式挖空。店铺层面的整体取舍见电商做 PWA;如果还在纠结打包形态,PWA 与 APK 的取舍覆盖了那个决策。

对于要把 Web 店铺投放给 Android 用户的团队,ROIBest 负责交付层——安装行为、service worker 作用域、推送链路——让支付接入回归成一次普通的 Web 接入,而不是一个 PWA 专项工程。

ROIBest

用 ROIBest 打造下一阶段增长引擎

联系我们,获取稳定、高转化的安卓 PWA 解决方案。