iOS苹果支付接入流程

本文采用StoreKit2+Swift,不兼容Storekit1,最低适配版本iOS15以上.

基础时序图

applePay.png

在第一步请求购买前,我们可能需要先向自己的业务服务器请求创建订单,创建后再向苹果打开支付流程。

需要在苹果后台上配置的

  1. 配置商品


    image.png

    在这里配置订阅和消耗和非消耗类商品

  2. 商务:
    入口:https://appstoreconnect.apple.com/business
    这一步主要是填收款的银行卡和报税表
  3. Issuer ID和秘钥ID


    image.png

    这一步同样会创建一个.p8的秘钥文件,保存好,只能下载一次,这个后面会给后台用

  4. 创建沙盒账户
    image.png

    这一步是沙盒环境购买时用到的测试账号,我们在开发者->沙盒APPLE账户(拉到最底下)登录测试员,后面每次在测试环境中购买,就用这个号了
    官方文档
    image.png
  5. 后端会给我们一个苹果回调的接口,分沙盒环境和生产环境,我们把它配置在App信息->App Store 服务器通知


    image.png

后台需要我们提供的

  • product id 第1步
  • bundle ID
  • key ID 第3步
  • issuer ID 第3步
  • 私钥那个文件 第3步

以上就是全部需要的配置了

写代码

  1. Storekit2有一个新的配置文件,可以同步下来创建的商品(可选)
    image.png

    image.png

    image.png

    这里可以模拟一些错误,方便测试
    image.png

    在Edit Scheme-> Options中可以选中这个配置文件,这样每次购买就会变成Xcode环境,方便我们写代码的时候调试,如果要切到沙盒测试环境,记得把这里换成None(笔者这里跟后台联调的时候忘记把这里选成None,导致一直是Xcode环境,怎么样都没有弹出沙盒账号)
    image.png

    Xcode这里获取到的transactionId是一个个位数字的字符串,例如"1","2","3","4",如果发现是这种transactionId,可以检查一下是不是出于Xcode环境。
import StoreKit
import Foundation

class HSSK2Manager {
    
    // MARK: - Singleton
    static let shared = HSSK2Manager()
    
    // MARK: - Properties
    private(set) var products: [Product] = []
    private(set) var isLoading = false
    
    // MARK: - Callbacks
    var onProductsLoaded: (([Product]) -> Void)?
    var onLoadingStateChanged: ((Bool) -> Void)?
    var onPurchaseCompleted: ((PurchaseResult) -> Void)?
    var onError: ((PurchaseError) -> Void)?
    
    // MARK: - Private Properties
    private var productIDs: Set<String> = []
    private var updateListenerTask: Task<Void, Error>?
    
    // MARK: - Initialization
    private init() {
        startTransactionListener()
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    // MARK: - Public Methods
    
    func preload() {
        self.productIDs = [
            // 配置你的productIds
        ]
        loadProducts()
    }
    
    /// 加载商品信息
    func loadProducts() {
        guard !isLoading else { return }
        guard products.isEmpty else {
            onProductsLoaded?(products)
            return
        }
        
        setLoading(true)
        
        Task {
            do {
                let products = try await Product.products(for: productIDs)
                let sortedProducts = products.sorted { $0.price < $1.price }
                
                await MainActor.run {
                    self.products = sortedProducts
                    self.setLoading(false)
                    self.onProductsLoaded?(sortedProducts)
                }
            } catch {
                await MainActor.run {
                    self.setLoading(false)
                    let purchaseError = PurchaseError.loadProductsFailed(error)
                    self.onError?(purchaseError)
                }
            }
        }
    }
    
    private func startTransactionListener() {
        updateListenerTask = Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    await MainActor.run {
                        self.onPurchaseCompleted?(.success(transaction))
                    }
                } catch {
                    print("Transaction verification failed: \(error)")
                    await MainActor.run {
                        self.onError?(.verificationFailed(error))
                    }
                }
            }
        }
    }
    
    func purchase(_ productId: String, appAccountToken: String) async throws -> Transaction {
        if products.isEmpty {
            guard let product = try await Product.products(for: [productId]).first else {
                throw PurchaseError.productNotFound
            }
            return try await purchase(product, appAccountToken: appAccountToken)
        } else {
            guard let product = products.first(where: { $0.id == productId }) else {
                throw PurchaseError.productNotFound
            }
            return try await purchase(product, appAccountToken: appAccountToken)
        }
    }
    
    /// 购买指定商品
    func purchase(_ product: Product, appAccountToken: String) async throws -> Transaction {
        let uuid = Product.PurchaseOption.appAccountToken(UUID.init(uuidString: appAccountToken)!)
        let result = try await product.purchase(options: [uuid])
        switch result {
        case .success(let verificationResult):
            return try checkVerified(verificationResult)
        case .userCancelled:
            throw PurchaseError.userCancelled
        case .pending:
            throw PurchaseError.purchasePending
        @unknown default:
            throw PurchaseError.unknownError
        }
    }
    

    // MARK: - Private Methods
    
    private func setLoading(_ loading: Bool) {
        isLoading = loading
        onLoadingStateChanged?(loading)
    }
    
    /// 验证交易签名
    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified(_, let error):
            throw PurchaseError.verificationFailed(error)
        case .verified(let safe):
            return safe
        }
    }
}

// MARK: - Supporting Types

enum PurchaseResult {
    case success(Transaction)
    case cancelled
    case pending
    case failed(PurchaseError)
}

enum PurchaseError: Error, LocalizedError {
    case productNotFound
    case loadProductsFailed(Error)
    case purchaseFailed(Error)
    case verificationFailed(Error)
    case userCancelled
    case purchasePending
    case unknownError
    
    var errorDescription: String? {
        switch self {
        case .loadProductsFailed(let error):
            return "load products failed: \(error.localizedDescription)"
        case .purchaseFailed(let error):
            return "purchase failed: \(error.localizedDescription)"
        case .verificationFailed(let error):
            return "verifify failed: \(error.localizedDescription)"
        case .unknownError:
            return "unknown error occurred"
        case .userCancelled:
            return "user cancelled the purchase"
        case .purchasePending:
            return "purchase is pending, please complete it later"
        case .productNotFound:
            return "product not found, please check the product ID"
        }
    }
}

// 外界调用
    private func requestProduct(_ productId: String) {
        self.productId = productId
        Task {
            do {
                let transaction = try await HSSK2Manager.shared.purchase(productId)
                // 打印transaction
                print("Transaction successful: \(transaction)")
                await MainActor.run {
                    handleTransaction(transaction)
                }
            } catch {
                purchaseFailed(with: error as? PurchaseError)
            }
        }

    private func handleTransaction(_ transaction: Transaction) {
                  // 和服务器校验订单
                  await checkOrderWithServer(transaction.id)
                  // 一定要调用,不然属于掉单,会在二次购买时提示重复购买
                await transaction.finish()
    }

这份代码只有消耗型商品,没有订阅类型的商品,其中有两个比较重要的部分

  1. 购买流程,urchase(_ productId: String, appAccountToken: String),第二个参数appAccountToken一般是后台创建的订单id,我们把这个转成uuid传给苹果服务器,购买完成后,苹果会校验checkVerified,如果订单ok,则传递给服务器去校验,服务器会根据前面的订单id去苹果服务器校验,没问题再通过接口返回给客户端,客户端再调用await transaction.finish(),完成这个订单,这一句非常关键,如果漏掉这一句,重新点击购买同一个productId的商品,是不会弹出购买弹窗的。
  2. 掉单处理,如果苹果服务器迟迟没有发数据给我们,或者服务端去苹果那边校验迟迟没有成功,这时我们只要不调用await transaction.finish(),在苹果看来我们的订单就没有算完成,我们创建一个分离的线程去监听它,也就是startTransactionListener(),这个函数最好在app启动的时候就监听,方便苹果在用户拉起app的时候就可以获取到之前没有完成的订单,这个Transaction.updates会把之前没有完成的订单都重新发给我们,重走校验流程。

切换到沙盒测试

如果我们在Xcode环境购买正常,就可以和后台联调了,先把Edit Scheme->Option->Storekit Configuration置为None


image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容