公司的項目最近集成了iOS內(nèi)購, 盡管網(wǎng)上有很多相當詳細的內(nèi)購集成教程, 但可能由于集成內(nèi)購的應用比較少, 市場需求不大, 所以教程都比較舊, 而且有幾個重點沒有提及到, 以至于小弟我踩了不少的坑...所以在這里打算就內(nèi)購的幾個注意點作一個小小的補充, 希望可以一解大家在集成內(nèi)購時所產(chǎn)生的困惑. 當然如果大家有好的做法也歡迎指正, 畢竟小弟也是第一次集成內(nèi)購.
1. 漏單問題
交易狀態(tài)變化回調(diào)方法是由系統(tǒng)進行回調(diào)的, 無論是正在購買, 購買失敗, 購買成功等都會被調(diào)用, 我們只需要在此方法中進行相應的操作即可.
// 交易狀態(tài)變化回調(diào)方法
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions NS_AVAILABLE_IOS(3_0);
一般來說, 對于消耗性商品, 我們用得最多的是在判斷用戶購買成功之后交給我們的服務器進行校驗, 收到服務器的確認后把交易 finish 掉.
// finish 交易
[[SKPaymentQueue defaultQueue] finishTransaction:transactions];
如果不把交易 finish 掉的話, 在下次重新打開應用待代碼執(zhí)行到監(jiān)聽內(nèi)購隊列后此方法都會被回調(diào), 直到被 finish 掉為止. 所以為了防止漏單, 建議將內(nèi)購抽類做成單例對象, 并在程序入口啟動內(nèi)購類, 第一時間監(jiān)聽內(nèi)購隊列. 這樣做的話, 即使用戶在成功購買商品后由于各種原因沒告知服務器就關閉了應用, 在下次打開應用時也能及時把交易補回, 這樣就不會造成漏單問題了.
// 監(jiān)聽內(nèi)購隊列
[[SKPaymentQueue defaultQueue] addTransactionObserver:_inPurchaseManager];
但事與愿違, 在調(diào)試中, 我們發(fā)現(xiàn)如果在有多個成功交易未 finish 掉的情況下把應用關閉后再打開, 往往會把其中某些任務漏掉, 即回調(diào)方法少回調(diào)了, 這讓我們非常郁悶. 既然官方的API不好使, 我們只能把這個重任交給后臺的驗證流程了, 具體的做法下面會講到.
2. 驗證問題
在確認用戶成功支付后, 我們需要把驗證密鑰發(fā)送給服務器, 密鑰的本身說白了其實就是一個文件, 我們需要把它轉(zhuǎn)成 ns64 字符串再交給服務器, 服務器拿到我們的密鑰后就可以去蘋果的后臺進行驗證了. 可能大家會很好奇, 后臺究竟是怎樣進行驗證的呢, 帶著這個疑問, 我們不妨來模擬一下.
我們先把本地的密鑰文件轉(zhuǎn)成 ns64 字符串.
// 獲取驗證文件url
NSURL *pathUrl = [[NSBundle mainBundle] appStoreReceiptURL];
// 文件不存在 return
if (![[NSFileManager defaultManager] fileExistsAtPath:pathUrl.path]) return;
// 把文件轉(zhuǎn)成數(shù)據(jù)流
NSData *receiptData = [NSData dataWithContentsOfURL:pathUrl];
// 把數(shù)據(jù)流轉(zhuǎn)成 ns64 字符串
NSString *baseString = [receiptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
沒錯, 這個 baseString 就是我們所說的密鑰. 什么, 你想看看它長什么樣? 相信我, 你不會想看的, 它就是一個大小約為7k的一大串字符. 另外, 蘋果的驗證接口有2個, 分別是調(diào)試接口和發(fā)布接口.
調(diào)試: https://sandbox.itunes.apple.com/verifyReceipt
發(fā)布: https://buy.itunes.apple.com/verifyReceipt
接下來我們就來模仿服務器的驗證流程.
// 設置請求參數(shù)(key是蘋果規(guī)定的)
NSDictionary *param = @{@"receipt-data":baseString};
// 獲取網(wǎng)絡管理者
AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
// 設置請求格式為json
manager.requestSerializer = [AFJSONRequestSerializer serializer];
// 發(fā)出請求
[manager POST:@"https://sandbox.itunes.apple.com/verifyReceipt" parameters:param progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
NSLog(@"responseObject = %@", responseObject);
} failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
NSLog(@"error = %@", error);
}];
這里我們訪問網(wǎng)絡用的是 AFNetworking 框架, 需要注意的是這里必須要設置請求格式告訴蘋果后臺這是 json 格式, 不然蘋果會不認識這些數(shù)據(jù). 并且由于我們用的是沙盒測試賬號, 所以訪問的也是蘋果的調(diào)試接口.
程序跑起來后, 很有可能會打印出錯誤日志, 提示Request failed: unacceptable content-type: text/plain"等一大串信息, 這是由于 AFNetworking 解析格式缺失的問題, 只要進入到 AFURLResponseSerialization.m 的源文件里, 在所屬類 AFJSONResponseSerializer 中的 init 方法內(nèi)添加一個字段即可.
- (instancetype)init {
self = [super init];
if (!self) {
return nil;
}
// 原來的樣子
// self.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/json", @"text/javascript", nil];
// 添加后的樣子
self.acceptableContentTypes = [NSSet setWithObjects:@"application/json", @"text/json", @"text/javascript", @"text/plain", nil];
return self;
}
現(xiàn)在再把程序跑起來就會看到如下的打印內(nèi)容了.
responseObject = {
environment = Sandbox;
receipt = {
"adam_id" = 0;
"app_item_id" = 0;
"application_version" = "1.0.3.2";
"bundle_id" = "**********";
"download_id" = 0;
"in_app" = (
{
"is_trial_period" = false;
"original_purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
"original_purchase_date_ms" = 1486520773000;
"original_purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
"original_transaction_id" = 1000000271607744;
"product_id" = "**********_06";
"purchase_date" = "2017-02-08 02:26:13 Etc/GMT";
"purchase_date_ms" = 1486520773000;
"purchase_date_pst" = "2017-02-07 18:26:13 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000271607744;
},
{
"is_trial_period" = false;
"original_purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
"original_purchase_date_ms" = 1488002375000;
"original_purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
"original_transaction_id" = 1000000276891381;
"product_id" = "**********_01";
"purchase_date" = "2017-02-25 05:59:35 Etc/GMT";
"purchase_date_ms" = 1488002375000;
"purchase_date_pst" = "2017-02-24 21:59:35 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000276891381;
},
{
"is_trial_period" = false;
"original_purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
"original_purchase_date_ms" = 1489124683000;
"original_purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
"original_transaction_id" = 1000000280765165;
"product_id" = "**********_01";
"purchase_date" = "2017-03-10 05:44:43 Etc/GMT";
"purchase_date_ms" = 1489124683000;
"purchase_date_pst" = "2017-03-09 21:44:43 America/Los_Angeles";
quantity = 1;
"transaction_id" = 1000000280765165;
}
);
"original_application_version" = "1.0";
"original_purchase_date" = "2013-08-01 07:00:00 Etc/GMT";
"original_purchase_date_ms" = 1375340400000;
"original_purchase_date_pst" = "2013-08-01 00:00:00 America/Los_Angeles";
"receipt_creation_date" = "2017-03-10 05:44:44 Etc/GMT";
"receipt_creation_date_ms" = 1489124684000;
"receipt_creation_date_pst" = "2017-03-09 21:44:44 America/Los_Angeles";
"receipt_type" = ProductionSandbox;
"request_date" = "2017-03-10 08:50:00 Etc/GMT";
"request_date_ms" = 1489135800761;
"request_date_pst" = "2017-03-10 00:50:00 America/Los_Angeles";
"version_external_identifier" = 0;
};
status = 0;
}
安全起見, 這里我把一些不方便展示的內(nèi)容用 * 代替了. 一開始看到這些可能會有點暈, 畢竟信息量有點大, 但其實有很多東西一般是用不上的. 這里面我們最關心的是 in_app 里的數(shù)組, 因為根據(jù)蘋果的官方文檔所示, 這些就是付款成功而未被 finish 掉的交易 (如下圖所示, 此處蘋果并沒有說消耗性的商品會出現(xiàn)在列表里, 不過本人例子中的商品全都屬于消耗性的, 有點不惑) , 而一般這個數(shù)組里只會存在一個元素, 這里會出現(xiàn)3個是因為這3個單子已經(jīng)被蘋果漏掉了, 是的, 這就是上面所提到的漏單情況, 回調(diào)方法是不會再走了, 惡心吧...

但生活還是得繼續(xù), 這里我們可以看到每個交易里都有一些很詳細的信息, 一般我們只對 original_transaction_id (交易ID) 和 product_id (商品ID) 感興趣, 服務器也是憑此作為用戶購買成功的依據(jù), 那么問題來了, 這里好像并沒有用戶的ID, 是的, 服務器是不知道商品是誰買的, 所以我們要把用戶的ID和交易ID也一起發(fā)給服務器, 讓服務器與驗證返回的數(shù)據(jù)進行匹對, 從而把買家和商品對應起來.
// 設置發(fā)送給服務器的參數(shù)
NSMutableDictionary *param = [NSMutableDictionary dictionary];
param[@"receipt"] = baseString;
param[@"userID"] = self.userID;
param[@"transactionID"] = transactions.transactionIdentifier;
來到這里, 剛才遺留的漏單問題是時候要拿出來解決了, 剛才也說到了, 回調(diào)方法有可能少走, 甚至還有可能在客戶端啟動后完全不走 (這個只是以防萬一) , 我個人建議的做法是, 首先在服務端建立2個表, 一個黑一個白, 黑表是記錄過往真正購買成功的歷史信息, 白表是記錄付款成功而未認領的交易信息. 在客戶端啟動后的10秒內(nèi) (時間可以自己定) 回調(diào)方法如果都沒有走, 我們就主動把密鑰上傳給服務器, 當然最好把用戶的一些信息, 包括賬號ID, 手機型號, 系統(tǒng)版本等信息一并帶上, 服務器拿到密鑰后去蘋果后臺驗證, 把得到的付款成功的交易信息全部寫進白表里 (檢測去重) . 以后如果有新交易產(chǎn)生, 客戶端會把密鑰和交易號等信息傳給服務器, 服務器同樣到蘋果后臺驗證后寫進白表, 接著在表里看看是否有客戶端所給的交易號信息, 如果有再去黑表里檢測是否存在, 黑表不存在則判斷為成功購買并結(jié)算商品, 這時要在白表中刪除對應數(shù)據(jù)和在黑表中添加新數(shù)據(jù), 之后回饋給客戶端, 客戶端把交易 finish 掉這個購買流程就算是結(jié)束了. 這時候白表里記錄著的很有可能就是一些被漏掉的單子, 為什么不是一定而是很有可能? 因為會存在已經(jīng)記錄在黑表中但未被客戶端 finish 掉的單子, 此時再到黑表中濾一遍就知道是否是真正的漏單了, 這時候只能通過人工的方式去解決了, 比如可以主動跟這位用戶溝通詢問情況, 或者是在有用戶反應漏單時, 可以在表中檢測相關信息判斷是否屬實等等. 另外服務器可以定時檢測兩個表中的數(shù)據(jù)進行去重操作, 當然也可以在每次添加進白表前先在黑表中過濾, 不過這樣比較耗性能. 目前想到的解決辦法就是這樣的, 如果有更好的想法希望大家可以給點思路.
好了, 調(diào)整一下心情咱們繼續(xù). 聰明的同學可能察覺到了, 上面說到蘋果有2個驗證的接口, 那后臺應該訪問哪個呢? 是這樣的, 無論應用上線與否, 只要是用沙盒測試賬號進行內(nèi)購的, 就應該訪問調(diào)試的接口, 相反, 如果是用普通賬號進行內(nèi)購的, 則要訪問發(fā)布的接口, 當然了, 未上線的應用是不允許用普通賬號進行內(nèi)購的. 那么問題來了, 我們怎么知道用戶是通過普通帳號還是沙盒測試賬號來進行內(nèi)購的呢? 別急, 蘋果提供了相關的狀態(tài)碼來幫助我們解決這個問題.
21000 App Store 不能讀取你提供的JSON對象
21002 receipt-data 域的數(shù)據(jù)有問題
21003 receipt 無法通過驗證
21004 提供的 shared secret 不匹配你賬號中的 shared secret
21005 receipt 服務器當前不可用
21006 receipt 合法, 但是訂閱已過期. 服務器接收到這個狀態(tài)碼時, receipt 數(shù)據(jù)仍然會解碼并一起發(fā)送
21007 receipt 是 Sandbox receipt, 但卻發(fā)送至生產(chǎn)系統(tǒng)的驗證服務
21008 receipt 是生產(chǎn) receipt, 但卻發(fā)送至 Sandbox 環(huán)境的驗證服務
沒錯, 細心的朋友應該留意到了, 在剛剛那一大串的驗證返回數(shù)據(jù)中有一個名為 status 的 key, 正常時值為0. 所以我們的做法是, 全部統(tǒng)一先訪問發(fā)布接口, 在返回的數(shù)據(jù)中檢測 status 的值, 如果為 21007 , 說明是通過沙盒測試賬號進行內(nèi)購的, 則再訪問調(diào)試接口. 事實上蘋果的官方推薦做法也是這樣的.

3. 誤充問題
關于這個問題還是挺有趣的, 因為存在這樣的一種情況: 用戶A登錄后買了一樣商品, 但與服務器交互失敗了, 導致沒有把交易信息告知服務器, 接著他退出了當前帳號, 這時候用戶B來了, 一登錄服務器, 我們就會用當前用戶ID把上次沒有走完的內(nèi)購邏輯繼續(xù)走下去, 接下來的事情相信大家都能想像到了, 用戶B會發(fā)現(xiàn)他獲得了一件商品, 是的, 用戶A買的東西被充到了用戶B的手上.
要解決這個問題必須要把交易和用戶ID綁定起來, 要怎么做呢? 其實很簡單, 我們只要在查詢商品結(jié)果回調(diào)方法里, 在添加交易隊列之前把用戶ID設進去即可.
// 查詢商品結(jié)果回調(diào)方法
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
// 遍歷每一件商品
for (SKProduct *product in response.products) {
// 生成可變訂單
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:product];
// 設置用戶ID
payment.applicationUsername = self.userID;
// 添加進交易隊列
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
}
然后給服務器發(fā)送的參數(shù)就不再像之前那樣寫了.
// 設置發(fā)送給服務器的參數(shù)
NSMutableDictionary *param = [NSMutableDictionary dictionary];
param[@"receipt"] = baseString;
// 之前
// param[@"userID"] = self.userID;
// 現(xiàn)在
param[@"userID"] = transactions.payment.applicationUsername;
param[@"transactionID"] = transactions.transactionIdentifier;
這樣就不會有誤充的問題了.
最后附上小弟寫的內(nèi)購工具類的github地址
https://github.com/Veeco/WGInPurchaseController