Apple Pay 是 Apple 生态内的免密快捷支付方式,基于 Token 化银行卡:用户卡号不直接给商户,而是由 Apple 生成加密的支付令牌(PKPaymentToken)。适用于 iOS App 与 Web(Safari)。
| 模式 | 说明 | 适用场景 |
|---|---|---|
| PSP 模式(推荐) | 通过 Stripe / Adyen 等支付服务商处理 | 有 Stripe 账户,流程简单,后端只需创建 PaymentIntent |
| 直连商户模式 | 商户自己申请商户证书,解密 Apple 支付令牌后走收单银行 | 已签约 Apple Pay 直连商户 |
本项目如果同时接 Stripe,强烈建议使用 PSP 模式(见 stripe.md),由 Stripe 负责令牌校验与扣款。本文两种模式都覆盖。
| 项目 | 说明 |
|---|---|
| 开发者账号 | 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 协议 |
Apple Pay 直连模式证书链:
1. Merchant Identity Certificate → 与 Apple 通信时验证商户身份
2. Payment Processing Certificate → 解密 PKPaymentToken.data
3. Apple Root CA - G3 → 验证 Apple 返回数据签名
直连模式在国内落地通常需要收单银行支持,门槛较高;绝大多数 App 选择通过 PSP(Stripe/Adyen)接入。
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. 查询最终状态
后端只需复用 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,
};
}
当 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 模式)。
# 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);
}
}
# 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'],
);
}
}
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结构一致。
POST /v1/payment/apple/token
请求:
{
"order_id": "pay_8f3a2b",
"paymentData": "base64密文(PKPaymentToken.data)",
"amount": 1100
}
响应:
{ "success": true, "payment_id": "pay_8f3a2b" }
ApplePayCartSummaryItem 仅用于展示,扣款以 PaymentIntent 为准。payment_intent.succeeded 事件必须用 Stripe 签名校验(见 stripe.md)。merchantCountryCode 与 currencyCode 展示,需与收款渠道支持范围一致。| 项 | 说明 |
|---|---|
| 真机要求 | Apple Pay 仅在真机可用(iPhone + Face ID / Touch ID / 密码) |
| 模拟器 | Xcode Simulator 可开启 Apple Pay 测试,需配置测试 Merchant 证书 |
| Stripe 沙箱 | Stripe 测试模式即可完整走通 Apple Pay 流程 |
| 测试卡 | Stripe 测试卡号 4242 4242 4242 4242,有效期任意未来日期,CVC 任意 |
| 问题 | 原因/解决 |
|---|---|
| 按钮不显示 | 设备不支持 / 未配置 merchantIdentifier / isPlatformPaySupported 返回 false |
| 支付面板不弹出 | Merchant ID 未在 Apple Developer 激活,或证书与 Merchant ID 不匹配 |
client_secret 无效 |
PaymentIntent 已使用或过期,需重新创建 |
| 直连解密失败 | 支付处理证书与令牌不匹配;需用与 Merchant ID 绑定的证书解密 |
| 结算币种错误 | merchantCountryCode 与 currencyCode 需匹配渠道支持范围 |