iOS内购全面实战

内购是啥

App 内购买项目允许顾客通过访问 App Store 购买您 App 中的内容、功能或服务,并安全处理来自用户的付款。

详情传送门https://help.apple.com/itunes-connect/developer/#/devb57be10e7

下面来说内购集成流程

1.协议

登录苹果开发者中心,进入iTunes Connect,再进入“协议、税务和银行业务”页面,如图

image

点击进入可以看到,目前共有两个分组,三种合同。(此处有坑,比如我们当前账号不能申请合同!如下图)

Request Contracts 可以申请的合同;

Contracts In Effect 已经生效的合同。

三种合同分别是

Free Applications 免费应用(默认已经生效);

Paid Applications 付费应用,需要申请;

iAd App Network 广告应用,需要申请。

image

内购对应的是Paid Applications 付费应用,需要申请,如图2.(如果Request按钮不显示,则说明当前账号权限有问题)

点击Request完善信息,提交就行.

2.内购集成

内购实现流程:

1.客户端向Appstore请求购买产品(假设产品信息已经取得),Appstore验证产品成功后,从用户的Apple账户余额中扣费。

2.Appstore向客户端返回一段receipt-data,里面记录了本次交易的证书和签名信息。

3.客户端向我们可以信任的服务器提供receipt-data

4.服务器对receipt-data进行一次base64编码

5.把编码后的receipt-data发往itunes.appstore进行验证

6.itunes.appstore返回验证结果给服务器

7.服务器对商品购买状态以及商品类型,向客户端发放相应的道具与推送数据更新通知

注,下图3步骤和上面流程不是一一对应

image

我项目里面的购买流程,加入了一点业务逻辑和后台验证流程,有什么问题欢迎大家指出.

image

3.去苹果开发者中心创建内购商品

如下图5,点击+号去创建内购商品,产品id最好是当前应用+数字,价格区间苹果提供了一张表,商品价格只能是表上的价格,苹果会抽取30%,商家能收到的钱是用户充值的70%.这就造成了部分平台区分安卓和苹果.两端账号不互通,也造就了代充行业,再次就不展开说了.

商品价格大于100$,提交审核的时候要说明这个金额是确认过的,不然可能会被拒

image

4.代码集成

建议单独建一个类来处理内购业务
.h类

//
//  EMAppStorePay.h
//  MobileFixCar
//
//  Created by Wcting on 2018/4/11.
//  Copyright ? 2018年 XXX有限公司. All rights reserved.
//

/*
 wct20180917 内购支付类,短视频e豆购买用到。
 */

#import <Foundation/Foundation.h>

@class EMAppStorePay;

@protocol EMAppStorePayDelegate <NSObject>;

@optional

/**
 wct20180418 内购支付成功回调

 @param appStorePay 当前类
 @param dicValue 返回值
 @param error 错误信息
 */
- (void)EMAppStorePay:(EMAppStorePay *)appStorePay responseAppStorePaySuccess:(NSDictionary *)dicValue error:(NSError*)error;


/**
 wct20180423 内购支付结果回调提示
 
 @param appStorePay 当前类
 @param dicValue 返回值
 @param error 错误信息
 */
- (void)EMAppStorePay:(EMAppStorePay *)appStorePay responseAppStorePayStatusshow:(NSDictionary *)dicValue error:(NSError*)error;

@end

@interface EMAppStorePay : NSObject

@property (nonatomic, weak)id<EMAppStorePayDelegate> delegate;/**<wct20180418 delegate*/


/**
  wct20180411 点击购买

 @param goodsID 商品id
 */
-(void)starBuyToAppStore:(NSString *)goodsID;

@end

.m类(里面有客户端验证receipt的代码,解开注释就可以,用于调试.验证建议放后台去做)

//
//  EMAppStorePay.m
//  MobileFixCar
//
//  Created by Wcting on 2018/4/11.
//  Copyright ? 2018年 XXX有限公司. All rights reserved.
//

#import "EMAppStorePay.h"
#import <StoreKit/StoreKit.h>

//#define goods1 @"net.ejiajx.MobileFixCar06"

@interface EMAppStorePay()<SKPaymentTransactionObserver,SKProductsRequestDelegate>

@property (nonatomic, strong)NSString *goodsId;/**<wct20180420  商品id*/

@end

@implementation EMAppStorePay

- (instancetype)init
{
    self = [super init];
    if (self) {
       
        [[SKPaymentQueue defaultQueue] addTransactionObserver:self];// 4.设置支付服务
    }
    return self;
}
//结束后一定要销毁
- (void)dealloc
{
    [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
}
#pragma mark - public
-(void)starBuyToAppStore:(NSString *)goodsID
{
    if ([SKPaymentQueue canMakePayments]) {//5.判断app是否允许apple支付
      
        [self getRequestAppleProduct:goodsID];// 6.请求苹果后台商品
        
    } else {
//        NSLog(@"not");
    }
}

#pragma mark - private
#pragma mark ------ 请求苹果商品
- (void)getRequestAppleProduct:(NSString *)goodsID
{
    self.goodsId = goodsID;//把前面传过来的商品id记录一下,下面要用
    // 7.这里的com.czchat.CZChat01就对应着苹果后台的商品ID,他们是通过这个ID进行联系的。
    NSArray *product = [[NSArray alloc] initWithObjects:goodsID,nil];
    NSSet *nsset = [NSSet setWithArray:product];
    
    //SKProductsRequest参考链接:https://developer.apple.com/documentation/storekit/skproductsrequest
    //SKProductsRequest 一个对象,可以从App Store检索有关指定产品列表的本地化信息。
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:nsset];// 8.初始化请求
    request.delegate = self;
    [request start];// 9.开始请求
}
#pragma mark ------ 支付完成
- (void)completeTransaction:(SKPaymentTransaction *)transaction{
    //交易验证 本地验证方法
    /*NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
     NSData *receipt = [NSData dataWithContentsOfURL:recepitURL];
     
     if(!receipt){
     
     }
     
     NSError *error;
     NSDictionary *requestContents = @{
     @"receipt-data": [receipt base64EncodedStringWithOptions:0]
     };
     NSLog(@"requestContentstr:%@",[receipt base64EncodedStringWithOptions:0]);
     NSData *requestData = [NSJSONSerialization dataWithJSONObject:requestContents
     options:0
     error:&error];
     
     
     //In the test environment, use https://sandbox.itunes.apple.com/verifyReceipt
     //In the real environment, use https://buy.itunes.apple.com/verifyReceipt
     // Create a POST request with the receipt data.
     NSURL *storeURL = [NSURL URLWithString:@"https://sandbox.itunes.apple.com/verifyReceipt"];
     NSMutableURLRequest *storeRequest = [NSMutableURLRequest requestWithURL:storeURL];
     [storeRequest setHTTPMethod:@"POST"];
     [storeRequest setHTTPBody:requestData];
     
     // Make a connection to the iTunes Store on a background queue.
     NSOperationQueue *queue = [[NSOperationQueue alloc] init];
     [NSURLConnection sendAsynchronousRequest:storeRequest queue:queue
     completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {
     if (connectionError) {
     } else {
     NSError *error;
     NSDictionary *jsonResponse = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
     if (!jsonResponse) {  }
     //Parse the Response
     NSLog(@"成功了:%@",jsonResponse);
     }
     }];*/
    
    //此时告诉后台交易成功,并把receipt传给后台验证
    NSString *transactionReceiptString= nil;
    //系统IOS7.0以上获取支付验证凭证的方式应该改变,切验证返回的数据结构也不一样了。
    // 验证凭据,获取到苹果返回的交易凭据
    // appStoreReceiptURL iOS7.0增加的,购买交易完成后,会将凭据存放在该地址
    NSURLRequest *appstoreRequest = [NSURLRequest requestWithURL:[[NSBundle mainBundle] appStoreReceiptURL]];
    NSError *error = nil;
    // 从沙盒中获取到购买凭据
    
    NSData * receiptData = [NSURLConnection sendSynchronousRequest:appstoreRequest returningResponse:nil error:&error];
    // 20 BASE64 常用的编码方案,通常用于数据传输,以及加密算法的基础算法,传输过程中能够保证数据传输的稳定性 21 BASE64是可以编码和解码的 22
    transactionReceiptString = [receiptData base64EncodedStringWithOptions:0];//[receiptData base64EncodedStringWithOptions:0];
    //    NSLog(@"requestContentstr:%@",[receiptData base64EncodedStringWithOptions:0]);
    
    //    NSDictionary *dic = @{@"orderCode":self.dataOrder.orderCode,
    //                          @"receipt":transactionReceiptString,
    //                          @"category":@"1"
    //                          };
    //    NSLog(@"diczhi:%@",dic);
    //
    //    self.tran = transaction;
    //    [self.bizEBeanBuy requestAppStorePaySuccessCallBack:dic];//苹果支付成功,传receipt-data给后台验证
    
    if (self.delegate && [self.delegate respondsToSelector:@selector(EMAppStorePay:responseAppStorePaySuccess:error:)]) {
        [self.delegate EMAppStorePay:self responseAppStorePaySuccess:@{@"value":transactionReceiptString} error:nil];
    }
    
    [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    
}

#pragma mark - delegate
#pragma mark ------ SKProductsRequestDelegate
// 10.接收到产品的返回信息,然后用返回的商品信息进行发起购买请求
- (void) productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
    NSArray *product = response.products;
    
    if([product count] == 0){//如果服务器没有产品
        return;
    }
    
    SKProduct *requestProduct = nil;
    for (SKProduct *pro in product) {
//        NSLog(@"%@", [pro description]);
//        NSLog(@"%@", [pro localizedTitle]);
//        NSLog(@"%@", [pro localizedDescription]);
//        NSLog(@"%@", [pro price]);
//        NSLog(@"%@", [pro productIdentifier]);
        // 11.如果后台消费条目的ID与我这里需要请求的一样(用于确保订单的正确性)
        if([pro.productIdentifier isEqualToString:self.goodsId]){
            requestProduct = pro;
        }
    }
    // 12.发送购买请求,创建票据  这个时候就会有弹框了
    SKPayment *payment = [SKPayment paymentWithProduct:requestProduct];
    [[SKPaymentQueue defaultQueue] addPayment:payment];//将票据加入到交易队列
    
}
#pragma mark ------ SKRequestDelegate (@protocol SKProductsRequestDelegate <SKRequestDelegate>)
//请求失败
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error
{
//    NSLog(@"error:%@", error);
}
//反馈请求的产品信息结束后
- (void)requestDidFinish:(SKRequest *)request
{
//    NSLog(@"信息反馈结束");
}

    
#pragma mark ------ SKPaymentTransactionObserver 监听购买结果
// 13.监听购买结果
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transaction
{

    if (self.delegate && [self.delegate respondsToSelector:@selector(EMAppStorePay:responseAppStorePayStatusshow:error:)]) {
        [self.delegate EMAppStorePay:self responseAppStorePayStatusshow:@{@"value":transaction} error:nil];
    }
    
//    if (transaction.count > 0) {
//        //检测是否有未完成的交易
//        SKPaymentTransaction* tran = [transaction firstObject];
//        if (tran.transactionState == SKPaymentTransactionStatePurchased) {
//            [self completeTransaction:tran];
//            [[SKPaymentQueue defaultQueue] finishTransaction:tran];//未完成的交易在此给它结束
//            return;
//        }
//    }

    for(SKPaymentTransaction *tran in transaction){

//        NSLog(@"%@",tran.payment.applicationUsername);
        switch (tran.transactionState) {
            case SKPaymentTransactionStatePurchased:{
//                NSLog(@"交易完成");
                // 购买后告诉交易队列,把这个成功的交易移除掉。
                //走到这就说明这单交易走完了,无论成功失败,所以要给它移出。finishTransaction
                [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;
                
            case SKPaymentTransactionStateDeferred:
//                NSLog(@"交易还在队列里面,但最终状态还没有决定");
                break;
                
            default:
                break;
        }
        
    }

    
}

@end

5.沙盒测试

如下图6,点添加创建沙盒测试账号,账号未未注册成AppleID的账号,测试前先到设置里退出当前AppleID,登录沙盒测试账号,沙盒测试账号只能用来测试沙盒支付,不具备正常AppleID的功能.

image
准备工作

1.第一次测试内购需要卸载之前APP,找开发人员安装可测试内购的APP。防止App Store下载的app走sandbox环境走不通;
2.在iPhone设置里面,退出原有账号。登录开发人员提供的内购测试账号(可找开发申请新测试账号);

6.交易安全机制

1.双重验证

苹果审核人员审核内购的时候走的是沙盒环境对应沙盒验证接口https://sandbox.itunes.apple.com/verifyReceipt,如果验证receipt只有正式环境https://buy.itunes.apple.com/verifyReceipt,苹果审核员走内购会验证失败,交易走不通,后果就是审核被拒.所以验证的时候先默认走正式环境,如果返回21007的错误码就去沙盒环境验证,保证审核通过.

2.交易凭据receipt判重

一般我们验证支付凭据(receipt)是否有效放后台去做,如果后台不做判重,同一个凭据就可以无数次验证通过(苹果也不判重),后台就会给前端发放无数次商品,但是用户只支付了一次钱取到一个支付凭据.所以安全的做法是后台把验证通过的支付凭据做个记录,每次来新的凭据先判断是否已经使用过,防止多次发放商品.

3.本地交易流水

在测试过程中,由于苹果不提供交易流水,所以会出现无法对账的情况,会提出一些莫名bug,因为测试不知道某个单的支付状态,这时前端需要做个交易流水记录,方便对账和避免不必要的bug及撕逼.

在支付成功回调里面把当前交易数据存在本地持久化,然后去后台验证,出问题就那本地存的交易数据和后台对,找出问题.

#pragma mark - EMAppStorePayDelegate
-(void)EMAppStorePay:(EMAppStorePay *)appStorePay responseAppStorePaySuccess:(NSDictionary *)dicValue error:(NSError *)error
{
   
    NSString *transactionReceiptString = [ZSTools objectOrNilForKey:@"value" fromDictionary:dicValue];
    
    NSDictionary *dic = @{@"orderCode":self.strOrderCode,
                          @"receipt":transactionReceiptString,
                          @"category":@"1"
                          };
//    NSLog(@"222diczhi:%@",dic);
    
    /*
     //wct20180601 本地交易流水,不测试内购就给注释吧,省手机内存
    NSMutableDictionary *dicRec = [NSMutableDictionary dictionaryWithDictionary:self.dicPay];
    [dicRec setValue:self.strOrderCode forKey:@"orderCode"];
    [dicRec setValue:transactionReceiptString forKey:@"receipt"];
    [dicRec setValue:@"1" forKey:@"category"];
    NSString *time = [self getCurrentTimes];
    [dicRec setValue:time forKey:@"creatTime"];

    [self.modelEBean addDicReconciliation:dicRec];//对应下面的实现方法
*/
    
    [self.bizEBeanBuy requestAppStorePaySuccessCallBack:dic];//苹果支付成功,传receipt-data给后台验证
    [ZSTools loadActivityIndicatorOn:self.view withCenterPoint:self.view.center withTitleString:@"正在购买..." sizeType:2];

}

存储持久化实现

-(void)addDicReconciliation:(NSDictionary *)dicEBean
{
    if (![self.arrReconciliationModel containsObject:dicEBean]) {
        [self.arrReconciliationModel addObject:dicEBean];
    }
    [self saveReconciliation];
}

- (void)saveReconciliation
{
    NSString *path = [NSString stringWithFormat:@"%@/%@_reconciliation.plist", [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0], [EMVideoUserSingleton sharedInstance].ugsvId];
    [self.arrReconciliationModel writeToFile:path atomically:NO];
}

7.注意事项

1.对账问题

通过textflight下载的app走内购也是在sandbox环境。这时走内购不需要支付相应金额,但是对应的咱们后台是正式环境,内购走通后返回的e豆(商品,以下e豆都对应商品)是正式环境。这就会造成没支付钱,但是正式环境得到e豆了,对账的时候要作记录。

2.漏单的情况:

先看看支付流程,如下:
app iTunes app 后台 app
1发起支付--->2扣费成功--->3得到receipt(支付凭据)--->4去后台验证凭据获取e豆--->5返回数据,前端刷新数据

漏单情况1

3到4的时候出问题,比如断网。此时前端会把支付凭据持久化存储下来(期间用户卸载APP此单在前端就真漏了),下次进入购买页会先判断有无未成功的支付,有就提示用户,用户选择找回,重走4,5流程。

漏单情况2

4到5的时候出问题。此时后台其实已经成功,只是前端没获取到数据,当漏单处理,还是上面的逻辑,会把该单存储。下次进入的时候会先刷新数据(此时未获取到e的豆已经获取到了),然后提示有未完成单,此时点找回会提示无效的凭据,这是正常的,因为豆已经给了,此单已结束。

漏单情况3

2到3环节出问题属于苹果的问题,目前没做处理。

3.漏单处理

1.在后台返回商品支付回调失败里面把当前交易数据持久化存储,成功状态下移除当前单数据.并检查是否有已扣款未返商品单,对应下面checkHaveDidNotPay

}else{
        if (dicPara) {
            [self.modelEBean addDicEBean:dicPara];//传receipt失败,
            [self checkHaveDidNotPay];
        }
- (void)checkHaveDidNotPay
{
    if (self.modelEBean.arrEBeanBuyModel.count) {
        [EMTextAlertView title:@"温馨提示" message:@"网络不给力,e豆数据可能更新不及时,请重新加载。" leftTitle:@"下次再说" rightTitle:@"重新加载" complete:^(NSInteger index, NSString *title) {

            if (index == 1){//重新获取会重新调用购买验证
                for (NSDictionary *dic in self.modelEBean.arrEBeanBuyModel) {
                    [self.bizEBeanBuy requestAppStorePaySuccessCallBack:dic];
                }
            }
            
        }];
    }
}

根据需求,每次购买前先检查有无之前漏单,有先处理漏单.视需求定.
我们目前是每次到购买页面先检查有无漏单

-(void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    [self.bizVideoMine requestVideoMineData:nil];
    [self checkHaveDidNotPay];

}

有问题下面留言,有不足的地方欢迎指正.

最后编辑于
?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,100评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,308评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,718评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,275评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,376评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,454评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,464评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,248评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,686评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,974评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,150评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,817评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,484评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,140评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,374评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,012评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,041评论 2 351