Apple Pay Web 跨浏览器二维码支付接入与踩坑实战:从 Safari 到 Windows Chrome

Apple Pay Web 跨浏览器二维码支付接入与踩坑实战:从 Safari 到 Windows Chrome

本文记录一次 Apple Pay on the Web 从 Safari 原生支付扩展到 Chrome、Edge 二维码支付的完整实践。示例中的域名、接口、商户信息、订单号、金额和 Token 均为虚构数据。

一、最终要实现什么

Apple Pay Web 现在可以覆盖两类体验:

访问环境 用户体验
iPhone、iPad Safari 直接打开原生 Apple Pay 支付面板
Mac Safari 使用 Touch ID,或通过配对的 iPhone、Apple Watch 确认
Windows Chrome、Edge 等受支持的桌面浏览器 弹出 Apple 托管窗口和二维码,使用 iOS 18 及以上 iPhone 扫码
不受支持的设备、浏览器或地区 能力检测失败,不展示 Apple Pay

业务系统不需要生成二维码,也不应该根据 User-Agent 自己决定展示原生面板还是二维码。前端统一创建 ApplePaySession,最终呈现方式由 Apple 官方 SDK 决定。

一条规范的支付链路如下:

text 复制代码
进入结算页
  -> 加载 Apple Pay JS SDK
  -> 检查 supportsVersion + canMakePayments
  -> 后端支付方式列表包含 Apple Pay 时展示选项
  -> 用户点击官方 Apple Pay 按钮
  -> 同步创建 ApplePaySession 并 begin
  -> onvalidatemerchant
  -> 后端向 Apple 请求 Merchant Session
  -> completeMerchantValidation
  -> 用户完成授权
  -> onpaymentauthorized
  -> 后端创建/确认订单并提交 Apple Pay Token 给 PSP
  -> 后端支付成功
  -> completePayment(STATUS_SUCCESS)
  -> 跳转支付成功页

二、最容易混淆的两个版本号

Apple Pay Web 接入中同时存在两个完全不同的版本概念。

1. Apple Pay JS SDK 版本

html 复制代码
<script
  src="https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js"
  crossorigin="anonymous"
></script>

这里的 1.latest 是 JavaScript SDK 发布版本。Apple 在 WWDC24 中说明,跨浏览器支付需要加载 Apple Pay JS SDK 1.2.0 或以上,并使用 SDK 提供的官方按钮。

2. ApplePaySession API 版本

ts 复制代码
const session = new ApplePaySession(3, paymentRequest)

这里的 3 是 Apple Pay on the Web API Version。它决定 Payment Request 可以使用哪些字段,与 SDK 的 1.latest 不是同一套版本号。

对于一次性支付、基础卡组织、Merchant Validation 和授权回调,Version 3 已经够用。Version 14 增加的是循环支付、自动充值、多 Token 等较新能力,并不是二维码支付的必要条件。

正确策略是使用满足业务功能的最低版本,并在运行时检查:

ts 复制代码
const APPLE_PAY_VERSION = 3

if (!ApplePaySession.supportsVersion(APPLE_PAY_VERSION)) {
  // 当前环境不支持项目使用的 API 版本
}

三、接入前置条件

正式写代码前,应先完成以下配置:

  1. 注册 Apple Pay Merchant ID。
  2. 配置 Merchant Identity Certificate。
  3. 配置 Payment Processing Certificate,或由 PSP 管理处理证书。
  4. 对所有实际结算域名完成 Merchant Domain Verification。
  5. 所有包含 Apple Pay 的页面使用有效 HTTPS。
  6. 后端允许访问 Apple 返回的 Merchant Validation URL,但必须做严格的 Host Allowlist。
  7. 测试证书、Merchant ID、PSP 环境和测试域名属于同一套环境。

以下信息不能混用:

text 复制代码
生产 Merchant ID + 测试 PSP
测试证书 + 生产 Merchant ID
已验证的 checkout.example.com + 实际访问 m.example.com

域名验证是精确到 Host 的。checkout.example.com 验证成功,不代表 m.example.com 自动生效。

四、在 Vue 中动态加载 Apple 官方 SDK

如果只有结算页需要 Apple Pay,可以动态加载 SDK,避免全站引入。

1. 扩展通用脚本加载方法

ts 复制代码
export interface InjectScriptOptions {
  crossOrigin?: string
  fetchPriority?: 'high' | 'low' | 'auto'
}

export function injectScript(
  url: string,
  async = true,
  id?: string,
  options: InjectScriptOptions = {}
) {
  return new Promise<boolean>((resolve, reject) => {
    const script = document.createElement('script')
    script.src = url
    script.async = async
    script.fetchPriority = options.fetchPriority ?? 'auto'

    if (id) script.id = id
    if (options.crossOrigin) script.crossOrigin = options.crossOrigin

    script.onload = () => resolve(true)
    script.onerror = () => reject(new Error(`Script failed: ${url}`))
    document.head.appendChild(script)
  })
}

2. 封装单例加载器

多个组件可能同时检查 Apple Pay。如果每次都插入 <script>,可能造成 SDK 重复初始化、事件混乱或偶发错误,因此应共享同一个 Promise。

ts 复制代码
import { injectScript } from './injectScript'

export const APPLE_PAY_VERSION = 3

const SDK_URL =
  'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'
const SDK_ID = 'apple-pay-js-sdk'
const SDK_TIMEOUT = 8000

let loadingPromise: Promise<boolean> | null = null

function sdkReady() {
  return Boolean(
    window.ApplePaySession &&
    window.customElements?.get('apple-pay-button')
  )
}

function withTimeout<T>(promise: Promise<T>, timeout: number) {
  return new Promise<T>((resolve, reject) => {
    const timer = window.setTimeout(
      () => reject(new Error('Apple Pay SDK loading timed out')),
      timeout
    )

    promise.then(
      value => {
        clearTimeout(timer)
        resolve(value)
      },
      error => {
        clearTimeout(timer)
        reject(error)
      }
    )
  })
}

export function loadApplePayScript() {
  if (typeof document === 'undefined') return Promise.resolve(false)
  if (sdkReady()) return Promise.resolve(true)
  if (loadingPromise) return loadingPromise

  const existing = document.getElementById(SDK_ID) as HTMLScriptElement | null

  const loadTask = existing
    ? new Promise<boolean>((resolve, reject) => {
        existing.addEventListener('load', () => resolve(sdkReady()), {
          once: true
        })
        existing.addEventListener(
          'error',
          () => reject(new Error('Apple Pay SDK failed to load')),
          { once: true }
        )
      })
    : injectScript(SDK_URL, true, SDK_ID, {
        crossOrigin: 'anonymous',
        fetchPriority: 'high'
      }).then(() => sdkReady())

  loadingPromise = withTimeout(loadTask, SDK_TIMEOUT)
    .then(ready => {
      if (!ready) throw new Error('Apple Pay SDK is not ready')
      return true
    })
    .catch(error => {
      document.getElementById(SDK_ID)?.remove()
      console.error('Failed to load Apple Pay SDK:', error)
      return false
    })
    .finally(() => {
      // 加载失败后允许用户刷新支付方式或重新进入页面时重试。
      if (!sdkReady()) loadingPromise = null
    })

  return loadingPromise
}

export function isApplePayAvailable() {
  if (typeof window === 'undefined') return false

  try {
    return Boolean(
      window.ApplePaySession?.supportsVersion?.(APPLE_PAY_VERSION) &&
      window.ApplePaySession?.canMakePayments?.()
    )
  } catch (error) {
    console.error('Apple Pay capability check failed:', error)
    return false
  }
}

export async function prepareApplePay() {
  const loaded = await loadApplePayScript()
  return loaded && isApplePayAvailable()
}

SSR 环境不能访问 windowdocument。加载器必须在最前面返回不可用,不能让 SDK 初始化代码进入服务端执行路径。

五、必须使用官方 Apple Pay 按钮

过去 Safari 可以使用 CSS 模拟按钮:

css 复制代码
-webkit-appearance: -apple-pay-button;

但这种按钮不能开启新的跨浏览器体验。二维码支付必须使用 SDK 注册的 Web Component:

vue 复制代码
<template>
  <apple-pay-button
    buttonstyle="black"
    type="buy"
    locale="en-US"
    @click="startApplePay"
  />
</template>

Vue 需要把它声明为自定义元素:

ts 复制代码
// vite.config.ts
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: tag => tag === 'apple-pay-button'
        }
      }
    })
  ]
}

按钮尺寸使用 Apple Web Component 的 CSS 变量:

css 复制代码
apple-pay-button {
  --apple-pay-button-width: 100%;
  --apple-pay-button-height: 52px;
  --apple-pay-button-border-radius: 6px;
  --apple-pay-button-padding: 0;
  --apple-pay-button-box-sizing: border-box;
  display: block;
  width: 100%;
  min-height: 52px;
}

不要自行绘制 Apple Logo 或二维码。

六、支付方式列表为什么有 Apple Pay,页面却不显示

后端返回支付方式,只代表业务上配置了 Apple Pay,不代表当前浏览器可以使用。

json 复制代码
{
  "payKey": "ApplePay",
  "payName": "Apple Pay",
  "paymethodCode": 27
}

前端通常还会做一次能力过滤:

ts 复制代码
const canApplePay = await prepareApplePay()
const response = await getPaymentMethods()

paymentMethods.value = response.data.filter(method => {
  return method.payKey !== 'ApplePay' || canApplePay
})

因此出现"接口返回了,但页面没显示"时,应先看 canMakePayments(),而不是继续查支付方式接口。

建议在结算页执行:

js 复制代码
({
  url: location.href,
  secureContext: window.isSecureContext,
  sdkScript: [...document.scripts]
    .find(script => script.src.includes('apple-pay-sdk'))?.src,
  applePaySession: typeof window.ApplePaySession,
  officialButton: Boolean(customElements.get('apple-pay-button')),
  supportsVersion3: window.ApplePaySession?.supportsVersion?.(3),
  canMakePayments: window.ApplePaySession?.canMakePayments?.(),
  userAgent: navigator.userAgent,
  platform: navigator.userAgentData?.platform,
  mobile: navigator.userAgentData?.mobile
})

理想结果如下:

json 复制代码
{
  "secureContext": true,
  "applePaySession": "function",
  "officialButton": true,
  "supportsVersion3": true,
  "canMakePayments": true,
  "platform": "Windows",
  "mobile": false
}

Chrome 手机模拟是一个典型误区

在 Windows Chrome 中打开设备模拟工具栏后,浏览器可能向 Apple SDK 暴露移动设备 User-Agent。此时即使页面仍运行在 Windows 上,也可能得到:

js 复制代码
ApplePaySession.canMakePayments() === false

正确的桌面二维码测试方法是关闭设备模拟,直接在普通 Windows Chrome/Edge 中访问移动站域名。可以缩小窗口测试响应式布局,但不要覆盖 User-Agent。

不要为了让模拟器显示按钮而忽略 canMakePayments()。按钮虽然可能出现,Apple 托管会话仍无法正常启动。

七、Payment Request 中最容易传错的 countryCode

Apple 对 countryCode 的定义是商户主要经营地或交易处理/结算国家,通常需要由 PSP 确认。

下面这些来源都是错误的:

ts 复制代码
countryCode: shippingAddress.countryCode // 客户收货国家
countryCode: geolocation.countryCode     // 客户 IP 国家
countryCode: serverRegion                // 网站服务器所在国家
countryCode: developerAccountRegion      // 开发者账号注册地

例如,一家跨境电商企业的客户在美国、墨西哥和欧洲,但 Apple Pay 收单机构在中国,则 Payment Request 应使用:

ts 复制代码
const paymentRequest = {
  countryCode: 'CN',
  currencyCode: 'USD',
  // ...
}

countryCodecurrencyCode 是两个独立概念:

  • countryCode:本次交易对应的商户/收单处理国家。
  • currencyCode:本次实际扣款币种。

客户在美国不等于 countryCode 必须是 US,使用美元也不等于 countryCode 必须是 US

如果商户存在多个国家的收单路由,后端应在用户点击 Apple Pay 之前提供明确配置:

json 复制代码
{
  "payKey": "ApplePay",
  "applePayCountryCode": "US",
  "applePayCurrencyCode": "USD"
}

不能等 ApplePaySession 启动后再猜测国家。尤其不要把含义不明的 countryIso2 直接当成 Apple Pay 商户国家。

八、完整的前端最小实现

下面示例覆盖一次性支付的完整生命周期。为了保持用户手势调用链,点击处理函数不能在创建 ApplePaySession 之前 await 网络请求。

ts 复制代码
const APPLE_PAY_VERSION = 3
const AUTH_TIMEOUT = 25_000

let sessionActive = false

function parseMerchantSession(response: any) {
  const value = response?.data ?? response
  const session = typeof value === 'string' ? JSON.parse(value) : value

  if (!session || typeof session !== 'object' || Array.isArray(session)) {
    throw new Error('Invalid merchant session')
  }

  return session
}

function withAuthorizationTimeout<T>(promise: Promise<T>) {
  return new Promise<T>((resolve, reject) => {
    const timer = window.setTimeout(
      () => reject(new Error('Apple Pay authorization timed out')),
      AUTH_TIMEOUT
    )

    promise.then(
      value => {
        clearTimeout(timer)
        resolve(value)
      },
      error => {
        clearTimeout(timer)
        reject(error)
      }
    )
  })
}

function startApplePay() {
  if (sessionActive || !isApplePayAvailable()) return

  const amount = Number('11.50')
  if (!Number.isFinite(amount) || amount <= 0) return

  const request = {
    countryCode: 'CN', // 示例:PSP 已确认收单处理国家为中国
    currencyCode: 'USD',
    supportedNetworks: [
      'visa',
      'masterCard',
      'amex',
      'discover',
      'chinaUnionPay'
    ],
    merchantCapabilities: [
      'supports3DS',
      'supportsDebit',
      'supportsCredit'
    ],
    total: {
      label: 'Example Store',
      type: 'final',
      amount: amount.toFixed(2)
    }
  }

  // 必须在用户点击事件的同步调用栈中构造 Session。
  let session: any
  try {
    session = new ApplePaySession(APPLE_PAY_VERSION, request)
  } catch (error) {
    console.error('Failed to create Apple Pay session:', error)
    return
  }

  sessionActive = true
  let finished = false

  // 可以并行创建订单,但不要在 new ApplePaySession 之前 await。
  const orderPromise = fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ paymethodCode: 27 })
  })
    .then(response => {
      if (!response.ok) throw new Error('Create order failed')
      return response.json()
    })
    .then(
      response => ({ response }),
      error => ({ error })
    )

  const release = () => {
    sessionActive = false
  }

  const abort = (error: unknown) => {
    if (finished) return
    finished = true
    release()
    console.error('Apple Pay session aborted:', error)

    try {
      session.abort()
    } catch (abortError) {
      console.error('Failed to abort Apple Pay session:', abortError)
    }
  }

  const complete = (success: boolean) => {
    if (finished) return false
    finished = true

    const status = success
      ? ApplePaySession.STATUS_SUCCESS
      : ApplePaySession.STATUS_FAILURE

    try {
      session.completePayment({ status })
      return true
    } catch (error) {
      console.error('Failed to complete Apple Pay session:', error)
      return false
    } finally {
      release()
    }
  }

  session.onvalidatemerchant = async (event: any) => {
    if (!event?.validationURL) {
      abort(new Error('Missing validationURL'))
      return
    }

    try {
      const response = await fetch('/api/apple-pay/merchant-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ validationURL: event.validationURL })
      })

      if (!response.ok) throw new Error('Merchant validation failed')

      const result = await response.json()
      if (finished) return

      session.completeMerchantValidation(parseMerchantSession(result))
    } catch (error) {
      abort(error)
    }
  }

  session.onpaymentmethodselected = () => {
    if (finished) return

    try {
      session.completePaymentMethodSelection({
        newTotal: request.total,
        newLineItems: []
      })
    } catch (error) {
      abort(error)
    }
  }

  session.onpaymentauthorized = async (event: any) => {
    try {
      const result = await withAuthorizationTimeout(
        (async () => {
          const { response: orderResult, error: orderError } =
            await orderPromise

          if (orderError) throw orderError

          const order = orderResult?.data

          if (!order?.orderNumber) {
            throw new Error('Missing order number')
          }

          const payment = event?.payment
          const network =
            payment?.token?.paymentMethod?.network ||
            payment?.token?.paymentMethod?.displayName?.split(' ')[0] ||
            ''

          const payResponse = await fetch('/api/payments/apple-pay', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              orderNumber: order.orderNumber,
              paymentNumber: order.paymentNumber,
              appleParam: {
                token: payment?.token,
                cardType: network
              }
            })
          })

          if (!payResponse.ok) throw new Error('Payment request failed')
          return {
            order,
            paymentResult: await payResponse.json()
          }
        })()
      )

      const paid = result.paymentResult?.success === true
      const completed = complete(paid)

      // 必须先通知 Apple 成功,再跳转业务成功页。
      if (paid && completed) {
        location.href = `/payment/success?order=${encodeURIComponent(
          result.order.orderNumber
        )}`
      }
    } catch (error) {
      complete(false)
      console.error('Apple Pay authorization failed:', error)
    }
  }

  session.oncancel = () => {
    finished = true
    release()
    // 用户取消后停留在结算页;预创建订单交给后端过期机制处理。
  }

  try {
    session.begin()
  } catch (error) {
    abort(error)
  }
}

九、后端 Merchant Validation 最小示例

Merchant Validation 必须由后端执行,因为 Merchant Identity Certificate 和私钥不能暴露给浏览器。

下面是简化的 Node.js 伪代码,生产环境还需要完善证书管理、日志脱敏和错误处理:

ts 复制代码
import fs from 'node:fs'
import https from 'node:https'
import express from 'express'

const app = express()
app.use(express.json())

const ALLOWED_VALIDATION_HOSTS = new Set([
  'apple-pay-gateway.apple.com',
  'apple-pay-gateway-nc-pod1.apple.com'
])

app.post('/api/apple-pay/merchant-session', async (req, res) => {
  const validationURL = new URL(req.body.validationURL)

  if (
    validationURL.protocol !== 'https:' ||
    !ALLOWED_VALIDATION_HOSTS.has(validationURL.hostname)
  ) {
    return res.status(400).json({ message: 'Invalid validation URL' })
  }

  const body = JSON.stringify({
    merchantIdentifier: 'merchant.com.example.store',
    displayName: 'Example Store',
    initiative: 'web',
    initiativeContext: 'checkout.example.com'
  })

  const merchantSession = await postToApple(validationURL, body, {
    cert: fs.readFileSync('/run/secrets/merchant-identity.crt'),
    key: fs.readFileSync('/run/secrets/merchant-identity.key')
  })

  // 推荐返回对象,避免前端二次 JSON.parse。
  res.json({ success: true, data: merchantSession })
})

必须注意:

  • initiativeContext 必须与用户当前访问的已验证域名一致。
  • 不要信任前端传入的任意 Validation URL,否则可能产生 SSRF 风险。
  • 不要在日志中记录证书、私钥、完整 Merchant Session 签名或支付 Token。
  • Merchant Session 有有效期,不能长期缓存复用。

Merchant Session 字符串和对象兼容

常见后端响应有两种:

json 复制代码
{
  "success": true,
  "data": {
    "epochTimestamp": 1700000000000,
    "expiresAt": 1700003600000,
    "merchantSessionIdentifier": "SESSION_...",
    "domainName": "checkout.example.com",
    "displayName": "Example Store",
    "signature": "..."
  }
}

或者:

json 复制代码
{
  "success": true,
  "data": "{\"epochTimestamp\":1700000000000,\"domainName\":\"checkout.example.com\",\"signature\":\"...\"}"
}

传给 Apple 的必须是 Merchant Session 对象,而不是业务包装对象或 JSON 字符串:

ts 复制代码
session.completeMerchantValidation(parseMerchantSession(response))

不要未经确认就固定写成:

ts 复制代码
session.completeMerchantValidation(response)

也不要假设所有环境永远都是:

ts 复制代码
session.completeMerchantValidation(response.data)

十、CSP 配置

结算页如果启用了 Content Security Policy,需要放行 Apple 官方 CDN:

text 复制代码
script-src 'self' https://applepay.cdn-apple.com;
img-src 'self' data: https://applepay.cdn-apple.com;
frame-src 'self' https://applepay.cdn-apple.com;
connect-src 'self' https://applepay.cdn-apple.com;

实际项目通常还包含 PayPal、Google Pay、信用卡风控等域名,应只增量加入 Apple 域名,不要覆盖现有白名单。

如果 SDK 加载失败,先查看浏览器 Console 是否出现:

text 复制代码
Refused to load the script because it violates Content Security Policy

十一、典型故障与定位方法

1. InvalidAccessError: Trying to start an Apple Pay session from an insecure document

原因:页面不是浏览器认可的安全上下文。

js 复制代码
({
  protocol: location.protocol,
  secureContext: window.isSecureContext
})

修复要求:

  • 页面使用 HTTPS。
  • TLS 证书有效并受浏览器信任。
  • 页面没有嵌套在不安全的 HTTP iframe 中。
  • 不要指望前端代码绕过 Apple 的安全限制。

2. 接口返回 Apple Pay,但支付选项不显示

按顺序检查:

text 复制代码
SDK 是否加载成功
  -> ApplePaySession 是否存在
  -> apple-pay-button 是否注册
  -> supportsVersion(3) 是否为 true
  -> canMakePayments() 是否为 true
  -> 前端过滤后的支付方式列表是否仍包含 ApplePay

后端支付方式接口返回 Apple Pay 只解决业务开关,浏览器能力检测仍可能将它过滤。

3. Windows Chrome 正常,手机模拟模式却不显示

这是模拟 User-Agent 导致的能力判断差异。关闭 DevTools 设备模拟、清理 Service Worker 和站点缓存后重新加载。

4. 弹出"当前无法在所在国家或地区使用 Apple Pay"

需要区分两个国家概念:

  • Payment Request 的商户 countryCode
  • 用户当前设备、Apple ID、Wallet、网络所在地对应的可用地区。

先确认 countryCode 没有误用客户收货地址,再在 Apple Pay 支持地区使用真实设备验证。canMakePayments() === true 表示浏览器可以启动流程,不等于最终地区、账户和卡片一定可用。

5. onvalidatemerchant 执行,但随后 Session aborted

重点检查:

  • validationURL 是否原样传给后端。
  • 后端是否成功请求 Apple,而不是只返回 HTTP 200 的业务失败对象。
  • Merchant Session 是对象还是 JSON 字符串。
  • domainName 是否等于当前页面 Host。
  • Merchant ID、证书、PSP 沙盒/生产环境是否一致。
  • 是否错误地把整个 { code, data, success } 包装对象传给 Apple。

6. onpaymentauthorized 没有执行

它不是点击按钮后立即执行。只有以下步骤全部成功才会触发:

text 复制代码
Session 创建成功
  -> begin 成功
  -> Merchant Validation 成功
  -> Apple 支付面板/二维码可用
  -> 用户选择卡片并完成授权

如果 Merchant Validation 失败、用户取消、地区不可用或 Session 被主动 abort(),就不会进入授权回调。

7. 支付成功但 Apple 面板显示失败

常见原因是业务成功后直接跳转,没有先完成 Apple Session:

ts 复制代码
// 错误顺序
location.href = '/payment/success'
session.completePayment({ status: ApplePaySession.STATUS_SUCCESS })

正确顺序:

ts 复制代码
session.completePayment({
  status: ApplePaySession.STATUS_SUCCESS
})

location.href = '/payment/success'

8. 同一个 Session 被完成两次

网络超时、后端迟到响应和异常分支可能同时调用 completePayment()。应设置 finished 标记,使完成操作幂等。否则会出现 Session 状态异常或重复跳转。

十二、Promise 作用域与订单竞态

下面写法是合法的:

ts 复制代码
function startApplePay() {
  const orderPromise = createOrder()

  session.onpaymentauthorized = async () => {
    const order = await orderPromise
  }
}

虽然回调在以后执行,但 JavaScript 闭包会保留 startApplePay 词法作用域中的 orderPromise 引用。

它解决的是订单竞态:

  • 用户点击后立即发起订单创建。
  • Apple 面板同时进行 Merchant Validation 和用户授权。
  • 授权回调真正执行时,再等待同一个订单 Promise。
  • 不依赖某个响应式变量"碰巧已经赋值"。

但不要在创建 Session 前这样写:

ts 复制代码
async function startApplePay() {
  const order = await createOrder()
  const session = new ApplePaySession(3, request)
}

await 可能破坏 Apple 对"必须由用户手势同步触发"的要求。

十三、如何避免影响其他支付方式

Apple Pay 改造应遵循最小隔离原则:

  1. SDK 只在结算页动态加载。
  2. 能力检测只过滤 payKey === 'ApplePay' 的项目。
  3. 不修改公共 HTTP 拦截器。
  4. 不改变 Google Pay、PayPal、信用卡等支付函数的默认跳转行为。
  5. Apple Pay 单独使用延迟跳转,即先完成 Apple Session,再跳成功页。
  6. SDK 加载失败只隐藏 Apple Pay,不能阻塞整个支付方式列表。
  7. CSP 只增量添加 Apple 域名,不删除其他支付域名。

示例:

ts 复制代码
const canApplePay = await prepareApplePay()

paymentMethods.value = serverMethods.filter(method => {
  if (method.payKey === 'ApplePay') return canApplePay
  return true
})

十四、测试矩阵

场景 预期结果
HTTP 测试地址 Apple Pay 不启动,控制台提示不安全文档
有效 HTTPS + SDK 加载失败 隐藏 Apple Pay,其他支付不受影响
Windows Chrome 普通桌面模式 能力检测通过,点击后出现二维码
Windows Chrome 手机模拟 可能返回 canMakePayments=false,不作为真实验收结果
iPhone Safari 打开原生 Apple Pay 面板,不显示二维码
Version 3 不支持 隐藏 Apple Pay
Merchant Session 为对象 Merchant Validation 成功
Merchant Session 为 JSON 字符串 解析后 Merchant Validation 成功
Merchant Session 域名不一致 验证失败,不进入授权回调
用户取消 释放 Session 锁,停留在结算页
后端扣款失败 只完成一次 STATUS_FAILURE,不跳成功页
授权超过 25 秒 完成失败状态,迟到结果不得触发成功跳转
用户连续点击 只创建一个 Session 和一份订单
后端扣款成功 STATUS_SUCCESS,再跳成功页

十五、上线检查清单

前端

  • 页面为有效 HTTPS,window.isSecureContext === true
  • 官方 SDK 只加载一次。
  • 使用官方 <apple-pay-button>
  • supportsVersion()canMakePayments() 均有异常保护。
  • 金额为大于 0 的两位小数字符串。
  • countryCode 来自 PSP 确认的商户/收单国家。
  • Merchant Session 字符串和对象响应均可处理。
  • completion 幂等,授权超时早于 Apple 最终超时。
  • 成功时先完成 Session,再跳转。
  • 取消时不误跳成功页。
  • 不打印完整支付 Token。

后端

  • Merchant Validation URL 使用 HTTPS Host Allowlist。
  • Merchant ID、证书和环境一致。
  • initiativeContext 等于实际访问域名。
  • 所有 PC/H5 域名分别完成 Merchant Domain Verification。
  • Merchant Session 未被长期缓存。
  • 支付接口可幂等处理重复请求。
  • PSP 明确支持当前币种、卡组织和收单国家。
  • 日志已脱敏,不包含证书、签名和完整 Token。

真实环境

  • Windows Chrome/Edge 使用正常桌面模式测试二维码。
  • 使用 iOS 18 及以上 iPhone 扫码。
  • 使用 Apple 沙盒账号和沙盒卡测试。
  • Safari 回归原生支付。
  • Google Pay、PayPal、信用卡等支付方式完成回归。

十六、结论

Apple Pay Web 跨浏览器接入本身不需要重写支付链路。真正关键的是:

  1. 加载 Apple 官方 SDK 1.2.0 以上版本。
  2. 使用官方 Apple Pay Web Component 按钮。
  3. 保持 ApplePaySession 在用户点击同步调用栈中创建。
  4. 正确区分 SDK 版本和 Apple Pay API Version。
  5. countryCode 当作商户/收单处理国家,而不是客户国家。
  6. Merchant Session 必须与当前域名、证书和环境严格一致。
  7. 后端支付成功后,先完成 Apple Session,再跳转业务页面。
  8. 用真实 HTTPS、真实浏览器模式和真实设备完成最终验收。

大量"接口成功但 Apple Pay 失败"的问题,实际上发生在后端扣款之前。沿着"安全上下文 -> SDK -> 能力检测 -> Merchant Validation -> 用户授权 -> PSP 扣款 -> completePayment"的顺序排查,通常可以快速定位问题所在。

参考资料

相关推荐
Anthony_2311 天前
Nginx基础
服务器·nginx·http·https·edge浏览器·web
無限進步D2 天前
Java Web 前端 简介
java·开发语言·前端·css·html·css3·web
山甫aa8 天前
【从零开始的 Web 后端学习】令牌技术一篇搞定(JWT 登录认证保姆级)
后端·学习·spring·web·jwt
llxxyy卢8 天前
polar秋季赛部分题目wp(web全解)
web
里欧跑得慢9 天前
Flutter主题与样式详解
前端·css·flutter·web
嘿嘿-6610 天前
Windows 一键使用 GPT-6 Astra:Codex CLI 配置教程
java·人工智能·windows·gpt·chatgpt·web
嘿嘿-6610 天前
gpt-6-astra测试,测试你的模型有没有降智
人工智能·gpt·chatgpt·web
cui_ruicheng10 天前
FastAPI 应用开发(二):请求响应、Pydantic 模型与依赖注入
python·fastapi·web
智者知已应修善业11 天前
C#用一个循环[等效]取出字符串中的数字
网络·c#·web·string·语言