IAP

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

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

Posted by WTJ on July 4, 2022

1. 充值流程(自动订阅)

1.1. 商品购买

等同于消耗型商品的购买。

无非也是添加支付队列监听,初始化SKPayment并添加到支付队列中,然后付款,回调Purchased状态。

在这里不再赘述。主要的区别是在后面。

1.2. 票据校验

请求苹果票据校验时,请求参数需要传一个新的参数,叫“共享秘钥”。这个在苹果后台配置商品ID的地方生成,如下所示:

fd2dd37bfd7d429d9374084f01a88efe~tplv-k3u1fbpfcp-zoom-in-crop-mark-3024-0-0-0 image

自动订阅商品的票据,与消耗型商品的票据有很多不同点。

{
    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_RENEWALApp 或者设置内交互式续订
DID_FAIL_TO_RENEW 计费问题未能续订
DID_RECOVER 已成功续订过去未能续订的过期订阅
DID_CHANGE_RENEWAL_STATUS 续订状态发生变化
DID_CHANGE_RENEWAL_PREF 续订降级
CONSUMPTION_REQUEST 发起退款申请R
EFUND 退款成功
PRICE_INCREASE_CONSENT 提价状态
INTERACTIVE_RENEWALApp 或者设置内以交互方式续订
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也会改变,所以不要使用这个字段。

票据合法性校验

一般情况下,只要实现了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对应的用户进行订阅续期操作,发放相应道具或者权限。

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]里。所以在这个位置去请求服务器做校验。

退订

不用客户端去监听,由服务器server to server的形式,等待苹果回调。如果用户退订,苹果会回调一个CANCEL状态的票据。 之后告知客户端取消App对应账号的订阅服务(也可以通过如下形式去处理订阅商品:每次续订告知客户端下发商品,如果退订了则不告知即不做操作,客户端未收到消息,则不再继续下发商品)。

注意:苹果的接口,有小概率出现“续订或退订不主动告知”的坑。所以建议App服务端在每次订阅即将到期前的24h,轮询校验票据的receipt_data,判断是否有续订或者退订。

用户退款过的订单依然会在receipt中出现,因此App服务器实现验证的时候需要能够识别出已经被退款的订单,不至于给退款的订单发货。

被退款订单的唯一标识是:它带有一个cancellation_date字段。服务端验证凭据时,如果有这个字段,则不分发商品。

参考:

https://juejin.cn/post/7050041490080268319