内购支付踩过的坑以及自己的解决途径

2,880 阅读9分钟

更新:经过这几天的用户反馈及自己的查找,发现了一些问题。首先,在添加观察者之前是获取不到未完成订单的,只有在观察者的updateTransaction方法中才能获取到,所以,我和服务端同事联调做了如下调整:

上个版本做的内购支付,在内购封装方法中有过初步介绍和整理,结果在版本上线后收到用户的反馈说是支付成功,但是充值账户却不能到账,结果引发了退款等恶性问题,下面就我在实际项目中遇到的问题以及解决方案给出详细的介绍(上述给出的链接是swift版本的,由于笔者项目依旧是OC语言,所以下面依旧以OC语言来介绍)

1.封装的内购工具一定要设置为单例模式,且在程序启动的时候初始化并在初始化中设置观察者模式

笔者上个版本中虽说封装了内购支付工具,但是由于经验缺乏,内购工具只在支付页面中有效,结果有一个巨大的坑,用户可能在支付完成之前就退出了支付页面,导致了支付成功但是却没有充值成功的情形,在检查代码之后,我将内购支付工具做成了单例,而且,这个单例的初始化放在了程序入口处,这一点要说明的是,为什么放到入口处呢?是因为放到这里,如果之前有未移除的订单,可以在这里做一些逻辑处理,因为项目及实际情况,笔者是这样处理的:

这个方法不能奏效,移除不用,此思路就是错的

- (void)removeOldTransaction {

/*
    NSArray *tansactions = [SKPaymentQueue defaultQueue].transactions;
    //如果没有移除过订单信息
    BOOL result = NO;
    
    if ( ![kUserDefaults boolForKey:@"hasFinishOldTransaction"] && tansactions.count > 0) {
        for (SKPaymentTransaction *transaction in tansactions) {
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
        }
        result = YES;
    }
    [kUserDefaults setBool:YES forKey:@"hasFinishOldTransaction"];
    if (result) {
        return;
    }
*/
}

+ (instancetype)sharedInstance {

    static YGIAPTool *tool;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        tool = [[YGIAPTool alloc] init];
    });
    return  tool;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
       // [self removeOldTransaction];移除不用
        [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
    }
    return self;
}

为什么要移除掉旧的订单呢?因为我之前的错误逻辑,导致一些订单就算支付成功而且成功充值,也没有移除订单,这个时候如果设置了观察者,苹果提供的系统API中会自动去查询有没有未移除的订单,这样就会继续执行充值逻辑,可能会造成重复充值的情形,为了避免这种情况带来的损失,笔者就只能硬性要求在版本升级后启动时移除旧的订单,这样就不会有这种隐忧了。

更新:此处描述有误,硬性移除订单是不可取的,会给用户造成一定的损失,这里只需要指定updateTranscation方法,按照正确逻辑走就可以了

didFinishLaunching中调用初始化方法 [YGIAPTool sharedInstance];

更新,关于何时移除订单的问题,之前想着本地存取凭证可以管理订单,后来偶然间发现,尽管是同一个订单,如果有未完成的,每次启动app,执行到updateTransaction方法后,走到Purchased状态后,取出的凭证都是不一样的,而交易的transactionIdentifier是一样的,所以在订单移除的问题上做了一些调整,首先,本地不用管理凭证,因为管理也没有用。因为业务需求,我们不再存储凭证,而是存储交易id,每次判断本地是否有交易id,如果某一条交易已经有交易id了,就记录到服务端,方便以后对账。这个时候结束交易我们选择放到了充值成功,也就是success之中,同时移除掉本地存储的交易id。

2.关于何时移除订单的问题

我之前搜索过相关的问题,网上给出的答案大都是在充值业务成功之后再移除订单,这个也有一定的问题,主要的就是网络问题或者是用户在充值完成之前就退出或者意外中断的时候引发的问题,这些情况下都会造成订单不能及时移除,给支付体验和充值风险上带来一定的问题。那么,怎么解决这种情况呢?当然,我所提供的方案也只是相对自己遇到的问题上有所改善,至于全面而深入的方案,有知道的大神麻烦指点一下,不胜感激。

我们都知道,如果在客户端去处理验证凭证的逻辑,很容易被有心人入侵做手脚,这个时候常用的保险做法就是客户端将本次交易产生的凭证发给服务端,让服务端去和苹果服务器验证,在一定程度上能够保证了安全性,那么这样也有一个隐忧,万一我传给服务端了,但是服务端验证失败了呢?或者万一由于网络问题传送失败呢?这个时候再加一层保险,就是客户端在传递给服务端之前先将本凭证存储下来(关于存储方法,笔者在后面会介绍,这里也有),然后服务器验证成功,返回到我们的success回调中去移除本地凭证,而相对应的服务端也已经存储了我们的凭证,当然考虑到服务器验证失败的问题,这个逻辑就要在服务端处理,笔者这里简单说下:就是服务器接到客户端传的凭证后,也是先存下来,直到验证成功并充值完成后才移除,否则就定时去发送验证,知道成功为止。 服务端不多做介绍,主要还是客户端逻辑,在移除本地凭证后,如果服务端正常处理,那么充值就应该到位了。

3.关于存储凭证的坑

笔者一开始存储用的是NSUserDefault方法,在每次支付成功后都会存储凭证到本地,然后在服务器验证成功后,将本地存储的凭证清空。这样看似乎没有毛病,但是如果用户频繁操作,会导致创建两次或者更多次订单,那么问题来了,NSUserDefault只能覆盖(因为存储的凭证对应的key是同一个),这样会造成只能保留最后一个存储的凭证,会产生一些意想不到的支付问题,所以在得知这个之后,笔者改成了用数据库存储到本地,这样我就可以在验证成功后根据当前凭证去删除数据库中的数据,而且还有一个好处是,如果凭证发送失败,在合适的地点我可以遍历数据库中的凭证,然后进行凭证验证,这样用户支付过的订单就很难出现充值不对等的问题(到账延迟问题是必然的,这个不知道有什么好方法没)

4.关于观察者方法updatedTransactions对应状态的处理问题。

SKPaymentTransactionStatePurchased:充值成功

SKPaymentTransactionStateFailed:充值失败

SKPaymentTransactionStateRestored:恢复内购

SKPaymentTransactionStatePurchasing:正在采购

对于这四种状态对应的处理情况,我这里简单介绍一下: 正在采购:只要添加订单,第一步就会走到这里,这里可以不作处理,要注意的是千万不能在这里移除订单,否则会崩溃,提示不能再采购状态移除订单。

至于恢复内购,笔者倒没有遇到,不过这里主要进行以下操作

- (void)removeTransaction {

    [[SKPaymentQueue defaultQueue] finishTransaction:self.currentTransaction];
}

只需要移除订单就好了

充值失败:毋庸置疑,这时候订单交易失败,就是废订单了,所以同样要移除

充值成功:能进入到这里,说明用户支付成功,钱已经扣掉了,那么它之后的相关处理就比较重要了,为了说明清晰,笔者用代码来展示:

更新

- (void)requestValidReceipt:(SKPaymentTransaction *)transaction {
    
    self.currentTransaction = transaction;

    //交易验证
    NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receiptData = [NSData dataWithContentsOfURL:recepitURL];
    
    if(!receiptData){
        [kWindow showLoadingView:@"获取支付凭证为空"];
        return;
    }
    //转化为base64字符串
    NSString *receiptString= [receiptData base64EncodedStringWithOptions:0];;
    NSString *source = @"";
    if ([YGDataBase isReceiptExists:self.currentTransaction.transactionIdentifier]) {
        self.buyId = [YGDataBase getBuyIdWithReceipt:self.currentTransaction.transactionIdentifier];
        source = @"self.buyId = [YGDataBase getBuyIdWithReceipt:receiptString];";
    }else {
        source = @"购买界面";
        [self buySuccess];
        //1.先将交易id存起来
        [YGDataBase saveReceiptAndGoodsID:self.currentTransaction.transactionIdentifier goodId:self.buyId];
    }
    [self startValidReceipt:receiptString source:source];

    //2.传给服务端凭证数据
    [kWindow showLoadingView];
    [[YGNetWorkTool sharedInstance] ApplePayReceiptVerifyBuyId:self.buyId buyType:1 receipt:receiptString success:^(id responseObj) {
        [kWindow hideLoadingView];
        if ([responseObj[@"code"] intValue] != 200 ) {
            [kWindow showLoadingView:responseObj[@"msg"]];
        }else {//充值成功之后将凭证移除
             [self removeTransaction];
            [YGDataBase removeReceipt:self.currentTransaction.transactionIdentifier];
        }
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        [self showAlert];
        self.buyId = nil;
       
        
    } failure:^(NSError *error) {
        [kWindow hideLoadingView];
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        self.buyId = nil;
    }];

}

- (void)requestValidReceipt:(SKPaymentTransaction *)transaction {
    
    self.currentTransaction = transaction;

    //获取交易的凭证
    NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receiptData = [NSData dataWithContentsOfURL:recepitURL];
    
    if(!receiptData){
        [kWindow showLoadingView:@"获取支付凭证为空"];
        return;
    }
    //转化为base64字符串
    NSString *receiptString= [receiptData base64EncodedStringWithOptions:0];
    //判断本地是否已经有过这个凭证,如果有,为了避免重复交易,什么也不做(这个可能没什么用,不过为了财政安全和保险,加上也不错)
    if ([YGDataBase isReceiptExists:receiptString]) {
        return;
    }

    [self buySuccess];//这个不用管,是项目中的统计作用

    //1.先将凭证存起来
    [YGDataBase saveReceiptAndGoodsID:receiptString goodId:self.ID];
//移除当前支付的交易
    [self removeTransaction];
//统计日志
    [self startValidReceipt:receiptString];
    
    //2.传给服务端凭证数据
    [kWindow showLoadingView];
    [[YGNetWorkTool sharedInstance] ApplePayReceiptVerifyBuyId:self.ID buyType:1 receipt:receiptString success:^(id responseObj) {
        [kWindow hideLoadingView];
        if ([responseObj[@"code"] intValue] != 200 ) {
            [kWindow showLoadingView:responseObj[@"msg"]];
        }else {//充值成功之后将凭证移除 这一点要注意,一定是服务端返回200的时候才能将本地凭证移除,否则会造成支付后没到账的丢单问题
            
            [YGDataBase removeReceipt:receiptString];
        }
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        [self showAlert];
        self.ID = nil;
        
    } failure:^(NSError *error) {
        [kWindow hideLoadingView];
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        self.ID = nil;
    }];

}

按照这个逻辑走下来,一般的内购支付问题应该能够解决了,笔者也是花了两天的时间,反复验证测试,将各种可能出现的奇葩操作都测试了一遍,结果充值都能够正常进行,希望能够给有需要的童鞋一些帮助,有需要源码的同学,可以到我的github上查看相关的逻辑(里面附带的一些牵扯到公司业务,笔者有做了详细的注释),喜欢的可以给个赞或者✨星哦

写在最后:由于苹果官方给出的验证方法非常简单,网上相关的内购资料也大都基于官方文档,许多实际问题根本找不到方法,希望大家能多多分享些这方面的实际问题,为以后内购的开发提供便利。