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

17,322 阅读39分钟

前言

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

众所周知,由于IAP相关的坑比较多,IAP有很多话题可以聊。IAP的很多行为在官方文档中并没有清晰描述,因此除了官方文档外,也建议一并阅读下面这些文章,它们各有侧重和特色:

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

文末可以找到所有参考文章的链接,基本涵盖了民间IAP开发相关的各路经验总结。

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

目录

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

一. 背景和痛点

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

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

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

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

掉单

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

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

坏账

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

  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掉单相关优化介绍完了。这一块代码量不会很多,思路理清即可。前期多加些打点,后期排问题会方便很多。

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

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

完。

参考链接