作者:凡几多 https://www.jianshu.com/p/9531a85ba165
介绍
经过完成这次的项目,我觉得其中最麻烦的就是自动续期订阅类型。因为其他几类都是一次性的内购类型,而只有自定续期订阅类是有连续性的,其中还有免费试用期、促销期的概念,用户还可以取消续订,恢复续订等。后台也需要有很多相应的逻辑操作。在这里总结一下完成自动续订订阅类型过程中遇到的问题和一些坑,希望帮助到大家。
内购流程
1. 流程简述
先来看一下iOS内购的通用流程:
简单来说就是将该购买凭证用Base64编码,然后POST给苹果的验证服务器,苹果将验证结果以JSON形式返回。
2. 具体实现
自动订阅类型需要注意: app开始运行时,一定要添加监听
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
因为自动订阅类型,除了第一次购买行为是用户主动触发的。后续续费都是Apple自动完成的,一般在要过期的前24小时开始,苹果会尝试扣费,扣费成功的话会在APP下次启动的时候主动推送给APP。所以,APP启动的时候一定要添加上面的那句话。
订单结束后一定要执行 finishTransaction 操作
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
下面看一下重要的几个代理方法的实现: 首先要引入苹果内购必须要的一个库 StoreKit
#import <StoreKit/StoreKit.h>
(1) 开始调起支付流程,请求商品信息,这里需要用到
SKProductsRequestDelegate,它是商品请求回调,可告诉你有没有这个商品
/**
调起支付,请求商品信息
@param productId 商品Id(在苹果connect上配置的内购地址)
*/
- (void)payWithAppleProductID:(NSString *)productId {
if ([SKPaymentQueue canMakePayments]) {
// 如果允许应用内付费购买
// 把商品ID信息放入一个集合中
NSArray *productIdentifiers = [[NSArray alloc] initWithObjects: productId, nil];
NSSet * set = [NSSet setWithArray:productIdentifiers];
// 请求内购商品信息,只返回你请求的产品(主要用于验证商品的有效性)
SKProductsRequest * request = [[SKProductsRequest alloc] initWithProductIdentifiers:set];
request.delegate = self;
[request start];
} else {
// 如果用户手机禁止应用内付费购买.
// 则弹出开启购买权限开关的提示等...
}
}
#pragma mark - SKProductRequestDelegate
/**
收到产品返回信息
SKProductsRequest是苹果封装好的一个对象,该对象有两个属性。
products是一个数组,代表的是你获取到的所有商品信息,每个商品 都是一个数组元素。
invalidProductIdentifiers是无效的商品id的数组,此id对应的是你在苹果后台构建的商品id。
*/
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{
NSLog(@"--------------收到产品反馈消息---------------------");
NSArray *product = response.products;
if([product count] == 0){
[SVProgressHUD dismiss];
NSLog(@"--------------没有商品------------------");
return;
}
NSLog(@"productID:%@", response.invalidProductIdentifiers);
NSLog(@"产品付费数量:%lu",(unsigned long)[product count]);
SKProduct *requestProduct = nil;
for (SKProduct *pro in product) {
NSLog(@"%@", [pro description]);
NSLog(@"%@", [pro localizedTitle]);
NSLog(@"%@", [pro localizedDescription]);
NSLog(@"%@", [pro price]);
NSLog(@"%@", [pro productIdentifier]);
// 如果后台消费条目的ID与我这里需要请求的一样(用于确保订单的正确性)
if([pro.productIdentifier isEqualToString:_currentProId]){
requestProduct = pro;
}
}
// 发送购买请求
//SKPayment *payment = [SKPayment paymentWithProduct:requestProduct];// 不可变的
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:requestProduct];// 可变的
payment.applicationUsername = @"123456";// 发起支付时候指定用户的userId
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
// 请求失败
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{
NSLog(@"------------------错误-----------------:%@", error);
}
- (void)requestDidFinish:(SKRequest *)request{
NSLog(@"------------反馈信息结束-----------------");
}
在发送购买请求的时候,我绑定了当前登录用户的id
payment.applicationUsername = [Global sharedGlobal].loginInfo.userId;
这样在之后收到交易回调的时候,我可以根据携带的applicationUsername 来判断当前用户是否是同一个用户,如果是同一个用户再去验证票据。但不要完全依赖这个参数,因为在网上也看到有人说这个参数有时候会为空,所以我们在验证的时候要首先判断是否为空,如果不为空,再去和当前用户id比对。如果为空,就照常接着走验证票据流程就行了。
// Array of SKProduct instances.
@property(nonatomic, readonly) NSArray<SKProduct *> *products NS_AVAILABLE(10_7, 3_0);
// Array of invalid product identifiers.
@property(nonatomic, readonly) NSArray<NSString *> *invalidProductIdentifiers NS_AVAILABLE(10_7, 3_0);
(2)判断购买结果,这里需要用到
SKPaymentTransactionObserver,SKPaymentTransactionObserver
是交易观察者,用来告诉你交易进行到哪个步骤了。
// 13.监听购买结果
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transaction {
for (SKPaymentTransaction *tran in transaction){
switch (tran.transactionState) {
case SKPaymentTransactionStatePurchased:
NSLog(@"交易完成");
// 订阅特殊处理
if (tran.originalTransaction) {
// 如果是自动续费的订单,originalTransaction会有内容
NSLog(@"自动续费的订单,originalTransaction = %@",tran.originalTransaction);
} else {
// 普通购买,以及第一次购买自动订阅
NSLog(@"普通购买,以及第一次购买自动订阅");
}
if ([Global sharedGlobal].loginInfo.logined) {
// 只有登录了才去处理票据 和 执行finish操作
NSString *orderUserId = [[tran payment] applicationUsername];// 得到该订单的用户Id
if ((orderUserId && orderUserId.length > 0 && [[Global sharedGlobal].loginInfo.userId isEqualToString:orderUserId]) || (nil == orderUserId || orderUserId.length == 0)) {
// 当订单的userId和当前userId一致 或者 订单userId为空时才处理票据、执行finish操作
[self completeTransaction:tran];
[[SKPaymentQueue defaultQueue] finishTransaction:tran];// 销毁本次操作,由本地数据库进行记录和恢复
}
}
break;
case SKPaymentTransactionStatePurchasing:
NSLog(@"商品添加进列表");
break;
case SKPaymentTransactionStateRestored:
NSLog(@"已经购买过商品");
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
break;
case SKPaymentTransactionStateFailed:
NSLog(@"交易失败");
[[SKPaymentQueue defaultQueue] finishTransaction:tran];
break;
default:
break;
}
}
}
// 交易结束,当交易结束后还要去appstore上验证支付信息是否都正确,只有所有都正确后,我们就可以给用户方法我们的虚拟物品了。
- (void)completeTransaction:(SKPaymentTransaction *)transaction {
NSString * str = [[NSString alloc] initWithData:transaction.transactionReceipt encoding:NSUTF8StringEncoding];
NSString *environment = [self environmentForReceipt:str];
NSLog(@"----- 完成交易调用的方法completeTransaction 1--------%@",environment);
// 验证凭据,获取到苹果返回的交易凭据
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];// appStoreReceiptURL iOS7.0增加的,购买交易完成后,会将凭据存放在该地址
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];// 从沙盒中获取到购买凭据
NSString *encodeStr = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];// BASE64 常用的编码方案,通常用于数据传输,以及加密算法的基础算法,传输过程中能够保证数据传输的稳定性,BASE64是可以编码和解码的
if (![UserOrderInfo isHasReceiptDate:encodeStr]) {
// 如果本地数据库没有此条票据记录
NSString *environmentStr;
if ([environment isEqualToString:@"environment=Sandbox"]) {
environmentStr = @"sandbox";
} else {
environmentStr = @"product";
}
// 将票据POST给自己的服务器去校验...
}
}
要发给后台同事的交易凭据长度会很大,一开始是7000多位,所以后台限制了10000位长度,结果随着订阅增多,交易凭据也越来越大,最后都达到了3万多位,后台只好把长度改为了30万位限制。
我在以上的基础上,添加了本地数据的订单记录,以防止掉单,在验证票据之前先把所有数据包括票据都插入到了本地数据库,并且执行了Objc [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
也就是告知苹果我的支付流程已经结束了。这样如果中途程序闪退或者其他情况出现,在下次启动app的时候会率先查询本地数据库有无未完成的订单操作并继续内购流程。就不依赖苹果自动的通知来继续完成内购了,因为苹果内购绑定的是appleId,而大部分公司需求都是绑定自己app的用户id。自己进行本地记录回复能更好的处理这种情况,当然如果用户换了设备当然就没办法了。
各种情况
用户可以在App Store或您应用的界面中的帐户设置中管理他们的订阅。对于每个订阅,App Store会显示订阅组提供的所有续订选项。用户可以轻松更改其服务级别,并根据需要随时选择升级,降级或交叉评级。任何持续时间的降级或具有不同持续时间的交叉等级将在下一个续订日期生效。
您可以查看收据的“订阅自动续订首选项”字段,以了解用户选择的任何计划更改,这些更改将在下一个续订日期生效。
订阅续订过程在到期日期前十天开始。在这十天内,App Store会检查可能会延迟或阻止订阅自动续订的任何结算问题,例如:
App Store可以通知用户任何问题,以便他们可以在订阅到期之前解决它,并避免其订阅服务中断。 在订阅到期之前的24小时内,App Store开始尝试自动续订。App Store会多次尝试在一段时间内自动续订订阅,但如果尝试失败次数过多,最终会停止。
订阅在购买时全额支付。用户只能通过联系Apple客户服务获得退款。例如,如果用户意外购买了错误的产品,客户支持可以取消订阅并发出全部或部分退款。客户可以在订阅期间取消订阅,但订阅仍在同一时期结束时支付。
要检查Apple客户支持是否已取消购买,请在收据中查找“取消日期”字段。如果该字段包含日期,则无论订阅的到期日期如何,购买都已取消。关于提供内容或服务,将取消的交易视为没有进行过购买。
根据您的应用提供的产品类型,您可能需要检查当前有效的订阅期,或者您可能需要检查所有过去的订阅期。例如,杂志应用程序需要检查所有过去的订阅期,以确定用户应该访问哪些问题。具有流服务的应用程序仅需要检查当前活动的订阅以确定用户是否应该有权访问其服务。
服务端验证
其实内购也可以完全靠客户端自己去验证,但是为了安全起见,大部分公司都会选择让服务器端去验证订单的有效性。当然我们项目也不例外。 首先要在itunes connection上配置自动续期订阅下,可以参考下面的苹果官方文档,《启用针对自动续期订阅的服务器通知》。 自动续订订阅和其他类型的区别还有必须在 App Store Connect 中生成一个共享密钥,把这个秘钥发给后台同事。 如果这样配置了server to server的通知,后台就会收到下面的几种状态更新通知类型:
由此可以看出并没有用户正常续订的通知,这块就和安卓不一样了,安卓是会有续订的通知的。苹果是默认就续订上了,取消才会有通知。
一开始后台这边也是遇到了很多不懂的问题,最后发现同一个订单凭据是可以一直使用的,不管你后面续订了多少次,随便这些中的一个凭据发给苹果验证,就能得到所有的订单信息和订阅状态,这样每个周期结束的时候(试用期最后一天或者月底),就可以根据票据信息去得到用户是否仍然续订的信息,这样就可以决定是否继续给下个月的VIP了。
沙盒测试
因为我们的项目要求第一次购买自动续订的享受七天免费试用期,而一个苹果沙盒账号只能享受一次免费试用期,所以导致我每自测一次都要申请一个新的沙盒账号,提交给测试部门测试的时候又要申请一堆账号,最后申请了47个沙盒账号…… 在我们测试自动续期订阅时,时限会缩短。此外,测试订阅最多仅能自动续期 6 次。
和安卓相比,苹果测试起来没那么友好,尤其是没办法模拟用户手动取消订阅的场景,因为沙盒账号没有办法管理订阅。而安卓是可以测试这一场景的。
另外需要注意,沙盒账号的续订,如果一直打开着app,可能过了5分钟续订周期也不会收到通知,最好是杀死app,5分钟后重新启动,这样就会收到续订的通知了。
使用户能够管理订阅 在非沙盒账号的情况下,项目中可以设置为打开此URL启动iTunes或iTunes Store并显示“管理订阅”页面。 https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/manageSubscriptions
关于审核
自动续订订阅,一定要在app中有详细的说明,类似下图这种:
上面只是个例子,我们的app只做了会员服务协议,没有右边那个自动续费条款也没事儿。
除了在app里要写,在iTunes Connect的应用描述里也要写,以喜马拉雅为例,如下图:
如果没有这些说明苹果基本是会拒你的。
因为苹果规定所有内购绑定的账号都应该是apple账号,所以不登陆你app自己的账号也应该可以购买,也就是游客状态下也要能购买,不然就耽误苹果赚钱了。 关于这个问题有两个解决办法: (1)做游客模式可购买(未登录是绑定设备,下一个账号登录以后绑定账号) (2)必须登录才可以使用app。 当然也可以做一个审核接口来应对。