注意:文章中討論的 IAP 是指使用蘋果內購購買消耗性的項目。github
此次爲你們帶來我司 IAP 的實現過程詳解,鑑於支付功能的重要性以及複雜性,文章會很長,並且支付驗證的細節也關係重大,因此這個主題會包含三篇。數據庫
第一篇:[iOS]貝聊 IAP 實戰之滿地是坑,這一篇是支付基礎知識的講解,主要會詳細介紹 IAP,同時也會對比支付寶和微信支付,從而引出 IAP 的坑和注意點。後端
第二篇:[iOS]貝聊 IAP 實戰之見坑填坑,這一篇是高潮性的一篇,主要針對第一篇文章中分析出的 IAP 的問題進行具體解決。安全
第三篇:[iOS]貝聊 IAP 實戰之訂單綁定,這一篇是關鍵性的一篇,主要講述做者探索將本身服務器生成的訂單號綁定到 IAP 上的過程。bash
不用擔憂,我歷來不會只講原理不留源碼,我已經將我司的源碼整理出來,你使用時只須要拽到工程中就能夠了,下面開始咱們的內容 。服務器
源碼在這裏。微信
上一篇的分析了 IAP 存在的問題,有九個點。若是你不知道是哪九個點,建議你先去看一下上一篇文章。如今咱們根據上一篇總結的問題一個一個來對應解決。網絡
關於越獄致使的問題,老是充滿了不肯定性,每一個人都不同,可是都是受到了攻擊致使的。因此,咱們採起的方式簡單粗暴,越獄用戶一概不容許使用 IAP 服務。這裏我也建議你這麼作。個人源碼中有一個工具類用來檢測用戶是否越獄,類名是 BLJailbreakDetectTool
,裏面只有一個方法:app
/** * 檢查當前設備是否已經越獄。 */
+ (BOOL)detectCurrentDeviceIsJailbroken;
複製代碼
若是你不想使用我封裝的方法,也可使用友盟統計裏有一個方法,若是你的項目接入了友盟統計,你 #import <UMMobClick/MobClick.h>
,裏面有個類方法:
/** * 判斷設備是否越獄,依據是否存在apt和Cydia.app */
+ (BOOL)isJailbroken;
複製代碼
上一篇文章說到,蘋果只會在交易成功之後經過 - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions
通知咱們交易結果,並且一個 APP 生命週期只通知一次,因此咱們萬萬不能依賴蘋果的這個方法來驅動收據的查詢。咱們要作的是,首先一旦蘋果通知咱們交易成功,咱們就要將交易數據本身存起來。而後再說而後,這樣一來咱們就能夠擺脫蘋果通知交易結果一個生命週期只通知一次的噩夢。
那這麼敏感的交易收據,咱們存在哪裏呢?存數據庫?存 UserDefault
?用戶一卸載 APP 就毛都沒有了。這樣的東西,只有一個地方存最合適,那就是 keychain
。keychain
的特色就是第一安全;第二,綁定 APP ID,不會丟,永遠不會丟,卸載 APP 之後重裝,仍然能從 keychain
裏恢復以前的數據。
好,咱們如今開始設計咱們的存儲工具。在開始以前,咱們要使用一個第三方框架 UICKeyChainStore,由於 keychain
是 C 接口,很難用,這個框架對其作了面向對象的封裝。咱們如今就基於這個框架進行封裝。
#import <UICKeyChainStore/UICKeyChainStore.h>
#import "BLWalletCompat.h"
NS_ASSUME_NONNULL_BEGIN
@class BLPaymentTransactionModel;
@protocol BLWalletTransactionModelsSaveProtocol<NSObject>
@optional
/** * 存儲交易模型. * * @param models 交易模型. @see `BLPaymentTransactionModel` * @param userid 用戶 id. */
- (void)bl_savePaymentTransactionModels:(NSArray<BLPaymentTransactionModel *> *)models
forUser:(NSString *)userid;
/** * 刪除指定 `transactionIdentifier` 的交易模型. * * @param transactionIdentifier 交易模型惟一標識. * @param userid 用戶 id. * * @return 是否刪除成功. 失敗的緣由多是由於標識無效(已存儲數據中沒有指定的標識的數據). */
- (BOOL)bl_deletePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
forUser:(NSString *)userid;
/** * 刪除全部的 `transactionIdentifier` 交易模型. * * @param userid 用戶 id. */
- (void)bl_deleteAllPaymentTransactionModelsIfNeedForUser:(NSString *)userid;
/** * 獲取全部交易模型, 並排序. * * @return models 交易模型. @see `BLPaymentTransactionModel` * @param userid 用戶 id. */
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsSortedArrayUsingComparator:(NSComparator NS_NOESCAPE _Nullable)cmptr
forUser:(NSString *)userid
error:(NSError * __nullable __autoreleasing * __nullable)error;
/** * 獲取全部交易模型. * * @param userid 用戶 id. * * @return models 交易模型. @see `BLPaymentTransactionModel` */
- (NSArray<BLPaymentTransactionModel *> * _Nullable)bl_fetchAllPaymentTransactionModelsForUser:(NSString *)userid
error:(NSError * __nullable __autoreleasing * __nullable)error;
/** * 改變某筆交易的驗證次數. * * @param transactionIdentifier 交易模型惟一標識. * @param modelVerifyCount 交易驗證次數. * @param userid 用戶 id. */
- (void)bl_updatePaymentTransactionModelStateWithTransactionIdentifier:(NSString *)transactionIdentifier
modelVerifyCount:(NSUInteger)modelVerifyCount
forUser:(NSString *)userid;
/** * 存儲某筆交易的訂單號和訂單價格以及 md5 值. * * @param transactionIdentifier 交易模型惟一標識. * @param orderNo 訂單號. * @param priceTagString 訂單價格. * @param md5 交易收據是否有變更的標識. * @param userid 用戶 id. */
- (void)bl_savePaymentTransactionModelWithTransactionIdentifier:(NSString *)transactionIdentifier
orderNo:(NSString *)orderNo
priceTagString:(NSString *)priceTagString
md5:(NSString *)md5
forUser:(NSString *)userid;
@end
/** * 存儲結構爲: dict - set - model. * * 第一層 data, 是字典的歸檔數據. * 第二層字典, 以 userid 爲 key, set 的歸檔 data. * 第二層集合, 是全部 model 的歸檔數據. */
@interface BLWalletKeyChainStore : UICKeyChainStore<BLWalletTransactionModelsSaveProtocol>
+ (BLWalletKeyChainStore *)keyChainStoreWithService:(NSString *_Nullable)service;
@end
NS_ASSUME_NONNULL_END
複製代碼
咱們要保存的對象是 BLPaymentTransactionModel
,這個對象是一個模型,頭文件以下:
#import <Foundation/Foundation.h>
#import "BLWalletCompat.h"
NS_ASSUME_NONNULL_BEGIN
@interface BLPaymentTransactionModel : NSObject<NSCoding>
#pragma mark - Properties
/** * 事務 id. */
@property(nonatomic, copy, nonnull, readonly) NSString *transactionIdentifier;
/** * 交易時間(添加到交易隊列時的時間). */
@property(nonatomic, strong, readonly) NSDate *transactionDate;
/** * 商品 id. */
@property(nonatomic, copy, readonly) NSString *productIdentifier;
/** * 後臺配置的訂單號. */
@property(nonatomic, copy, nullable) NSString *orderNo;
/** * 價格字符. */
@property(nonatomic, copy, nullable) NSString *priceTagString;
/** * 交易收據是否有變更的標識. */
@property(nonatomic, copy, nullable) NSString *md5;
/* * 任務被驗證的次數. * 初始狀態爲 0,從未和後臺驗證過. * 當次數大於 1 時, 至少和後臺驗證過一次,而且未能驗證當前交易的狀態. */
@property(nonatomic, assign) NSUInteger modelVerifyCount;
#pragma mark - Method
/** * 初始化方法(沒有收據的). * * @warning: 全部數據都必須有值, 不然會報錯, 並返回 nil. * * @param productIdentifier 商品 id. * @param transactionIdentifier 事務 id. * @param transactionDate 交易時間(添加到交易隊列時的時間). */
- (instancetype)initWithProductIdentifier:(NSString *)productIdentifier
transactionIdentifier:(NSString *)transactionIdentifier
transactionDate:(NSDate *)transactionDate;
@end
NS_ASSUME_NONNULL_END
複製代碼
就是一些交易的關鍵信息。咱們在這個對象實現歸檔和解檔的方法之後,就能夠將這個對象歸檔成爲一段 data
,也能夠從一段 data
中解檔出這個對象。同時,咱們須要實現這個對象的 -isEqual:
方法,由於,由於咱們在進行對象判等的時候,要進行一些關鍵信息的比對,來肯定兩個交易是不是同一筆交易。代碼太多了,我就不粘貼了,細節還須要您本身下載代碼進去看。
如今回到 keyChain
上來。每一個 BLPaymentTransactionModel
對象歸檔成一個 NSData
,多個 data
組成一個集合,再將這個集合歸檔,而後保存在一個以 userid
爲 key 的字典中,而後再對字典進行歸檔,而後再保存到 keyChain
中。
請記住這個數據歸檔的層級,要否則,實現文件裏看起來有點懵。
到如今爲止咱們能夠對交易數據進行存儲了,也就是說,一旦 IAP 通知咱們有新的成功的交易,咱們立馬把這筆交易相關的數據轉換成爲一個交易模型,而後把這個模型歸檔存到 keyChain
,這樣咱們就能將驗證數據的邏輯獨立出來了,而不用依賴 IAP 的回調。
如今咱們開始考慮如何根據已有的數據來上傳到咱們本身的服務器,從而驅動咱們的服務器向蘋果服務器的查詢,以下圖所示。
咱們能夠設計一個隊列,隊列裏有當前須要查詢的交易 model
,而後將 model
組裝成爲一個 task
,而後在這個 task
中向咱們的服務器發起請求,根據服務器返回結果再發起下一次請求,就是上圖的驅動方式 5,這樣造成一個閉環,直到這個隊列中全部的模型都被處理完了,那麼隊列就處於休眠狀態。
而第一次驅動隊列執行的有四種狀況。
第一種是初始化的時候,發現 keyChain
中還有沒有處理完須要驗證的交易,那麼此時就開始從 keyChain
動態篩選出數據初始化隊列,初始化完之後,就能夠開始向服務器發起驗證請求了,也就是驅動方式 1。至於爲何說是動態篩選,由於這裏的任務有優先級,咱們等會再說。
第二種驅動任務執行的方式是,當前隊列處於休眠狀態,沒有任務要執行,此時用戶發起購買,就會直接將當前交易放到任務隊列中,開始向服務器發起驗證請求,也就是驅動方式 2。
第三種是用戶從沒有網絡到有網絡的時候,會去對 keyChain
作一次檢查,若是有沒有處理完的交易,同樣會向服務器發起請求,也就是驅動方式 3。
第四種是用戶從後臺進入前臺的時候,會去對 keyChain
作一次檢查,若是有沒有處理完的交易,同樣會向服務器發起請求,也就是驅動方式 4。
有了上面四種類型的觸發驗證的邏輯之後,咱們就能最大程度保證全部的交易都會向服務器發起驗證請求,並且是永不中止的進行,直到全部的交易都驗證完纔會中止。
剛纔說從 keyChain
中取數據有一個動態篩選的操做,這是什麼意思呢?首先,咱們向服務器發起的驗證,不必定成功,若是失敗了,咱們就要給這個交易模型打上一個標記,下次驗證的時候,應該優先驗證那些沒有被打上標記的交易模型。若是不打標記,可能會出現一直在驗證同一個交易模型,阻塞了其餘交易模型的驗證。
// 動態規劃當前應該驗證哪一筆訂單.
- (NSArray<BLPaymentTransactionModel *> *)dynamicPlanNeedVerifyModelsWithAllModels:(NSArray<BLPaymentTransactionModel *> *) allTransationModels {
// 防止出現: 第一個失敗的訂單一直在驗證, 排隊的訂單得不到驗證.
NSMutableArray<BLPaymentTransactionModel *> *transactionModelsNeverVerify = [NSMutableArray array];
NSMutableArray<BLPaymentTransactionModel *> *transactionModelsRetry = [NSMutableArray array];
for (BLPaymentTransactionModel *model in allTransationModels) {
if (model.modelVerifyCount == 0) {
[transactionModelsNeverVerify addObject:model];
}
else {
[transactionModelsRetry addObject:model];
}
}
// 從未驗證過的訂單, 優先驗證.
if (transactionModelsNeverVerify.count) {
return transactionModelsNeverVerify.copy;
}
// 驗證次數少的排前面.
[transactionModelsRetry sortUsingComparator:^NSComparisonResult(BLPaymentTransactionModel * obj1, BLPaymentTransactionModel * obj2) {
return obj1.modelVerifyCount < obj2.modelVerifyCount;
}];
return transactionModelsRetry.copy;
}
複製代碼
上面驗證隊列裏我還有壓入情景沒有解釋,壓入情景有三種狀況。
第一種是出現意外,就是初始化的時候,若是出現用戶恰好交易完,可是 IAP 沒有通知咱們交易完成的狀況,那麼此時再去 IAP 的交易隊列裏檢查一遍,若是有沒有被持久化到 keyChain
的,就直接壓入 keyChain
中進行持久化,一旦進入 keyChain
中,那麼這筆交易就能被正確處理,這種狀況在測試環境下常常出現。
第二種是正常交易,IAP 通知交易完成,此時將交易數據壓入 keyChain
中。
第三種和第一種相似,用戶從後臺進入前臺的時候,也會去檢查一遍沙盒中有沒有沒有持久化的交易,一旦有,就把這些交易壓入 keyChain
中。
上面三個壓入情景,能最大程度上保證咱們的持久化數據能和用戶真實的交易同步,從而預防蘋果出現交易成功卻沒有通知咱們而致使的 bug。
到如今爲止,咱們的結構已經有了大致了,如今咱們來總結一下咱們如今的項目結構。
BLPaymentManager
是交易管理者,負責和 IAP 通信,包括商品查詢和購買功能,也是交易狀態的監聽者,對接沙盒中收據數據的獲取和更新,是咱們整個支付的入口。它是一個單例,咱們的驗證隊列是掛在它身上的。每當有新的交易進來的時候(無論是什麼情景進來的),它都會把這筆交易丟給 BLPaymentVerifyManager
,讓 BLPaymentVerifyManager
負責去驗證這筆交易是否有效。最後,BLPaymentVerifyManager
也會和 BLPaymentManager
通信,告訴 BLPaymentManager
某筆交易的狀態,讓 BLPaymentManager
處理掉指定的交易。
BLPaymentVerifyManager
是驗證交易隊列管理者,它內部有一個須要驗證的交易 task 隊列,它負責管理這些隊列的狀態,而且驅動這些任務的執行,保證每筆交易驗證的前後循序。它的內部有一個 keyChain
,它的隊列中的任務都是從 keyChain
中初始化過來的。同時它也管理着keyChain
中的數據,對keyChain
進行增刪改查等操做,維護keyChain
的狀態。同時也和 BLPaymentManager
通信,更新交易的狀態(finish 某筆交易)。
keyChain
不用說了,負責交易數據的持久化,提供增刪改查等接口給它的管理者使用。
BLPaymentVerifyTask
負責和服務器通信,而且將通信結果回調出來給 BLPaymentVerifyManager
,驅動下一個驗證操做。
有同行反饋說,IAP
有 bug
,這個 bug
就是明明通知交易已經成功了,可是去沙盒中取收據時,發現收據爲空,這個問題也是要具體應對的。
如今作了如下的處理,每次和後臺通信的結果歸爲三類,第一類,收據有效,驗證經過;第二類,收據無效,驗證失敗;第三類,發生錯誤,須要從新驗證。每一個 task 回來都是隻有多是這三種狀況的一種,而後 task 的回調會給隊列管理者,隊列管理者會把回調傳出去給交易管理者,此時交易管理者在下面的代理方法中更新最新的收據,並把新收據從新傳給隊列管理者,隊列管理者下次發起請求就是使用最新的收據進行驗證操做。
@protocol BLPaymentVerifyTaskDelegate<NSObject>
@required
/**
* 驗證收到結果通知, 驗證收據有效.
*/
- (void)paymentVerifyTaskDidReceiveResponseReceiptValid:(BLPaymentVerifyTask *)task;
/**
* 驗證收到結果通知, 驗證收據無效.
*/
- (void)paymentVerifyTaskDidReceiveResponseReceiptInvalid:(BLPaymentVerifyTask *)task;
/**
* 驗證請求出現錯誤, 須要從新請求.
*/
- (void)paymentVerifyTaskUploadCertificateRequestFailed:(BLPaymentVerifyTask *)task;
@end
複製代碼
從 iOS 7 開始,蘋果的收據不是每筆交易一個收據,而是將全部的交易收據組成一個集合放在沙盒中,而後咱們在沙盒中取到的收據是當前全部收據的集合,並且咱們也不知道當前收據裏都有哪些訂單,咱們的後臺也不知道,只有 IAP 服務器知道。因此,咱們不用管收據裏的數據,只要拿出來懟給後臺,後臺再懟給蘋果就能夠了。
對於咱們提交給後臺的收據,後臺可能會作過時的標記。可是後臺要判斷當前的這個收據是否以前已經上傳過了,這時咱們能夠作一個 MD5,咱們把 MD5 的結果一塊兒上傳給服務器。
項目裏作了不少報警的處理,比方說咱們把收據存到 keyChain
中,存儲完成之後,要作一次檢查,檢查這個數據確實是存進去了,若是沒有,那此時應該報警,並將報警信息上傳到咱們的服務器,以防出現意外。又比方說,IAP 通知咱們交易完成,咱們就會去取收據,若是此時收據爲空,那絕對出問題了,此時應該報警,並將報警信息上傳(項目裏已經對這種狀況進行了容錯)。還有好比某筆交易驗證了幾十次,仍是未能驗證,那此時應該設定一個驗證次數的報警閾值,比方說十次,若是超過十次就報警。
在持久化到 keyChain
時,數據是綁定用戶 userid
的,這一點也是相當重要,要否則會出現 A 用戶的交易在 B 用戶那裏驗證。
對於已經失敗過的驗證請求,每兩次請求之間的時間步長也是應該考慮的。這裏採用的比較簡單的方式,只要是已經和後臺驗證過而且失敗過的交易, 兩次請求之間的時間間隔是 失敗的次數 * BLPaymentVerifyUploadReceiptDataIntervalDelta
。同時也對步長的最大值作了限制,防止步長愈來愈大,用戶體驗差。
還有一些細節,下面兩個方法必定要在按照要求調用,不然後果很嚴重。下面的第二個方法,若是用戶已經等錄,從新啓動的時候也要調用一次。
/**
* 註銷當前支付管理者.
*
* @warning ⚠️ 在用戶退出登陸時調用.
*/
- (void)logoutPaymentManager;
/**
* 開始支付事務監聽, 而且開始支付憑證驗證隊列.
*
* @warning ⚠️ 請在用戶登陸時和用戶從新啓動 APP 時調用.
*
* @param userid 用戶 ID.
*/
- (void)startTransactionObservingAndPaymentTransactionVerifingWithUserID:(NSString *)userid;
複製代碼
/**
* 是否全部的待驗證任務都完成了.
*
* @warning error ⚠️ 退出前的警告信息(好比用戶有還沒有獲得驗證的訂單).
*/
- (BOOL)didNeedVerifyQueueClearedForCurrentUser;
複製代碼
還有對於支付是串行仍是並行的選擇。串行的意思是若是用戶當前有未完成的交易,那麼就不容許進行購買。並行的意思是,當前用戶有未完成的交易,仍然能夠進行購買。我提供的源碼是支持並行的,由於當時設計的時候就考慮到這個問題了。事實上,蘋果對同一個交易標識的產品的購買是串行的,就是你當前有未付款成功的商品 A,當你再次購買這個商品 A 的時候,是不能購買成功的。咱們最後兼顧後臺的邏輯,爲了讓後臺同事更加方便,咱們採起了串行的方式。採用串行就會帶來一個邏輯漏洞就是,假如某個用戶他購買之後出現異常,致使沒法使用正常的方式充錢而且 finish
某筆交易,最後經過和咱們客服聯繫的方式手動充錢,那麼他的鑰匙鏈就一直有一筆未完成的交易,因爲咱們的購買時串行的,這樣會致使這個用戶再也無法購買產品。這種狀況也是須要警戒的,此時只須要和後端同時約定一下,再次驗證這筆訂單的時候返回一個錯誤碼,把這筆訂單特別的 finish
掉就行了。
還有一個 IAP 的 bug
,就是 IAP 通知交易完成,而後咱們把交易數據存起來去後臺驗證,驗證成功之後,回到 APP 使用 transactionIndetify
從 IAP 未完成交易列表中取出對應的交易,將這比交易 finish
掉,當 IAP 出現 bug
的時候,這個交易找不到,整個未完成交易列表都爲空。並且復現也很簡單,只要在弱網下交易成功當即殺掉 APP 就能夠復現。因此咱們必須應對這個問題。應對的策略就是給咱們存儲的數據加一個狀態,一旦出現驗證成功回來 finish
的時候找不到對應的交易,就先給存儲數據加一個 flag
,標識這筆訂單已經驗證過了,只是尚未找到對應的 IAP 交易進行 finish
,因此之後每次從未驗證交易裏取數據的時候,都須要將有這個 flag
的交易對比一下,若是出現已經驗證過的交易,就直接將那一筆交易 finish
掉。
到如今爲止,第一篇上說起的八個問題,有七個在這一篇文章中都有對應的解決方案。因爲篇幅緣由,我就不大段大段的貼代碼了,具體實踐,確定要看源碼的,而且我寫了鉅細無比的註釋,保證每一個人都能看懂。
可是真的就沒有問題了嗎?不是的,如今已知的問題還有兩個。
APP ID
, 致使 keychain
被更改。第一個問題,看起來要雞蛋放在兩個籃子裏,比方說,數據要同時持久化到 keyChain
和沙盒中。可是此次沒有作,接下來看狀況,若是確實有這種問題,可能會這麼作。
第二個問題,是蘋果 IAP 設計上的一個大的缺陷,看似無解,出現這種狀況,也就是用戶想方設法要阻止交易成功,那隻能他把蘋果的訂單郵件發給咱們,咱們手動給他加錢。
其餘還有問題的話,請各位在評論區補充,一塊兒討論,謝謝你的閱讀!!