內購支付踩過的坑以及自己的解決途徑

文藝範兒的小貓咪發表於2018-04-03

更新:經過這幾天的使用者反饋及自己的查詢,發現了一些問題。首先,在新增觀察者之前是獲取不到未完成訂單的,只有在觀察者的updateTransaction方法中才能獲取到,所以,我和服務端同事聯調做了如下調整:

上個版本做的內購支付,在內購封裝方法中有過初步介紹和整理,結果在版本上線後收到使用者的反饋說是支付成功,但是充值賬戶卻不能到賬,結果引發了退款等惡性問題,下面就我在實際專案中遇到的問題以及解決方案給出詳細的介紹(上述給出的連結是swift版本的,由於筆者專案依舊是OC語言,所以下面依舊以OC語言來介紹)

1.封裝的內購工具一定要設定為單例模式,且在程式啟動的時候初始化並在初始化中設定觀察者模式

筆者上個版本中雖說封裝了內購支付工具,但是由於經驗缺乏,內購工具只在支付頁面中有效,結果有一個巨大的坑,使用者可能在支付完成之前就退出了支付頁面,導致了支付成功但是卻沒有充值成功的情形,在檢查程式碼之後,我將內購支付工具做成了單例,而且,這個單例的初始化放在了程式入口處,這一點要說明的是,為什麼放到入口處呢?是因為放到這裡,如果之前有未移除的訂單,可以在這裡做一些邏輯處理,因為專案及實際情況,筆者是這樣處理的:

這個方法不能奏效,移除不用,此思路就是錯的

- (void)removeOldTransaction {

/*
    NSArray *tansactions = [SKPaymentQueue defaultQueue].transactions;
    //如果沒有移除過訂單資訊
    BOOL result = NO;
    
    if ( ![kUserDefaults boolForKey:@"hasFinishOldTransaction"] && tansactions.count > 0) {
        for (SKPaymentTransaction *transaction in tansactions) {
            [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
        }
        result = YES;
    }
    [kUserDefaults setBool:YES forKey:@"hasFinishOldTransaction"];
    if (result) {
        return;
    }
*/
}

複製程式碼
+ (instancetype)sharedInstance {

    static YGIAPTool *tool;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        tool = [[YGIAPTool alloc] init];
    });
    return  tool;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
       // [self removeOldTransaction];移除不用
        [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
    }
    return self;
}

複製程式碼

為什麼要移除掉舊的訂單呢?因為我之前的錯誤邏輯,導致一些訂單就算支付成功而且成功充值,也沒有移除訂單,這個時候如果設定了觀察者,蘋果提供的系統API中會自動去查詢有沒有未移除的訂單,這樣就會繼續執行充值邏輯,可能會造成重複充值的情形,為了避免這種情況帶來的損失,筆者就只能硬性要求在版本升級後啟動時移除舊的訂單,這樣就不會有這種隱憂了。

更新:此處描述有誤,硬性移除訂單是不可取的,會給使用者造成一定的損失,這裡只需要指定updateTranscation方法,按照正確邏輯走就可以了

didFinishLaunching中呼叫初始化方法 [YGIAPTool sharedInstance];

更新,關於何時移除訂單的問題,之前想著本地存取憑證可以管理訂單,後來偶然間發現,儘管是同一個訂單,如果有未完成的,每次啟動app,執行到updateTransaction方法後,走到Purchased狀態後,取出的憑證都是不一樣的,而交易的transactionIdentifier是一樣的,所以在訂單移除的問題上做了一些調整,首先,本地不用管理憑證,因為管理也沒有用。因為業務需求,我們不再儲存憑證,而是儲存交易id,每次判斷本地是否有交易id,如果某一條交易已經有交易id了,就記錄到服務端,方便以後對賬。這個時候結束交易我們選擇放到了充值成功,也就是success之中,同時移除掉本地儲存的交易id。

2.關於何時移除訂單的問題

我之前搜尋過相關的問題,網上給出的答案大都是在充值業務成功之後再移除訂單,這個也有一定的問題,主要的就是網路問題或者是使用者在充值完成之前就退出或者意外中斷的時候引發的問題,這些情況下都會造成訂單不能及時移除,給支付體驗和充值風險上帶來一定的問題。那麼,怎麼解決這種情況呢?當然,我所提供的方案也只是相對自己遇到的問題上有所改善,至於全面而深入的方案,有知道的大神麻煩指點一下,不勝感激。

我們都知道,如果在客戶端去處理驗證憑證的邏輯,很容易被有心人入侵做手腳,這個時候常用的保險做法就是客戶端將本次交易產生的憑證發給服務端,讓服務端去和蘋果伺服器驗證,在一定程度上能夠保證了安全性,那麼這樣也有一個隱憂,萬一我傳給服務端了,但是服務端驗證失敗了呢?或者萬一由於網路問題傳送失敗呢?這個時候再加一層保險,就是客戶端在傳遞給服務端之前先將本憑證儲存下來(關於儲存方法,筆者在後面會介紹,這裡也有),然後伺服器驗證成功,返回到我們的success回撥中去移除本地憑證,而相對應的服務端也已經儲存了我們的憑證,當然考慮到伺服器驗證失敗的問題,這個邏輯就要在服務端處理,筆者這裡簡單說下:就是伺服器接到客戶端傳的憑證後,也是先存下來,直到驗證成功並充值完成後才移除,否則就定時去傳送驗證,知道成功為止。 服務端不多做介紹,主要還是客戶端邏輯,在移除本地憑證後,如果服務端正常處理,那麼充值就應該到位了。

3.關於儲存憑證的坑

筆者一開始儲存用的是NSUserDefault方法,在每次支付成功後都會儲存憑證到本地,然後在伺服器驗證成功後,將本地儲存的憑證清空。這樣看似乎沒有毛病,但是如果使用者頻繁操作,會導致建立兩次或者更多次訂單,那麼問題來了,NSUserDefault只能覆蓋(因為儲存的憑證對應的key是同一個),這樣會造成只能保留最後一個儲存的憑證,會產生一些意想不到的支付問題,所以在得知這個之後,筆者改成了用資料庫儲存到本地,這樣我就可以在驗證成功後根據當前憑證去刪除資料庫中的資料,而且還有一個好處是,如果憑證傳送失敗,在合適的地點我可以遍歷資料庫中的憑證,然後進行憑證驗證,這樣使用者支付過的訂單就很難出現充值不對等的問題(到賬延遲問題是必然的,這個不知道有什麼好方法沒)

4.關於觀察者方法updatedTransactions對應狀態的處理問題。

SKPaymentTransactionStatePurchased:充值成功

SKPaymentTransactionStateFailed:充值失敗

SKPaymentTransactionStateRestored:恢復內購

SKPaymentTransactionStatePurchasing:正在採購

對於這四種狀態對應的處理情況,我這裡簡單介紹一下: 正在採購:只要新增訂單,第一步就會走到這裡,這裡可以不作處理,要注意的是千萬不能在這裡移除訂單,否則會崩潰,提示不能再採購狀態移除訂單。

至於恢復內購,筆者倒沒有遇到,不過這裡主要進行以下操作

- (void)removeTransaction {

    [[SKPaymentQueue defaultQueue] finishTransaction:self.currentTransaction];
}

複製程式碼

只需要移除訂單就好了

充值失敗:毋庸置疑,這時候訂單交易失敗,就是廢訂單了,所以同樣要移除

充值成功:能進入到這裡,說明使用者支付成功,錢已經扣掉了,那麼它之後的相關處理就比較重要了,為了說明清晰,筆者用程式碼來展示:

更新

- (void)requestValidReceipt:(SKPaymentTransaction *)transaction {
    
    self.currentTransaction = transaction;

    //交易驗證
    NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receiptData = [NSData dataWithContentsOfURL:recepitURL];
    
    if(!receiptData){
        [kWindow showLoadingView:@"獲取支付憑證為空"];
        return;
    }
    //轉化為base64字串
    NSString *receiptString= [receiptData base64EncodedStringWithOptions:0];;
    NSString *source = @"";
    if ([YGDataBase isReceiptExists:self.currentTransaction.transactionIdentifier]) {
        self.buyId = [YGDataBase getBuyIdWithReceipt:self.currentTransaction.transactionIdentifier];
        source = @"self.buyId = [YGDataBase getBuyIdWithReceipt:receiptString];";
    }else {
        source = @"購買介面";
        [self buySuccess];
        //1.先將交易id存起來
        [YGDataBase saveReceiptAndGoodsID:self.currentTransaction.transactionIdentifier goodId:self.buyId];
    }
    [self startValidReceipt:receiptString source:source];

    //2.傳給服務端憑證資料
    [kWindow showLoadingView];
    [[YGNetWorkTool sharedInstance] ApplePayReceiptVerifyBuyId:self.buyId buyType:1 receipt:receiptString success:^(id responseObj) {
        [kWindow hideLoadingView];
        if ([responseObj[@"code"] intValue] != 200 ) {
            [kWindow showLoadingView:responseObj[@"msg"]];
        }else {//充值成功之後將憑證移除
             [self removeTransaction];
            [YGDataBase removeReceipt:self.currentTransaction.transactionIdentifier];
        }
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        [self showAlert];
        self.buyId = nil;
       
        
    } failure:^(NSError *error) {
        [kWindow hideLoadingView];
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        self.buyId = nil;
    }];

}

複製程式碼
- (void)requestValidReceipt:(SKPaymentTransaction *)transaction {
    
    self.currentTransaction = transaction;

    //獲取交易的憑證
    NSURL *recepitURL = [[NSBundle mainBundle] appStoreReceiptURL];
    NSData *receiptData = [NSData dataWithContentsOfURL:recepitURL];
    
    if(!receiptData){
        [kWindow showLoadingView:@"獲取支付憑證為空"];
        return;
    }
    //轉化為base64字串
    NSString *receiptString= [receiptData base64EncodedStringWithOptions:0];
    //判斷本地是否已經有過這個憑證,如果有,為了避免重複交易,什麼也不做(這個可能沒什麼用,不過為了財政安全和保險,加上也不錯)
    if ([YGDataBase isReceiptExists:receiptString]) {
        return;
    }

    [self buySuccess];//這個不用管,是專案中的統計作用

    //1.先將憑證存起來
    [YGDataBase saveReceiptAndGoodsID:receiptString goodId:self.ID];
//移除當前支付的交易
    [self removeTransaction];
//統計日誌
    [self startValidReceipt:receiptString];
    
    //2.傳給服務端憑證資料
    [kWindow showLoadingView];
    [[YGNetWorkTool sharedInstance] ApplePayReceiptVerifyBuyId:self.ID buyType:1 receipt:receiptString success:^(id responseObj) {
        [kWindow hideLoadingView];
        if ([responseObj[@"code"] intValue] != 200 ) {
            [kWindow showLoadingView:responseObj[@"msg"]];
        }else {//充值成功之後將憑證移除 這一點要注意,一定是服務端返回200的時候才能將本地憑證移除,否則會造成支付後沒到賬的丟單問題
            
            [YGDataBase removeReceipt:receiptString];
        }
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        [self showAlert];
        self.ID = nil;
        
    } failure:^(NSError *error) {
        [kWindow hideLoadingView];
        if (self.transactionSuccess) {
            self.transactionSuccess(self.currentTransaction);
        }
        self.ID = nil;
    }];

}

複製程式碼

按照這個邏輯走下來,一般的內購支付問題應該能夠解決了,筆者也是花了兩天的時間,反覆驗證測試,將各種可能出現的奇葩操作都測試了一遍,結果充值都能夠正常進行,希望能夠給有需要的童鞋一些幫助,有需要原始碼的同學,可以到我的github上檢視相關的邏輯(裡面附帶的一些牽扯到公司業務,筆者有做了詳細的註釋),喜歡的可以給個贊或者✨星哦

寫在最後:由於蘋果官方給出的驗證方法非常簡單,網上相關的內購資料也大都基於官方文件,許多實際問題根本找不到方法,希望大家能多多分享些這方面的實際問題,為以後內購的開發提供便利。

相關文章