# 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.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. 支付流程 ```mermaid 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](stripe.md)): ```javascript // 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 给后端,后端用**商户支付处理证书私钥**解密令牌,得到卡号/有效期等,再走收单渠道扣款。 ```javascript // 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(推荐) ```yaml # pubspec.yaml dependencies: flutter_stripe: ^10.0.0 ``` ```dart // lib/service/pay_service.dart import 'package:flutter_stripe/flutter_stripe.dart'; /// 使用 Stripe 拉起 Apple Pay Future 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 插件(直接令牌模式) ```yaml # pubspec.yaml dependencies: pay: ^2.0.0 ``` ```dart // 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 _sendTokenToBackend(Map result) async { await APIServer().submitApplePayToken( orderId: product.id, paymentData: result['paymentData'], ); } } ``` ## 6. 接口定义 ### 6.1 创建支付意图(PSP 模式) `POST /v1/payment/apple/` 请求: ```json { "product_id": "p001", "currency": "usd" } ``` 响应: ```json { "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` 请求: ```json { "order_id": "pay_8f3a2b", "paymentData": "base64密文(PKPaymentToken.data)", "amount": 1100 } ``` 响应: ```json { "success": true, "payment_id": "pay_8f3a2b" } ``` ## 7. 安全注意事项 1. **直连模式解密令牌是高风险操作**:解出的卡号属敏感数据(PAN),须符合 PCI DSS,建议直接走 PSP。 2. **金额与商品摘要必须在服务端生成**:`ApplePayCartSummaryItem` 仅用于展示,扣款以 PaymentIntent 为准。 3. **Webhook 验签**:通过 Stripe 时,`payment_intent.succeeded` 事件必须用 Stripe 签名校验(见 [stripe.md](stripe.md))。 4. **结算货币与地区**:Apple Pay 按 `merchantCountryCode` 与 `currencyCode` 展示,需与收款渠道支持范围一致。 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 绑定的证书解密 | | 结算币种错误 | `merchantCountryCode` 与 `currencyCode` 需匹配渠道支持范围 |