苹果内购(IAP)及掉单处理

官方文档In-App Purchase

内购的前期准备等工作本文不讲述,有需要的可以查看网上其他文章,不少讲的挺详细的。
首先Xcode里的Capabilities中的In-App Purchase的能力打开, 如下图
image.png

关于内支付的文章网上很多,解决掉单问题的文章及方案也是一搜一大堆,但是文章中讲的掉单解决方案的实施难易程度以及可行性或者说是否适合你的产品就需要你自己做好判断了。我写这篇文章一方面总结下之前自身解决内购掉单的经验另外希望能帮得到需要的人就更好了。

上代码前先讲一下我们产品的充值流程:
app端根据用户选择的商品ID发起请求(请求苹果后台商品) -> 请求回调中找到与刚商品的ID一致的产品然后发送购买请求 -> 监听购买结果回调中状态为SKPaymentTransactionStatePurchased(即交易完成)时,调用自己服务接口将苹果回调给的凭据传给服务端 -> 服务端验证凭据成功后将用户充值的商品分发给该账户下


对于内购,我写了一个单例(建议单例,保证全局只有一个内购监听)

单例.h文件:
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface IWRechargeTool : NSObject

+ (instancetype)sharedInstance;

/**
 掉单处理
 */
- (void)checkIAPHandle;

/**
 内购

 @param goodsId 商品ID
 */
- (void)iapHandleWithGoodsId:(NSString *)goodsId;

@end

NS_ASSUME_NONNULL_END
.m文件

内购StoreKit肯定是要添加的

#import <StoreKit/StoreKit.h>

添加内购监听与代理:

SKPaymentTransactionObserver, SKProductsRequestDelegate
  • 这里添加observerExist只是为了确保监听始终只有一个,loading为了确保loading的显示
static IWRechargeTool *tool = nil;

@interface IWRechargeTool ()<SKPaymentTransactionObserver, SKProductsRequestDelegate>

@property (nonatomic, copy) NSString *goodsId;
@property (nonatomic, assign) BOOL observerExist;//观察是否存在 YES(存在) NO(不存在 默认)
@property (nonatomic, assign) BOOL loading;//loading是否存在 YES(存在) NO(不存在 默认)

@end
+ (instancetype)sharedInstance
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        tool = [[IWRechargeTool alloc] init];
    });
    
    return tool;
}

- (instancetype)init
{
    if (self = [super init]) {
        // 3.设置支付服务 监听
        NSLog(@"==tool init");
        self.observerExist = NO;
        self.loading = NO;
        [self addIAPObserverHandle];
    }
    return self;
}

请不要吐槽单例写法_

这个方法可以理解为用户选择了某一商品并且点击了购买按钮:

- (void)iapHandleWithGoodsId:(NSString *)goodsId
{
    NSLog(@"内支付开始: goodsId: %@", goodsId);
    if (StrEmpty(goodsId)) {
        return;
    }
    self.goodsId = goodsId;
    
    [self addLoadingHandle];
    [self addIAPObserverHandle];
    
    // 5.点击按钮的时候判断app是否允许apple支付
    //如果app允许applepay
    if ([SKPaymentQueue canMakePayments]) {
        NSLog(@"==canMakePayments");
        NSLog(@"==goodsId: %@", self.goodsId);
        // 6.请求苹果后台商品
        [self getRequestAppleProduct];
    } else {
        NSLog(@"not canMakePayments");
        [self removeLoadingHandle];
        [self removeIAPObserverHandle];
        [MBProgressHUD showToast:@"请打开Apple支付"];
    }
}
//请求苹果商品
- (void)getRequestAppleProduct
{
    NSLog(@"====请求苹果商品");
    // 7.这里的goodId就对应着苹果后台的商品ID,他们是通过这个ID进行联系的。
    NSArray *product = [[NSArray alloc] initWithObjects:self.goodsId, nil];
    NSSet *nsset = [NSSet setWithArray:product];
    
    // 8.初始化请求
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:nsset];
    request.delegate = self;
    
    // 9.开始请求
    [request start];
}
// 10.接收到产品的返回信息,然后用返回的商品信息进行发起购买请求
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response
{
    NSLog(@"无效商品列表: %@", response.invalidProductIdentifiers);
    NSArray *product = response.products;
    //如果服务器没有产品
    if([product count] == 0){
        NSLog(@"nothing");
        [self removeLoadingHandle];
        [self removeIAPObserverHandle];
        [MBProgressHUD showToast:@"没有有效商品"];
        return;
    }
    
    NSLog(@"====product count: %lu", (unsigned long)product.count);
    
    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]){
            
            [self addLoadingHandle];
            [self addIAPObserverHandle];
            
            requestProduct = pro;
            // 12.发送购买请求
            SKPayment *payment = [SKPayment paymentWithProduct:requestProduct];
            [[SKPaymentQueue defaultQueue] addPayment:payment];
            NSLog(@"goodsId: %@", self.goodsId);
            NSLog(@"======addPayment");
            break;
        }
    }
}
// 13.监听购买结果
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
    
    NSLog(@"==监听购买结果==");
    [self addLoadingHandle];
    [self addIAPObserverHandle];
    
    for(SKPaymentTransaction *tran in transactions){
        NSLog(@"==%@", tran.payment.productIdentifier);
        switch (tran.transactionState) {
            case SKPaymentTransactionStatePurchased:
                NSLog(@"交易完成");
                [self completeTransaction:tran];
                break;
            case SKPaymentTransactionStatePurchasing:
                NSLog(@"商品添加进列表=正在购买");
                [MBProgressHUD showToast:@"正在购买"];
                break;
            case SKPaymentTransactionStateRestored:
                NSLog(@"已经购买过商品");
                [self removeLoadingHandle];
                [self removeIAPObserverHandle];
                [[SKPaymentQueue defaultQueue] finishTransaction:tran];
                break;
            case SKPaymentTransactionStateFailed:
                NSLog(@"交易失败");
                [self removeLoadingHandle];
                [self removeIAPObserverHandle];
                [[SKPaymentQueue defaultQueue] finishTransaction:tran];
                [MBProgressHUD showToast:@"交易失败"];
                break;
            case SKPaymentTransactionStateDeferred:
                NSLog(@"==还在队列里 状态还未决定");
                [MBProgressHUD showToast:@"正在购买..."];
                break;
            default:
                NSLog(@"==updatedTransactions default");
                break;
        }
    }
}
  • 无论前端是否验证返回的凭据,后台均需要验证,因此前端我选择不验证凭据
// 14.交易结束,当交易结束后还要去appstore上验证支付信息是否都正确,只有所有都正确后,我们就可以给用户方法我们的虚拟物品了。
- (void)completeTransaction:(SKPaymentTransaction *)transaction
{
    // 验证凭据,获取到苹果返回的交易凭据
    // appStoreReceiptURL iOS7.0增加的,购买交易完成后,会将凭据存放在该地址
    NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
    // 从沙盒中获取到购买凭据
    NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
    /**
     20      BASE64 常用的编码方案,通常用于数据传输,以及加密算法的基础算法,传输过程中能够保证数据传输的稳定性
     21      BASE64是可以编码和解码的
     22      */
    NSString *encodeStr = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
    [self getApplePayDataToServerRequsetWith:transaction sendString:encodeStr];
}
注意如下的[[SKPaymentQueue defaultQueue] finishTransaction:transaction] 与发送购买请求的[[SKPaymentQueue defaultQueue] addPayment:payment]必须确保成对出现,若有未结束掉的,每次添加内购监听,回调结果中均会将未结束掉的事务回调到app端;
解决掉单也是依据该机制来处理的
- (void)getApplePayDataToServerRequsetWith:(SKPaymentTransaction *)transaction sendString:(NSString *)sendString{
    
    NSLog(@"==========getApplePayDataToServerRequsetWith=========");
    NSLog(@"==凭据: %@", sendString);
    NSMutableDictionary *parms = [NSMutableDictionary dictionary];
    /*
     receipt    String    是    苹果支付后返回的签名字符串    -
     */
    parms[@"receipt"] = sendString;
    
    WEAKSELF
    [IWNetworkManager postDataWithUrl:kPayApplePay_update parameters:parms type:IWLoadingTypeAll activityInView:nil alertMessage:nil success:^(id response, NSInteger resultCode, NSString *resultMessage) {
        if (resultCode == k200) {
            
            [[NSNotificationCenter defaultCenter] postNotificationName:kUpdateUserInfoNotification object:nil];
            [[NSNotificationCenter defaultCenter] postNotificationName:kMissingOrderHandleNotification object:nil];
            [weakSelf removeLoadingHandle];
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
            [weakSelf removeIAPObserverHandle];
            [MBProgressHUD showToast:@"购买成功"];
        } else {
            [weakSelf removeLoadingHandle];
            [MBProgressHUD showToast:@"购买失败"];
        }
    } failure:^(NSError *error) {
        [weakSelf removeLoadingHandle];
        [MBProgressHUD showToast:@"购买失败, 请重启App等待数秒或联系客服"];
    }];
}
//请求失败
- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{
    [self removeLoadingHandle];
    [self removeIAPObserverHandle];
    NSLog(@"error: %@", error);
    [MBProgressHUD showToast:@"支付请求失败"];
}

//反馈请求的产品信息结束后
- (void)requestDidFinish:(SKRequest *)request{
    NSLog(@"信息反馈结束");
}
- (void)removeIAPObserverHandle
{
    NSLog(@"removeIAPObserverHandle");
    if (self.observerExist) {
        NSLog(@"==removeIAPObserverHandle");
        self.observerExist = NO;
        [[SKPaymentQueue defaultQueue] removeTransactionObserver:self];
    }
}

- (void)addIAPObserverHandle
{
    NSLog(@"addIAPObserverHandle");
    if (!self.observerExist) {
        NSLog(@"==addIAPObserverHandle");
        self.observerExist = YES;
        [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
    }
}

- (void)removeLoadingHandle
{
    NSLog(@"removeLoadingHandle");
    if (self.loading) {
        NSLog(@"==removeLoadingHandle");
        self.loading = NO;
        [MBProgressHUD hideHUDForView:[UIApplication sharedApplication].keyWindow animated:YES];
    }
}

- (void)addLoadingHandle
{
    NSLog(@"addLoadingHandle");
    if (!self.loading) {
        NSLog(@"==addLoadingHandle");
        self.loading = YES;
        [MBProgressHUD showHUDWithMessage:nil];
    }
}

掉单处理
其实只是添加了内购监听,若有未结束掉的事务,则添加了内购监听后,苹果会将未结束掉的事务通过- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions回调到app,再进行相应处理
/**
 掉单处理
 */
- (void)checkIAPHandle
{
    [self addIAPObserverHandle];
}

在你认为合适的地方添加内购监听,比如App启动时,或者某些页面,又或者你可以自行添加个定时器等进行相应的修改:

///掉单处理
[[IWRechargeTool sharedInstance] checkIAPHandle];

PS: 如果你们的内购流程需要app端将苹果返回的购买凭据和与其对应的一个唯一值(比如orderId,接下来我暂且称为orderId)传输给后台,建议和后台重新设计一下内购流程。
因为如果是需要你将orderId和与其对应的transaction传给后台的话,因为可能存在多个掉单情况,那么你可能需要将所有你没成功的orderId与其对应的transaction都保存下来,在某些时机尝试将其再发送给后台;但这样做 不仅前端的工作很累,并且不能够处理所有的掉单,比如你FaceID/TouchID通过后,在监听支付回调还未回调到app端时,杀掉了app,此时虽然app进程结束了,但用户付款申请已经发出,有可能用户被扣款,但app端并未将该orderId对应的transaction保存下来,也未将此次购买行为告诉自己后台,用户购买的商品也就不会到账,因为未保存该transaction你也无法对其发起重试。

IAP机制,只要你加了监听,检测到有未结束的事务,在- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions方法中就会将未结束掉的再次返回给你

设计的内购流程,应该做到你只需要将凭据传输给自己服务,后台就可以通过验证得知该凭据是否有效以及用户购买的哪个商品(用户信息是请求头里通过token获取的)

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

推荐阅读更多精彩内容