苹果内购(IAP)从入门到精通(5)- 掉单处理、防hook以及一些坑

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

该系列的其他文章:

【1】苹果内购(IAP)从入门到精通(1)-内购商品类型与配置

【2】苹果内购(IAP)从入门到精通(2)-银行卡与税务信息配置

【3】苹果内购(IAP)从入门到精通(3)- 商品充值流程(非订阅型)

【4】苹果内购(IAP)从入门到精通(4)- 订阅、续订、退订、恢复订阅

1. 结束交易-finishTransaction

[SKPaymentQueue defaultQueue]这个队列里面存着所有的已支付,未支付的订单,而且需要通过finishTransaction手动移除完成了的订单。一般情况下,我们都会在以下几种情况移除订单:

a.支付失败。比如手动取消支付、卡里没钱了等。即SKPaymentTransactionStateFailed状态下。

b.支付成功。票据上传给服务器去做校验后,校验返回结果失败。

c.支付成功。票据上传给服务器去做校验后,校验成功。

但是,一般情况下,请求苹果的票据校验接口,都是由App服务端完成的。加密的原始票据,上传给服务器后,服务器再去请求对应的苹果票据校验接口,拿到解密的票据数据。之后,再解析票据合法性,合法后才告知前端并下发道具。这个校验合法性的逻辑,只有前端通过延迟轮询,才能够获得结果。

扫描二维码关注公众号,回复: 13671359 查看本文章

理想情况下,我们需要在确保商品已经成功发放到用户手中再调用finishTransaction。但因为服务端的校验结果,前端不能马上知道,有些时候因为网络问题,甚至会校验失败。所以我们可以在请求服务器并且回调结果后,就finishTransaction。这样不至于导致充值队列卡住,但却很容易导致掉单。有利有弊。

针对票据上传成功但校验失败的情况,我现在的操作是记录下这笔交易的信息(用户信息、订单号、transaction_id、交易时间等),然后上报日志给服务器,之后finishTransaction。服务器记录下这笔异常订单,方便后续给用户补单或者查询坏账。

2. 透传参数-applicationUsername

在发起充值之前,我们会创建SKMutablePayment对象,并添加到支付队列中。

SKMutablePayment *payment = [[SKMutablePayment alloc] init];
payment.applicationUsername = orderId; //透传参数
payment.productIdentifier = _productId;
payment.quantity = count;
[[SKPaymentQueue defaultQueue] addPayment:payment];
复制代码

其中,有个applicationUsername参数,是一个透传参数。在你添加支付到支付完成获得SKPaymentTransaction后,这个参数都会存在于transaction.payment.applicationUsername中。在开发的实际应用中,我们往往需要将我们自己服务器的订单号、或者用户ID跟苹果的订单关联。我们可以通过缓存读写的形式,但也可以通过applicationUsername这个透传字段来传递参数。比如我们把orderID(订单号)写入透传字段中。我们在创建订单发起支付,到完成支付后,我们都能关联到这个订单。

这个透传字段,看似是非常方便地解决了我们的关联问题。但是,实践中我们却发现,applicationName这个参数在支付完成的回调的时候有可能为nil。以下场景是已经发现的情况:

  • 我们充值完成,在paymentQueue:updatedTransactions:拿到Purchased状态时,我们的应用程序因为异常崩溃了。这个时候,这笔transaction并没有finish。当我们重启app初始化支付队列时,这笔交易会再次执行,回调Purchased状态。但这个时候,回调的transaction里的applicationUsername却是空的。
  • 刚刚已订阅过的商品(未结束App进程),再次发起订阅,会提示“已订阅”并回调“purchased”状态。但这个时候的transationId是新的, transaction.payment.applicationUsername有值。苹果回调回来的票据信息里能拿到原来订阅时的transationId。如果这个时候点击“管理”跳出app,然后再切换回app。这个时候又会回调一次“purchased”状态,但这个时候transationId也变了,transaction.payment.applicationUsername的值为空了。
  • 正式环境下,中国区用户用支付宝充值,苹果会发起短信验证。这个时候你跳出app查看短信,然后回到app输入验证码,输入密码支付。支付成功后,会回调两次purchased状态。第二次状态里的applicationUsername为空。

所以我们可以采用“透传字段+双重keychain”的多重策略。如下所示:

a.尝试从transaction.payment.applicationUsername中读取orderId,如果uid为orderId,则继续下一步;

b.尝试从keyChain中恢复orderId,检查transactionDate和keyChain里记录的购买开始时间戳是否在允许范围内。存keyChain的位置是第一次返回Purchased状态的时候。如果这时候未拿到值,则继续下一步;

c.再次尝试从keyChain中恢复orderId,而这个keyChain的值是创建订单时缓存的。

d.在每次启动App和每次发起充值之前,都检查交易队列里是否有没有finished的交易(非订阅的)。如果有则处理这笔交易,重新上传票据给服务器校验。

3.防hook

  • 服务端拿到客户端传上来的receipt_data,并向苹果请求验证成功,之后才处理票据合法性,并发放道具。但是这比订单也有可能是刷的,黑客修改了这个receipt_data,并把同一个凭证绑定不同的app订单号进行模拟接口请求。这个时候,服务区需要验证这个凭证的订单号。因为对于支付来说,订单号是唯一的,所以对于同一个订单号,可能被人为的修改成了多笔订单。服务端对每笔订单的订单号要做个记录,订单号与票据是一一对应的关系。每次需要验证订单号与票据的唯一性。如果不匹配,那这笔订单就可能有问题。这个一一对应关系,可以在付款purchased状态回来的时候,通过transctionId和orderId进行对应。网上不建议使用receipt_data进行一一对应,因为貌似存在同一笔IAP订单但receipt_data不同的情况。但这里自己也有一个建议:订阅商品在restore的时候,transctionId也是不同的。所以恢复订阅的时候不能用transctionId去匹配唯一性。

  • receipt_data在越狱环境下是可以被插件伪造的,后台向苹果验证时,居然还能验证通过。但是,这个票据里可能是没有in_app数据段的。实际上,iOS7以下的API获取到的receipt_data就没有in_app,但这个receipt_data请求苹果校验时也能过。所以,我们通知我们服务器去请求苹果校验时,可以判断receipt_data是否有值

  • 通过我们服务器去请求苹果校验时,除了传receipt_data外,还可以补充上传商品ID(product_id)和transaction对象里的transactionId。然后请求苹果校验后,去匹配票据里的transaction_id、product_id。

  • 有些刷单的,在越狱环境下,可以伪造receipt_data,而且是正常的receipt_data,能够校验通过并且有in_app数据段。但是,这种订单,票据里的in_app里面的purchase_date要早于服务器创建订单的时间,说明这个票据是非法的。所以,服务器需要判断票据里的in_app里面的purchase_date是否遭遇订单创建时间。如果早于订单创建时间,则为刷单

  • 还有一些高手,完全不知道是怎么刷的,但就是过了,上面所说的点都是正确的。但我们通过其他手段判断出来他们是刷的(举报、付款信息等)。这样就没办法了吗?也不全是。我们可以设置一些规则。比如,检测到是越狱手机,就不允许充值;或者短时间内充值多笔,就跟设置限制;或者这个设备给不同的账号疯狂充值,那就封掉这个设备和账号......诸如此类,实际根据自己App的刷单情况做调整。

4. 掉单与补单

(1)连续购买多个商品

比如一个商品按钮,被飞速连续点击多次。这时也会调起多次IAP购买。如果不涉及到APP自身的订单的逻辑,充值然后拿到票据校验,苹果一整套逻辑下来是没什么问题的。但是,基本每个APP都会让IAP订单跟自身服务器的订单系统做关联。

如果本地只缓存一个订单号,然后在校验订单请求的时候作为请求参数,在当前情况下,就会导致订单号与实际的receipt_data关联错误的情况;

如果本地通过队列缓存订单,或许能避免上面的情况,但队列的计算也会考验开发者的计算能力,队列内订单判断失误也会导致掉单;

如果通过透传参数applicationUsername传递订单号或者其他信息,又会遇到applicationUsername为空的情况。

当然,办法总是有的,我这里暂时没想出一个完美的处理连续购买商品且能不掉单的方案。相对地,本人更提倡的是,避免“连续购买多个商品”的情况,不让用户触发这种情况。

方法一:一次支付完成之前,只允许一笔订单在支付队列中操作。订单支付完成后,才能发起新的支付。

最简单的方法,就是将按钮enable。但前提是UIKit控件直接触发支付。如果是SDK,或者unity、cocos游戏则不行。

- (IBAction)clickBtn:(UIbutton *)sender
{
    sender.enabled = NO;
    [[PayKit shareKit] startPay:^(BOOL result) {
        sender.enabled = YES;
    }];
}
复制代码

也可以采用属性标记,BOOL值。

if (isOrdering) {
    NSLog(@"订单正在支付中...");
    return;
}
isOrdering = YES;
[[PayKit shareKit] startPay:^(BOOL result) {
    if(支付完成){isOrdering = NO}
}];
复制代码

方法二:用户一点击按钮后就展示蒙版。防止用户再次点击按钮。订单支付完成后,再关闭蒙版。

[[MaskView shareView] showView];
[[PayKit shareKit] startPay:^(BOOL result) {
    if(支付完成){
        [[MaskView shareView] hideView];
    }
}];
复制代码

坑:如果你不是在支付按钮刚点击下去的第一时间触发,也可能导致支付调起多次。

方法三:设置在1s内,只能调起一笔支付(需搭配方法二使用)

[self performSelector:@selector(payPlatformWithModel:) withObject:payModel afterDelay:1.0];
- (void)payPlatformWithModel:(id)payModel
{
    [[MaskView shareView] showView];
    [[PayKit shareKit] startPay:^(BOOL result) {
        if(支付完成){
            [[MaskView shareView] hideView];
        }
    }];
}
复制代码

个人建议:防止支付多次触发,建议所有方法都用上。多多益善。

(2)多个商品合并付款,苹果票据返回多个商品

如果开发者并没有采用“屏蔽多次触发支付”的方法,而是支持多笔订单连续调起,通过支付队列去正确匹配订单号与苹果票据的形式(这的确是技术上的正统方法),你就很可能踩到苹果爸爸给你埋的另一个坑。线上环境下可能触发,多笔订单合并付款的情况。比如你点击了6元商品和30元商品购买,苹果会让你输入密码付款36元。付款成功后,苹果只会回调你一个transaction,然后receipt_data解析后in_app内会有两笔支付数据。但对于服务器而言,6元和30元是两笔订单,因为是分别创建的订单。那我到底用那个订单号去匹配票据呢?或许能匹配上其中一个订单,但却很难匹配上两个订单(苹果将app服务器的两笔订单当成1比订单处理了),那势必会导致其中一个订单掉单。你或许可以通过下次启动[SKPaymentQueue defaultQueue]时去重新校验订单进行补单,或者直接等用户联系客服后进行服务器手动补单。

这个问题没有一个很好的解决办法。所以个人建议还是采用“屏蔽多次触发支付”的方法。

(3)苹果接口网络问题

苹果的接口,在线上环境下,即使网络不错的情况,也会出现回调很慢、甚至请求失败的情况。在以下几个情况下,我们会涉及到跟苹果请求:

场景一:通过SKProductsRequest获取商品的时候,请求失败。

建议不通过SKProductsRequest去获取SKPayment对象,而是手动创建,绕过这一步。

SKMutablePayment *payment = [[SKMutablePayment alloc] init];
payment.applicationUsername = orderId; //透传参数
payment.productIdentifier = _productId; //商品ID直接传字符串
payment.quantity = count;
[[SKPaymentQueue defaultQueue] addPayment:payment];
复制代码

场景二:支付队列addPayment后,并没有调起充值而直接回调"充值失败";或者在输入密码后由于网络问题回调“充值失败”(实际并没有扣款)。

因为这个时候,这笔订单实际并没有发生任何扣款,所以不用继续这笔订单,可以直接finishTransaction。

场景三:请求苹果接口校验票据失败。

因为票据校验逻辑,是由服务端异步完成的。由上所说,充值成功后最好的finishTransaction的时机,应该是在票据校验成功并且道具(权益)已经下发了,再finish。但有些时候为了避免等待,我们也会在请求服务端去校验,服务端返回receipt_data上传成功时,就会finishTransaction。但后者的情况下,如果在服务器都请求苹果校验接口失败,而客户端已经finishTransaction了,就会导致掉单。这种情况下,服务端应该给这笔订单加上“已付款,未到账”的标识,前端也可以上报一个自定义的日志告诉服务器“这笔订单异常”。 在客户端因为提前finishTransaction导致已经无法再次发起校验时,等用户找上客服后,服务端需要根据订单的这个状态进行手动补单。

但如果我们在道具下发之后再finishTransaction,就会避免这个问题出现。只不过需要等待更长的时间。有利有弊。

(4)在哪儿开启支付队列监听合适呢?

当我们调用[[SKPaymentQueue defaultQueue] addTransactionObserver:self]开启支付队列时,支付队列会自动检查是否有没有finish的transaction,并回调给我们。那在哪儿开启这个队列监听合适呢?

1. 启动App时调用

[[SKPaymentQueue defaultQueue] addTransactionObserver:**self**];
复制代码

注意这里是调用addTransactionObserver而不是restoreCompletedTransactions。因为这时候支付队列还没有初始化,直接调用restoreCompletedTransactions是没效果的。

如果有个未结束的交易,会回调这笔交易最新的状态。如果是Pruchasing状态,则输入密码,成功后继续走校验逻辑;如果是Pruchased状态,则上传票据给服务器校验。注意:上面提到的applicationUserName这个透传参数有可能在这种有未结束的交易的情况下为空值,所以注意订单数据要匹配。

2. 在发起新的支付之前调用

[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
复制代码

描述同上。

以上的办法,都只是尽量减少掉单的发生,并不能完全解决掉单问题。每个app的逻辑不同,坑也各不相同。掉单之后如何补单?补给那个账号?补给哪个订单?很多问题都需要结合具体问题具体分析。

【参考资料】

【6】IAP苹果支付防丢单策略

【7】谈谈苹果应用内支付(IAP)的坑

【8】iOS内购-防越狱破解刷单

猜你喜欢

转载自juejin.im/post/7050408490682023966