apple_pay.md 11 KB

Apple Pay 技术文档

1. 概述

Apple Pay 是 Apple 生态内的免密快捷支付方式,基于 Token 化银行卡:用户卡号不直接给商户,而是由 Apple 生成加密的支付令牌(PKPaymentToken)。适用于 iOS App 与 Web(Safari)。

1.1 两种集成模式

模式 说明 适用场景
PSP 模式(推荐) 通过 Stripe / Adyen 等支付服务商处理 有 Stripe 账户,流程简单,后端只需创建 PaymentIntent
直连商户模式 商户自己申请商户证书,解密 Apple 支付令牌后走收单银行 已签约 Apple Pay 直连商户

本项目如果同时接 Stripe,强烈建议使用 PSP 模式(见 stripe.md),由 Stripe 负责令牌校验与扣款。本文两种模式都覆盖。

2. 申请与配置

2.1 需要申请的内容

项目 说明
开发者账号 Apple Developer Program(年费 $99)
Merchant ID Apple Developer → Identifiers → Merchant IDs,形如 merchant.com.example.pay
Merchant Identity Certificate 为 Merchant ID 生成证书,用于与 Apple Pay 通信
Payment Processing Certificate 用于解密支付令牌(直连模式必需)
商户收款能力 直连模式需与收单银行/PSP 签订 Apple Pay 协议

2.2 证书用途

Apple Pay 直连模式证书链:
  1. Merchant Identity Certificate  → 与 Apple 通信时验证商户身份
  2. Payment Processing Certificate  → 解密 PKPaymentToken.data
  3. Apple Root CA - G3               → 验证 Apple 返回数据签名

直连模式在国内落地通常需要收单银行支持,门槛较高;绝大多数 App 选择通过 PSP(Stripe/Adyen)接入

3. 支付流程

sequenceDiagram
    participant App as Flutter App (iOS)
    participant Backend as 业务后端
    participant PSP as 支付服务商(如Stripe)
    participant Apple as Apple Pay

    App->>Backend: 1. 创建订单 / 获取client_secret
    Backend->>PSP: 2. 创建 PaymentIntent
    PSP-->>Backend: client_secret
    Backend-->>App: client_secret
    App->>Apple: 3. 拉起 Apple Pay 授权面板(商品/金额)
    Apple-->>App: PKPaymentToken(加密)
    App->>PSP: 4. 确认支付(confirmPlatformPayPaymentIntent)
    PSP->>PSP: 校验令牌、扣款
    PSP->>Backend: 5. Webhook 通知(payment_intent.succeeded)
    Backend->>Backend: 验签 + 幂等更新订单 + 发货
    App->>Backend: 6. 查询最终状态

4. 后端集成

4.1 方式一:通过 Stripe(推荐)

后端只需复用 Stripe 的 PaymentIntent 流程(详见 stripe.md):

// Node.js + stripe SDK
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

/**
 * 创建 Apple Pay 支付意图
 * payment_method_types 需包含 'card'
 */
async function createApplePayPaymentIntent({ outTradeNo, amount, currency }) {
  const paymentIntent = await stripe.paymentIntents.create({
    amount: Math.round(amount * 100), // 单位:分
    currency,                         // 如 usd / cny
    payment_method_types: ['card'],
    metadata: { out_trade_no: outTradeNo },
  });
  return {
    clientSecret: paymentIntent.client_secret,
    paymentId: paymentIntent.id,
  };
}

4.2 方式二:直连商户(令牌解密)

当 App 拿到 PKPaymentToken 后,将其(Base64)POST 给后端,后端用商户支付处理证书私钥解密令牌,得到卡号/有效期等,再走收单渠道扣款。

// src/services/applepay_service.js
const crypto = require('crypto');
const forge = require('node-forge'); // 用于处理 PKCS7 与 ECIES

/**
 * 解密 Apple Pay 支付令牌
 * @param {string} tokenDataBase64 PKPaymentToken.data (Base64, PKCS#7)
 * @returns {object} { applicationPrimaryAccountNumber, applicationExpirationDate, currencyCode, ... }
 */
function decryptPaymentToken(tokenDataBase64, merchantPrivateKeyPem) {
  // 1. 解析 PKCS#7 / CMS EnvelopedData
  const p7 = forge.pkcs7.messageFromPem(
    forge.util.decode64(tokenDataBase64).toString('binary')
  );
  // 2. 用商户支付处理证书私钥解开信封
  //    (node 生态需结合 forge + crypto,或使用官方 open-source SDK)
  // 3. 使用 RSA-OAEP(EME-OAEP with SHA-256) 解出对称密钥
  // 4. 用对称密钥 AES-CCM 解密数据得到 ECDH 公钥
  // 5. 与令牌内的 ephemeralPublicKey 做 ECDH,得到最终密钥解密卡数据

  // 说明:解密步骤官方提供 Apple PKPayment token 文档与示例代码,
  // 生产实现建议封装为独立模块并做充分测试。
}

// 业务:App 支付成功后回调
app.post('/v1/payment/apple/token', async (req, res) => {
  const { paymentData, orderId } = req.body; // paymentData: token.data base64
  const cardInfo = decryptPaymentToken(paymentData, MERCHANT_PRIVATE_KEY);

  // 校验金额后,将卡信息提交给收单银行/收单网关完成扣款
  const result = await acquiringGateway.charge({
    orderId,
    amount: order.amount,
    card: cardInfo,
  });
  res.json({ success: result.success, paymentId: orderId });
});

安全提示:直连模式下解密得到的是真实卡号数据,必须:

  • 解密与扣款过程不落日志、不存储明文卡号;
  • 卡信息仅供提交收单渠道使用,使用后立即销毁;
  • 满足 PCI DSS 要求(如不适用,强烈建议走 PSP 模式)。

5. 前端集成(Flutter)

5.1 方式一:通过 flutter_stripe(推荐)

# pubspec.yaml
dependencies:
  flutter_stripe: ^10.0.0
// lib/service/pay_service.dart
import 'package:flutter_stripe/flutter_stripe.dart';

/// 使用 Stripe 拉起 Apple Pay
Future<void> applePayWithStripe() async {
  try {
    // 1. 检查设备是否支持 Apple Pay
    final supported = await Stripe.instance.isPlatformPaySupported();

    if (!supported) {
      showErrorMessage('当前设备不支持 Apple Pay');
      return;
    }

    // 2. 后端创建 PaymentIntent,返回 client_secret
    final created = await APIServer().createStripePayment(
      productId: product.id,
      source: paymentSource(),
    );

    // 3. 确认 Apple Pay 支付
    await Stripe.instance.confirmPlatformPayPaymentIntent(
      clientSecret: created.paymentIntent,
      confirmParams: PlatformPayConfirmParams.applePay(
        applePay: ApplePayParams(
          cartItems: [
            ApplePayCartSummaryItem.immediate(
              label: product.name,
              amount: '${product.retailPriceUSD / 100}',
            ),
          ],
          merchantCountryCode: 'US',
          currencyCode: 'USD',
        ),
      ),
    );

    // 4. 确认成功,以后端查询为准
    final resp = await APIServer().queryPaymentStatus(created.paymentId);
    if (resp.success) {
      showSuccessMessage(resp.note ?? '支付成功');
    }
  } on StripeException catch (e) {
    showErrorMessage(e.error.localizedMessage ?? 'Apple Pay 支付失败');
  } on Exception catch (e) {
    showErrorMessageEnhanced(context, e);
  }
}

5.2 方式二:使用 pay 插件(直接令牌模式)

# pubspec.yaml
dependencies:
  pay: ^2.0.0
// lib/components/apple_pay_button.dart
import 'package:pay/pay.dart';

/// Apple Pay 配置(merchantIdentifier 为 Apple Developer 中的 Merchant ID)
const applePayConfig = '''
{
  "provider": "apple_pay",
  "merchantIdentifier": "merchant.com.example.pay",
  "merchantCountryCode": "US",
  "displayName": "示例商店",
  "supportedNetworks": ["visa", "mastercard", "amex", "discover"],
  "supportedCountries": ["US"],
  "capabilities": ["supports3DS"]
}
''';

class ApplePayButtonDemo extends StatelessWidget {
  final PaymentProduct product;

  ApplePayButtonDemo({required this.product});

  @override
  Widget build(BuildContext context) {
    return ApplePayButton(
      paymentConfiguration: PaymentConfiguration.fromJsonString(applePayConfig),
      paymentItems: [
        PaymentItem(
          label: product.name,
          amount: (product.retailPriceUSD / 100).toStringAsFixed(2),
          status: PaymentItemStatus.final_price,
        ),
      ],
      style: ApplePayButtonStyle.black,
      type: ApplePayButtonType.buy,
      onPaymentResult: (result) {
        // result['paymentData'] 为加密的 PKPaymentToken.data
        _sendTokenToBackend(result);
      },
      onError: (error) => showErrorMessage('Apple Pay 失败: $error'),
    );
  }

  /// 将令牌发送到后端解密并扣款(直连模式)
  Future<void> _sendTokenToBackend(Map<String, dynamic> result) async {
    await APIServer().submitApplePayToken(
      orderId: product.id,
      paymentData: result['paymentData'],
    );
  }
}

6. 接口定义

6.1 创建支付意图(PSP 模式)

POST /v1/payment/apple/

请求:

{ "product_id": "p001", "currency": "usd" }

响应:

{
  "payment_id": "pi_3Qx...",
  "payment_intent": "pi_3Qx..._secret_xxx",  // client_secret
  "publishable_key": "pk_test_...",
  "ephemeral_key": "ek_test_..."             // 如需绑定客户
}

与前端模型 StripePaymentCreatedResponse 结构一致。

6.2 提交支付令牌(直连模式)

POST /v1/payment/apple/token

请求:

{
  "order_id": "pay_8f3a2b",
  "paymentData": "base64密文(PKPaymentToken.data)",
  "amount": 1100
}

响应:

{ "success": true, "payment_id": "pay_8f3a2b" }

7. 安全注意事项

  1. 直连模式解密令牌是高风险操作:解出的卡号属敏感数据(PAN),须符合 PCI DSS,建议直接走 PSP。
  2. 金额与商品摘要必须在服务端生成ApplePayCartSummaryItem 仅用于展示,扣款以 PaymentIntent 为准。
  3. Webhook 验签:通过 Stripe 时,payment_intent.succeeded 事件必须用 Stripe 签名校验(见 stripe.md)。
  4. 结算货币与地区:Apple Pay 按 merchantCountryCodecurrencyCode 展示,需与收款渠道支持范围一致。
  5. 沙箱测试:真实 Apple Pay 需在真机上测试;Xcode 模拟器可配置沙箱测试账户(Payment Processing Certificate 需包含沙箱用途)。

8. 沙箱与测试

说明
真机要求 Apple Pay 仅在真机可用(iPhone + Face ID / Touch ID / 密码)
模拟器 Xcode Simulator 可开启 Apple Pay 测试,需配置测试 Merchant 证书
Stripe 沙箱 Stripe 测试模式即可完整走通 Apple Pay 流程
测试卡 Stripe 测试卡号 4242 4242 4242 4242,有效期任意未来日期,CVC 任意

9. 常见问题

问题 原因/解决
按钮不显示 设备不支持 / 未配置 merchantIdentifier / isPlatformPaySupported 返回 false
支付面板不弹出 Merchant ID 未在 Apple Developer 激活,或证书与 Merchant ID 不匹配
client_secret 无效 PaymentIntent 已使用或过期,需重新创建
直连解密失败 支付处理证书与令牌不匹配;需用与 Merchant ID 绑定的证书解密
结算币种错误 merchantCountryCodecurrencyCode 需匹配渠道支持范围