目录
- 背景和痛点
- 掉单问题分析
- 堵漏洞之旅
- 小结
一. 背景和痛点
时间回到2018年底,公司的主App在收到多次 IAP 整改的警告后,苹果爸爸终于下了最后通牒,两周内得提审一个版本,所有虚拟商品的购买必须走 IAP ,否则全线产品下架。这下所有那些惯用的试图绕过 IAP 的手段都灰飞烟灭:支付宝、微信支付、审核开关等。刚接手项目,从同事那了解到两年前实现过一套 IAP 的方案,既然时间紧迫,不妨直接拿来试试。于是接入、调整产品流程、提测、准出、提审、上线一条龙,终于达到了 IAP 合规,平稳度过了危机。
上线以来情况大体稳定,只不过时不时会收到一些报障,主要集中在下面几个方面:
1.掉单
2.坏账
3.退款
顺便说一句,我司是做线上服务的,所有 IAP 商品都是 非自动续期订阅 类别,用户购买后享有一定期限内的服务。 IAP 商品价格从几块到几千块不等。
掉单
每天都会接到几例用户报障说钱扣了但货没到,要求退款。掉单的危害性不言而喻:
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 购买流程后下单的方案,大致流程如下:
图中把下单和验证票据合并到一个接口里了,贝聊是拆成了两个接口,前者的话 order_id 对客户端是透明的,后者客户端需要拿到 order_id 并且发起验证票据请求。不过这两者差不多,对我们的分析过程没影响。
按照作者的说法,采用 方案A 这种架构可以更好地完成 App 订单和 IAP 交易的映射,有效解决串单问题。
注:本文把 串单 也作为 掉单 的一种一起讨论了。所谓 串单 ,就是通过 IAP 购买了商品 A ,却和商品 B 的订单绑一起发往 App 服务端验证了,导致最终错发了商品 B ,或者验证失败。对系统来讲是 串单 ,对于用户来讲付了钱但想买的商品没买到,就是 掉单 了,而且 串单 和 掉单 在设计流程时密不可分。
之所以不采用先下单后走 IAP 购买流程的方案,作者认为那样无法将一开始创建订单生成的 order_id 完美地映射到 IAP 的交易上,会造成掉单。而采用先走 IAP 购买流程后下单的方案,就可以完美避开这个问题。
我们暂时不作分析,继续看另一个方案。
方案B:先下单后走 IAP 购买流程
Leo的这篇 更推荐先下单后走 IAP 购买流程的方案,大致流程如下:
作者认为这样更符合常见的支付系统的设计,优点是:
iTunesConnect
简单对比
我们先来看一下,如果采用 方案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 商品,比如:
iap_product_生活大爆炸
iap_product_行尸走肉
iap_product_绝命毒师
iap_product_无耻家庭
这样,每部剧的价格都分开维护,每当有新剧上架,都要在 iTunesConnect 后台配置。终于有一天,运营同事受不了了,说这样太累,我们可以设置一些价格档位,然后相同价格的剧配同一个 IAP 商品么?从此 iTunesConnect 后台出现了一些新的商品类型:
iap_product_100元剧集
iap_product_500元剧集
iap_product_1元限时促销剧集
同时在 App 内的“绝命毒师”、“无耻家庭”等剧集所关联的 IAP 商品改成了 iap_product_500元剧集 。
这种情况下当用户点击购买“绝命毒师”时,当 IAP 购买完成收到 purchased 通知后,从 transaction 中取到的 iap_product_id 变成了 iap_product_500元剧集 ,此时再去下单的话就必须带上“绝命毒师”剧集的 id 了,否则无法区分用户购买的是“绝命毒师”还是“无耻家庭”。
那似乎又回到了一开始的问题上了:该怎么把剧集 id 给映射到 IAP 交易上。
稍微想想便知,和 方案B 的订单 id 映射一样,这里也不存在一个完美的映射方案。
于是手撸了一张图,简单对比下 方案A 和 方案B 在订单映射方面的表现:
一对一 指的 iap_product_id 和业务 id 一一对应,比如剧集“绝命毒师”的 IAP 商品 id 是 iap_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 购买流程的,试图在这里将订单 id 和 IAP 交易绑定并持久化。个人认为这里是有问题的。如果订单 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.当收到 purchased 或 failed 通知时调用
2.当收到 purchased 通知时不调用,等到这笔交易完成了 App 服务端验证后再调用
我们知道,当调用 finishTransaction: 后, IAP 才会认为这笔交易真正结束了。否则,每次 App 启动时都会收到相应的 purchased 通知(如果注册了 observer 的话),即便 App 卸载重装以后也能收到。
按理来讲,当我们加了交易持久化等机制以后,已经可以完全脱离开 IAP 自身的事务机制来完成订单的验证任务了,那早早地 finishTransaction: 应该也没事,做法1和2的效果在大多数情况下是一致的。
然而有这么一种情况让我最后选择了做法2:当用户 IAP 购买成功,进行后续验证流程不太顺利时(发生网络不好或者崩溃等异常),有时会去尝试点击重新购买。如果是做法1,重新购买会让用户重新扣款,用户就崩溃了,而做法2不会,当尝试支付一个没有完成的交易时,输入密码后会出现下面的弹窗,并不会重复扣款:
作者:njuxjy 原文链接:juejin.cn/post/684490…