该系列的其他文章:
【1】苹果内购(IAP)从入门到精通(1)-内购商品类型与配置
【2】苹果内购(IAP)从入门到精通(2)-银行卡与税务信息配置
【3】苹果内购(IAP)从入门到精通(3)- 商品充值流程(非订阅型)
1. 充值流程(自动订阅)
1.1. 商品购买
等同于消耗型商品的购买。无非也是添加支付队列监听,初始化SKPayment并添加到支付队列中,然后付款,回调Purchased状态。在这里不再赘述。详细看上一篇文章:苹果内购(IAP)从入门到精通(3)- 商品充值流程(非订阅型)。主要的区别是在后面。
1.2. 票据校验
请求苹果票据校验时,请求参数需要传一个新的参数,叫“共享秘钥”。这个在苹果后台配置商品ID的地方生成,如下所示:
自动订阅商品的票据,与消耗型商品的票据有很多不同点。
{
environment = Sandbox;
"latest_receipt" = "很长一串,是请求时的加密票据";
"latest_receipt_info" = (
{
"expires_date" = "2019-09-18 06:44:15 Etc/GMT";
"expires_date_ms" = 1568789055000;
"expires_date_pst" = "2019-09-17 23:44:15 America/Los_Angeles";
"is_in_intro_offer_period" = false;
"is_trial_period" = true;
"original_purchase_date" = "2019-09-18 06:41:16 Etc/GMT";
"original_purchase_date_ms" = 1568788876000;
"original_purchase_date_pst" = "2019-09-17 23:41:16 America/Los_Angeles";
"original_transaction_id" = 1000000569412514;
"product_id" = "com.auto.pay6";
"purchase_date" = "2019-09-18 06:41:15 Etc/GMT";
"purchase_date_ms" = 1568788875000;
"purchase_date_pst" = "2019-09-17 23:41:15 America/Los_Angeles";
quantity = 1;
"subscription_group_identifier" = 20548697;
"transaction_id" = 1000000569412514;
"web_order_line_item_id" = 1000000046978708;
}
);
"pending_renewal_info" = (
{
"auto_renew_product_id" = "com.auto.pay6"; //自动订阅商品ID
"auto_renew_status" = 1; //自动订阅状态(0说明订阅已关闭)
"original_transaction_id" = 1000000569412514;
"product_id" = "com.auto.pay6";
}
);
receipt = {
"adam_id" = 0;
"app_item_id" = 0;
"application_version" = 1;
"bundle_id" = "com.mytest.0522";
"download_id" = 0;
"in_app" = (
{
"expires_date" = "2019-09-18 06:44:15 Etc/GMT"; need //订阅到期时间
"expires_date_ms" = 1568789055000; //订阅到期时间戳
"expires_date_pst" = "2019-09-17 23:44:15 America/Los_Angeles"; //订阅到期时间(美国)
"is_in_intro_offer_period" = false; //是否在享受优惠价格期间
"is_trial_period" = true; //是否享受免费试用
"original_purchase_date" = "2019-09-18 06:41:16 Etc/GMT"; //原始购买时间
"original_purchase_date_ms" = 1568788876000; //原始购买时间戳
"original_purchase_date_pst" = "2019-09-17 23:41:16 America/Los_Angeles"; //原始购买时间(美国)
"original_transaction_id" = 1000000569412514; //原始购买票据ID
"product_id" = "com.auto.pay6"; //商品ID
"purchase_date" = "2019-09-18 06:41:15 Etc/GMT"; //购买时间
"purchase_date_ms" = 1568788875000; //购买时间戳
"purchase_date_pst" = "2019-09-17 23:41:15 America/Los_Angeles"; //购买时间(美国)
quantity = 1; //购买商品数量
"transaction_id" = 1000000569412514; //票据ID
"web_order_line_item_id" = 1000000046978708; ////跨设备购买事件(包括订阅更新事件)的唯一标识符。此值是识别订阅购买的主键
}
);
"original_application_version" = "1.0";
"original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT";
"original_purchase_date_ms" = 1375340400000;
"original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles";
"receipt_creation_date" = "2019-09-18 06:41:16 Etc/GMT";
"receipt_creation_date_ms" = 1568788876000;
"receipt_creation_date_pst" = "2019-09-17 23:41:16 America/Los_Angeles";
"receipt_type" = ProductionSandbox;
"request_date" = "2019-09-18 06:42:32 Etc/GMT";
"request_date_ms" = 1568788952928;
"request_date_pst" = "2019-09-17 23:42:32 America/Los_Angeles";
"version_external_identifier" = 0;
};
status = 0;
}
在in_app内,多了以下几个字段
"expires_date" = "2019-09-18 06:44:15 Etc/GMT"; //订阅到期时间
"expires_date_ms" = 1568789055000; //订阅到期时间戳
"expires_date_pst" = "2019-09-17 23:44:15 America/Los_Angeles"; //订阅到期时间(美国)
"is_in_intro_offer_period" = false; //是否在享受优惠期间
"web_order_line_item_id" = 1000000046978708; ////跨设备购买事件(包括订阅更新事件)的唯一标识符。此值是识别订阅购买的主键
排查其他商品类型的票据后,我发现,即使是“非续期订阅”商品,也没有这几个字段。所以,可以判定,拥有这几个字段,就说明这个商品是自动订阅商品。(服务端可以用这5个字段来判断,这个票据是不是自动订阅的票据)
校验票据是否合法的逻辑同理于消耗型商品,如果校验通过了。就通知后台下发道具、更新客户端UI等。
2. 订阅续期
“自动订阅”商品,顾名思义,苹果会自动扣款续费。
最常见的有两种情况自动订阅商品会失效:
(1)用户手动取消订阅。需要用户手动去“设置”里去取消订阅。取消后,从下一个订阅周期开始,将不再扣费。同时,游戏内需要处理这个逻辑,停止下个周期的订阅服务的下发。
(2)购买订阅的AppleID绑定的银行卡或者信用卡没钱了。苹果会反复尝试扣款。如果都没有扣款成功,将停止订阅。(有坑,后面会说)
除此之外,苹果都会自动扣款去续订。有两种方式去判断续订是否成功:
一、每次启动app时,客户端主动获取receipt_data,上传给服务器做票据校验,服务器检查票据内是否有新的续订交易(时间可以判断),有则下发新的订阅商品。
二、server to server的校验方式,也是 苹果推荐的校验方式 ,由苹果主动告知我们状态。需要在appstore connect后台配置订阅状态URL,具体参考苹果官方文档启用针对自动续期订阅的服务器通知,同时也需要用到共享秘钥(上面有说到)。
参考上面的官方文档可以发现,苹果提供了两个server to server的接口,V1和V2。两个版本的接口、参数、回调都不一样。业界内主要用的还是V1版本,更加稳定(听说V2有一些bug),因此我们这里介绍V1版本的:
如果这样配置了server to server的通知,后台就会收到下面的几种状态更新通知类型:
NOTIFICATION_TYPE | 描述 |
---|---|
INITIAL_BUY | 首次订阅 |
CANCEL | 取消订阅 |
DID_RENEW | 自动续订成功 |
INTERACTIVE_RENEWAL | App或者设置内交互式续订 |
DID_FAIL_TO_RENEW | 计费问题未能续订 |
DID_RECOVER | 已成功续订过去未能续订的过期订阅 |
DID_CHANGE_RENEWAL_STATUS | 续订状态发生变化 |
DID_CHANGE_RENEWAL_PREF | 续订降级 |
CONSUMPTION_REQUEST | 发起退款申请 |
REFUND | 退款成功 |
PRICE_INCREASE_CONSENT | 提价状态 |
INTERACTIVE_RENEWAL | App或者设置内以交互方式续订 |
REVOKE | 不能继续家庭共享 |
通过server to server,服务端开发可以通过状态和回调的original_transaction_id匹配的订单与用户,进行相应的逻辑。
注意:服务端最好处理CANCEL类型。因为IAP存在黑产:比如买了一年会员,然后打电话给苹果客服退款,如果服务端不处理,这一年会员是生效的。
(1)更新票据:
在续费的前10天,Apple会进行续费的前期检查,尽量确保用户能够正常扣款。如果前期检查出了问题,会提醒用户应该处理对应的问题。
在续费的前24小时,Apple会尝试扣款,Apple会尝试几次扣款,如果一直扣款失败会停止扣款,订阅被动取消。注意,如果是支付相关的问题,Apple可能会进行长达60天的尝试。可以通过收据中的is_in_billing_retry_period判断Apple是否还在尝试中。
同一个订单凭据是可以一直使用的,不管你后面续订了多少次,随便这些中的一个凭据发给苹果验证,就能得到所有的订单信息和订阅状态。服务端需要保存这笔订单的recipt_data,并在每个周期结束之前请求苹果票据校验接口,根据返回的票据信息去得到用户是否仍然续订的信息。
续订后,in_app内会多一组新的票据数据。代表新的续订。如下所示:
{
environment = Sandbox;
"latest_receipt" = "==========很长串票据字符============";
"latest_receipt_info" = (
{
"expires_date" = "2019-09-23 09:18:17 Etc/GMT";
"expires_date_ms" = 1569230297000;
"expires_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles";
"is_in_intro_offer_period" = false;
"is_trial_period" = true;
"original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT";
"original_purchase_date_ms" = 1569230118000;
"original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles";
"original_transaction_id" = 1000000571201990;
"product_id" = "com.auto.pay6";
"purchase_date" = "2019-09-23 09:15:17 Etc/GMT";
"purchase_date_ms" = 1569230117000;
"purchase_date_pst" = "2019-09-23 02:15:17 America/Los_Angeles";
quantity = 1;
"subscription_group_identifier" = 20548697;
"transaction_id" = 1000000571201990;
"web_order_line_item_id" = 1000000047083065;
},
{
"expires_date" = "2019-09-23 09:21:17 Etc/GMT";
"expires_date_ms" = 1569230477000;
"expires_date_pst" = "2019-09-23 02:21:17 America/Los_Angeles";
"is_in_intro_offer_period" = false;
"is_trial_period" = false;
"original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT";
"original_purchase_date_ms" = 1569230118000;
"original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles";
"original_transaction_id" = 1000000571201990;
"product_id" = "com.auto.pay6";
"purchase_date" = "2019-09-23 09:18:17 Etc/GMT";
"purchase_date_ms" = 1569230297000;
"purchase_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles";
quantity = 1;
"subscription_group_identifier" = 20548697;
"transaction_id" = 1000000571203602;
"web_order_line_item_id" = 1000000047083066;
}
);
"pending_renewal_info" = (
{
"auto_renew_product_id" = "com.auto.pay6";
"auto_renew_status" = 1;
"original_transaction_id" = 1000000571201990;
"product_id" = "com.auto.pay6";
}
);
receipt = {
"adam_id" = 0;
"app_item_id" = 0;
"application_version" = 1;
"bundle_id" = "com.mytest.0522";
"download_id" = 0;
"in_app" = (
{
"expires_date" = "2019-09-23 09:21:17 Etc/GMT";
"expires_date_ms" = 1569230477000;
"expires_date_pst" = "2019-09-23 02:21:17 America/Los_Angeles";
"is_in_intro_offer_period" = false;
"is_trial_period" = false;
"original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT";
"original_purchase_date_ms" = 1569230118000;
"original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles";
"original_transaction_id" = 1000000571201990;
"product_id" = "com.auto.pay6";
"purchase_date" = "2019-09-23 09:18:17 Etc/GMT";
"purchase_date_ms" = 1569230297000;
"purchase_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000571203602;
"web_order_line_item_id" = 1000000047083066;
},
{
"expires_date" = "2019-09-23 09:18:17 Etc/GMT";
"expires_date_ms" = 1569230297000;
"expires_date_pst" = "2019-09-23 02:18:17 America/Los_Angeles";
"is_in_intro_offer_period" = false;
"is_trial_period" = true;
"original_purchase_date" = "2019-09-23 09:15:18 Etc/GMT";
"original_purchase_date_ms" = 1569230118000;
"original_purchase_date_pst" = "2019-09-23 02:15:18 America/Los_Angeles";
"original_transaction_id" = 1000000571201990;
"product_id" = "com.auto.pay6";
"purchase_date" = "2019-09-23 09:15:17 Etc/GMT";
"purchase_date_ms" = 1569230117000;
"purchase_date_pst" = "2019-09-23 02:15:17 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000571201990;
"web_order_line_item_id" = 1000000047083065;
}
);
"original_application_version" = "1.0";
"original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT";
"original_purchase_date_ms" = 1375340400000;
"original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles";
"receipt_creation_date" = "2019-09-23 09:20:00 Etc/GMT";
"receipt_creation_date_ms" = 1569230400000;
"receipt_creation_date_pst" = "2019-09-23 02:20:00 America/Los_Angeles";
"receipt_type" = ProductionSandbox;
"request_date" = "2019-09-23 09:20:10 Etc/GMT";
"request_date_ms" = 1569230410417;
"request_date_pst" = "2019-09-23 02:20:10 America/Los_Angeles";
"version_external_identifier" = 0;
};
status = 0;
}
我们关注in_app内的如下几个字段:
- purchase_date:代表当前这笔订单的扣款时间。续订也是扣款。如果是续订,这个时间正好是上一笔扣款数据里purchase_date+订阅周期。
- original_purchase_date:代表这个自动订阅商品的第一次购买的时间。服务端可以用来判断这个用户是什么时候开启订阅的。
- original_transaction_id:表示第一次订阅的时候的票据ID。因为服务端肯定用自己的订单号(orderId)和这个票据ID进行的绑定。所以要想跟踪是哪笔订单的续订,就使用这个。transaction_id每次都会改变,即使是恢复订阅,transaction_id也会改变,所以不要使用这个字段。
(2)票据合法性校验
一般情况下,只要实现了server to server的通知,苹果都会在续订后告知你。但有小概率出现不告知的情况。所因此建议,服务端记录下来最后一个收据(receipt_data),在订阅过期时间expires_date前24小时,定时用最后一条收据轮询,如果用户续费未成功,检查is_in_billing_retry_period,如果这个为true,那么放到下个轮训队列里继续检查,直到is_in_billing_retry_period为false,表示Apple已经放弃了扣款。如果续订成功,服务端拿到最新的票据后,判断时间、商品ID是否合法,原始票据ID对应的订单ID是否合法。之后,给这个订单ID对应的用户进行订阅续期操作,发放相应道具或者权限。
(3)sandbox环境下的订阅周期
在沙盒环境下,测试自动续期订阅时,时限会缩短。此外,每天的订阅次数最多仅能自动续期12次(包括首次订阅)。
实际时限 | 测试时限 |
---|---|
1 周 | 3 分钟 |
1个月 | 5 分钟 |
2 个月 | 10 分钟 |
3 个月 | 15 分钟 |
6 个月 | 30 分钟 |
1 年 | 1 小时 |
而取消订阅,苹果是没办法模拟的。所以一般是采用新建一个新的沙盒账号去解决。
3. 恢复订阅
比如一个用户(人),他在A设备上购买了App的VIP(自动订阅商品)。他买了一台新的手机B,重新下载了这个App。但这个App的VIP是本地Keychain缓存设置。换了设备后,keychain没有了缓存,这个用户在设备B上不享有VIP。但用户付了钱,肯定需要享受服务,所以苹果提供了“restore”服务,恢复订阅的权限。这个是正常的逻辑。
但还有一种情况,是苹果允许,但却很bug的设定。订阅服务是跟AppleID绑定的。但我们每个app基本都是有自己的用户系统,这个用户系统跟AppleID无关(实际上iOS开发者也拿不到用户的AppleID)。所以,苹果要求:只要当前App是由一个已订阅过的AppleID下载的,这个App下的任何App账号,都可以享受这个订阅权限。
是不是很绕?我们举个最简单的例子(真的是最简单的):
用户A,有AppleID_A给这个APP下的账号A购买了会员(自动订阅);这时,用户A换了一个账号B。那么这个账号B也需要享受会员。如果没有享受,APP内可以开放一个“恢复订阅”功能,让用户A可以操作给账号B恢复订阅服务。
因为苹果这个不讲道理的要求,实际上的场景要复杂很多。AB用户、AB设备、AB苹果ID、AB账号、AB角色......我们不在这个地方过分展开说,具体场景需要结合自己的APP进行调试。
恢复流程如下所示。
开启恢复订阅:
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; //恢复已订阅商品
点击恢复按钮后,支付队列开启监听订阅商品状态。
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
for (int i = 0; i < [transactions count]; ++i)
{
SKPaymentTransaction *transaction = [transactions objectAtIndex:i];
switch (transaction.transactionState)
{
case SKPaymentTransactionStatePurchasing: //消费中
//...
break;
case SKPaymentTransactionStatePurchased: //消费成功
//...
break;
case SKPaymentTransactionStateFailed: //消费失败
//...
break;
case SKPaymentTransactionStateRestored: //恢复已购买的商品(消耗型产品不能恢复)
{
_isRestoring = YES;
NSLog(@"恢复订阅商品:%@;订阅购买时间:%@",transaction.originalTransaction.originalTransaction,transaction.transactionDate);
}
break;
default: //购买处于待定状态
break;
}
}
}
updatedTransactions代理方法中,SKPaymentTransactionStateRestored状态代表了订阅恢复状态。如果商品A从购买到续订总共支付3次(即续订2次),那么这个时候transactions内会有3个transaction。但这些transactions都在一个票据里,所以建议是在这里不做太多处理。
那在哪儿告知服务器去重新校验票据合法性呢?如下方法:
//代理方法来自于SKPaymentTransactionObserver
// 从用户的购买历史记录中的所有事务成功添加回队列时发送
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
{
//有待恢复的订阅
if (_isRestoring) {
_isRestoring = NO; //恢复到默认值
[self verifyTransactionReceiptWithQueue:queue]; //票据校验
}
}
//模拟服务器校验票据的逻辑(这个最好都交给服务器去处理)
- (void)verifyTransactionReceiptWithQueue:(SKPaymentQueue *)paymentQueue
{
NSString *localTestRequestUrl = @"https://sandbox.itunes.apple.com/verifyReceipt";
NSData *receiptData = [NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]];
NSString *receiptNewStr = [receiptData base64EncodedStringWithOptions:0];
[self localReceiptVerifyingWithUrl:localTestRequestUrl AndReceipt:receiptNewStr AndPaymentQueue:paymentQueue];
}
//校验恢复票据
- (void)localReceiptVerifyingWithUrl:(NSString *)requestUrl AndReceipt:(NSString *)receiptStr AndPaymentQueue:(SKPaymentQueue *)paymentQueue
{
NSDictionary *requestContents = @{
@"receipt-data": receiptStr,
@"password" : @"48f920000fd8440d98262000003370e3"
};
NSError *error;
// 转换为 JSON 格式
NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents
options:0
error:&error];
NSString *verifyUrlString = requestUrl;
NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:[[NSURL alloc] initWithString:verifyUrlString] cachePolicy:NSURLRequestUseProtocolCachePolicy timeoutInterval:10.0f];
[storeRequest setHTTPMethod:@"POST"];
[storeRequest setHTTPBody:requestData];
// 在后台对列中提交验证请求,并获得官方的验证JSON结果
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *task = [session dataTaskWithRequest:storeRequest completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
NSLog(@"链接失败");
for (SKPaymentTransaction *transaction in paymentQueue.transactions) {
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
} else {
NSError *error;
NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (!jsonResponse) {
NSLog(@"验证失败");
for (SKPaymentTransaction *transaction in paymentQueue.transactions) {
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
}
NSLog(@"验证成功");
//TODO:取这个json的数据去判断,道具是否下发
}
}];
[task resume];
}
paymentQueueRestoreCompletedTransactionsFinished:
方法只会调用一次。而所有恢复的票据信息都在[[NSBundle mainBundle] appStoreReceiptURL]
里。所以在这个位置去请求服务器做校验。
4. 退订
不用客户端去监听,由服务器server to server的形式,等待苹果回调。如果用户退订,苹果会回调一个CANCEL状态的票据。 之后告知客户端取消App对应账号的订阅服务(也可以通过如下形式去处理订阅商品:每次续订告知客户端下发商品,如果退订了则不告知即不做操作,客户端未收到消息,则不再继续下发商品)。
注意:苹果的接口,有小概率出现“续订或退订不主动告知”的坑。所以建议App服务端在每次订阅即将到期前的24h,轮询校验票据的receipt_data,判断是否有续订或者退订。
用户退款过的订单依然会在receipt中出现,因此App服务器实现验证的时候需要能够识别出已经被退款的订单,不至于给退款的订单发货。
被退款订单的唯一标识是:它带有一个cancellation_date字段。服务端验证凭据时,如果有这个字段,则不分发商品。
参考资料:
【1】iOS 自动订阅开发
【3】内购之恢复购买记录