iOS内购中碰到的问题与解决方案13.3.1最新问题

公司项目中需要做一个会员购买功能,当时一听到这个需求就知道要跟内购扯上关系了,于是乎开始在网上大量找相关资料,也包括其他开发者遇到的问题。不过....但是....在实际上线后还是碰到了意想不到的问题,真是措手不及啊!


委屈

iOS13.3.1最新bug,如果事先未处理好会导致的漏单,这点在最下方详细介绍。

在网上找到了一个比较厉害的第三方库[RMStore](https://github.com/robotmedia/RMStore),虽然我项目中确实最终导入了它,但是感觉它针对我们普通购买功能来说,代码显的有些冗余了。介于人家良好的口碑,最终采用他了,不过需要在它里面增加自己的代码哦。

我们采用的是服务器接口验证流程,这样应该更加靠谱一些。

发起内购

并将我们服务器生成的预订单号存储起来且一并传入

- (void)addPayment:(NSString*)productIdentifier
              user:(NSString*)userIdentifier
           success:(void (^)(SKPaymentTransaction *transaction))successBlock
           failure:(void (^)(SKPaymentTransaction *transaction, NSError *error))failureBlock
{
    SKProduct *product = [self productForIdentifier:productIdentifier];
    if (product == nil)
    {
        RMStoreLog(@"unknown product id %@", productIdentifier)
        if (failureBlock != nil)
        {
            NSError *error = [NSError errorWithDomain:RMStoreErrorDomain code:RMStoreErrorCodeUnknownProductIdentifier userInfo:@{NSLocalizedDescriptionKey: NSLocalizedStringFromTable(@"Unknown product identifier", @"RMStore", @"Error description")}];
            failureBlock(nil, error);
        }
        return;
    }
    SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
    if ([payment respondsToSelector:@selector(setApplicationUsername:)])
    {
        payment.applicationUsername = userIdentifier;
    }
    RMAddPaymentParameters *parameters = [[RMAddPaymentParameters alloc] init];
    if(userIdentifier){
        parameters.userid = userIdentifier;
    }
    parameters.successBlock = successBlock;
    parameters.failureBlock = failureBlock;
    _addPaymentParameters[productIdentifier] = parameters;
    [[SKPaymentQueue defaultQueue] addPayment:payment];
}

在支付成功后,将预订单号保存起来,并与苹果的订单号绑定起来,并存储到keychain中

#pragma mark Transaction State
- (void)didPurchaseTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue
{
    RMStoreLog(@"transaction purchased with product %@", transaction.payment.productIdentifier);
    if(transaction.payment.productIdentifier != nil){
        RMAddPaymentParameters *parameters = _addPaymentParameters[transaction.payment.productIdentifier];
        if(parameters){
            //如果这个参数存在,则肯定是通过主动发起购买请求引起的
            //在支付成功后,将parameters中的预订单号存起来,并与苹果的订单号绑定起来,并存储到keychain中
            if(parameters.userid && transaction.transactionIdentifier){
                [SAMKeychain setPassword:parameters.userid forService:@"qixiubaodian.server" account:transaction.transactionIdentifier];
                NSString *openid = [[BDUserManager manager] getCurrentUserInfo].tokenInfo.uid;
                if(openid.length > 0){
                    ///每次用户支付完之后,将记录保存一份到有盟平台
                    [[BDEventMonitor monitor] event:Event_Purchase_Buy attributes:@{@"openid":openid,@"orderNumber":parameters.userid.copy}];
                    ///本地数据库也保存一份
                    [[BDLocalityDataManager manager] savePruchaseTransactionIdentifier:transaction.transactionIdentifier orderNumber:parameters.userid openId:openid];
                }
            }
        }
    }
    if (self.receiptVerifier != nil)
    {
        [self.receiptVerifier verifyTransaction:transaction success:^{
            [self didVerifyTransaction:transaction queue:queue];
        } failure:^(NSError *error) {
            [self didFailTransaction:transaction queue:queue error:error];
        }];
    }else{
        RMStoreLog(@"WARNING: no receipt verification");
        [self didVerifyTransaction:transaction queue:queue];
    }

}


最后就仿照RMStore中的验证代理,实现我们服务器验证接口即可。

重点来了...可能的原因

1、我们在前面将服务器生产的预订单号存在applicationUsername上,但是在实际上线中,发现在需要验证的时候,一定概率上取出来的为nil值。

2、根据很多用户的反馈,发现用户在成功支付后,竟然没有支付成功的回调,就是)didPurchaseTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue没有被调用。当然这个问题猜测是这样的,因为线上的问题只能根据事先埋下的点来分析了。既然在验证的时候取不到预订单号,那么最终我们采取的是在内购发起之前就将生成号的预订单号保存到本地,只有当用户取消或者支付验证成功后,才从本地移除。

我这边做了一个持续验证的管理着,只有队列中有没有完成的订单,则间隔一段时间不停的去验证,直到验证成功为止,因此,就算本地有多个预订单号也没有关系。

这一操作上线后,丢单率从30%一下子降到了几乎为0。

然后还是有些用户比较极端,在付款成功后,可能由于网络差的缘故没能充值会员成功,用户竟然将app卸载然后重装了。针对这个问题我们采取了如下思路

1、只有用户点击购买的时候,将获取的预订单号存储到keychain中

2、假如用户取消支付,则在取消支付的回调中将keychain值删除,或者更新为空字符串

3、假设用户成功支付后,并且有回调成功,且后台充值回调也完成,则将keychain值更新或者删除

经过以上步骤,即使漏单了,并且用户卸载后重装app,都可以重新调用找回预订单号进行重新充值会员。当然了,如果用户卸载后换手机那就没辙了。

苹果内购反正就是这样坑,我们只能尽可能降低丢单率,但是谁也无法保证没有丢单。

下面再贴一下我这边验证的方法:

- (void)verifyTransaction:(SKPaymentTransaction*)transaction
                  success:(void (^)())successBlock
                  failure:(void (^)(NSError *error))failureBlock
{
    NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
    // 从沙盒中获取到购买凭据
    NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
    NSString *resultText = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
    if(resultText.length == 0){
        NSString *errorText = [NSString stringWithFormat:@"获取的凭证为空,transactionIdentifier:->%@",transaction.transactionIdentifier];
        if(transaction.payment.applicationUsername != nil){
           errorText = [errorText stringByAppendingFormat:@"applicationUsername:->%@",transaction.payment.applicationUsername];
        }
        NSError *error = [NSError errorWithDomain:errorText code:RMStoreErrorCodeUnableToCompleteVerification userInfo:@{NSLocalizedDescriptionKey:@"获取的凭证为空"}];
        if(failureBlock){
            failureBlock(error);
        }
        //加入任务队列,持续进行验证请求
        [[BDPurchaseManager manager] verifityPurchaseWhenFailWithParams:nil transaction:transaction];
        [Bugly reportError:error];
        return;
    }
    //苹果的订单ID
    NSString *identifier = transaction.transactionIdentifier;
    // 2.5.1第一版内购中,由于applicationUsername返回空值,导致漏单,针对部分用户后台手动添加了,所以有些则需要进行数据清除
    NSString *uid = [[BDUserManager manager] getCurrentUserInfo].tokenInfo.uid;
    if(uid.length > 0){
        if(([identifier isEqualToString:@"420000514257547"] && [uid isEqualToString:@"269378"])){
            NSError *error = [NSError errorWithDomain:@"已经支付过" code:BDPruchaseManagerERrorCodeVerifiHadPay userInfo:@{NSLocalizedDescriptionKey:@"已经支付过"}];
            if(failureBlock){
                failureBlock(error);
            }
            return;
        }
    }
    //自家服务器生成的预订单号
    NSString *orderId = transaction.payment.applicationUsername;
    __block BOOL orderNumberFromFirstTable = false;
    if(orderId.length == 0){
        //则从本地数据库进行查找
        orderId = [[BDLocalityDataManager manager] purchaseOrderNumberWithTransactionIdentifier:identifier];
        if(orderId.length == 0){
            //从keychain上找找有没有这个对应的预订单号
            NSString *savedOrderNumber = [SAMKeychain passwordForService:@"qixiubaodian.server" account:identifier];
            if(savedOrderNumber != nil && savedOrderNumber.length != 0){
                orderId = savedOrderNumber;
            }
            //如果keychain上还是没有,则从本地的初始预订单中寻找
            if(orderId.length == 0){
                if(uid.length > 0){
                    orderId = [[BDLocalityDataManager manager] getFirstOrderNumberWithUserId:uid];
                    if(orderId.length > 0){
                        orderNumberFromFirstTable = true;
                    }
                }
            }
        }
    }
    if(orderId.length == 0){
        NSError *error = [NSError errorWithDomain:@"预订单号为空" code:RMStoreErrorCodeUnableToCompleteVerification userInfo:@{NSLocalizedDescriptionKey:@"预订单号为空"}];
        if(failureBlock){
            failureBlock(error);
        }
        //加入任务队列,持续进行验证请求
        [[BDPurchaseManager manager] verifityPurchaseWhenFailWithParams:nil transaction:transaction];
        NSError *buglyError = [NSError errorWithDomain:[NSString stringWithFormat:@"预订单号为空 -> transactionIdentifier:%@,票据:...",identifier] code:0 userInfo:nil];
        [Bugly reportError:buglyError];
        return;
    }
    //生成一个加密Key,用resultText拼接key之后在md5加密即可
    NSString *secretText = [resultText stringByAppendingString:BD_PURCHASE_SGIN_KEY];
    //生成了加密value
    NSString *sginValue = [BDPublicWays MD5EncodedString:secretText];

///接下去就是那这些参数调用自家服务器验证接口了

如何在App Store中显示要推荐的内购商品

1、需要在App Store后台勾选推广按钮


勾选推广

2、需要实现- (BOOL)paymentQueue:(SKPaymentQueue *)queue shouldAddStorePayment:(SKPayment *)payment forProduct:(SKProduct *)product

一般返回false即可,表示自己处理,如果返回true,则表示系统帮你处理,(我没有试过哈,别人说的...)

内购遇到的问题就这些了,如果各位有什么问题可以留言回复哈!

2020年4月1号最新更新:

在iOS13.3.1中公司线上出现了几粒漏单,系统全部为13.3.1,通过事先代码埋点和本人精明大脑的分析,终于得出一个结论,那就是:在支付成功回调结果

didPurchaseTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue

transaction的transactionIdentifier大概率为nil,这应该是iOS13.3.1的bug,但是如果缓存机制处理的好的话,是不会影响最终的结果,坏就坏在在缓存处理中我出现了一个失误而导致漏单。好了,这次更新内容导致为止,希望可以帮到大家。

2020年4月24号最新更新:

最近线上依然存在漏单情况,通过对用户的电话回访和自己实测分析得出,有些情况会导致明明支付成功,但是回调显示fail,不知道是不是RMStore的问题,或者是苹果的bug问题,但也可能是自己的问题。以下场景有可能导致支付成功但是回调显示失败: 用户点击内购 -> 弹出商品购买页->用户输入密码或指纹支付->显示支付成功->用户appstore没有绑定付款方式->用户在手机系统设置页面进行绑定操作->系统跳转到appstore进行再次验证支付->支付成功。

在以上操作可能发现,app一直在后台,因为有可能在切会app时导致回调提示失败。因此我另外建了一张表,只要发起内购请求前就将订单号存储到表中,直到自己这边验证成功并给用户充值后才将这个订单号从数据库中移除。至此,内购漏单问题告一段落。

2021年4月9号最新更新

线上发生了同一个用户的三次漏单,

第二个漏单与第一次漏单间隔了4秒,第三次漏单与第二次漏单间隔了15秒。这个地方有点奇怪,我们UI处理上不太可能存在4秒之内连续支付的情况。

最终这三次在下面回掉中:

- (void)didPurchaseTransaction:(SKPaymentTransaction *)transaction queue:(SKPaymentQueue*)queue


transaction.transactionIdentifier全部为nil,

可以分析出:用户三次支付后,都未成功充值到具体的商品,但是用户居然至此之后就没有登陆过了,是用户太任性了还是用户本身操作有什么玄机吗?

2021年4月15号更新

在一次测试环境中 发现了一个奇怪的bug,在完成一个订单后,且执行了finishTransaction操作,但是在下次启动的时候,在下面的回调中

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions

居然又返回了同一个productIdentifier的订单,我擦,这是什么鬼,然后我又强制调用finishTransaction操作,下次启动时后仍然存在!幸亏这个是在 测试环境下,如果正式环境下不得出乱子啊。估计苹果是在测试啥么玩意吧!

后续有问题将继续跟踪并更新。

目前更新于2021年8月4日,暂未发现新的问题!

举报
评论 0