[贝聊科技]贝聊 IAP 实战之见坑填坑

6,991 阅读18分钟

大家好,我是贝聊科技 的 iOS 工程师 @NewPan

注意:文章中讨论的 IAP 是指使用苹果内购购买消耗性的项目。

这次为大家带来我司 IAP 的实现过程详解,鉴于支付功能的重要性以及复杂性,文章会很长,而且支付验证的细节也关系重大,所以这个主题会包含三篇。

第一篇:[iOS]贝聊 IAP 实战之满地是坑,这一篇是支付基础知识的讲解,主要会详细介绍 IAP,同时也会对比支付宝和微信支付,从而引出 IAP 的坑和注意点。

第二篇:[iOS]贝聊 IAP 实战之见坑填坑,这一篇是高潮性的一篇,主要针对第一篇文章中分析出的 IAP 的问题进行具体解决。

第三篇:[iOS]贝聊 IAP 实战之订单绑定,这一篇是关键性的一篇,主要讲述作者探索将自己服务器生成的订单号绑定到 IAP 上的过程。

不用担心,我从来不会只讲原理不留源码,我已经将我司的源码整理出来,你使用时只需要拽到工程中就可以了,下面开始我们的内容 。

源码在这里。

上一篇的分析了 IAP 存在的问题,有九个点。如果你不知道是哪九个点,建议你先去看一下上一篇文章。现在我们根据上一篇总结的问题一个一个来对应解决。

01.越狱的问题

关于越狱导致的问题,总是充满了不确定性,每个人都不一样,但是都是受到了攻击导致的。所以,我们采取的方式简单粗暴,越狱用户一律不允许使用 IAP 服务。这里我也建议你这么做。我的源码中有一个工具类用来检测用户是否越狱,类名是 BLJailbreakDetectTool,里面只有一个方法:

/**
 * 检查当前设备是否已经越狱。
 */
+ (BOOL)detectCurrentDeviceIsJailbroken;

如果你不想使用我封装的方法,也可以使用友盟统计里有一个方法,如果你的项目接入了友盟统计,你 #import <UMMobClick/MobClick.h> ,里面有个类方法:

/**
 * 判断设备是否越狱,依据是否存在apt和Cydia.app
 */
+ (BOOL)isJailbroken;

02.交易订单的存储

上一篇文章说到,苹果只会在交易成功以后通过 - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions 通知我们交易结果,而且一个 APP 生命周期只通知一次,所以我们万万不能依赖苹果的这个方法来驱动收据的查询。我们要做的是,首先一旦苹果通知我们交易成功,我们就要将交易数据自己存起来。然后再说然后,这样一来我们就可以摆脱苹果通知交易结果一个生命周期只通知一次的噩梦。

那这么敏感的交易收据,我们存在哪里呢?存数据库?存 UserDefault?用户一卸载 APP 就毛都没有了。这样的东西,只有一个地方存最合适,那就是 keychainkeychain 的特点就是第一安全;第二,绑定 APP ID,不会丢,永远不会丢,卸载 APP 以后重装,仍然能从 keychain 里恢复之前的数据。

好,我们现在开始设计我们的存储工具。在开始之前,我们要使用一个第三方框架 UICKeyChainStore,因为 keychain 是 C 接口,很难用,这个框架对其做了面向对象的封装。我们现在就基于这个框架进行封装。

#import <UICKeyChainStore/UICKeyChainStore.h>
#import "BLWalletCompat.h"

NS_ASSUME_NONNULL_BEGIN

@class BLPaymentTransactionModel;

@protocol BLWalletTransactionModelsSaveProtocol<NSObject>

@optional

/**
 * 存储交易模型.
 *
 * @param models 交易模型. @see `BLPaymentTransactionModel`
 * @param userid 用户 id.
 */
- (void)bl_savePaymentTransactionModels:(NSArray<BLPaymentTransactionModel *> *)models
                                forUser:(NSString *)userid;

/**
 * 删除指定 `transactionIdentifier` 的交易模型.
 *
 * @param transactionIdentifier 交易模型唯一标识.
 * @param userid                用户 id.
 *
 * @return 是否删除成功. 失败的原因可能是因为标识无效(已存储数据中没有指定的标识的数据).
 */
- (BOOL)bl_deletePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
                                                          forUser:(NSString *)userid;

/**
 * 删除所有的 `transactionIdentifier` 交易模型.
 *
 * @param userid 用户 id.
 */
- (void)bl_deleteAllPaymentTransactionModelsIfNeedForUser:(NSString *)userid;

/**
 * 获取所有交易模型, 并排序.
 *
 * @return models 交易模型. @see `BLPaymentTransactionModel`
 * @param userid  用户 id.
 */
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsSortedArrayUsingComparator:(NSComparator NS_NOESCAPE _Nullable)cmptr
                                                                                                          forUser:(NSString *)userid
                                                                                                            error:(NSError * __nullable __autoreleasing * __nullable)error;

/**
 * 获取所有交易模型.
 *
 * @param userid 用户 id.
 *
 * @return models 交易模型. @see `BLPaymentTransactionModel`
 */
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsForUser:(NSString *)userid
                                                                                         error:(NSError * __nullable __autoreleasing * __nullable)error;

/**
 * 改变某笔交易的验证次数.
 *
 * @param transactionIdentifier 交易模型唯一标识.
 * @param modelVerifyCount      交易验证次数.
 * @param userid                用户 id.
 */
- (void)bl_updatePaymentTransactionModelStateWithTransactionIdentifier:(NSString *)transactionIdentifier
                                                      modelVerifyCount:(NSUInteger)modelVerifyCount
                                                               forUser:(NSString *)userid;

/**
 * 存储某笔交易的订单号和订单价格以及 md5 值.
 *
 * @param transactionIdentifier 交易模型唯一标识.
 * @param orderNo               订单号.
 * @param priceTagString        订单价格.
 * @param md5                   交易收据是否有变动的标识.
 * @param userid                用户 id.
 */
- (void)bl_savePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
                                                        orderNo:(NSString *)orderNo
                                                 priceTagString:(NSString *)priceTagString
                                                            md5:(NSString *)md5
                                                        forUser:(NSString *)userid;

@end

/**
 * 存储结构为: dict - set - model.
 *
 * 第一层 data, 是字典的归档数据.
 * 第二层字典, 以 userid 为 key, set 的归档 data.
 * 第二层集合, 是所有 model 的归档数据.
 */
@interface BLWalletKeyChainStore : UICKeyChainStore<BLWalletTransactionModelsSaveProtocol>

+ (BLWalletKeyChainStore *)keyChainStoreWithService:(NSString *_Nullable)service;

@end

NS_ASSUME_NONNULL_END

我们要保存的对象是 BLPaymentTransactionModel,这个对象是一个模型,头文件如下:

#import <Foundation/Foundation.h>
#import "BLWalletCompat.h"

NS_ASSUME_NONNULL_BEGIN

@interface BLPaymentTransactionModel : NSObject<NSCoding>

#pragma mark - Properties

/**
 * 事务 id.
 */
@property(nonatomic, copy, nonnull, readonly) NSString *transactionIdentifier;

/**
 * 交易时间(添加到交易队列时的时间).
 */
@property(nonatomic, strong, readonly) NSDate *transactionDate;

/**
 * 商品 id.
 */
@property(nonatomic, copy, readonly) NSString *productIdentifier;

/**
 * 后台配置的订单号.
 */
@property(nonatomic, copy, nullable) NSString *orderNo;

/**
 * 价格字符.
 */
@property(nonatomic, copy, nullable) NSString *priceTagString;

/**
 * 交易收据是否有变动的标识.
 */
@property(nonatomic, copy, nullable) NSString *md5;

/*
 * 任务被验证的次数.
 * 初始状态为 0,从未和后台验证过.
 * 当次数大于 1 时, 至少和后台验证过一次,并且未能验证当前交易的状态.
 */
@property(nonatomic, assign) NSUInteger modelVerifyCount;

#pragma mark - Method

/**
 * 初始化方法(没有收据的).
 *
 * @warning: 所有数据都必须有值, 否则会报错, 并返回 nil.
 *
 * @param productIdentifier       商品 id.
 * @param transactionIdentifier   事务 id.
 * @param transactionDate         交易时间(添加到交易队列时的时间).
 */
- (instancetype)initWithProductIdentifier:(NSString *)productIdentifier
                    transactionIdentifier:(NSString *)transactionIdentifier
                          transactionDate:(NSDate *)transactionDate;

@end

NS_ASSUME_NONNULL_END

就是一些交易的关键信息。我们在这个对象实现归档和解档的方法以后,就可以将这个对象归档成为一段 data,也可以从一段 data 中解档出这个对象。同时,我们需要实现这个对象的 -isEqual: 方法,因为,因为我们在进行对象判等的时候,要进行一些关键信息的比对,来确定两个交易是否是同一笔交易。代码太多了,我就不粘贴了,细节还需要您自己下载代码进去看。

现在回到 keyChain 上来。每个 BLPaymentTransactionModel 对象归档成一个 NSData,多个 data 组成一个集合,再将这个集合归档,然后保存在一个以 userid 为 key 的字典中,然后再对字典进行归档,然后再保存到 keyChain 中。

请记住这个数据归档的层级,要不然,实现文件里看起来有点懵。

03.验证队列

到现在为止我们可以对交易数据进行存储了,也就是说,一旦 IAP 通知我们有新的成功的交易,我们立马把这笔交易相关的数据转换成为一个交易模型,然后把这个模型归档存到 keyChain,这样我们就能将验证数据的逻辑独立出来了,而不用依赖 IAP 的回调。

现在我们开始考虑如何根据已有的数据来上传到我们自己的服务器,从而驱动我们的服务器向苹果服务器的查询,如下图所示。

我们可以设计一个队列,队列里有当前需要查询的交易 model,然后将 model 组装成为一个 task,然后在这个 task 中向我们的服务器发起请求,根据服务器返回结果再发起下一次请求,就是上图的驱动方式 5,这样形成一个闭环,直到这个队列中所有的模型都被处理完了,那么队列就处于休眠状态。

而第一次驱动队列执行的有四种情况。

第一种是初始化的时候,发现 keyChain 中还有没有处理完需要验证的交易,那么此时就开始从 keyChain 动态筛选出数据初始化队列,初始化完以后,就可以开始向服务器发起验证请求了,也就是驱动方式 1。至于为什么说是动态筛选,因为这里的任务有优先级,我们等会再说。

第二种驱动任务执行的方式是,当前队列处于休眠状态,没有任务要执行,此时用户发起购买,就会直接将当前交易放到任务队列中,开始向服务器发起验证请求,也就是驱动方式 2

第三种是用户从没有网络到有网络的时候,会去对 keyChain 做一次检查,如果有没有处理完的交易,一样会向服务器发起请求,也就是驱动方式 3

第四种是用户从后台进入前台的时候,会去对 keyChain 做一次检查,如果有没有处理完的交易,一样会向服务器发起请求,也就是驱动方式 4

有了上面四种类型的触发验证的逻辑以后,我们就能最大程度保证所有的交易都会向服务器发起验证请求,而且是永不停止的进行,直到所有的交易都验证完才会停止。

刚才说从 keyChain 中取数据有一个动态筛选的操作,这是什么意思呢?首先,我们向服务器发起的验证,不一定成功,如果失败了,我们就要给这个交易模型打上一个标记,下次验证的时候,应该优先验证那些没有被打上标记的交易模型。如果不打标记,可能会出现一直在验证同一个交易模型,阻塞了其他交易模型的验证。

// 动态规划当前应该验证哪一笔订单.
- (NSArray<BLPaymentTransactionModel *> *)dynamicPlanNeedVerifyModelsWithAllModels:(NSArray<BLPaymentTransactionModel *> *) allTransationModels {
    // 防止出现: 第一个失败的订单一直在验证, 排队的订单得不到验证.
    NSMutableArray<BLPaymentTransactionModel *> *transactionModelsNeverVerify = [NSMutableArray array];
    NSMutableArray<BLPaymentTransactionModel *> *transactionModelsRetry = [NSMutableArray array];
    for (BLPaymentTransactionModel *model in allTransationModels) {
        if (model.modelVerifyCount == 0) {
            [transactionModelsNeverVerify addObject:model];
        }
        else {
            [transactionModelsRetry addObject:model];
        }
    }
    
    // 从未验证过的订单, 优先验证.
    if (transactionModelsNeverVerify.count) {
        return transactionModelsNeverVerify.copy;
    }
    
    // 验证次数少的排前面.
    [transactionModelsRetry sortUsingComparator:^NSComparisonResult(BLPaymentTransactionModel * obj1, BLPaymentTransactionModel * obj2) {
       
        return obj1.modelVerifyCount < obj2.modelVerifyCount;
        
    }];
    
    return transactionModelsRetry.copy;
}

04.压入新交易

上面验证队列里我还有压入情景没有解释,压入情景有三种情况。

第一种是出现意外,就是初始化的时候,如果出现用户刚好交易完,但是 IAP 没有通知我们交易完成的情况,那么此时再去 IAP 的交易队列里检查一遍,如果有没有被持久化到 keyChain 的,就直接压入 keyChain 中进行持久化,一旦进入 keyChain 中,那么这笔交易就能被正确处理,这种情况在测试环境下经常出现。

第二种是正常交易,IAP 通知交易完成,此时将交易数据压入 keyChain 中。

第三种和第一种类似,用户从后台进入前台的时候,也会去检查一遍沙盒中有没有没有持久化的交易,一旦有,就把这些交易压入 keyChain 中。

上面三个压入情景,能最大程度上保证我们的持久化数据能和用户真实的交易同步,从而预防苹果出现交易成功却没有通知我们而导致的 bug。

05.项目结构总结

到现在为止,我们的结构已经有了大体了,现在我们来总结一下我们现在的项目结构。

BLPaymentManager 是交易管理者,负责和 IAP 通讯,包括商品查询和购买功能,也是交易状态的监听者,对接沙盒中收据数据的获取和更新,是我们整个支付的入口。它是一个单例,我们的验证队列是挂在它身上的。每当有新的交易进来的时候(不管是什么情景进来的),它都会把这笔交易丢给 BLPaymentVerifyManager,让 BLPaymentVerifyManager 负责去验证这笔交易是否有效。最后,BLPaymentVerifyManager 也会和 BLPaymentManager 通讯,告诉 BLPaymentManager 某笔交易的状态,让 BLPaymentManager 处理掉指定的交易。

BLPaymentVerifyManager 是验证交易队列管理者,它内部有一个需要验证的交易 task 队列,它负责管理这些队列的状态,并且驱动这些任务的执行,保证每笔交易验证的先后循序。它的内部有一个 keyChain,它的队列中的任务都是从 keyChain 中初始化过来的。同时它也管理着keyChain 中的数据,对keyChain 进行增删改查等操作,维护keyChain 的状态。同时也和 BLPaymentManager 通讯,更新交易的状态(finish 某笔交易)。

keyChain 不用说了,负责交易数据的持久化,提供增删改查等接口给它的管理者使用。

BLPaymentVerifyTask 负责和服务器通讯,并且将通讯结果回调出来给 BLPaymentVerifyManager,驱动下一个验证操作。

06.收据不同步处理

有同行反馈说,IAPbug,这个 bug 就是明明通知交易已经成功了,但是去沙盒中取收据时,发现收据为空,这个问题也是要具体应对的。

现在做了以下的处理,每次和后台通讯的结果归为三类,第一类,收据有效,验证通过;第二类,收据无效,验证失败;第三类,发生错误,需要重新验证。每个 task 回来都是只有可能是这三种情况的一种,然后 task 的回调会给队列管理者,队列管理者会把回调传出去给交易管理者,此时交易管理者在下面的代理方法中更新最新的收据,并把新收据重新传给队列管理者,队列管理者下次发起请求就是使用最新的收据进行验证操作。

@protocol BLPaymentVerifyTaskDelegate<NSObject>

@required

/**
 * 验证收到结果通知, 验证收据有效.
 */
- (void)paymentVerifyTaskDidReceiveResponseReceiptValid:(BLPaymentVerifyTask *)task;

/**
 * 验证收到结果通知, 验证收据无效.
 */
- (void)paymentVerifyTaskDidReceiveResponseReceiptInvalid:(BLPaymentVerifyTask *)task;

/**
 * 验证请求出现错误, 需要重新请求.
 */
- (void)paymentVerifyTaskUploadCertificateRequestFailed:(BLPaymentVerifyTask *)task;

@end

07.注意点

  • 从 iOS 7 开始,苹果的收据不是每笔交易一个收据,而是将所有的交易收据组成一个集合放在沙盒中,然后我们在沙盒中取到的收据是当前所有收据的集合,而且我们也不知道当前收据里都有哪些订单,我们的后台也不知道,只有 IAP 服务器知道。所以,我们不用管收据里的数据,只要拿出来怼给后台,后台再怼给苹果就可以了。

  • 对于我们提交给后台的收据,后台可能会做过期的标记。但是后台要判断当前的这个收据是否之前已经上传过了,这时我们可以做一个 MD5,我们把 MD5 的结果一起上传给服务器。

  • 项目里做了很多报警的处理,比方说我们把收据存到 keyChain 中,存储完成以后,要做一次检查,检查这个数据确实是存进去了,如果没有,那此时应该报警,并将报警信息上传到我们的服务器,以防出现意外。又比方说,IAP 通知我们交易完成,我们就会去取收据,如果此时收据为空,那绝对出问题了,此时应该报警,并将报警信息上传(项目里已经对这种情况进行了容错)。还有比如某笔交易验证了几十次,还是未能验证,那此时应该设定一个验证次数的报警阈值,比方说十次,如果超过十次就报警。

  • 在持久化到 keyChain 时,数据是绑定用户 userid 的,这一点也是至关重要,要不然会出现 A 用户的交易在 B 用户那里验证。

  • 对于已经失败过的验证请求,每两次请求之间的时间步长也是应该考虑的。这里采用的比较简单的方式,只要是已经和后台验证过并且失败过的交易, 两次请求之间的时间间隔是 失败的次数 * BLPaymentVerifyUploadReceiptDataIntervalDelta。同时也对步长的最大值做了限制,防止步长越来越大,用户体验差。

  • 还有一些细节,下面两个方法一定要在按照要求调用,否则后果很严重。下面的第二个方法,如果用户已经等录,重新启动的时候也要调用一次。

/**
 * 注销当前支付管理者.
 *
 * @warning ⚠️ 在用户退出登录时调用.
 */
- (void)logoutPaymentManager;

/**
 * 开始支付事务监听, 并且开始支付凭证验证队列.
 *
 * @warning ⚠️ 请在用户登录时和用户重新启动 APP 时调用.
 *
 * @param userid 用户 ID.
 */
- (void)startTransactionObservingAndPaymentTransactionVerifingWithUserID:(NSString *)userid;
  • 还有一个问题,如果用户当前还有未得到验证的交易,那么此时他退出登录,我们应该给个 UI 上的提示。通过下面这个方法去拿用户当前是否有未得到验证的交易。
/**
 * 是否所有的待验证任务都完成了.
 *
 * @warning error ⚠️ 退出前的警告信息(比如用户有尚未得到验证的订单).
 */
- (BOOL)didNeedVerifyQueueClearedForCurrentUser;
  • 还有对于支付是串行还是并行的选择。串行的意思是如果用户当前有未完成的交易,那么就不允许进行购买。并行的意思是,当前用户有未完成的交易,仍然可以进行购买。我提供的源码是支持并行的,因为当时设计的时候就考虑到这个问题了。事实上,苹果对同一个交易标识的产品的购买是串行的,就是你当前有未付款成功的商品 A,当你再次购买这个商品 A 的时候,是不能购买成功的。我们最后兼顾后台的逻辑,为了让后台同事更加方便,我们采取了串行的方式。采用串行就会带来一个逻辑漏洞就是,假如某个用户他购买以后出现异常,导致无法使用正常的方式充钱并且 finish 某笔交易,最后通过和我们客服联系的方式手动充钱,那么他的钥匙链就一直有一笔未完成的交易,由于我们的购买时串行的,这样会导致这个用户再也没法购买产品。这种情况也是需要警惕的,此时只需要和后端同时约定一下,再次验证这笔订单的时候返回一个错误码,把这笔订单特别的 finish 掉就好了。

  • 还有一个 IAP 的 bug,就是 IAP 通知交易完成,然后我们把交易数据存起来去后台验证,验证成功以后,回到 APP 使用 transactionIndetify 从 IAP 未完成交易列表中取出对应的交易,将这比交易 finish 掉,当 IAP 出现 bug 的时候,这个交易找不到,整个未完成交易列表都为空。而且复现也很简单,只要在弱网下交易成功立即杀掉 APP 就可以复现。所以我们必须应对这个问题。应对的策略就是给我们存储的数据加一个状态,一旦出现验证成功回来 finish 的时候找不到对应的交易,就先给存储数据加一个 flag,标识这笔订单已经验证过了,只是还没有找到对应的 IAP 交易进行 finish,所以以后每次从未验证交易里取数据的时候,都需要将有这个 flag 的交易对比一下,如果出现已经验证过的交易,就直接将那一笔交易 finish 掉。

08.还有哪些问题?

到现在为止,第一篇上提及的八个问题,有七个在这一篇文章中都有对应的解决方案。由于篇幅原因,我就不大段大段的贴代码了,具体实践,肯定要看源码的,并且我写了巨细无比的注释,保证每个人都能看懂。

但是真的就没有问题了吗?不是的,现在已知的问题还有两个。

  • 没验证完, 用户更换了 APP ID, 导致 keychain 被更改。
  • 订单没有拿到收据, 此时用户更换了手机, 那么此时收据肯定是拿不到的。
  • ......

第一个问题,看起来要鸡蛋放在两个篮子里,比方说,数据要同时持久化到 keyChain 和沙盒中。但是这次没有做,接下来看情况,如果确实有这种问题,可能会这么做。

第二个问题,是苹果 IAP 设计上的一个大的缺陷,看似无解,出现这种情况,也就是用户千方百计要阻止交易成功,那只能他把苹果的订单邮件发给我们,我们手动给他加钱。

其他还有问题的话,请各位在评论区补充,一起讨论,谢谢你的阅读!!