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 里没有商量余地
这个环境会放大普通错误,所以几条常规实践变成了硬性要求:
- 每次支付调用都带幂等键。 移动网络不稳就一定会有重试。幂等键要在结算开始时生成,而不是每次尝试各生成一个——否则同一笔购买的重试会变成第二次扣款。
- 订单状态以 webhook 为准。 浏览器可能永远到不了你的确认页(App 被切后台、断网、跳转被吞)。发货必须由网关的服务端回调驱动,页面只负责展示服务端已经知道的结果。
- 扣款前在服务端重新校验价格与库存。 在一个带缓存外壳的应用里,你必须假设客户端展示的东西可能是旧的。真正作数的价格,是服务端在扣款时刻算出来的那个。
- 校验 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 专项工程。


