1. 適用情況
想使用In-App Purchase(以下簡稱IAP)完成App內付費前,先確定需求是不是能用這個方案來滿足。
除了IAP以外,還有支付寶SDK、信用卡等其他方式完成軟件內付費。
在蘋果制定的遊戲規則中,所有在App內提供的服務需要付費時,都應當使用IAP,比如軟件功能、遊戲道具;所有在App外提供的服務需要付費時,都應使用其他支付方式,比如Uber的信用卡,淘寶、快的打車的支付寶SDK等。
在IAP裏,可以出售:
- 數字內容:比如雜誌、圖片、遊戲關卡解鎖、相機付費濾鏡等;
- 軟件功能:如各種擴展features;
- 一次性服務:比如一次語音通話等。注意是「一次性」服務,不是一次「性服務」。
在IAP裏,不能出售:
- 現實世界的商品或服務:比如剛纔提到的一次「性服務」。嚴格遵守此方案有個好處:IAP 如果被破解,用戶無法得到大量實物,開發商也不會有很大經濟損失。非要做的話想繞過也是可以的:用 IAP 購買代幣,審覈通過後用代幣購買實物。
- 其他the App Review GuideLines中規定的不允許的內容:比如剛纔提到的一次「性服務」。
順便說下,有次大網易的同事分享時提到:使用兌換碼兌換App內服務是一條高壓線。像Uber和Amazon裏允許有碼,是因爲他們的碼是用在現實世界的產品或服務上的。
如果你確定內購需求符合IAP的使用要求,可以繼續往下讀了。
2. 購買及發放虛擬產品流程
官方給出的流程圖是這樣的:
- 獲取內購列表(從App內讀取或從自己服務器讀取)
- App Store請求可用的內購列表
- 向用戶展示內購列表
- 用戶選擇了內購列表,再發個購買請求
- 收到購買完成的回掉
- 發放虛擬產品
3.虛擬產品
虛擬產品的分類
虛擬產品分爲以下幾種類型:
- 消耗品(Consumable products):比如遊戲內金幣等。
- 不可消耗品(Non-consumable products):簡單來說就是一次購買,終身可用(用戶可隨時從App Store restore)。
- 自動更新訂閱品(Auto-renewable subscriptions):和不可消耗品的不同點是有失效時間。比如一整年的付費週刊。在這種模式下,開發者定期投遞內容,用戶在訂閱期內隨時可以訪問這些內容。訂閱快要過期時,系統將自動更新訂閱(如果用戶同意)。
- 非自動更新訂閱品(Non-renewable subscriptions):一般使用場景是從用戶從IAP購買後,購買信息存放在自己的開發者服務器上。失效日期/可用是由開發者服務器自行控制的,而非由App Store控制,這一點與自動更新訂閱品有差異。
- 免費訂閱品(Free subscriptions):在Newsstand中放置免費訂閱的一種方式。免費訂閱永不過期。只能用於Newsstand-enabled apps。
類型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 |
關於自動更新訂閱品更新週期組(Auto-Renewable Subscription Duration Families):
每種訂閱品的每種更新週期可以在iTunes Connect中設置一個單獨的價格。圖中給出了一種訂閱品的不同長度的更新週期的價格截圖:
你可以把每種訂閱品的每個長度的更新週期看成一個單獨的產品,每個產品有自己獨有的時長、價格、市場促銷屬性。
爲了讓用戶可以從中挑選一個偏好的更新週期,上圖中我們爲此種訂閱的每個長度的更新週期分別設值了一個單獨的價格,有一週的、一個月的、兩個月的、季度的、半年的和一年的。
上圖中這種訂閱品的全部六種更新週期合起來稱爲一個自動更新訂閱品更新週期組(Auto-Renewable Subscription Duration Families)。
4. 人肉和iTunes Connect交互
填寫銀行卡與納稅信息
- 登錄iTunes Connect
- 點擊Agreements, Tax, and Banking,填寫Contact Info, Bank Info, Tax Info。如果填過就不用再填了。剛剛填寫完成後,各種信息正在審覈,如下圖:
即使信息正在審覈,沙箱環境下也是可以訪問IAP服務的,並不需要等審覈完成才能測試。
新建虛擬產品
- 登錄iTunes Connect
- 點擊My Apps
- 進入想使用IAP的App詳情
- 選擇In-App Purchases
- 點擊Create New
- 選擇IAP虛擬產品類型。注意虛擬產品一旦新建,類型無法修改。
- 填入Internal Name。只能在iTunes Connect中看到這個名字。不會出現在App Store中。最長255字節。
- 填入Product ID。每件產品有一個單獨的Product ID,Product ID用於從App Store獲取價格信息,以及付費時標識是哪種產品被購買了。例如com.163.neteasemusic.skin.dog。這個新建之後也是不能修改的。
- 然後是設置價格。Cleared For Sale選爲YES是虛擬產品被審覈通過自動上架。NO是手動上架。Price Tier則是價格。
- 多語言描述,這個是給用戶看的。
- 是否要在iTunes託管可購買內容,這個後面再說
- 填入review notes和review用的截圖。
- 保存。
- 保存後除了類型和Product ID都可以修改
新建完,不用等待蘋果審覈就可以在沙箱環境使用了。
新建測試帳號
- 登錄iTunes Connect
- 點擊Users and Roles
- 點擊Sandbox Testers
- 添加Tester。添加後在測試機上用tester帳號登錄app store
附:在蘋果託管不可消耗品(Non-consumable products)的內容需知
託管內容僅限於針對不可消耗品。
首次創建不可消耗品時可以選擇把內容託管到蘋果服務器,當然也可以隨時將自己服務器上的內容遷移到蘋果服務器由蘋果託管。
需要使用託管功能的話,首先在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。
5. 代碼裏該做的事情
獲取產品列表
- 首先讀取出App中內嵌的或是服務端中的Product IDs。
- 使用SKProductRequest向蘋果服務器驗證哪些Product IDs是可用的。注意不要在[Class load]裏發送請求,一定要等到App didFinishLauching之後再發,不然無法接到請求返回。核心代碼如下:
1 2 3 4 5 6 7 |
#import <StoreKit/StoreKit.h> #define kInAppPurchaseProUpgradeProductId @"com.163.neteasemusic.skin.dog" ... NSSet *productIDs = [NSSet setWithObject:kInAppPurchaseProUpgradeProductId]; SKProductsRequest *request= [[SKProductsRequest alloc] initWithProductIdentifiers:productIDs]; request.delegate = self; [request start]; |
接收結果
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 <StoreKit/StoreKit.h> ... SKProduct *product = <# products request中返回的SKProduct #>; SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product]; payment.quantity = 2; [[SKPaymentQueue defaultQueue] addPayment:payment]; |
或者
1 2 3 4 5 |
#import <StoreKit/StoreKit.h> ... SKProduct *product = <# products request中返回的SKProduct #>; SKPayment *payment = [SKPayment paymentWithProduct:product]; [[SKPaymentQueue defaultQueue] addPayment:payment]; |
觀察購買狀態
首先在程序啓動時註冊觀察者
1 2 3 |
#import <StoreKit/StoreKit.h> ... [[SKPaymentQueue defaultQueue] addTransactionObserver:observer]; |
並且實現回調,處理相應的購買返回。
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代理均可讓用戶做到僞造購買記錄。當我們收到購買完成的回調後,最好經過自己服務器驗證購買是否合法。
經過 App Store 驗證
以下代碼用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 了),用戶無法通過向服務端重複發送同一張有效小票並關聯不同的訂單來僞造購買記錄。
6.服務端二次驗證後再發放數據中的安全問題
由於是和錢關係最緊密的功能,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 過來。
7.切換線上/測試環境
需要在代碼裏顯示聲明的環境,就只有二次驗證地址:
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/