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

你们好,我是贝聊科技 的 iOS 工程师 @NewPangit

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

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

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

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

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

不用担忧,我历来不会只讲原理不留源码,我已经将我司的源码整理出来,你使用时只须要拽到工程中就能够了,下面开始咱们的内容 。服务器

源码在这里。微信

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

01.越狱的问题

关于越狱致使的问题,老是充满了不肯定性,每一个人都不同,可是都是受到了攻击致使的。因此,咱们采起的方式简单粗暴,越狱用户一概不容许使用 IAP 服务。这里我也建议你这么作。个人源码中有一个工具类用来检测用户是否越狱,类名是 BLJailbreakDetectTool,里面只有一个方法:app

/** * 检查当前设备是否已经越狱。 */
+ (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 设计上的一个大的缺陷,看似无解,出现这种状况,也就是用户想方设法要阻止交易成功,那只能他把苹果的订单邮件发给咱们,咱们手动给他加钱。

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

相关文章
相关标签/搜索