关于苹果支付IAP的那些事

前言

说起苹果支付,可能对于大多数的开发者,或者准确来说大多数老板们比较头痛的事情。因为苹果支付(即IAP,In-App Purchase,后文统称IAP)意味着要分苹果一杯羹,差不多30%的提成给苹果。尤其是游戏充值,这种动辄百万的流水,怪不得苹果的IAP抽成的主要营收都在游戏业务。

当然,这不是今天要说的重点。作为一位开发工程师,这种抽成问题不是我们讨论的焦点,但是如果老板们热切期待你给出一个绕过IAP抽成的方案的时候,你可以参考我之前写过的一篇记录支付宝手机网站(WAP)支付踩过的坑

做过支付开发的童鞋都知道,就客户端开发来说,IAP不管是对比支付宝还是微信支付,其集成难易程度都明显高于这些第三方支付,第一感觉甚至觉得有些逻辑显得臃肿和啰嗦,当然,随着我们一步一步揭开了IAP的神秘面纱,就会慢慢明白,苹果工程师的设计哲学及其用意。这篇文章我们就好好说说IAP那些事。

哪些商品可以使用IAP?

按照苹果官方的说法,如下:

You can use In-App Purchase to sell content, app functionality, and services.

总结来说,如下三点商品,必须走IAP。

  1. 付费数字内容。如电子杂志,小说,音乐,游戏虚拟道具等。
  2. App付费功能。如付费去除广告、付费提供高级功能等。
  3. 付费虚拟服务。

只要出售的产品不属于上面三条,就可以不用IAP。
比如出售一些实物或者实体服务。
当然,还是强烈建议遵守苹果的规则来选型支付方案,因为苹果对IAP的打压非常严格,在近期的审核策略下,两次被同样IAP问题拒审,就会被延审,再而就会有可能被封禁账号。

支持哪些IAP产品类型?

为了保证文章知识的完整性,这里稍微介绍一下IAP所支持的产品类型。当然我们的重点是Coding。

  • 消耗型项目
    只可使用一次的产品,使用之后即失效,必须再次购买。
    示例:钓鱼 App 中的鱼食。
  • 非消耗型项目
    只需购买一次,不会过期或随着使用而减少的产品。
    示例:游戏 App 的赛道。
  • 自动续期订阅
    允许用户在固定时间段内购买动态内容的产品。除非用户选择取消,否则此类订阅会自动续期。
    示例:每月订阅提供流媒体服务的 App。
  • 非自动续期订阅
    允许用户购买有时限性服务的产品。此 App 内购买项目的内容可以是静态的。此类订阅不会自动续期。
    示例:为期一年的已归档文章目录订阅。

上面两小节是系统介绍IAP,下面正式开始IAP的集成

集成IAP步骤一:填写银行卡和税务信息

登录 iTC后台,进入 协议、税务和银行业务来进行系列初始化。
因为这小节不是重点,而且比较简单,这里就不再赘述具体细节。简单说下注意事项:

  • 提前让运营童鞋准备好一张银行卡,将来IAP挣的钱苹果就会打到这个卡上。注意要知道行号和地址信息等。
  • 报税表只需填写美国的就行。

集成IAP步骤二:创建付费商品

在 我的App > 功能 > App内购买项目 栏目中,点击+按钮,根据具体情况添加商品,大多数都是消耗型商品,如游戏币等。
这小节同样很简单就不再赘述。这里只说注意点:

  • 产品ID要好好设计,不能重复且有意义,因为不管是后面的编程,还是服务端的产品配置,都要这个ID。举例: com.tencent.mm.pay.coin_100
  • 注意价格不能自定义。要按照苹果预设好的等级来添加商品,目前一共有87等级,最大支持6498CNY,一般来说足够。

集成IAP步骤三:开始Coding

说起IAP的编程,我先多说两句。其实,看到过很多童鞋吐槽IAP的集成流程有些复杂且易出错,的确是这样,相比于AliPay、WePay,的确稍显复杂,可能这也是移动支付没竞争过天朝的原因吧哈哈。 不过玩笑归玩笑,其实集成起来,真的不难。
编码之前,要先知道支付流程的各个阶段。否则就会难以下手。看下图:


image.png

从上图得知,支付分三个阶段:获取支付信息、支付请求和交付商品。
我们分阶段编程。

1、获取商品信息

首先,我们从上文知道,IAP商品是以产品ID做标识的。我们展示给用户商品之前,得需要对这些标识做一些处理,来避免一些异常情况。具体如下:

  • Product Identifiers(产品ID列表)
    Product IDs 作为出售商品的唯一标识,其重要性不言而喻。即上面步骤中在iTC添加商品的时候手动填写的,所以要保证唯一和语义。
    一般来说,Product IDs 是放在自己的服务端进行动态获取的,当然,如果你足够懒,也可以写死在App本地。放在服务端的好处很多,比如防止过期的产品还在展出,导致用户购买无效产品。以后如果新增了产品,可以不用提包更新版本,就可以将最新产品展示给用户。这点很实用,比如游戏经常会推出新的道具商品等,不需发版,即可完成新商品的添加。
    不管是放在服务端,还是App本地,Product IDs 的序列化方式都是一样的,即以数组方式存储若干ID字符串。
[
    "com.hoolai.bmt.level1",
    "com.hoolai.bmt.level2",
]
  • 检测 Product IDs 的有效性
    为了避免过期商品还在展示,导致用户购买无效商品,我们需要对上一步获取到的 IDs 进行验证过滤,将无效的产品进行置灰或者直接删除。代码如下:
- (void)validateProductIDs:(NSArray <NSString *> *)ids {
    _productRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:[NSSet setWithArray:ids]];
    _productRequest.delegate = self;
    [_productRequest start];
}

#pragma mark - <SKProductsRequestDelegate>

- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
    for (NSString *invalidID in response.invalidProductIdentifiers) {
         // 处理无效的产品ID
    }
    [self displayStoreUI];
}

- (void)displayStoreUI {
    
}
2、请求支付

上一步我们获取到了有效的 ProductIDs,当用户点击了某个商品的购买按钮时,如“购买10钻石”,我们就需要发送支付请求给苹果内购系统。

// 该处的product是上面的协议方法didReceiveResponse返回的。
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
payment.quantity = 1; //!< 购买该商品的数量
// 将payment添加到支付队列
[[SKPaymentQueue defaultQueue] addPayment:payment];

但是,在向苹果发送支付请求的时候,有一个特别要注意的点,就是要做一下反欺诈引擎。为什么要这么做呢?
就买游戏币为例,我们知道,正常情况下,我们App的大多数不同的用户会使用不同的AppleID账号来购买游戏币。而如果发现一个同样的用户多次使用不同的AppleID账号来购买游戏币,这就不正常了,很可能这个用户涉嫌支付欺诈。我们App之前就遭遇过类似情形,非法分子通过汇率不对称,进行支付诡法来获取差价,扰乱了我们的价格定位和产品形象,影响恶劣。

那,怎么解决这个问题呢?
苹果给出的建议是,在我们发送支付请求的时候,需要告诉苹果这个用户的唯一性关联的不透明标识符。有些绕口,通白来讲,就是想要这个用户uid的单向哈希后的值。可以通过下面的哈希函数将用户uid进行指纹计算,获取到一个不可逆的hash值。

// Custom method to calculate the SHA-256 hash using Common Crypto

- (NSString *)hashedValueForAccountName:(NSString*)userAccountName {
    const int HASH_SIZE = 32;
    unsigned char hashedChars[HASH_SIZE];
    const char *accountName = [userAccountName UTF8String];
    size_t accountNameLen = strlen(accountName);
    
    // Confirm that the length of the user name is small enough
    // to be recast when calling the hash function.
    if (accountNameLen > UINT32_MAX) {
        NSLog(@"Account name too long to hash: %@", userAccountName);
        return nil;
    }
    CC_SHA256(accountName, (CC_LONG)accountNameLen, hashedChars);
    // Convert the array of bytes into a string showing its hex representation.
    NSMutableString *userAccountHash = [[NSMutableString alloc] init];
    for (int i = 0; i < HASH_SIZE; i++) {
        // Add a dash every four bytes, for readability.
        if (i != 0 && i%4 == 0) {
            [userAccountHash appendString:@"-"];
        }
        [userAccountHash appendFormat:@"%02x", hashedChars[i]];
    }
    return userAccountHash;
}

获取到hash值后,通过SKPaymentapplicationUsername属性告诉苹果,即下行代码,IAP内部系统就会自动检测反欺诈处理。

payment.applicationUsername = [self hashedValueForAccountName:@"<UID>"];
3、获取交付回调

发送支付请求后,接下来IAP系统(即StoreKit框架)就会自动调起支付页面,这时transaction queue的起到了枢纽般的重要作用,他是我们App与AppStore进行支付沟通的桥梁。我们需要给transaction queue绑定一个监听者,来获取实时的状态变化,比如支付完成、支付失败的状态。

这里要注意一点,注册监听者的时间点要尽量提前。因为用户的网络环境是多变不稳定的,如果在用户支付成功后,由于网络问题或者崩溃Bug导致获取购买的商品失败,在应用启动的时候,StoreKit系统会再次通知transaction queue observer,从而进行自动补单,防止订单丢失。
所以,在什么地方添加监听者呢?苹果建议在application:didFinishLaunchingWithOptions:方法中添加。如下:

- (BOOL)application:(UIApplication *)application
 didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [[SKPaymentQueue defaultQueue] addTransactionObserver:observer];
}

然后这个监听者需要遵守SKPaymentTransactionObserver协议,并实现下面方法:

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            // Call the appropriate custom method for the transaction state.
            case SKPaymentTransactionStatePurchasing:
                [self showTransactionAsInProgress:transaction deferred:NO];
                break;
            case SKPaymentTransactionStateDeferred:
                [self showTransactionAsInProgress:transaction deferred:YES];
                break;
            case SKPaymentTransactionStateFailed:
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStatePurchased:
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                [self restoreTransaction:transaction];
                break;
            default:
                // For debugging
                NSLog(@"Unexpected transaction state %@", @(transaction.transactionState));
                break;
        }
    }
}

在用户输入密码,支付完成后,上面回调会被调用,transactionStateSKPaymentTransactionStatePurchased,注意这时要了解一下支付凭证receipt的概念,他是通过[[NSBundle mainBundle] appStoreReceiptURL]获取到。

他是什么?

从App Store安装了一个应用程序,它包含一个只有苹果才能校验的加密签名后的receipt收据。 receipt存储在应用程序包中, 调用NSBundle类的appStoreReceiptURL方法来查找收据。里面包含了用户的购买信息记录,注意,里面有可能会包含多条购买记录,因为购买信息记录只会在finishTransaction 后才会从中删除。他是一个二进制包,内部结构如下图:

Receipt

他有什么作用?

  • 先说一下最重要的作用,就是校验用户支付购买的可靠性。
    当用户支付完成后,我们可以拿着receipt数据进行校验用户购买商品行为的合法性。这是一个重点,单独放在下面第4小节讲解。
  • 防止软件被重签名复刻,导致自己辛辛苦苦开发的付费软件被非法分子破解。
  • 可以查询用户有没有购买该件商品。方法如下:
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
BOOL rocketCarEnabled = [self receipt:receiptData includesProductID:@"com.tencent.mm.pay.coin_100"];
  • 如果是非消耗型商品或者是自动续期的订阅的商品,可以通过SKReceiptRefreshRequest来恢复购买。
4、校验购买凭证

我们知道,一旦涉及到支付行为,如果出现差错,后果往往非常严重。所以为了保证用户购买行为的合法性,苹果使用的安全机制是receipt校验。

上面小节也提到了receipt校验,这种校验本质上是发送POST请求到苹果服务器,(生产环境:https://buy.itunes.apple.com/verifyReceipt,沙盒环境:https://sandbox.itunes.apple.com/verifyReceipt),但是切记要通过自己的服务端去跟苹果服务器进行校验交互,因为如果使用App直接去跟苹果服务器进行校验无法避免中间人攻击。

在用户支付完成后,我们在transaction queue observer的代理方法paymentQueue: updatedTransactions:中可以获取通过appStoreReceiptURL获取到含有刚刚支付信息的receipt数据。我们需要把它传到自己的服务端,然后让服务端去跟苹果服务器交互校验其合法性。

NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receipt = [NSData dataWithContentsOfURL:receiptURL];
if (!receipt) { 
  // 没有收据
  return;
 }
/* ... 发送给自己服务端 ... */

服务端拿到receipt数据后,访问上面提到的苹果服务器,参数receipt-data为必传,需要将其base64编码。
这时候重点关注苹果服务器的响应体,核心在status字段。
status=0,标志着校验成功,用户的购买为合法的。
status=21007,说明这个receipt是来自测试环境但是却发送到了苹果的生成环境校验地址。自己服务端在收到这个状态码时只需要将域名定向到苹果的生成环境校验地址再进行访问即可。这个很重要,苹果也建议除非是在测试阶段,否则都优先使用生产地址来校验(如审核阶段,online阶段)。

后记

到这里,整个IAP的流程就走完了,也许他确实不如支付宝、微信支付那样简单粗暴,但是这仅是针对客户端同学来说的。其实真实对比来看,对于服务端同学,拿支付宝来说,又要在支付宝后台生成、上传公钥,又要设置安全域名,又要进行签名、生成预订单、接收支付宝服务器回调、安全校验等等系列工作,你会发现其实IAP对服务端几乎没有工作量。换句话说,IAP只是因为将工作量转移到了客户端而显得貌似复杂了而已。

但是,我们还是得要注意,为了保证IAP的稳定性,即避免丢单,我们还是得要做一些额外工作,简单总结,是如下几小点:

  1. 使用iCloud或者App服务端保存receipt等支付元信息防止用户刚支付完成还没来得及处理购买业务就把App卸载而造成的丢单(别笑,测试同学会这么干)。
  2. transaction queue observer的尽早前置,以保证苹果IAP的回调能够及时响应处理而非进入到特定页面才能收到回调。推荐放在didFinishLaunchingWithOptions中执行SKPaymentQueue.default().add(your_observer)
  3. 暂时就想到上面这些,等想到其他的再补充。

OK,先这样。

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

推荐阅读更多精彩内容