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

11,307 阅读2分钟

该系列的其他文章:

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

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

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

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

【6】苹果内购(IAP)从入门到精通(6)- 实际业务结合&线上异常情况处理

以下为非订阅型的商品(包括最常用的消耗型,以及不怎么用到的非消耗型、非续期订阅商品)的充值流程。

1. 初始化IAP->获取商品->创建订单

(1)启动支付队列监听

继承协议,不用去设置delegate。然后去启动支付队列监听:


[[SKPaymentQueue defaultQueue] addTransactionObserver:self];

(可选)检测是否可以进行支付

用户可以禁用在程序内部支付的功能。在发送支付请求之前,程序应该检查该功能是否被开启。

App可在显示商店界面之前就检查该设置(没启用就不显示商店界面了);

也可以在发起支付前,检查是否关闭支付功能(如果关闭就弹出相应提示)。


if([SKPaymentQueue canMakePayments]){

...//Display a store to the user

}

else{

...//Warn the user that purchases are disabled.

}

(2)添加商品到支付队列中

添加商品有两种方式。

第一种方式:是去苹果后台请求获取商品,成功后将获取到的SKPayment对象添加到充值队列中。这样有个好处时,如果回调成功,说明你这个商品是有效的,这样设置到队列里肯定也是能够正常充值的。

- (void) requestProductData{   
    NSArray *arr = [[NSArray alloc]initWithObjects:@"com.test.pay6", nil]; //com.test.pay6是商品ID,苹果后台已经配置了的
    NSSet *productSet = [NSSet setWithArray:arr];
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:productSet];
    request.delegate = self; //需要继承<SKProductsRequestDelegate>协议
    [request start];
}

//SKProductsRequestDelegate Methods
//请求成功
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
    for (int i = 0; i < response.products.count; ++i)
    {
        SKProduct* product = [response.products objectAtIndex:i];
        NSLog(@"苹果后台获得商品ID:%@ 商品描述:%@",product.productIdentifier,product.localizedDescription);
        SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
        payment.quantity = 1;
        payment.applicationUsername = orderId;  //透传参数,一会儿会说
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    }
}
//请求失败
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
    NSLog(@"苹果后台商品请求失败:%@",error.description);
}

但这里有个 :苹果的接口,在线上环境极其不稳定,而且很慢。有些时候你可能要等待3、4秒才会回调;或者在网络状态一般的情况下,可能会直接回调你失败,导致你无法充值(即使你这个商品ID是有效的)。所以,为了提高用户体验,不建议使用第一个方法

第二种方式:直接将商品ID设置到SKPayment对象中。实际上,只要商品ID是有效的(苹果后台配置了的),那么直接设置后添加到支付队列中,是没问题的。这样,减少了一个向苹果请求SKProduct对象的时间。

SKMutablePayment *payment = [[SKMutablePayment alloc] init];
payment.applicationUsername = _orderId; //透传参数。可以传你自己的订单号,后续可能用得到
payment.productIdentifier = _productId; //商品ID
payment.quantity = _count;  //商品数量,一般默认都传1
[[SKPaymentQueue defaultQueue] addPayment:payment];

但如果你这个商品ID是无效的,那么添加进支付队列后,代码层并不会报错,但苹果自己会去检测,检测到是无效的商品,就会直接回调充值失败。但作为开发者而言,在提审、上线之前,理应是保证我们的商品ID是合法的,以及 检查银行卡后是否已经配置通过 ,所以基本不会存在什么问题。

2. 付款

测试环境下输入沙盒账号和密码进行付款,TestFlight环境下使用下载app的Apple id进行付款。这两种测试环境,都只是模拟充值,即不会扣真正的钱。以下主要讲解沙盒账号的配置与使用。

(1)配置沙盒账号: 沙盒账号的邮箱,除了必须是邮箱的格式外(xxx@xxx.com),没有其他要求。这个邮箱不一定是真实存在的,可以是test@test.com这种。但前提是这个沙盒邮箱没有在其他地方配置过。所以可以随便命名了;

密码必须包含大小写字母与数字,至少8位;

密保问题随便填;生日随便填;

地区最好选择中国,测试方便。

image.png

(2)设置使用沙盒账号

如果你曾经登录过沙盒账号,那么在充值时的界面上是不会显示账号的,只会让你输入密码。这个时候,你需要检查这个沙盒账号是否是当前App绑定的沙盒账号而不是其他开发者账号下绑定的。

检查沙盒账号,去手机上的设置 -> App Store -> 沙盒账号(拉到最下面)。 iOS12等低版本下,你需要退掉你的个人Appleid。然后点击商品充值时,在app内输入沙盒账号(之后这个沙盒账号会出现在你“设置”里的appleid上)

3. 获取票据

(1)监听支付状态

因为继承了协议,监听支付状态的代理方法是必须实现的。

- (void)paymentQueue:(nonnull SKPaymentQueue *)queue updatedTransactions:(nonnull NSArray<SKPaymentTransaction *> *)transactions { 
    for (int i = 0; i < [transactions count]; ++i)
    {
        SKPaymentTransaction *transaction = [transactions objectAtIndex:i];
        if (transaction.transactionState != SKPaymentTransactionStatePurchasing)
        {
            NSLog(@"updatedTransactions with tid: %@", transaction.transactionIdentifier);
        }
        
        switch (transaction.transactionState)
        {
            case SKPaymentTransactionStatePurchasing: //消费中
                [[NSNotificationCenter defaultCenter] addObserver:self  selector:@selector(inAppAlertAppeared)  name:UIApplicationWillResignActiveNotification object:nil];
                break;
            case SKPaymentTransactionStatePurchased: //消费成功
                [self verifyTransactionReceipt:transaction];
                break;
            case SKPaymentTransactionStateFailed:    //消费失败
                [self transactionFailed:transaction];
                break;
            case SKPaymentTransactionStateRestored:  //恢复已购买的商品(消耗型产品不能恢复)
                [self transactionRestored:transaction];
                break;
            default:            //购买处于待定状态,比如iOS的家长监管功能(小孩子购买,需要家长同意);之后会根据逻辑变成purchased或者failed状态。所以这个位置可以不用去操作
                break;
        }
    }
}

查看支付状态的源码,我们发现,支付状态有以下几种:

typedef NS_ENUM(NSInteger, SKPaymentTransactionState) {
    SKPaymentTransactionStatePurchasing,    // Transaction is being added to the server queue.(支付中)
    SKPaymentTransactionStatePurchased,     // Transaction is in queue, user has been charged.  Client should complete the transaction.(支付完成)
    SKPaymentTransactionStateFailed,        // Transaction was cancelled or failed before being added to the server queue.(支付失败)
    SKPaymentTransactionStateRestored,      // Transaction was restored from user's purchase history.  Client should complete the transaction.(恢复购买)
    SKPaymentTransactionStateDeferred API_AVAILABLE(ios(8.0), macos(10.10)),   // The transaction is in the queue, but its final status is pending external action.(待处理)
}

SKPaymentTransactionStateDeferred为支付待处理状态,跟支付中不太一样。iOS有一个所谓的家长控制(小孩子购买,需要家长同意),在需要家长确认时就会走到这个状态来。购买之后会根据支付的结果回调purchased或者failed状态。一般的App不用监听这个状态做什么操作。

SKPaymentTransactionStateRestored为恢复购买的状态。消耗型商品、非续期订阅都不会走到这个状态里来。只有自动订阅和一次性商品,在启动restore监听的时候会走到这里来。这个地方的逻辑我们会在后面讲“自动订阅商品”的时候详细展开说明。

SKPaymentTransactionStatePurchasing为购买中的状态。addPayment之后,就会走到这个状态中。因为弹出沙盒支付界面、正式支付界面,都算是当前应用跳出活跃状态(跟跳到桌面是一样的),所以这个时候需要添加监听方法:

[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(inAppAlertAppeared) name:UIApplicationWillResignActiveNotification object:nil];

但因为这个状态下可操作性的逻辑比较少。开发者可以根据自己的业务需求,选择性地实现这个inAppAlertAppeared方法。没有逻辑,就不用去管这个状态。

SKPaymentTransactionStatePurchased为购买成功状态。这个时候只是表明,你这个app已经成功付钱了。但付钱不代表商品到账。这个时候,我们需要去处理苹果返回给我们的一个叫“票据”的东西。这个在下一段会讲解到。

SKPaymentTransactionStateFailed为购买失败状态。比如你手动取消购买、付款不成功、网络请求失败,都算是“购买失败”。这个时候,你需要finish掉你这个SKPaymentTransaction。这个很重要。不然,你下次启动支付队列监听,这个SKPaymentTransaction又会跑出来。

- (void)transactionFailed:(SKPaymentTransaction *)transaction
{
    if (transaction.error.code == SKErrorPaymentCancelled){
        NSLog(@"您取消了内购操作.");
    }else{
        NSLog(@"内购失败,原因:%@",[transaction.error localizedDescription]);
    }
    NSLog(@"transactionFailed with tid: %@ and code:%li and msg:%@", transaction.transactionIdentifier,(long)transaction.error.code,transaction.error.localizedDescription);
    
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}

4. 票据校验

当我们付款成功,支付状态回调SKPaymentTransactionStatePurchased,之后,我们需要校验票据。

票据,是苹果将支付的相关信息,整理成了一个json返回给我们。里面包含比较常用的一些数据段是商品ID、支付时间、苹果的订单ID(transactionId),以及自动订阅商品的优惠政策、过期时间、续订时间等。

逻辑是,我们去拿到这个票据receipt(看着像base64格式的,但实际不是base64加密的),然后去请求苹果的票据验证接口。成功后会回调你一个json格式的数据。我们根据自己的服务端逻辑,去判断这个票据是否是有效、合法的。

(1)获取receipt:

NSData *receiptData = [NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]];
NSString *receiptNewStr = [receiptData base64EncodedStringWithOptions:0];

注意:这个API是iOS7之后的新的API。鉴于iOS7以下的系统,当前的市面上已经不适配了,这里我就不说明原来的API是什么的了。但这里需要提示一下:旧API和新API,获取到的票据结构是不一样的(旧的票据里是没有in_app相关字段的)。而且,旧的API只能获取到消耗型商品的票据,自动订阅的商品请求票据校验接口的时候会报错。

(2)请求苹果接口进行票据校验

苹果有两个票据校验的接口。一个是沙盒环境的(sandbox.itunes.apple.com/verifyRecei… ,一个是正式环境的(buy.itunes.apple.com/verifyRecei… 。因为客户端不方便在提审和过审之后,分别使用不同的校验接口去做校验。所以一般情况下,这个票据校验的逻辑,都是客户端将receipt传给服务器,服务端去做校验。

苹果也是建议这个校验逻辑由服务端完成。服务器需要先去请求正式环境。如果receipt是正式环境的,那么这个时候苹果会返回(21007)告诉我们这个是沙盒的receipt,那么服务器再去请求sandbox环境。

以下,我在客户端去模拟这个票据校验。(实际开发中客户端不用去做哈)

- (void)localReceiptVerifyingWithUrl:(NSString *)requestUrl AndReceipt:(NSString *)receiptStr AndTransaction:(SKPaymentTransaction *)transaction
{
    NSDictionary *requestContents = @{
                                      @"receipt-data": receiptStr,
                                      };
    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(@"链接失败");
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
        } else {
            NSError *error;
            NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
            if (!jsonResponse) {
                NSLog(@"验证失败");
                [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
            }
            NSLog(@"验证成功");
            //TODO:取这个json的数据去判断,道具是否下发
        }
    }];
    [task resume];
}

如果验证成功,就根据回调的json数据去匹配判断购买的道具是否应该下发;如果验证失败,就finishTransaction。

对于消耗型商品,返回的票据的json是最标准的。非消耗型商品(一次性商品)、非续期订阅商品,票据的json和消耗型商品相同。如下所示:

{
    environment = Sandbox;  //说明是沙盒环境
    receipt =     {
        "adam_id" = 0;
        "app_item_id" = 0;
        "application_version" = 1;
        "bundle_id" = "com.mytest.test";    //bundle id
        "download_id" = 0;
        "in_app" =         (
                        {
                "is_trial_period" = false;  //是否有优惠(这个一般是自动订阅和一次性商品会使用到,消耗型商品是没用到的)
                "original_purchase_date" = "2019-09-18 06:38:46 Etc/GMT";   //购买时间
                "original_purchase_date_ms" = 1568788726000;    //购买时间戳
                "original_purchase_date_pst" = "2019-09-17 23:38:46 America/Los_Angeles";
                "original_transaction_id" = 1000000569411111;   //购买时的票据ID
                "product_id" = "com.test.pay6";     //商品ID
                "purchase_date" = "2019-09-18 06:38:46 Etc/GMT";
                "purchase_date_ms" = 1568788726000;
                "purchase_date_pst" = "2019-09-17 23:38:46 America/Los_Angeles";
                quantity = 1;   //商品数量
                "transaction_id" = 1000000569411111;
            }
        );
        "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:38:46 Etc/GMT";
        "receipt_creation_date_ms" = 1568788726000;
        "receipt_creation_date_pst" = "2019-09-17 23:38:46 America/Los_Angeles";
        "receipt_type" = ProductionSandbox;
        "request_date" = "2019-09-18 06:39:00 Etc/GMT";
        "request_date_ms" = 1568788740085;
        "request_date_pst" = "2019-09-17 23:39:00 America/Los_Angeles";
        "version_external_identifier" = 0;
    };
    status = 0;     //付款状态(0为已付款)
}

这个票据的处理,由服务端负责。

实际上我们发现,票据里有很多字段,value都是一样的。那到底用哪个呢?

在in_app中,original_purchase_date代表你第一次购买商品付款的时间,purchase_date表示你当前付款的时间。对于消耗型商品,这个值是一样的。但对于自动订阅商品,当这个自动订阅商品续期后,票据里的会有一组数据的purchase_date表示当前续订的时间,original_purchase_date表示第一次订阅的时间。purchase_date会大于original_purchase_date。

同样,对于自动订阅而言,original_transaction_id表示第一次订阅的票据ID,transaction_id表示当前续订时的票据ID。这个时候两个票据ID就是不同的。这个也是会在后面“自动订阅”详细展开讲解。

对于服务器而言,如果消耗型商品,需要判断如下几个值:

status需要等于0,表示支付成功;status也有其他状态码:

状态码描述
0票据校验成功
21000未使用HTTP POST请求方法向App Store发送请求。
21001此状态代码不再由App Store发送。
21002receipt-data属性中的数据格式错误或丢失。
21003收据无法认证。
21004您提供的共享密码与您帐户的文件共享密码不匹配。
21005收据服务器当前不可用。
21006该收据有效,但订阅已过期。当此状态代码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。
21007该收据来自测试环境,但已发送到生产环境以进行验证。
21008该收据来自生产环境,但是已发送到测试环境以进行验证。
21009内部数据访问错误。稍后再试。
21010找不到或删除了该用户帐户。

bundle_id为当前包体的bundle id(因为可能有人反编译重签,然后用沙盒账号充值);

in_app内,purchase_date要大于服务器订单的创建时间;

in_app内,transaction_id要等于请求前SKPaymentTransaction对象的transaction_id;

in_app内,product_id要等于充值时的商品ID;

in_app内,quantity要等于充值时的数量(一般都是1);

如果以上参数都能匹配,说明当前票据时合法的。就算服务端的真正的校验通过(并不是说请求苹果接口返回成功就算校验通过了)。

校验票据的官方文档

5. 商品下发

因为前端请求服务器的票据校验接口时,服务器获取请求后,还需要去请求苹果的接口。所以这个时候,服务器只会返回你这个接口请求是否成功,无法返回你这个票据是否合法。所以这个时候,客户端在收到请求成功之后,就可以根据前端的逻辑进行界面展示,然后在适当的时候finishTransaction。

[[SKPaymentQueue defaultQueue] finishTransaction:transaction];

(以下为可选功能)

如果客户端需要拿到这个票据的真实校验状态,可以延时处理,去向服务端获取校验结果。

我们app设置然后在适当的时候finishTransaction之后,等待10s进行第一次获取校验结果。如果正在处理中,则继续等待30s、120s...即轮询获取校验结果。直到校验回调成功或者失败。

int64_t delayInSeconds = 10.0;  //延迟10s再去后台校验订单结果,避免研发那边的商品还未下发
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
    //第一次校验
    [确认服务器校验票据情况:^(id response) {
        if (票据合法) {
            //TODO:(如果是客户端做的话)下发道具
        }else{
            int64_t delayInSeconds1 = 30.0;     //延迟30s再去后台进行第二次校验订单结果
            dispatch_time_t popTime1 = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds1 * NSEC_PER_SEC);
            dispatch_after(popTime1, dispatch_get_main_queue(), ^(void){
                //第二次校验
                [确认服务器校验票据情况:^(id response) {
                    if (票据合法) {
                        //TODO:(如果是客户端做的话)下发道具
                    }else{
                        int64_t delayInSeconds2 = 120.0;    //延迟120s再去后台进行第二次校验订单结果
                        dispatch_time_t popTime2 = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds2 * NSEC_PER_SEC);
                        dispatch_after(popTime2, dispatch_get_main_queue(), ^(void){
                            //第三次校验
                            [确认服务器校验票据情况:^(id response) {
                                if (票据合法) {
                                    (如果是客户端做的话)下发道具
                                }else{
                                    NSLog(@"订单校验失败");
                                }
                            }];
                        });
                    }
                }];
            });
        }
    }];
});

当服务器回传给客户端,说明票据是合法的(充值的对应的商品且付款),那么就可以下发充值的道具或者权益了。一般而言,这些权益因为要跟用户绑定,所以服务端肯定还有一堆其他逻辑要处理。告知客户端后,客户端根据自身业务需求,更改客户端的UI、角色权益等等。如果你们是游戏App,那么可以由客户端通知游戏端(比如unity或者cocos)进行对应功能修改;或者是server to server,sdk server通知game server进行权益修改,game server通知unity层进行功能修改,而oc(或者swift)层面不用做操作。

参考资料:

【1】促进您的应用内购买

【2】聊聊应用内购买