iOS项目技术还债之路《二》IAP掉单优化

前言

上篇中咱们聊了聊iOS后台下载优化,经过一个成本较低的方案达到了业务预期的效果。这篇文章继续聊一聊今年初完成的另外一个优化点:IAP掉单优化。html

众所周知,因为IAP相关的坑比较多,IAP有不少话题能够聊。IAP的不少行为在官方文档中并无清晰描述,所以除了官方文档外,也建议一并阅读下面这些文章,它们各有侧重和特点:python

  1. 聊聊应用内购买 做者聊得很全面,介绍了IAP开发中的各类注意点,包括了应用审核及后续运营的注意点
  2. 贝聊系列 做者从趟坑的角度入手,总结了IAP开发中遇到的各类坑,也开源了他们的实现,很适合根据本身公司需求作一些二次开发。事实上,本文也会对贝聊方案中的一些细节进行探讨
  3. iOS内购-防越狱破解刷单 做者侧重探讨了越狱手机上IAP如何防破解
  4. 揭秘苹果内购的大漏洞和内购订阅的黑陷阱 做者列举了IAP的常见漏洞和相关黑产
  5. 苹果IAP开发中的那些坑和掉单问题 这篇时间比较久了,但东西并不过期,梳理了IAP开发中常见的一些注意点

文末能够找到全部参考文章的连接,基本涵盖了民间IAP开发相关的各路经验总结。ios

那么本文还能聊点什么呢。没精力也不必写成一个大而全的教程,要么就写写本身的项目实践的过程,从IAP掉单问题入手,聊一聊分析和解决过程。算是给本身作过的事一个交代,若是能碰巧帮到一部分人,那即是坠吼的。git

目录

  • 背景和痛点
  • 掉单问题分析
  • 堵漏洞之旅
  • 小结

一. 背景和痛点

时间回到2018年末,公司的主App在收到屡次IAP整改的警告后,苹果爸爸终于下了最后通牒,两周内得提审一个版本,全部虚拟商品的购买必须走IAP,不然全线产品下架。这下全部那些惯用的试图绕过IAP的手段都灰飞烟灭:支付宝、微信支付、审核开关等。刚接手项目,从同事那了解到两年前实现过一套IAP的方案,既然时间紧迫,不妨直接拿来试试。因而接入、调整产品流程、提测、准出、提审、上线一条龙,终于达到了IAP合规,平稳度过了危机。github

上线以来状况大致稳定,只不过期不时会收到一些报障,主要集中在下面几个方面:后端

  1. 掉单
  2. 坏帐
  3. 退款

顺便说一句,我司是作线上服务的,全部IAP商品都是非自动续期订阅类别,用户购买后享有必定期限内的服务。IAP商品价格从几块到几千块不等。服务器

掉单

天天都会接到几例用户报障说钱扣了但货没到,要求退款。掉单的危害性不言而喻:微信

  1. 下降了用户信任度,形成用户流失,并且流失的都是有付费意愿的用户
  2. 多数状况后台查不到用户任何购买记录,没法判断是否恶意退款,只能引导用户先去试试苹果的退款流程,增长了技术和客服的工做成本,开发的平常工做常常被打断去排查线上问题
  3. 苹果退款流程常常会碰壁,这时只能根据用户提供的AppStore扣款邮件等凭证来确认并退款。这一趟流程下来,用户的耐心估计磨得差很少了,不去微博骂你几句已经算客气了,对公司品牌伤害很大

坏帐

坏帐的报障主要来自内部反馈。财务在对帐时发现AppStore里的实际收入和公司订单系统结算的收入不一致。坏帐的成因比较多,主要有如下几点:markdown

  1. 公司电商前台商品标价和IAP价格不一致,好比App端显示白金会员398元一年,实际苹果弹窗付款298元。多是在iTunesConnect修改了IAP价格,没有同步内部系统
  2. 公司不一样子系统间商品价格不一样步,跨部门、跨系统的数据同步流程出了问题
  3. 商品重复配送,致使实际收入偏低,抬高了运营成本
  4. 用户恶意退款,这一点下面会提到

坏帐问题大多能够经过规范流程来尽可能规避,不一样公司处理方式可能各不相同,本文就不作重点讨论了。网络

退款

用户恶意退款这一点在游戏行业可能发生得会比较多,App端变现不是那么容易,发生得较少。不过也不乏有贪小便宜的用户购买了公司服务,去苹果那申请退款成功的例子,这种状况下公司是收不到任何消息的,用户能够继续享有服务。这种也会形成必定的坏帐率,由于数值在合理范围内,咱们也基本上不能作什么,就暂时不去管它了。

若是硬要处理恶意退款的话,有两个方向能够试下(没有实践过,本文就不作重点讨论了):

  1. 若是IAP类别是订阅类(包括自动续期非自动续期),iOS7之后的App Receipt API返回的订单信息中,能够根据cancellation_date字段来判断是不是已退款交易
  2. 若是IAP类别是自动续期订阅类,今年的WWDC中提出的Server to Server Notifications可能会有帮助,苹果会将用户订阅状态的改变通知到App的服务端,从而识别出已退款交易

这些报障中对用户伤害最大的就是掉单了,亟待解决,也是本文要讨论的重点。

咱们的目标是,零掉单。

二. 掉单问题分析

一开始面对掉单问题基本上是比较懵逼的:

  1. 没有用户购买相关行为日志可查
  2. 服务端没有用户购买记录

感受像面对了一个黑盒,只知道test case fail了,殊不知具体哪里的问题。

手头的线索只有代码和网上的各类文章。因而打算先把全部能Google到的IAP文章里关于掉单的部分所有撸一遍,看看业界通常是怎么处理的,而后再去撸代码。

业界方案对比

假定读者对IAP开发都有必定基础,对基本流程都熟悉,这里就直接上各类名词了。

一般来说,业界都会从如下几个方面去努力防止掉单:

  1. 下单顺序优化
  2. 交易持久化
  3. 订单映射
  4. 用户映射
  5. 完成交易时机
  6. 重试机制

关于每一个方面,业界又有一些不一样的处理方案。

下单顺序优化

下单和IAP购买流程是整个流程中必不可少的两个环节。

调整下单环节在整个流程中的位置,看看对解决掉单问题会有什么样的影响。

这里所引伸出的问题就是先走IAP购买流程仍是先下单

方案A:先走IAP购买流程后下单

贝聊采用的是先走IAP购买流程后下单的方案,大体流程以下:

先iap流程后下单时序图.png

图中把下单和验证票据合并到一个接口里了,贝聊是拆成了两个接口,前者的话order_id对客户端是透明的,后者客户端须要拿到order_id而且发起验证票据请求。不过这二者差很少,对咱们的分析过程没影响。

按照做者的说法,采用方案A这种架构能够更好地完成App订单和IAP交易的映射,有效解决串单问题。

注:本文把串单也做为掉单的一种一块儿讨论了。所谓串单,就是经过IAP购买了商品A,却和商品B的订单绑一块儿发往App服务端验证了,致使最终错发了商品B,或者验证失败。对系统来说是串单,对于用户来说付了钱但想买的商品没买到,就是掉单了,并且串单掉单在设计流程时密不可分。

之因此不采用先下单后走IAP购买流程的方案,做者认为那样没法将一开始建立订单生成的order_id完美地映射到IAP的交易上,会形成掉单。而采用先走IAP购买流程后下单的方案,就能够完美避开这个问题。

咱们暂时不做分析,继续看另外一个方案。

方案B:先下单后走IAP购买流程

Leo的这篇更推荐先下单后走IAP购买流程的方案,大体流程以下:

先下单后iap流程时序图.png

做者认为这样更符合常见的支付系统的设计,优势是:

  1. 服务端动态可控是否能够发生购买,好比下架某一个商品,直接后端下架便可,无需从iTunesConnect里下架
  2. 发生丢单的时候,服务端会有用建立订单的日志,有助于后期定位问题

简单对比

咱们先来看一下,若是采用方案B,能不能完美解决订单映射问题,即将order_id完美映射到IAP的交易上。

利用applicationUsername来透传order_id是能够完美映射,但咱们都知道applicationUsername不靠谱,这边先pass掉。

想象一个稍微极端点的例子,用户对着同一件IAP商品屡次快速点击,若是没有作防重的话,应该会发起多个下单请求,拿到多个order_id,每个都映射到了同一个iap_product_id上,当IAP购买完成收到purchased通知时,确实是没法肯定究竟该对应哪个order_id

方案B确实没法完美解决问题。可是方案A必定就是完美的么?也不见得,咱们来看看。

咱们先来翻一下贝聊方案的源码,找到里面关于下单请求的部分:

NSString *md5 = [NSData MD5HexDigest:[receipts dataUsingEncoding:NSUTF8StringEncoding]];
BOOL needStartVerify = self.transactionModel.orderNo.length && self.transactionModel.md5 && [self.transactionModel.md5 isEqualToString:md5];
self.taskState = BLPaymentVerifyTaskStateWaitingForServersResponse;
if (needStartVerify) {
    NSLog(@"开始上传收据验证");
    [self sendUploadCertificateRequest];
}
else {
    NSLog(@"开始建立订单");
    [self sendCreateOrderRequestWithProductIdentifier:self.transactionModel.productIdentifier md5:md5];
}

- (void)sendCreateOrderRequestWithProductIdentifier:(NSString *)productIdentifier md5:(NSString *)md5 {
    // 执行建立订单请求.
}
复制代码

能够看到,贝聊的下单请求实质上只跟iap_product_id有关。当IAP购买完成收到purchased通知后,直接能够从transaction中拿到iap_product_id,从而开始下单流程。不存在任何须要映射的过程,Perfect。

可是有另外一种状况,下单请求所须要的参数除了iap_product_id之外,还须要一些别的id一块儿来定位某个商品,这样的话就存在一个须要映射的过程了。

你可能会以为,存在这样的状况么?我举个例子。

假定有这么一家提供在线视频订阅服务的公司,用户经过App能够在必定时间内订阅观看某部剧集,每部剧集都是独立销售的。这样iTunesConnect后台就配置了一堆的IAP商品,好比:

  1. iap_product_生活大爆炸:400元
  2. iap_product_行尸走肉:600元
  3. iap_product_绝命毒师:500元
  4. iap_product_无耻家庭:500元

这样,每部剧的价格都分开维护,每当有新剧上架,都要在iTunesConnect后台配置。终于有一天,运营同事受不了了,说这样太累,咱们能够设置一些价格档位,而后相同价格的剧配同一个IAP商品么?今后iTunesConnect后台出现了一些新的商品类型:

  1. iap_product_100元剧集:100元
  2. iap_product_500元剧集:500元
  3. iap_product_1元限时促销剧集:1元

同时在App内的“绝命毒师”、“无耻家庭”等剧集所关联的IAP商品改为了iap_product_500元剧集

这种状况下当用户点击购买“绝命毒师”时,当IAP购买完成收到purchased通知后,从transaction中取到的iap_product_id变成了iap_product_500元剧集,此时再去下单的话就必须带上“绝命毒师”剧集的id了,不然没法区分用户购买的是“绝命毒师”仍是“无耻家庭”。

那彷佛又回到了一开始的问题上了:该怎么把剧集id给映射到IAP交易上。

稍微想一想便知,和方案B的订单id映射同样,这里也不存在一个完美的映射方案。

因而手撸了一张图,简单对比下方案A方案B在订单映射方面的表现:

下单顺序在不一样IAP业务形态下的对比.png

一对一指的iap_product_id和业务id一一对应,好比剧集“绝命毒师”的IAP商品idiap_product_绝命毒师

多对一指的是多个业务id对应了一个iap_product_id,好比剧集“绝命毒师”和“无耻家庭”的IAP商品id都是iap_product_500元剧集

另外,在多对一形态下的方案B中,因为订单id自然就携带了iap_product_id和业务id的信息,因此发起App端验证请求时带上订单id便可,本质上和一对一形态下的方案B是同样的

从上图可见,只有当业务形态为一对一时,方案A在订单映射方面才是优于方案B的。可是谁又能保证之后业务形态不会发生变化呢?

回过头来看上文中Leo认为方案B具有的两个优点:

  1. 服务端动态可控可否购买。无须从iTunesConnect下架商品确实能够节省一些人力,对于方案A来说,当用户在购买页面停留期间该商品下架了,就必须从iTunesConnect同时下架,不然App端还有购买入口,点击购买又没从服务端过一道,就会发生掉单了。
  2. 便于定位掉单问题。我的认为建立订单日志对于排查掉单问题用处不大。因为在方案B中,IAP购买流程是在建立订单成功以后,而掉单又是在IAP购买成功以后才会发生(这不废话,都没扣款怎么掉单),因此全部的掉单用户在服务端都会有建立订单成功的记录,从建立订单日志上来看跟非掉单用户是没什么区别的。最多就是从日志中得知用户建立订单的时间,推算出用户在客户端内的一些行为,可是经过客户端自己的打点能够更精确详细地还原出用户的行为轨迹。真正有用的服务端日志是发生在IAP购买成功之后的订单验证日志,服务端能够经过日志记录的有无知道客户端请求是否可达,经过请求详情知道到底哪出了问题。

二者对比下来,双方都没有一面倒的优点,没必要特地为了防掉单去重构现有的方案,沿用既有架构便可

事实上,因为这两个方案对于本文后续的讨论没有本质的区别,为了行文的方便,后面将更多地按照订单能不能完美映射来分状况讨论

订单完美映射方案 = 业务形态为一对一 + 先走IAP购买流程后下单

订单非完美映射方案 = 除订单完美映射方案外的其余3种

交易持久化

IAP交易持久化下来,不依赖IAP自身的事务机制,是解决掉单的另外一个关键点。

业界对此也有不一样方案,主要区别在下面两方面:

1. 持久化到沙盒 vs 持久化到keychain

业界大多数都采用持久化到沙盒,相对简单,应付大多数状况够了。

keychain的方案以贝聊为表明,为了应付用户删除app致使数据丢失的问题。

实际场景中确实发生过相似报障,用户端掉单了,用户找客服说卸载重装都试过了,仍是没用。客服也无语,不卸载的话还能够引导用户重启App,从新启动本地交易票据的验证流程,帮用户找回那笔订单。

为了不这种状况,实现零掉单,决定采用持久化到keychain的方案。

2. 持久化的时机

咱们找一段最多见的IAP流程代码,看看其中哪些位置作持久化比较合适。通常会选择位置1~4里的一个或多个。

// 查询商品信息
- (void)fetchProductInfo:(NSSet<NSString *> *)productIdentifiers {
    //**************位置3**************
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
    request.delegate = self;
    [request start];
}

// 查询商品成功回调
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
    NSArray *validProducts = response.products;
    SKProduct *currentProduct = validProducts.lastObject;
    if (currentProduct) {        
        //**************位置4**************
        SKPayment *payment = [SKPayment paymentWithProduct:currentProduct];
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    }
}

// 购买操做后的回调
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                //**************位置2**************
                [self transactionPurchasing:transaction];
                break;
                
            case SKPaymentTransactionStatePurchased:
                //**************位置1**************
                [self transactionPurchased:transaction];
                break;
                
            case SKPaymentTransactionStateFailed:
                [self transactionFailed:transaction];
                break;
                
            case SKPaymentTransactionStateRestored:
                [self transactionRestored:transaction];
                break;
                
            case SKPaymentTransactionStateDeferred:
                [self transactionDeferred:transaction];
                break;
        }
    }
}

复制代码
  • 位置1:IAP购买成功通知。贝聊的方案仅在这里作了持久化。上文也提到,贝聊的方案是订单完美映射方案,在这个位置经过transaction对象能够拿到后续建立订单所须要的一切信息,没有额外信息是须要在这以前持久化下来的。事实上,无论是否订单完美映射方案,在这个位置作持久化都是必须的
  • 位置2:IAP正在购买通知。贝聊在引出订单完美映射方案以前提到的粗放式验证就是在这个位置作持久化,是基于先下单后走IAP购买流程的,试图在这里将订单idIAP交易绑定并持久化。我的认为这里是有问题的。若是订单id来自内存的话,那么极可能由于崩溃等缘由丢失。好比用户点击购买后当即杀app,完成付款后从新打开app,此时订单id就不存在了,形成了掉单。若是提早把订单id也给持久化了,那位置2就不必作持久化了,在位置1作便可:根据iap_product_id在持久化的订单列表里找出匹配项(不完美映射),完成粗放式验证
  • 位置3:发起查询商品信息请求。这里不必作持久化。在fetchProductInfo:函数结束后当即杀app,此时并无调用[[SKPaymentQueue defaultQueue] addPayment:payment];,所以用户是不会收到付款弹窗的,也就不存在掉单问题
  • 位置4:发起IAP购买流程。我的认为非订单完美映射方案都应该在这里作持久化。将订单id或者业务id(上文提到的剧集id)跟iap_product_id绑定并持久化,此后就不用担忧app崩溃或删除或网络很差等各类异常状况了,收到purchased通知后均可以经过iap_product_id找到数据。惟一须要处理的是当用户取消了购买或者购买失败时,须要把持久化的数据清除(关于这一点咱们踩到了坑,形成了掉单,后文中会谈到)

综上,对于非完美映射方案,位置1和位置4都作持久化,位置4先占个位,位置1拿到iap_transaction_id后再填充进去。

订单映射

下单顺序讨论中已经讨论过,分为订单完美映射订单非完美映射两种方案,这里再也不赘述。

用户映射

因为IAP的用户系统和App的用户系统是割裂开来的,官方并无一套完美方案把用户id映射到IAP交易上,Leo的这篇中提到他和苹果工程师确认过,对方给的答复是这点须要开发者本身解决

Leo给出的方案是applicationName + KeyChain,具体步骤以下:

  1. 尝试从applicationName中读取uid,若是uid为nil,则继续下一步
  2. 尝试从内存中根据productId来恢复uid,若是恢复失败,则继续下一步
  3. 尝试从keyChain中恢复uid,检查transactionDate和keyChain里记录的购买开始时间戳在容许范围内,若是恢复失败,则继续下一步
  4. 若是App内有IAP找回功能,这笔订单放到待找回列表里;若是App没有提供找回功能,继续下一步。
  5. 认为当前用户的uid是发生IAP购买的uid,若是当前用户已退出登陆,那么下一个登录的uid认为是购买的uid

这种多重防范机制可靠性应该不错,不过也相对复杂,增长了排查问题难度。

像步骤1和2依赖于不算可靠的applicationUsername和内存,我的倾向于能够省去,直接从步骤3的keychain开始尝试恢复。

同时步骤5做为兜底,有可能会错把A用户购买的商品配送给B用户。我的倾向于谁买的就一直为谁保留,即使当时恢复失败,且用户切换帐号登陆后,也不把以前的购买同步给新登陆帐号,当购买帐号再次登陆时继续尝试为其恢复。固然,这只是我的偏好,不是什么大问题,用户对这两种处理应该都有预期,不会以为奇怪。

贝聊给出的方案相对简单,做者提到了他们的方案有这么个问题:

若是是按照这个逻辑来走的话,有一个很显而易见的逻辑缺陷,从 IAP 支付到咱们去后台建立订单这个过程有苹果支付的和咱们建立订单的延时。如今情景是用户 A 发起了支付,而后还未购买就退出了登陆,而后用 B 帐号登陆了,而后 IAP 支付成功,咱们将支付信息存进了以 B 的 userid 为 key 的帐户中,这样就会致使咱们去后台验证的时候会把钱充到 B 帐户中

做者给出的方案是:

因此咱们在用户退出登陆的时候须要去检查他是否有未完成交易,若是有就要给个警告。可是仍是没办法完全解决掉这个问题,可是考虑到这个结果是用户的行为致使的,并且出现这个问题的概率不大,暂时就这样处理。若是你确实有这方面的担忧,那就应该采用上面说的粗放式的验证,粗放式的验证是不存在这个问题的。

因为完美映射方案是不记录任何用户id信息的,因此没法处理帐号切换的问题,只能从产品设计上增长一些警示措施。

对于非完美映射方案,因为原本就要持久化订单id或者业务id,同时把用户id绑定在一块儿,这样即使切换了用户,也知道IAP交易对应的持久化数据是否和当前登陆用户一致,一致则发起验证,不然忽略。

固然,也有做者认为切换帐号致使串单的状况太过极限,不必处理,好比这篇提到:

网上博客还爱用那种切换帐号的场景举例,A内购成功了,但用户各类骚操做后,本身换到B帐号,而后服务器那边把商品发到B帐号上了,等等。 这些状况都是存在的,由于苹果的内购机制问题,你是不能百分百保证不丢单的,不要把丢单状况看的那么严重,逻辑写的那么复杂。你看看全部大厂的App上都会写充值遇到问题,点我联系客服 巴拉巴拉。

若是你们开发时间充足,能够慢慢去弥补极端操做漏洞。

赞成做者说的,这确实不是个大问题,咱们的方案也没花什么力气去专门解决它,只是把思路理清后得出的方案中发现这个问题正好也迎刃而解了。

完成交易时机

这里指的是finishTransaction:的调用时机。通常有两种作法:

  1. 当收到purchasedfailed通知时调用
  2. 当收到purchased通知时不调用,等到这笔交易完成了App服务端验证后再调用

咱们知道,当调用finishTransaction:后,IAP才会认为这笔交易真正结束了。不然,每次App启动时都会收到相应的purchased通知(若是注册了observer的话),即使App卸载重装之后也能收到。

按理来说,当咱们加了交易持久化等机制之后,已经能够彻底脱离开IAP自身的事务机制来完成订单的验证任务了,那早早地finishTransaction:应该也没事,作法1和2的效果在大多数状况下是一致的。

然而有这么一种状况让我最后选择了作法2:当用户IAP购买成功,进行后续验证流程不太顺利时(发生网络很差或者崩溃等异常),有时会去尝试点击从新购买。若是是作法1,从新购买会让用户从新扣款,用户就崩溃了,而作法2不会,当尝试支付一个没有完成的交易时,输入密码后会出现下面的弹窗,并不会重复扣款:

IAP免费恢复.png

利用这一特性,一旦收到掉单报障,客服还能够引导用户经过再次点击购买去作补救。事实上,在我司方案实施过程当中,也确实发生过这样的案例,后文中会提到。若是采用作法1,就无法补救了。

另外,作法1使得IAP事务机制提早结束了,整个流程中只剩下了App端本身维护的验证任务,而作法2保留了IAP事务机制,能够和App端验证任务一块儿提供双重保证,二者是不冲突的。

重试机制

关于初次下单或验证失败后的重试机制,业界也是五花八门。

贝聊的方案以下图所示:

贝聊重试验证流程.png

这个流程和支付宝微信支付的重试机制有些相似,能够看出随着验证失败次数增长,重试间隔会愈来愈大。同时因为重试间隔的存在,整个重试流程应该是不阻断用户操做界面的,从代码中看不出是否静默重试,或是给了用户一些提示信息,好比“正在重试中,请耐心等待”等。若是重试一直不成功,则App会无限重试下去,以最多一分钟一次的频率。App每次从后台进入前台都会启动这个流程,重试成功的话会弹alert

另外,这篇也提到了另外一个方案,没有队列的概念,侧重发货任务的状态检查和去重,以下图所示:

zhangtielei重试验证流程.png

不一样IAP商品发货任务互不影响,固定的重试间隔,无限次数重试,保证发货任务惟一性。和贝聊相似的是,该方案应该也是非阻断式重试,也没有提到交互流程上是否静默重试。

对比下来,发现业界重试方案都大同小异。我的更倾向于:

  1. 采用验证队列:能够更好地管理App内全部验证请求优先级
  2. 不间断重试:由于对于用户来说,钱扣了之后内心会比较急,等待重试期间用户说不定已经来客诉了
  3. 最多重试3次,请求15秒超时:若是这45秒内重试始终不成功,那就大几率不是网络的问题了,再多的重试也没用
  4. 阻断式非静默重试:在重试过程当中,模态弹窗显示一些文案来安抚用户,同时能够阻止用户相似点击重试购买等有可能会让状况变得更复杂的操做
  5. 增长兜底方案:当3次自动重试都失败之后,明确告知用户订单不会丢失,引导用户去订单找回页面继续手动重试,提供多种途径联系到客服,能够方便地将App本地保存的加密交易信息提供给客服做为找回订单的依据。即使App卸载重装,因为存了keychain,也能高亮显示找回订单的入口。目的只有一个,不让技术侧收到任何掉单报障。

我司方案分析

对比完了业界方案之后,内心有个大体的优化方向了,无非是上面的六大方面都尽可能取最优解。而后把目光转向我司的实际状况上来,能够从业务、现象和代码三块来着手分析。

业务

我公司的IAP业务形态正是上文中介绍的多对一形态,采用了先走IAP购买流程再下单的模式。

正常购买的流程以下图所示:

我司正常购买时序图.png

购买完成后重试流程以下图所示:

我司重试验证流程.png

启动App后重试流程以下图所示:

我司重试验证流程_启动后.png

结合上文的讨论,光从流程的角度已经能够看出有不少能够优化的地方,大体的优化方向以下:

  1. 下单顺序优化:因为多对一IAP业务形态不存在订单完美映射方案,所以维持现有的下单顺序,不作重构
  2. 交易持久化:持久化到keychain,点击购买当即持久化
  3. 订单映射订单非完美映射方案
  4. 用户映射:在订单映射同时加入用户id信息,保证切换用户不串单
  5. 完成交易时机:等完成验证后再finishTransaction:
  6. 重试机制:验证队列 + 不间阻断式非静默重试3次 + 兜底方案

现象

因为以前这块没有详细打点,因此掉单用户没有任何相关行为日志可查。

同时因为服务端也没有任何下单的记录,于是只能模糊判断为网络缘由致使请求不可达,或者是崩溃致使没发起请求。

不肯定的时候最好本身去试一试,感觉下用户一样的购买流程。

用线上App作实验,在点击购买后,会弹出一个模态的loading框,应该是防止用户屡次点击或离开页面,猜想是为了简化一些程序逻辑。这个loading持续的时间会比较长,一直得等到IAP购买成功而且订单在服务端验证成功后才消失,遇到网络慢的时候确实会等待比较久,而整个界面又不可点击,失去耐心的用户可能就会选择杀掉App。在尝试中我也遇到了一次,在IAP支付成功后,等待服务端验证的时间太长了,因而就杀掉了App。重启后App内一片祥和,像什么都没发生过,固然本该发货的商品也没收到。就这么常规的一个小case,就把掉单测出来了。测试和开发都该打屁屁,算了,当时时间紧,毕竟IAP合规要紧,总比下架强。

好了,接下来就能够手撕代码了。

代码

经过debug发现支付成功后交易加入验证队列时,这个队列居然是个null。这个队列初始化的地方只有一处:

- (instancetype)initWithCoder:(NSCoder *)coder {
    self = [super init];
    if (self) {
        _iapArray = [coder decodeObjectForKey:IAPModelIapArrayKey];
        
        if (!_iapArray) {
            _iapArray = [NSMutableArray array];
        }
    }
    return self;
}

复制代码

而当这个类不是从Archive中恢复的时候,根本不会调用-initWithCoder:来初始化对象,而是调用-init,致使_iapArray根本没被初始化过。

就是这么个不起眼的地方,致使了IAP的重试机制形同虚设了,这必定是形成掉单的一个很重要缘由了。

不少严重的问题到最后都是一些弱智的小失误引发的。

细节是魔鬼。

三. 堵漏洞之旅

那是否是简单地把这bug修了就完事了呢,这不符合我一向的风格。我更但愿系统化地解决问题。

固然,此次得借助产品的力量,由技术驱动产品,从技术和产品两方面来改造了。

产品层面

为了零掉单的目标,本着让用户以为很稳的原则,全部异常环节都得给足提示和保障,全部等待环节要及时反馈进度。

改造后的IAP购买流程以下图所示:

产品改造_购买流程.png

改造后的App启动补单流程以下图所示(其中重试流程同上图,再也不重复做图):

版面 2.png

另外,订单找回页面做为兜底方案,须要考虑怎么可让用户方便地把异常订单信息上报过来,而且后续怎么跟进。最理想的固然是经过接口,一键上传,同时提供客服联系方式,由于用户掉单都比较急,急需联系客服。客服经过后台帮用户确认票据是否有效,若是有效则帮用户手动补单。

这样一来,就须要开发接口,以及一套供客服使用的后台。因为种种缘由,这方面的资源无法搞定。只能另想办法。

上报方式决定了后续处理方式,可供选择的有:

  1. App在线客服?只能用来联系上客服,因为RSA加密过的票据信息过长,没法发送票据信息
  2. 客服微信?先加客服微信,再微信发送票据,但票据信息很长,要考虑将文本做为文件发送
  3. 邮件?用户不必定配置了系统邮箱,但也能够一试
  4. 其余?用户能想到的任何能够上报的方式,App能够一键拷贝票据到系统剪贴板

初期先简陋些,能让票据到咱们这里就行。要不都做为备选一并提供了,简单粗暴 (逃:

找回订单页面.jpg

上图中没有显示全的方法一是手动重试下单,做为3次自动重试的补充。

虽然这个页面只是权衡下来的一个结果,并不是最佳方案,考虑到上线后能有机会看到这个页面的用户不多(但愿是没有),初版能够接受。

技术层面

因为以前的技术方案从流程、设计理念等方面相比新方案有较多区别,在原有代码上修修补补会很别扭,因而就把IAP模块彻底重构了。(重构过程省略1000字...)

重构完的代码须要保证能经过下面的异常状况测试用例:

  1. 点击IAP购买,杀App,在桌面完成付款,打开App,可以启动自动重试流程
  2. 点击IAP购买,完成付款,断网或切换到弱网,自动重试3次都超时,可以提示找回订单入口,切换到正常网络,在找回订单页面可以重试下单成功
  3. 在出现异常订单后,删除并重装App,登陆相同用户后还能找到这笔订单
  4. 在出现异常订单后,点击购买相同的IAP商品(iap_product_id和业务id都相同),直接发起重试
  5. 在出现异常订单后,点击购买相同iap_product_id的另外一个商品(业务id不一样),提示没法购买,避免出现您已购买此App内购买项目,此项目将免费恢复的系统提示,由于一旦出现这个提示,系统是不给IAP回调的,App的模态loading就无法隐藏,用户只能杀App

有些极端的测试用例就不考虑了,好比用户在某台手机掉单了,结果手机也丢了,换了台手机来找回订单等状况。难不成还为了这种case作服务端或者iCloud同步么? Are you kidding me? 过分优化是万恶之源,有这时间多写点业务也好啊。

另外,因为IAP的流程中有不少异步行为,这中间用到的内存变量都有可能由于崩溃等缘由丢失,因此重构时把关键内存变量都换成了持久化存储。

同时因为IAP的有些问题沙盒环境是没法测出来的,为了方便定位线上问题,在各环节加入详细打点,好比:

BI打点1.png

BI打点2.png

最后,为了更好地监控线上IAP的运行状况,用python撸了个脚本天天从打点后台捞日志并监控异常打点发送日报,下图是2019-07-08这天收到的监控邮件:

监控邮件.png

监控固然也能用ELK来作,可是感受定制化不如这样更自由一些,能够用本身最舒服的姿式看更干净的数据。

经过关键事件的打点数,能够看出当天运行是否平稳。

另外加了个小彩蛋,能够看到IAP优化上线以来天天以及累计挽回的收入。计算方法很简单:用户订单验证成功时,若是此前发生太重试,那么把这笔订单的收入计入挽回的损失中(打点里的iap_retry_verify_succeed事件会上报单笔订单挽回收入)。天天看看这个项目又为公司省了多少多少钱,干活也颇有动力有木有。

最后为了方便排查具体用户的问题,把全部有过异常事件的用户详细日志捞出来,按客户端时间排好序放入excel表格,做为邮件附件,同时搭配quicklook-csv一块儿食用,用空格直接预览csv内容,效果更佳。下图是2019-07-08某用户IAP相关详细日志:

某用户监控日志.png

过后证实,这些监控对排查线上问题帮助很大。

甚至还借此挖出一个非IAP相关问题:某天查日志发现有用户重试验证始终不成功,用户在订单找回页面手动重试了若干次也都失败了,订单验证API返回显示用户token已失效。正常状况下App端用户token失效会让用户从新去登陆,用户是不可能丢了登陆态还继续在App内使用的。后来发现是服务端最近新接入的登陆组件擅自改写了返回码,App端用来判断登陆失效的返回码不生效了。这个问题发生有段时间了,因为没有用户报障,就差点被时间掩埋,酿成大问题。

OK,我的认为已经稳了,上线吧。

零掉单1.0上线

2019-03-14上线。

谁料,上线之后被啪啪打脸。

客服同事找到我,说感受新版本上了之后天天的报障量不降反升了。

跟全部码农收到bug的第一反应同样,“不可能,必定是哪里搞错了”。

挑了其中一个用户反馈,准备挖掘一番。下图是用户的IAP支付成功凭证:

掉单用户支付截屏.png

能够看出是下午2019-03-19 13:06左右支付成功的。

而后去看用户的IAP相关行为日志,以下图所示:

掉单用户日志.png

能够看到从12:59开始到13:02之间,用户在犹豫要不要购买,点击了购买,随后又取消,犹豫了两次。

随后注意13:0513:06那次,从iap_purchase_click --> iap_purchase_transaction_cancel --> iap_purchase_transaction_succeed居然先取消后又支付成功了

13:06以后用户有点懵逼了,不断点击购买再取消,试图恢复订单,最后发现不行就过来报障了。

日志中支付成功的时间和用户的截屏高度吻合,能够认为那次确实支付成功了。可是以前那次cancel事件是怎么回事。又查了几个其余掉单用户,发现都是类似的行为日志:先cancelsucceed了。

App端在收到cancel事件后会把keychain中持久化的交易给清理掉,因此后续收到succeed事件时,就没法经过iap_product_id匹配到以前的交易了,以致于无法发起后续的订单验证流程,这一点和用户日志也是高度吻合。因此掉单缘由应该就是这个cancel致使。

至于为何新版本报障量上升了,是由于老版本不走这套逻辑,只是用临时变量记录了点击购买的商品,在cancel时也不会清理,因此succeed时能够对应上。

真的是解决了一个bug,又带来几个新bug。至于为何有cancel,联系了几个用户,发现共性是IAP支付时都曾经跳出须要他们验证Apple帐号的弹窗。网上搜了下发现也有个别开发者提到过这个问题。应该就是那次验证弹窗致使IAP先给了cancel回调。Leo这篇也提到了另外一种因为App Store的policy更新致使这个状况的可能。

这样的掉单实际上是能够修复的,只不过稍微迂回一些,须要用户配合。本身的锅,含着泪也要扛。我联系了几个用户,引导他们能够再次点击购买相同的商品,此时keychain会再次把商品信息持久化,同时因为用户已经购买过,而且没有finishTransaction:,不会重复扣款,会收到您已购买此App内购买项目,此项目将免费恢复的提示,但因为这个消息是没有回调的,模态loading会一直在,此时杀掉并重进App,就能再次收到succeed,并从keychain中对应到以前的交易信息,并发起订单验证流程了。

有一个用户配合我走完了整个流程并最终恢复成功,让我验证了以前的推断,有惊无险,毕竟,万一用户再次购买又发生了扣款,那用户的愤怒值就。。。

最简单的方案就是在收到cancel回调时不清理kaychain中数据。惟一的问题是这些数据有可能没有办法被清理,即使App被删除。但由于数据量很小,先简单上一版hotfix,后续再想优化方案,无非是找个时机帮用户清理一把。

零掉单1.1上线

上线后,报障量果真逐渐少下来了,一两个礼拜后,基本趋于零。

稳定了两个月,到了五月初,又零星收到几个掉单报障。经过查日志,发现是一种新的状况:先收到了fail后收到了succeed回调。和最先的cancel同样,fail也会清理keychain中的数据,致使后续succeed时找不到相应交易。

这点不能吐槽更多了,只能说IAPAPI设计有些反人类了。

无奈只能在收到fail回调时也不清理keychain,再上一版。

零掉单1.2上线

这个版本上线至今半年多了,线上没有再收到过一例掉单报障。此事能够告一段落了。

其余

游客模式

7月份App提审被拒,苹果要求咱们的App支持游客模式,即不注册登陆也须要能够购买IAP。这一点对现有IAP流程会有必定影响,大体改造思路以下:

  1. 未登陆时,把设备id当作用户id,用户发生的一切IAP购买都关联到设备id
  2. 未登陆时购买的一切商品都不发起服务端订单验证,仅作本地记录
  3. 未登陆时点击购买的商品都提示须要登录才能用,相似文案:“尊敬的用户,根据相关法律法规和监管要求,全部未实名登记或身份信息不全的用户必须进行补登记,请您登陆帐号后开始使用”
  4. 登陆后,把游客模式购买的IAP记录都迁移到当前用户下,并当即发起服务端订单验证,此后流程与以前一致

四. 小结

至此,IAP掉单相关优化介绍完了。这一块代码量不会不少,思路理清便可。前期多加些打点,后期排问题会方便不少。

本文不是一篇关于纯技术的文章,而是笔者项目实践中方方面面的一个记录,旨在还原一些作决策的过程。

做为系列的第二篇,有了一些压力,断断续续写了挺长时间,接下来有一些新的挑战,更新也会比较慢一些。

完。

参考连接

相关文章
相关标签/搜索