APP 内购功能开发

1,039 阅读9分钟

一、背景

对于虚拟商品的购买,不管在 ios 还是 在 google play 上都要求必须完成使用内部购买的方式,才可以上架, 以下是对 ios 和 android 的google play 内购功能开发时功能以及遇到问题的记录

二、Ios 内购开发

1、项目的Signing&Capabilities中添加内购能力

image.png

很坑,若不添加, 发布后真实支付的场景下,无法拿到recipet 凭证

Appconnect 中添加购买项目, 注意产品id是唯一固定的,无法修改,删除后也无法再用,支付时候可以直接使用 产品id 去唤起支付, 需要注意的是, ios 上的购买项目价格的设置是需要在ios内定的价格区间内选择接近值, 无法自定义价格

image.png

2、 IAPManager 文件中管理支付
2-1、抽离支付业务,传入 产品id 和 回调函数
- ( void )startPurchaseWithID:( NSString *)purchID completeHandle:(IAPCompletionHandle)handle; 
2-2、传入purchID ,发起支付
- (void)startPurchaseWithID:(NSString *)purchID completeHandle:(IAPCompletionHandle)handle{
    if (purchID) {
        // 应用程序内是否可以购买
        if ([SKPaymentQueue canMakePayments]) {
            _currentPurchasedID = purchID;
            _iAPCompletionHandle = handle;
            //从App Store中检索关于指定产品列表的本地化信息
            NSSet *nsset = [NSSet setWithArray:@[purchID]];
            SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:nsset];
            request.delegate = self;
            [request start];
        }else{
            // 不允许程序内购买
            [self handleActionWithType:IAPPurchNotArrow data:nil];
        }
    }
}
2-3、发起购买请求的 delegate
#pragma mark - SKProductsRequestDelegate
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{
    NSArray *product = response.products;
    if([product count] <= 0){
#if DEBUG
        NSLog(@"--------------没有商品------------------");
#endif
        return;
    }
     
    SKProduct *p = nil;
    for(SKProduct *pro in product){
        if([pro.productIdentifier isEqualToString:_currentPurchasedID]){
            p = pro;
            break;
        }     
        NSLog(@"Product found: %@", pro.localizedTitle);
        // 存储产品以便稍后购买
    }
   
#if DEBUG
    NSLog(@"productID:%@", response.invalidProductIdentifiers);
    NSLog(@"产品付费数量:%lu",(unsigned long)[product count]);
    NSLog(@"产品描述:%@",[p description]);
    NSLog(@"产品标题%@",[p localizedTitle]);
    NSLog(@"产品本地化描述%@",[p localizedDescription]);
    NSLog(@"产品价格:%@",[p price]);
    NSLog(@"产品productIdentifier:%@",[p productIdentifier]);
#endif
     
    SKPayment *payment = [SKPayment paymentWithProduct:p];
    [[SKPaymentQueue defaultQueue] addPayment:payment];
}
2-4、实现交易处理的方法
/**
 实现交易处理方法
 */
#pragma mark - SKPaymentTransactionObserver
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions{
    for (SKPaymentTransaction *tran in transactions) {
        switch (tran.transactionState) {
            case SKPaymentTransactionStatePurchased:
                [self verifyPurchaseWithPaymentTransaction:tran];
                break;
            case SKPaymentTransactionStatePurchasing:
#if DEBUG
                NSLog(@"商品添加进列表");
#endif
                break;
            case SKPaymentTransactionStateRestored:
#if DEBUG
                NSLog(@"已经购买过商品");
#endif
                // 消耗型不支持恢复购买
                [[SKPaymentQueue defaultQueue] finishTransaction:tran];
                break;
            case SKPaymentTransactionStateFailed:
                [self failedTransaction:tran];
                break;
            default:
                break;
        }
    }
}

2-5、验证交易 verifyPurchaseWithPaymentTransaction
  - (void)verifyPurchaseWithPaymentTransaction:(SKPaymentTransaction *)transaction{
  //交易验证
    // 1. 获取应用的收据URL
    NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
    // 2. 读取收据数据
    NSData *receipt = [NSData dataWithContentsOfURL:recepitURL];

    NSLog(@"receipt found: %@", receipt);

    // 3. Base64编码收据数据
    NSString *base64EncodedReceipt = [receipt base64EncodedStringWithOptions:0];
    if(!receipt){
        // 交易凭证为空验证失败
        [self handleActionWithType:IAPPurchVerFailed data:nil];
        return;
    }
    
    // 购买成功将交易凭证发送给服务端进行再次校验
    [self handleActionWithType:IAPPurchSuccess data:base64EncodedReceipt];
    
    // 这里也可以从这里发送接口请求到服务端校验,也可以回调到h5 发送
    
    // 验证成功与否都注销交易,否则会出现虚假凭证信息一直验证不通过,每次进程序都得输入苹果账号
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    }
    

验证交易是否成功,apple 会给交易凭证 receipt 服务端校验当前凭证是否有效, 然后发放权益, 这时候已经是支付成功,可以通过购买成功,将交易凭证发送到h5端,在向服务端发送校验请求并处理发放权益, 也可以在这里继续发送请求调用服务端接口进行校验发放权益, 这里选择的是发送到h5 后在向服务端发起后续接口调用, 一方面是h5发送无需再次鉴权,二是可以将购买和权益发放两个事分开

如果想在 ios端直接向服务端发送请求,校验接口 用下面的代码实现, 如果在h5 发送ajax 请求忽略这段

  // 5. 创建请求
    NSString *serverString = @"https://server.com/webhook/apple/store";
    NSURL *storeURL = [NSURL URLWithString:serverString];
    NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:storeURL];
    // 设置请求头,例如,如果你发送的是JSON数据
    [storeRequest setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    
    // 设置请求体
    NSDictionary *parameters = @{
        @"receipt": base64EncodedReceipt
    };
    // 设置请求方法
    [storeRequest setHTTPMethod:@"POST"];
    
    // 将请求体添加到请求中
    NSError *error;
    NSData *postData = [NSJSONSerialization dataWithJSONObject:parameters options:0 error:&error];
    [storeRequest setHTTPBody:postData];
    
    // 创建NSURLSession
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
    
    // 6. 启动任务 
    [dataTask resume];
2-6、交易失败处理
//请求失败
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{
#if DEBUG
    NSLog(@"------------------从App Store中检索关于指定产品列表的本地化信息错误-----------------:%@", error);
#endif
}
 
- (void)requestDidFinish:(SKRequest *)request{
#if DEBUG
    NSLog(@"------------requestDidFinish-----------------");
#endif
}
2-7、处理不同交易类型,执行回调方法将参数返回
- (void)handleActionWithType:(IAPPurchType)type data:(NSString *)data{
#if DEBUG
    switch (type) {
        case IAPPurchSuccess:
            NSLog(@"购买成功");
            break;
        case IAPPurchFailed:
            NSLog(@"购买失败");
            break;
        case IAPPurchCancel:
            NSLog(@"用户取消购买");
            break;
        case IAPPurchVerFailed:
            NSLog(@"订单校验失败");
            break;
        case IAPPurchVerSuccess:
            NSLog(@"订单校验成功");
            break;
        case IAPPurchNotArrow:
            NSLog(@"不允许程序内付费");
            break;
        default:
            break;
    }
#endif
    if(_iAPCompletionHandle){
        _iAPCompletionHandle(type,data);
    }
}
2-8、ViewController 中调用
// h5发起购买
- (void)buyClickWithProductID:(NSString *)productID {
    [[IAPManager shareIAPManager] startPurchaseWithID:productID completeHandle:^(IAPPurchType type, NSString * _Nullable data) {
        switch (type) {
            case IAPPurchSuccess:
                NSLog(@"购买成功: %@",data);
                // 处理购买成功的逻辑
                [self channelMessage:@"buysuccess" withData:data];
                break;
            case IAPPurchFailed:
                NSLog(@"购买失败");
                // 处理购买失败的逻辑
                [self channelMessage:@"buyfail" withData: @"购买失败"];
                break;
            case IAPPurchCancel:
                NSLog(@"取消购买");
                // 处理取消购买的逻辑
                [self channelMessage:@"buyReport" withData: @"购买取消"];
                break;
            case IAPPurchVerFailed:
                NSLog(@"订单校验失败");
                // 处理订单校验失败的逻辑
                [self channelMessage:@"buyReport" withData: @"订单校验失败"];
                break;
            case IAPPurchVerSuccess:
                NSLog(@"订单校验成功");
                // 处理订单校验成功的逻辑
                [self channelMessage:@"buyReport" withData: @"订单校验成功"];
                break;
            case IAPPurchNotArrow:
                NSLog(@"不允许内购");
                // 处理不允许内购的逻辑
                [self channelMessage:@"buyfail" withData: @"不允许内购"];
                break;
            default:
                break;
        }
    }];
}

三、Android 内购开发

3-1、初始化账单信息

初始化账单信息, 支付客户端初始化失败,增加三次初始化重连机制

override fun onCreate(savedInstanceState: Bundle?) {
   initializeBillingClient()
}

private fun initializeBillingClient() {
    billingClient = BillingClient.newBuilder(this)
        .setListener(purchasesUpdatedListener)
        .enablePendingPurchases()
        .build()
    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(billingResult: BillingResult) {
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                // 支付客户端初始化成功
                callJsFromAndroid("channelMessage","buyReport", "The payment client was initialized successfully.")
            }else{
                callJsFromAndroid("channelMessage","buyReport", "The payment client was initialized fail.")
            }
        }
        override fun onBillingServiceDisconnected() {
            // 处理支付服务断开的情况
            callJsFromAndroid("channelMessage","buyReport", "Handle payment service disconnection")
            // 增加三次重连机制
            retryConnectingToBillingService()
        }
})
}

// 增加三次重连机制
private fun retryConnectingToBillingService() {
    // 可以设置重试次数和重试间隔
    val maxRetries = 3
    val retryDelaySeconds = 5L
    object : CountDownTimer((retryDelaySeconds * 1000).toLong(), (retryDelaySeconds * 1000).toLong()) {
        var retriesCount = 0
        override fun onTick(millisUntilFinished: Long) {
            // 在重试之前,可以在这里显示倒计时或提供反馈
        }
        override fun onFinish() {
            if (retriesCount < maxRetries) {
                retriesCount++
                // 尝试重新连接
                billingClient.startConnection(retriesBillingClientStateListener)
                // Toast.makeText(this@MainActivity, "Retrying to connect to the payment service...", Toast.LENGTH_SHORT).show()
            } else {
                // 达到最大重试次数,停止重试
                // Toast.makeText(this@MainActivity, "Failed to connect to the payment service after several attempts.", Toast.LENGTH_LONG).show()
            }
        }
    }.start()
}

// 重连
private val retriesBillingClientStateListener = object : BillingClientStateListener {
    override fun onBillingSetupFinished(billingResult: BillingResult) {
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            // 重连成功
            // Toast.makeText(this@MainActivity, "Reconnected to the payment service successfully.", Toast.LENGTH_SHORT).show()
            callJsFromAndroid("channelMessage", "buyReport", "Reconnected to the payment service successfully.")
        } else {
            // 重连失败,可以在这里决定是否需要进一步的重试或处理
            callJsFromAndroid("channelMessage", "buyReport", "Reconnected to the payment service fail.")
        }
    }

    override fun onBillingServiceDisconnected() {
        // 如果在重试过程中再次断开,可以在这里处理
        callJsFromAndroid("channelMessage", "buyReport", "Billing service disconnected during reconnection attempts.")
    }
}

3-2、处理支付回调 purchasesUpdatedListener

支付成功调用 handlePurchase 方法,失败处理提示等

private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases!= null) {
        Log.d("支付成功", "Payment successful: " + billingResult.responseCode);
        for (purchase in purchases) {
            handlePurchase(purchase)
        }
    } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
        Log.d("支付取消", "User canceled the payment: " + billingResult.responseCode);
        callJsFromAndroid("channelMessage", "buyReport", "User canceled the payment: " + billingResult.responseCode)
    } else {
        Log.d("支付失败", "Payment failed: " + billingResult.responseCode);
        // 根据具体的响应码处理不同的错误情况
        callJsFromAndroid("channelMessage","buyReport", "Payment failed: ${billingResult.responseCode}");
        Toast.makeText(this, "Payment failed: ${billingResult.responseCode}", Toast.LENGTH_LONG).show()
    }
} 

处理购买成功

private fun handlePurchase(purchase: Purchase) {
        val consumeParams = ConsumeParams.newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()
        val listener = ConsumeResponseListener { billingResult, purchaseToken ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                val json = purchaseInfoToJson(purchase)
                // 购买成功 将json 发送到h5在调用服务端接口验证购买凭证发放权益
                callJsFromAndroid("channelMessage","buysuccess",json)
            }else{
                callJsFromAndroid("channelMessage","buyfail", "Purchase consumption failed, please try again")
                // 消耗失败,可以处理错误或重试
                consumeAsyncWithRetries(billingClient, consumeParams, 0, 3)
                // 例如,你可以提示用户消耗失败,并尝试再次消耗
//                Toast.makeText(this, "Purchase consumption failed, please try again", Toast.LENGTH_LONG).show()
            }
        }
if (billingClient!= null) {
            billingClient.consumeAsync(consumeParams, listener)
        }
    }

并且消耗成功后,调用消息通知将支付信息发送到h5端,调用服务端接口进行验证支付凭证,发放权益,这里遇到一个问题,json 对象序列化后在发送到前端时, json 对象反解后格式化有一些问题,无法正常反解出正确的值,所有这里对 purchase 用purchaseInfoToJson方法处理了一下, 用base64Encode编码一下, 在h5端在解码解决了这个问题

fun base64Encode(input: String): String {
    val encodedBytes = Base64.getEncoder().encode(input.toByteArray(Charsets.UTF_8))
    return String(encodedBytes, Charsets.UTF_8)
}

fun purchaseInfoToJson(purchase: Purchase): String {
    val gson = Gson()
    val purchase = mapOf(
        "data" to purchase.originalJson,
        "signature" to purchase.signature
)
    return base64Encode(gson.toJson(purchase))
}

支付成功后会存在消耗失败的情况, 增加三次消耗失败的重试

 private fun consumeAsyncWithRetries(billingClient: BillingClient, consumeParams: ConsumeParams, currentRetry: Int, maxRetries: Int) {
    val listener = ConsumeResponseListener { billingResult, _ ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            // 消耗成功,执行后续操作
            // 例如,通知用户购买成功
            callJsFromAndroid("channelMessage", "buysuccess", "retry Purchase consumed successfully")
        } else if (currentRetry < maxRetries) {
            // 如果当前重试次数小于最大重试次数,则进行重试
            val delay = (currentRetry + 1) * 1000L // 延迟时间,例如:1000毫秒、2000毫秒、3000毫秒...
            Thread.sleep(delay) // 等待一段时间后重试
            consumeAsyncWithRetries(billingClient, consumeParams, currentRetry + 1, maxRetries)
        } else {
            // 达到最大重试次数,处理失败情况
            // 例如,通知用户重试失败,并提示他们稍后尝试
            callJsFromAndroid("channelMessage", "buyfail", "retry Purchase consumption failed, please try again")
            consumeAsyncWithRetries(billingClient, consumeParams, 0, 3)
        }
    }

billingClient.consumeAsync(consumeParams, listener)
}

3-3、发起支付

override fun toGooglePay(productId:String, userId:String) {
    // 调用H5中的一个方法
    val productList = listOf(
        QueryProductDetailsParams.Product.newBuilder()
            .setProductId(productId)
            .setProductType(BillingClient.ProductType.INAPP)
            .build()
    )
    val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
        .setProductList(productList)
        .build()

    if (billingClient!= null) {
        billingClient.queryProductDetailsAsync(
            queryProductDetailsParams,
            object : ProductDetailsResponseListener {
                override fun onProductDetailsResponse(billingResult: BillingResult, productDetailsList: List<ProductDetails>) {
                    if (productDetailsList.isNotEmpty()) {
                        val productDetails = productDetailsList[0]
                        val productDetailsParamsList = listOf(
                            BillingFlowParams.ProductDetailsParams.newBuilder()
                                .setProductDetails(productDetails)
                                .build()
                        )
                        val billingFlowParams = BillingFlowParams.newBuilder()
                            .setProductDetailsParamsList(productDetailsParamsList)
                            .setObfuscatedAccountId(userId)
                            .build()

                        billingClient.launchBillingFlow(this@MainActivity, billingFlowParams)
                    } else {
                        callJsFromAndroid("channelMessage","buyfail", "Invalid product ID")
                    }
                }
            }
        )
    }
}

四、总结

ios端和android端调用支付的流程大体都是一样的,通过特定的方法和库,通过传入productId 发起支付,唤起内购弹窗, 在后台编辑好产品id和价格,支付成功后,会返回购买凭证,用于发送到服务端进行验证账单的真伪,然后发放权益, 这步验证可以在端发起请求,也可以发送到h5端发起请求, 我们选择的是发送到h5 在从h5发起请求,好处是可以将逻辑都统一到h5端处理, 无须关心端上的登录态问题,同时带来的问题比如android端返回的购买信息在序列化后传输到h5端无法反解正确的值,通过base64编码后传输再解码进行解决, ios 端项目需要特别注意的是项目内一定要声明内购的能力, 否则真实支付拿不到recpet,就无法在服务端进行验证真伪,无法正常发放权益, android 端通过埋点观测发现初始化和支付成功后的消耗订单阶段容易失败, 分别增加了三次重试的逻辑, 在后台订单管理中仍然可以看到不少用户会由于自身原因导致购买失败,所以增加开发了失败后引导到h5网站去下单的逻辑