以下为非订阅型的商品(包括最常用的消耗型,以及不怎么用到的非消耗型、非续期订阅商品)的充值流程。
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];
付款
测试环境下输入沙盒账号和密码进行付款,TestFlight环境下使用下载app的Apple id进行付款。这两种测试环境,都只是模拟充值,即不会扣真正的钱。以下主要讲解沙盒账号的配置与使用。
1、配置沙盒账号
沙盒账号的邮箱,除了必须是邮箱的格式外(xxx@xxx.com),没有其他要求。这个邮箱不一定是真实存在的,可以是test@test.com
这种。
但前提是这个沙盒邮箱没有在其他地方配置过。所以可以随便命名了;
- 密码必须包含大小写字母与数字,至少8位;
- 密保问题随便填;生日随便填;
- 地区最好选择中国,测试方便。
设置使用沙盒账号
如果你曾经登录过沙盒账号,那么在充值时的界面上是不会显示账号的,只会让你输入密码。
这个时候,你需要检查这个沙盒账号是否是当前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又会跑出来。
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发送。 |
21002 | receipt-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)层面不用做操作。
参考:
https://juejin.cn/post/7049626884765646884