想使用In-App Purchase(以下简称IAP)完成App内付费前,先确定需求是不是能用这个方案来满足。
除了IAP以外,还有支付宝SDK、信用卡等其他方式完成软件内付费。
在苹果制定的游戏规则中,所有在App内提供的服务需要付费时,都应当使用IAP,比如软件功能、游戏道具;所有在App外提供的服务需要付费时,都应使用其他支付方式,比如Uber的信用卡,淘宝、快的打车的支付宝SDK等。
在IAP里,可以出售:
在IAP里,不能出售:
顺便说下,有次大网易的同事分享时提到:使用兑换码兑换App内服务是一条高压线。像Uber和Amazon里允许有码,是因为他们的码是用在现实世界的产品或服务上的。
如果你确定内购需求符合IAP的使用要求,可以继续往下读了。
官方给出的流程图是这样的:
虚拟产品分为以下几种类型:
类型2、3、5都是以Apple ID为粒度的。比如小张有三个iPad,有一个Apple ID购买了不可消耗品,则三个iPad上都可以使用。
类型1、4一般来说则是现买现用。如果开发者自己想做更多控制,一般选4。
几种产品的区别如下(表格懒得翻译了):
Table 1-1 Comparison of product types
Product type | Non-consumable | Consumable |
---|---|---|
Users can buy | Once | Multiple times |
Appears in the receipt | Always | Once |
Synced across devices | By the system | Not synced |
Restored | By the system | Not restored |
Table 1-2 Comparison of subscription types
Subscription type | Auto-renewable | Non-renewing | Free |
---|---|---|---|
Users can buy | Multiple times | Multiple times | Once |
Appears in the receipt | Always | Once | Always |
Synced across devices | By the system | By your app | By the system |
Restored | By the system | By your app | By the system |
每种订阅品的每种更新周期可以在iTunes Connect中设置一个单独的价格。图中给出了一种订阅品的不同长度的更新周期的价格截图:
你可以把每种订阅品的每个长度的更新周期看成一个单独的产品,每个产品有自己独有的时长、价格、市场促销属性。
为了让用户可以从中挑选一个偏好的更新周期,上图中我们为此种订阅的每个长度的更新周期分别设值了一个单独的价格,有一周的、一个月的、两个月的、季度的、半年的和一年的。
上图中这种订阅品的全部六种更新周期合起来称为一个自动更新订阅品更新周期组(Auto-Renewable Subscription Duration Families)。
新建完,不用等待苹果审核就可以在沙箱环境使用了。
托管内容仅限于针对不可消耗品。
首次创建不可消耗品时可以选择把内容托管到苹果服务器,当然也可以随时将自己服务器上的内容迁移到苹果服务器由苹果托管。
需要使用托管功能的话,首先在iTunes Connect中提交不可消耗品让苹果审核。然后在Xcode中选取In-App Purchase Content template创建虚拟产品, 放入需要托管的内容, 然后使用Archive功能上传。或者使用Xcode为每一种虚拟产品创建一个.pkg文件,然后使用Application Loader一次性上传。
具体细节请参考Using Application Loader中和In-App Purchase有关的章节。
关于和iTunes Connect的交互,更多细节请参考In-App Purchase Configuration Guide for iTunes Connect。
1 2 3 4 5 6 7 |
#import |
接收结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response { NSArray *myProducts = response.products; for (SKProduct *product in myProducts) { //product } } - (void)request:(SKRequest *)request didFailWithError:(NSError *)error { //处理错误 } |
如果需要经过自己的服务器做二次验证,建议在调用苹果支付接口前做这一步。
订单中必须要保存的是订单ID和用户想要购买的商品ID。这个记录是为了在二次验证时服务端做检查,防止 A 商品的 receipt 被用户拿来做 B 商品的购买结果校验。
1 2 3 4 5 6 |
#import |
或者
1 2 3 4 5 |
#import |
首先在程序启动时注册观察者
1 2 3 |
#import |
并且实现回调,处理相应的购买返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
- (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; } } } |
需要监听SKPaymentQueue的更多状态变更,请实现SKPaymentTransactionObserver协议中提供的更多方法。
在收到Purchased或Restored回调后,持久化购买记录以及receipt data。
然后通知PaymentQueue,购买已经完成了。对finishTransaction则会触发系统IAP的UI刷新:
1 2 |
SKPaymentTransaction *transaction = <# The current payment #>; [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; |
另外在发放功能或道具之前,最好在自己服务端做一次二次校验,防止越狱插件或者Wifi的HTTP代理伪造购买记录。
越狱插件或者HTTP代理均可让用户做到伪造购买记录。当我们收到购买完成的回调后,最好经过自己服务器验证购买是否合法。
以下代码用Cocoa实现了二次验证的过程。但是这个过程最好通过自己的后台服务器来做,不然非常容易在客户端被伪造返回结果。
这里使用Cocoa实现只是为了阐述请求与返回值的格式。 发送二次验证请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#define SANDBOX_VERIFY_RECEIPT_URL [NSURL URLWithString:@"https://sandbox.itunes.apple.com/verifyReceipt"] #define APP_STORE_VERIFY_RECEIPT_URL [NSURL URLWithString:@"https://buy.itunes.apple.com/verifyReceipt"] #ifdef DEBUG #define VERIFY_RECEIPT_URL SANDBOX_VERIFY_RECEIPT_URL #else #define VERIFY_RECEIPT_URL APP_STORE_VERIFY_RECEIPT_URL #endif ... - (void)verifyTransaction:(SKPaymentTransaction *)transaction { NSData *transactionReceipt = transaction.transactionReceipt; NSString *base64String = [OTBase64Helper base64forData:transactionReceipt]; NSDictionary *receiptDictionary = @{@"receipt-data":base64String}; NSData *data = [receiptDictionary JSONData]; if (_receiptRequest) { [_receiptRequest cancel]; _receiptRequest = nil; } _receiptRequest = [[ASIFormDataRequest alloc] initWithURL:VERIFY_RECEIPT_URL]; _receiptRequest.userInfo = @{@"ProductIdentifier" : transaction.payment.productIdentifier}; _receiptRequest.delegate = self; [_receiptRequest appendPostData:data]; [_receiptRequest startAsynchronous]; } |
接收二次验证结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
- (void)requestFinished:(ASIHTTPRequest *)request { NSString *responseString = [request responseString]; NSDictionary *dictionary = [responseString objectFromJSONString]; NSString *productId = dictionary[@"receipt"][@"product_id"]; NSNumber *status = dictionary[@"status"]; if (status.intValue == 0) { //校验成功,发放内容 //status code 0为成功 } else { //校验失败,不做处理或相应惩罚 //21000 App Store不能读取你提供的JSON对象 //21002 receipt-data域的数据有问题 //21003 receipt无法通过验证 //21004 提供的shared secret不匹配你账号中的shared secret //21005 receipt服务器当前不可用 //21006 receipt合法,但是订阅已过期。服务器接收到这个状态码时,receipt数据仍然会解码并一起发送 //21007 receipt是Sandbox receipt,但却发送至生产系统的验证服务 //21008 receipt是生产receipt,但却发送至Sandbox环境的验证服务 } } - (void)requestFailed:(ASIHTTPRequest *)request { //出错处理 } |
苹果的返回值如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
{ "receipt": { "original_purchase_date_pst":"2015-06-03 04:00:37 America/Los_Angeles", "purchase_date_ms":"1433329237329", "unique_identifier":"secret9f135e2cd8f7dda951a15c01cd2220c60b", "original_transaction_id":"1000000157783770", "bvrs":"2.6.0", "transaction_id":"1000000157783770", "quantity":"1", "unique_vendor_identifier":"SECRETCD-89AD-45C4-8937-359CCA9E8F36", "item_id":"SECRET509", "product_id":"com.your.iap.product.id", "purchase_date":"2015-06-03 11:00:37 Etc/GMT", "original_purchase_date":"2015-06-03 11:00:37 Etc/GMT", "purchase_date_pst":"2015-06-03 04:00:37 America/Los_Angeles", "bid":"com.your.app.bundle.id", "original_purchase_date_ms":"1433329237329" }, "status": 0 } |
除了网络验证以外,苹果提供了纯粹的本地验证方式:Validating Receipts Locally.
Receipt data 经过 App Store 证书签名,所以第三方无法凭空生成能够通过此法验证的 receipt data。只要做好证书校验,无需担心用户会伪造 receipt data。
在客户端使用这种方式可以做到防止被通用破解方式破解,但并不能防止针对特定 App 的破解。
实际上,这种验证方式是苹果为服务端设计的。Receipt data 的格式遵守ASN.1格式,服务端安装asn1c就可以解析 receipt data,并不需要纯手写一份解析代码。只要服务端代码和 asn1c 不出 bug,在服务端使用这种方式验证就是安全的。
有些第三方网站提供了经服务端的验证服务。比如urbanairship. 但是我并没有用过,所以不知道具体效果如何。毕竟第三方服务无法做到在用户发起购买之前生成订单记录,与购买后验证结果比对,所以我还是比较担心第三方验证服务的安全性的。而且鸡国网络连国外验证服务器,你懂的。。
总之想要万无一失,建议开发自己的验证接口。
更多验证相关问题,请参考Receipt Validation Programming Guide
大多数产品在验证成功后,才是真正的发放内容、道具等。特别是充值后立即消费的虚拟货币基本都是这么处理的。
但是据我猜测, IAP 的设计者是想让开发者在购买完成时发放内容、道具,在二次验证失败时以删除内容、道具等方式来进行处罚。这样做的好处是:服务端不做小票对应商品验证/失效小票记录的话(后面会提到具体做法。带侥幸心理不做这个是很危险的,我们第二天就被 hack 了),用户无法通过向服务端重复发送同一张有效小票并关联不同的订单来伪造购买记录。
由于是和钱关系最紧密的功能,IAP安全性显得无比重要。
如果是用的“经服务端二次验证成功发放道具”的逻辑,而非“购买成功发放道具,二次验证失败惩罚处理”,则在我的实践过程中,以下几件事是必须要做的。
不像支付宝SDK那样全部校验在服务端做,用IAP时部分流程的完整性是需要客户端保证的。
在transaction完成后,和服务端的二次验证完成前,要对transaction.transactionReceipt做持久化。
删除此持久化的时机应当是收到从服务端发回的二次验证请求的响应时,确认服务端已和苹果完成通信之后(服务端返回和苹果连接失败则不应删除已保存的receiptData)。
由于和开发者自身网站业务耦合紧,这部分内容任意一篇 IAP 的文档中都没有提到。但是在我实践中,这部分工作一旦有疏漏,被 hack 是分分钟的事。强烈建议认真阅读本部分,并在服务端完成类似实践。
服务端防盗主要有两点:
1. 自己服务器的线上环境避免使用苹果sandbox环境做二次验证,防止公司内部使用同一个apple developer id的人建立sandbox test user监守自盗。
2. 对验证通过的小票做废弃记录,防止黑客使用同一个小票反复验证购买。
再回顾一下,苹果二次验证接口的返回值如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
{ "receipt": { "original_purchase_date_pst":"2015-06-03 04:00:37 America/Los_Angeles", "purchase_date_ms":"1433329237329", "unique_identifier":"secret9f135e2cd8f7dda951a15c01cd2220c60b", "original_transaction_id":"1000000157783770", "bvrs":"2.6.0", "transaction_id":"1000000157783770", "quantity":"1", "unique_vendor_identifier":"SECRETCD-89AD-45C4-8937-359CCA9E8F36", "item_id":"SECRET509", "product_id":"com.your.iap.product.id", "purchase_date":"2015-06-03 11:00:37 Etc/GMT", "original_purchase_date":"2015-06-03 11:00:37 Etc/GMT", "purchase_date_pst":"2015-06-03 04:00:37 America/Los_Angeles", "bid":"com.your.app.bundle.id", "original_purchase_date_ms":"1433329237329" }, "status": 0 } |
第5点中提到过,后台需要在发起支付前针对每一笔支付生成一个订单。服务端使用客户端发来的receiptData得到苹果的二次验证返回时,首先比较订单中的商品和返回值中的 product_id 是否对应,若不一致则用户用作弊手段传了另一个商品的 receiptData 过来,视本次支付无效。若一致,则需要判断返回值中的unique_identifier是否被使用过;若未被使用过,则视此次交易完成,并将此unique_identifier标记为已使用;若使用过,则用户使用作弊手段传了之前购买时的 receiptData 过来。
需要在代码里显示声明的环境,就只有二次验证地址:
1 2 |
#define SANDBOX_VERIFY_RECEIPT_URL [NSURL URLWithString:@"https://sandbox.itunes.apple.com/verifyReceipt"] #define APP_STORE_VERIFY_RECEIPT_URL [NSURL URLWithString:@"https://buy.itunes.apple.com/verifyReceipt"] |
而调用苹果接口时连接的是线上环境还是测试环境,猜测是由编译 App 的证书决定的。目前看来,开发证书编译后,连接的是苹果的 Sandbox 环境;AppStore 上下载的则是连接苹果的线上环境。
另外再次强调,除非少量必要的自己线上环境的测试需要连接苹果的 Sandbox 验证服务之外,自己服务端的二次验证 API 应该严格做到自己的环境是线上环境,则连接苹果的线上环境二次验证接口。防止监守自盗的情况出现。
上文介绍了现在整个IAP支付的流程,可能随着版本的更新,个别过程不太一样,但都是大同小异
原文地址:http://openfibers.github.io/blog/2015/02/28/in-app-purchase-walk-through/