Bootstrap

iOS 内购接入StoreKit2 及低与iOS 15 版本StoreKit 1 兼容方案实现

背景

随着CSDN APP用户 iOS 15以上系统占比覆盖度到98%,我们与支付中台决定接入StoreKit 2,彻底告别掉单,恶意退款等iOS内购遇到的问题。本文不阐述,StoreKit2 的详细说明,如果有兴趣的可以查看官方文档。本文只演示项目接入代码。

实现方案

因为StoreKit2 只支持 Swift语言,下面案例均用Swift来实现,如有OC项目需要混编,可以暴漏了方法给OC调用就行。

获取商品列表

     /// 通过 productIds 请求 Product 列表
    /// - Parameter productIds: product ids
    /// - Returns: Product 列表
    public func requestProducts(productIds: [String]) async -> [Product]? {
        products = try? await Product.products(for: Set.init(productIds))
        return products
    }

拉起应用内购买(唤起支付)

StoreKit 2 支持传入一个appAccountToken 这个值必须是UUID格式,这个UUID可以关联我们的订单和苹果的交易。我们可以将我们的订单编号,补充转换成UUID格式。这样我们就可以保证我们的订单与苹果订单唯一绑定,如果有发生掉单,用户可以拿着交易凭证上面的订单号,通过苹果给的查询交易的接口,先查询到苹果的交易id,然后再找到我们的订单号,完成补单操作。

/// 发起支付
    /// - Parameter product: Product对象
    public func purchase(product: Product, uid: String) async throws -> Transaction? {
        guard purchaseState != .inProgress else {
            throw PurchaseException.purchaseInProgressException
        }
        
        purchaseState = .inProgress
        
        //App account token
        //用于将用户订单ID 绑定到交易(Transcation)中,即可建立苹果的交易订单数据与用户信息的映射关系,方便数据整合与追溯
        let uuid = Product.PurchaseOption.appAccountToken(UUID.init(uuidString: uid)!)
        //发起支付流程
        guard let res = try? await product.purchase(options: [uuid]) else {
            purchaseState = .failed
            throw PurchaseException.transactionVerificationFailed
        }
        
        var validateTransaction: Transaction? = nil
        
        switch res {
        case .success(let verificationResult):
            //购买状态:成功
            
            print("用户购买成功")
            purchaseState = .complete

          //可以将交易ID回传给服务端,服务端通过调用Appstore API来验证交易的可信,然后下发对应权益
          let checkResult = checkTransactionVerificationResult(verificationResult)
            if !checkResult.verified {
                purchaseState = .failedVerification
                throw PurchaseException.transactionVerificationFailed
            }
            
            validateTransaction = checkResult.transaction
            
            //结束交易
            await validateTransaction!.finish()
            
        case .userCancelled:
            //购买状态:用户取消
            print("用户取消购买")
            purchaseState = .cancelled
            throw PurchaseException.purchaseUserCancelled
            
        case .pending:
            //购买状态:进行中
            print("用户购买中")
            purchaseState = .pending
            
        default:
            //购买状态:未知
            print("用户购买状态:未知")
            purchaseState = .unknown
        }
        
        return validateTransaction
    }

交易信息校验

     /// 校验
    /// - Parameter result: 支付返回结果
    /// - Returns: 是否验证成功
    private func checkTransactionVerificationResult(_ result: VerificationResult<Transaction>) -> (transaction: Transaction, verified: Bool) {
        //Check whether the JWS parses StoreKit verification.
        switch result {
        case .unverified(let transaction, _):
            //StoreKit parses the JWS, but it fails verification.
            return (transaction: transaction, verified: false)
        case .verified(let transaction):
            //The reult is verified. Return the unwrapped value.
            return (transaction: transaction, verified: true)
        }
    }

交易状态监听

我们测试发现,沙盒环境会将已经完成的订单也会通过该方法通知过来,所以服务端要对已经验证过,下发权益的交易做处理。

/// 支付监听事件
    public func listenForTransaction(completion:@escaping (Transaction) -> Void) -> Void {
         Task.detached {
            for await verificationResult in Transaction.updates {
                let checkResult = self.checkTransactionVerificationResult(verificationResult)
                
                if checkResult.verified {
                    let validatedTransaction = checkResult.transaction
                    await validatedTransaction.finish()
                    //有未完成的订单,需要重新发送给服务端验证,是否下发权益
                    completion(validatedTransaction)
                } else {
                    print("Transaction failed verification.")
                }
            }
        }
    }

恢复购买

/// 恢复购买
    public func reStorePurchase(){
        Task {
            try? await AppStore.sync()
        }
    }

发起退款

StoreKit 2 也提供了退款方法,使得退款流程更加简洁。沙盒测试退款,也更加容易。

/// 发起退款
    /// - Parameters:
    ///   - transactionId: transaction.originalID
    ///   - scene: Window scene
    public func refunRequest(for transactionId: UInt64, scene: UIWindowScene!) async {
        do {
            let res = try await Transaction.beginRefundRequest(for: transactionId, in: scene)
            switch res {
            case .userCancelled:
                // Customer cancelled refund request.
                print("用户取消退款。")
            case .success:
                print("退款提交成功。")
                // Refund request was successfully submitted.
            @unknown default:
                print("退款返回错误:未知")
            }
        }
        catch StoreKit.Transaction.RefundRequestError.duplicateRequest {
            print("退款请求错误:重复请求")
        }
        catch StoreKit.Transaction.RefundRequestError.failed {
            print("退款请求错误:失败")
        }
        catch {
            print("退款请求错误:其他")
        }
    }

StoreKit 1 兼容

因为StoreKit2 只支持iOS 15以上系统设备,那对于低于iOS 15的设备还需要使用StoreKit1,并且StoreKit2 不支持 AppStore 活动与AppStore 订阅SKU的推广,对于有这个需求的也是需要使用StoreKit1来实现

- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product {
    // product里存放的有我们配置在App Store的产品ID,以及价格等等
    return NO;
}

StoreKit1 中,如果给SKPayment 设置了applicationUsername 并且这个值是UUID类型的话,服务端在调用苹果API的时候,也是可以拿到这个值的。这块可以在官网看到具体介绍。所以我们可以和StoreKit2 一样,给applicationUsername 增加UUID类型的订单号绑定。这样不管是StoreKit1 还是StoreKit2,都不会有掉单找不到订单的风险。

SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
if ([payment respondsToSelector:@selector(setApplicationUsername:)])
 {
   payment.applicationUsername = uuid;
 }
[[SKPaymentQueue defaultQueue] addPayment:payment];

服务端

服务端不再采用原来的上传沙盒票据校验的方式,统一使用App Store Server API JWS方式来验证。这块具体查看官方文档就行。对于自动续订,及退款,使用 App Store Server Notifications 来实现,续订权益下发,及退款的权益回收。

;