iOS項目技術還債之路《二》IAP掉單優化

前言

上篇中咱們聊了聊iOS後臺下載優化,經過一個成本較低的方案達到了業務預期的效果。這篇文章繼續聊一聊今年初完成的另外一個優化點:IAP掉單優化。html

衆所周知,因爲IAP相關的坑比較多,IAP有不少話題能夠聊。IAP的不少行爲在官方文檔中並無清晰描述,所以除了官方文檔外,也建議一併閱讀下面這些文章,它們各有側重和特點:python

  1. 聊聊應用內購買 做者聊得很全面,介紹了IAP開發中的各類注意點,包括了應用審覈及後續運營的注意點
  2. 貝聊系列 做者從趟坑的角度入手,總結了IAP開發中遇到的各類坑,也開源了他們的實現,很適合根據本身公司需求作一些二次開發。事實上,本文也會對貝聊方案中的一些細節進行探討
  3. iOS內購-防越獄破解刷單 做者側重探討了越獄手機上IAP如何防破解
  4. 揭祕蘋果內購的大漏洞和內購訂閱的黑陷阱 做者列舉了IAP的常見漏洞和相關黑產
  5. 蘋果IAP開發中的那些坑和掉單問題 這篇時間比較久了,但東西並不過期,梳理了IAP開發中常見的一些注意點

文末能夠找到全部參考文章的連接,基本涵蓋了民間IAP開發相關的各路經驗總結。ios

那麼本文還能聊點什麼呢。沒精力也不必寫成一個大而全的教程,要麼就寫寫本身的項目實踐的過程,從IAP掉單問題入手,聊一聊分析和解決過程。算是給本身作過的事一個交代,若是能碰巧幫到一部分人,那即是墜吼的。git

目錄

  • 背景和痛點
  • 掉單問題分析
  • 堵漏洞之旅
  • 小結

一. 背景和痛點

時間回到2018年末,公司的主App在收到屢次IAP整改的警告後,蘋果爸爸終於下了最後通牒,兩週內得提審一個版本,全部虛擬商品的購買必須走IAP,不然全線產品下架。這下全部那些慣用的試圖繞過IAP的手段都灰飛煙滅:支付寶、微信支付、審覈開關等。剛接手項目,從同事那瞭解到兩年前實現過一套IAP的方案,既然時間緊迫,不妨直接拿來試試。因而接入、調整產品流程、提測、準出、提審、上線一條龍,終於達到了IAP合規,平穩度過了危機。github

上線以來狀況大致穩定,只不過期不時會收到一些報障,主要集中在下面幾個方面:後端

  1. 掉單
  2. 壞帳
  3. 退款

順便說一句,我司是作線上服務的,全部IAP商品都是非自動續期訂閱類別,用戶購買後享有必定期限內的服務。IAP商品價格從幾塊到幾千塊不等。服務器

掉單

天天都會接到幾例用戶報障說錢扣了但貨沒到,要求退款。掉單的危害性不言而喻:微信

  1. 下降了用戶信任度,形成用戶流失,並且流失的都是有付費意願的用戶
  2. 多數狀況後臺查不到用戶任何購買記錄,沒法判斷是否惡意退款,只能引導用戶先去試試蘋果的退款流程,增長了技術和客服的工做成本,開發的平常工做常常被打斷去排查線上問題
  3. 蘋果退款流程常常會碰壁,這時只能根據用戶提供的AppStore扣款郵件等憑證來確認並退款。這一趟流程下來,用戶的耐心估計磨得差很少了,不去微博罵你幾句已經算客氣了,對公司品牌傷害很大

壞帳

壞帳的報障主要來自內部反饋。財務在對帳時發現AppStore裏的實際收入和公司訂單系統結算的收入不一致。壞帳的成因比較多,主要有如下幾點:markdown

  1. 公司電商前臺商品標價和IAP價格不一致,好比App端顯示白金會員398元一年,實際蘋果彈窗付款298元。多是在iTunesConnect修改了IAP價格,沒有同步內部系統
  2. 公司不一樣子系統間商品價格不一樣步,跨部門、跨系統的數據同步流程出了問題
  3. 商品重複配送,致使實際收入偏低,擡高了運營成本
  4. 用戶惡意退款,這一點下面會提到

壞帳問題大多能夠經過規範流程來儘可能規避,不一樣公司處理方式可能各不相同,本文就不作重點討論了。網絡

退款

用戶惡意退款這一點在遊戲行業可能發生得會比較多,App端變現不是那麼容易,發生得較少。不過也不乏有貪小便宜的用戶購買了公司服務,去蘋果那申請退款成功的例子,這種狀況下公司是收不到任何消息的,用戶能夠繼續享有服務。這種也會形成必定的壞帳率,由於數值在合理範圍內,咱們也基本上不能作什麼,就暫時不去管它了。

若是硬要處理惡意退款的話,有兩個方向能夠試下(沒有實踐過,本文就不作重點討論了):

  1. 若是IAP類別是訂閱類(包括自動續期非自動續期),iOS7之後的App Receipt API返回的訂單信息中,能夠根據cancellation_date字段來判斷是不是已退款交易
  2. 若是IAP類別是自動續期訂閱類,今年的WWDC中提出的Server to Server Notifications可能會有幫助,蘋果會將用戶訂閱狀態的改變通知到App的服務端,從而識別出已退款交易

這些報障中對用戶傷害最大的就是掉單了,亟待解決,也是本文要討論的重點。

咱們的目標是,零掉單。

二. 掉單問題分析

一開始面對掉單問題基本上是比較懵逼的:

  1. 沒有用戶購買相關行爲日誌可查
  2. 服務端沒有用戶購買記錄

感受像面對了一個黑盒,只知道test case fail了,殊不知具體哪裏的問題。

手頭的線索只有代碼和網上的各類文章。因而打算先把全部能Google到的IAP文章裏關於掉單的部分所有擼一遍,看看業界通常是怎麼處理的,而後再去擼代碼。

業界方案對比

假定讀者對IAP開發都有必定基礎,對基本流程都熟悉,這裏就直接上各類名詞了。

一般來說,業界都會從如下幾個方面去努力防止掉單:

  1. 下單順序優化
  2. 交易持久化
  3. 訂單映射
  4. 用戶映射
  5. 完成交易時機
  6. 重試機制

關於每一個方面,業界又有一些不一樣的處理方案。

下單順序優化

下單和IAP購買流程是整個流程中必不可少的兩個環節。

調整下單環節在整個流程中的位置,看看對解決掉單問題會有什麼樣的影響。

這裏所引伸出的問題就是先走IAP購買流程仍是先下單

方案A:先走IAP購買流程後下單

貝聊採用的是先走IAP購買流程後下單的方案,大體流程以下:

先iap流程後下單時序圖.png

圖中把下單和驗證票據合併到一個接口裏了,貝聊是拆成了兩個接口,前者的話order_id對客戶端是透明的,後者客戶端須要拿到order_id而且發起驗證票據請求。不過這二者差很少,對咱們的分析過程沒影響。

按照做者的說法,採用方案A這種架構能夠更好地完成App訂單和IAP交易的映射,有效解決串單問題。

注:本文把串單也做爲掉單的一種一塊兒討論了。所謂串單,就是經過IAP購買了商品A,卻和商品B的訂單綁一塊兒發往App服務端驗證了,致使最終錯發了商品B,或者驗證失敗。對系統來說是串單,對於用戶來說付了錢但想買的商品沒買到,就是掉單了,並且串單掉單在設計流程時密不可分。

之因此不採用先下單後走IAP購買流程的方案,做者認爲那樣沒法將一開始建立訂單生成的order_id完美地映射到IAP的交易上,會形成掉單。而採用先走IAP購買流程後下單的方案,就能夠完美避開這個問題。

咱們暫時不做分析,繼續看另外一個方案。

方案B:先下單後走IAP購買流程

Leo的這篇更推薦先下單後走IAP購買流程的方案,大體流程以下:

先下單後iap流程時序圖.png

做者認爲這樣更符合常見的支付系統的設計,優勢是:

  1. 服務端動態可控是否能夠發生購買,好比下架某一個商品,直接後端下架便可,無需從iTunesConnect裏下架
  2. 發生丟單的時候,服務端會有用建立訂單的日誌,有助於後期定位問題

簡單對比

咱們先來看一下,若是採用方案B,能不能完美解決訂單映射問題,即將order_id完美映射到IAP的交易上。

利用applicationUsername來透傳order_id是能夠完美映射,但咱們都知道applicationUsername不靠譜,這邊先pass掉。

想象一個稍微極端點的例子,用戶對着同一件IAP商品屢次快速點擊,若是沒有作防重的話,應該會發起多個下單請求,拿到多個order_id,每個都映射到了同一個iap_product_id上,當IAP購買完成收到purchased通知時,確實是沒法肯定究竟該對應哪個order_id

方案B確實沒法完美解決問題。可是方案A必定就是完美的麼?也不見得,咱們來看看。

咱們先來翻一下貝聊方案的源碼,找到裏面關於下單請求的部分:

NSString *md5 = [NSData MD5HexDigest:[receipts dataUsingEncoding:NSUTF8StringEncoding]];
BOOL needStartVerify = self.transactionModel.orderNo.length && self.transactionModel.md5 && [self.transactionModel.md5 isEqualToString:md5];
self.taskState = BLPaymentVerifyTaskStateWaitingForServersResponse;
if (needStartVerify) {
    NSLog(@"開始上傳收據驗證");
    [self sendUploadCertificateRequest];
}
else {
    NSLog(@"開始建立訂單");
    [self sendCreateOrderRequestWithProductIdentifier:self.transactionModel.productIdentifier md5:md5];
}

- (void)sendCreateOrderRequestWithProductIdentifier:(NSString *)productIdentifier md5:(NSString *)md5 {
    // 執行建立訂單請求.
}
複製代碼

能夠看到,貝聊的下單請求實質上只跟iap_product_id有關。當IAP購買完成收到purchased通知後,直接能夠從transaction中拿到iap_product_id,從而開始下單流程。不存在任何須要映射的過程,Perfect。

可是有另外一種狀況,下單請求所須要的參數除了iap_product_id之外,還須要一些別的id一塊兒來定位某個商品,這樣的話就存在一個須要映射的過程了。

你可能會以爲,存在這樣的狀況麼?我舉個例子。

假定有這麼一家提供在線視頻訂閱服務的公司,用戶經過App能夠在必定時間內訂閱觀看某部劇集,每部劇集都是獨立銷售的。這樣iTunesConnect後臺就配置了一堆的IAP商品,好比:

  1. iap_product_生活大爆炸:400元
  2. iap_product_行屍走肉:600元
  3. iap_product_絕命毒師:500元
  4. iap_product_無恥家庭:500元

這樣,每部劇的價格都分開維護,每當有新劇上架,都要在iTunesConnect後臺配置。終於有一天,運營同事受不了了,說這樣太累,咱們能夠設置一些價格檔位,而後相同價格的劇配同一個IAP商品麼?今後iTunesConnect後臺出現了一些新的商品類型:

  1. iap_product_100元劇集:100元
  2. iap_product_500元劇集:500元
  3. iap_product_1元限時促銷劇集:1元

同時在App內的「絕命毒師」、「無恥家庭」等劇集所關聯的IAP商品改爲了iap_product_500元劇集

這種狀況下當用戶點擊購買「絕命毒師」時,當IAP購買完成收到purchased通知後,從transaction中取到的iap_product_id變成了iap_product_500元劇集,此時再去下單的話就必須帶上「絕命毒師」劇集的id了,不然沒法區分用戶購買的是「絕命毒師」仍是「無恥家庭」。

那彷佛又回到了一開始的問題上了:該怎麼把劇集id給映射到IAP交易上。

稍微想一想便知,和方案B的訂單id映射同樣,這裏也不存在一個完美的映射方案。

因而手擼了一張圖,簡單對比下方案A方案B在訂單映射方面的表現:

下單順序在不一樣IAP業務形態下的對比.png

一對一指的iap_product_id和業務id一一對應,好比劇集「絕命毒師」的IAP商品idiap_product_絕命毒師

多對一指的是多個業務id對應了一個iap_product_id,好比劇集「絕命毒師」和「無恥家庭」的IAP商品id都是iap_product_500元劇集

另外,在多對一形態下的方案B中,因爲訂單id自然就攜帶了iap_product_id和業務id的信息,因此發起App端驗證請求時帶上訂單id便可,本質上和一對一形態下的方案B是同樣的

從上圖可見,只有當業務形態爲一對一時,方案A在訂單映射方面纔是優於方案B的。可是誰又能保證之後業務形態不會發生變化呢?

回過頭來看上文中Leo認爲方案B具有的兩個優點:

  1. 服務端動態可控可否購買。無須從iTunesConnect下架商品確實能夠節省一些人力,對於方案A來說,當用戶在購買頁面停留期間該商品下架了,就必須從iTunesConnect同時下架,不然App端還有購買入口,點擊購買又沒從服務端過一道,就會發生掉單了。
  2. 便於定位掉單問題。我的認爲建立訂單日誌對於排查掉單問題用處不大。因爲在方案B中,IAP購買流程是在建立訂單成功以後,而掉單又是在IAP購買成功以後纔會發生(這不廢話,都沒扣款怎麼掉單),因此全部的掉單用戶在服務端都會有建立訂單成功的記錄,從建立訂單日誌上來看跟非掉單用戶是沒什麼區別的。最多就是從日誌中得知用戶建立訂單的時間,推算出用戶在客戶端內的一些行爲,可是經過客戶端自己的打點能夠更精確詳細地還原出用戶的行爲軌跡。真正有用的服務端日誌是發生在IAP購買成功之後的訂單驗證日誌,服務端能夠經過日誌記錄的有無知道客戶端請求是否可達,經過請求詳情知道到底哪出了問題。

二者對比下來,雙方都沒有一面倒的優點,沒必要特地爲了防掉單去重構現有的方案,沿用既有架構便可

事實上,因爲這兩個方案對於本文後續的討論沒有本質的區別,爲了行文的方便,後面將更多地按照訂單能不能完美映射來分狀況討論

訂單完美映射方案 = 業務形態爲一對一 + 先走IAP購買流程後下單

訂單非完美映射方案 = 除訂單完美映射方案外的其餘3種

交易持久化

IAP交易持久化下來,不依賴IAP自身的事務機制,是解決掉單的另外一個關鍵點。

業界對此也有不一樣方案,主要區別在下面兩方面:

1. 持久化到沙盒 vs 持久化到keychain

業界大多數都採用持久化到沙盒,相對簡單,應付大多數狀況夠了。

keychain的方案以貝聊爲表明,爲了應付用戶刪除app致使數據丟失的問題。

實際場景中確實發生過相似報障,用戶端掉單了,用戶找客服說卸載重裝都試過了,仍是沒用。客服也無語,不卸載的話還能夠引導用戶重啓App,從新啓動本地交易票據的驗證流程,幫用戶找回那筆訂單。

爲了不這種狀況,實現零掉單,決定採用持久化到keychain的方案。

2. 持久化的時機

咱們找一段最多見的IAP流程代碼,看看其中哪些位置作持久化比較合適。通常會選擇位置1~4裏的一個或多個。

// 查詢商品信息
- (void)fetchProductInfo:(NSSet<NSString *> *)productIdentifiers {
    //**************位置3**************
    SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
    request.delegate = self;
    [request start];
}

// 查詢商品成功回調
- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
    NSArray *validProducts = response.products;
    SKProduct *currentProduct = validProducts.lastObject;
    if (currentProduct) {        
        //**************位置4**************
        SKPayment *payment = [SKPayment paymentWithProduct:currentProduct];
        [[SKPaymentQueue defaultQueue] addPayment:payment];
    }
}

// 購買操做後的回調
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                //**************位置2**************
                [self transactionPurchasing:transaction];
                break;
                
            case SKPaymentTransactionStatePurchased:
                //**************位置1**************
                [self transactionPurchased:transaction];
                break;
                
            case SKPaymentTransactionStateFailed:
                [self transactionFailed:transaction];
                break;
                
            case SKPaymentTransactionStateRestored:
                [self transactionRestored:transaction];
                break;
                
            case SKPaymentTransactionStateDeferred:
                [self transactionDeferred:transaction];
                break;
        }
    }
}

複製代碼
  • 位置1:IAP購買成功通知。貝聊的方案僅在這裏作了持久化。上文也提到,貝聊的方案是訂單完美映射方案,在這個位置經過transaction對象能夠拿到後續建立訂單所須要的一切信息,沒有額外信息是須要在這以前持久化下來的。事實上,無論是否訂單完美映射方案,在這個位置作持久化都是必須的
  • 位置2:IAP正在購買通知。貝聊在引出訂單完美映射方案以前提到的粗放式驗證就是在這個位置作持久化,是基於先下單後走IAP購買流程的,試圖在這裏將訂單idIAP交易綁定並持久化。我的認爲這裏是有問題的。若是訂單id來自內存的話,那麼極可能由於崩潰等緣由丟失。好比用戶點擊購買後當即殺app,完成付款後從新打開app,此時訂單id就不存在了,形成了掉單。若是提早把訂單id也給持久化了,那位置2就不必作持久化了,在位置1作便可:根據iap_product_id在持久化的訂單列表裏找出匹配項(不完美映射),完成粗放式驗證
  • 位置3:發起查詢商品信息請求。這裏不必作持久化。在fetchProductInfo:函數結束後當即殺app,此時並無調用[[SKPaymentQueue defaultQueue] addPayment:payment];,所以用戶是不會收到付款彈窗的,也就不存在掉單問題
  • 位置4:發起IAP購買流程。我的認爲非訂單完美映射方案都應該在這裏作持久化。將訂單id或者業務id(上文提到的劇集id)跟iap_product_id綁定並持久化,此後就不用擔憂app崩潰或刪除或網絡很差等各類異常狀況了,收到purchased通知後均可以經過iap_product_id找到數據。惟一須要處理的是當用戶取消了購買或者購買失敗時,須要把持久化的數據清除(關於這一點咱們踩到了坑,形成了掉單,後文中會談到)

綜上,對於非完美映射方案,位置1和位置4都作持久化,位置4先佔個位,位置1拿到iap_transaction_id後再填充進去。

訂單映射

下單順序討論中已經討論過,分爲訂單完美映射訂單非完美映射兩種方案,這裏再也不贅述。

用戶映射

因爲IAP的用戶系統和App的用戶系統是割裂開來的,官方並無一套完美方案把用戶id映射到IAP交易上,Leo的這篇中提到他和蘋果工程師確認過,對方給的答覆是這點須要開發者本身解決

Leo給出的方案是applicationName + KeyChain,具體步驟以下:

  1. 嘗試從applicationName中讀取uid,若是uid爲nil,則繼續下一步
  2. 嘗試從內存中根據productId來恢復uid,若是恢復失敗,則繼續下一步
  3. 嘗試從keyChain中恢復uid,檢查transactionDate和keyChain裏記錄的購買開始時間戳在容許範圍內,若是恢復失敗,則繼續下一步
  4. 若是App內有IAP找回功能,這筆訂單放到待找回列表裏;若是App沒有提供找回功能,繼續下一步。
  5. 認爲當前用戶的uid是發生IAP購買的uid,若是當前用戶已退出登陸,那麼下一個登錄的uid認爲是購買的uid

這種多重防範機制可靠性應該不錯,不過也相對複雜,增長了排查問題難度。

像步驟1和2依賴於不算可靠的applicationUsername和內存,我的傾向於能夠省去,直接從步驟3的keychain開始嘗試恢復。

同時步驟5做爲兜底,有可能會錯把A用戶購買的商品配送給B用戶。我的傾向於誰買的就一直爲誰保留,即使當時恢復失敗,且用戶切換帳號登陸後,也不把以前的購買同步給新登陸帳號,當購買帳號再次登陸時繼續嘗試爲其恢復。固然,這只是我的偏好,不是什麼大問題,用戶對這兩種處理應該都有預期,不會以爲奇怪。

貝聊給出的方案相對簡單,做者提到了他們的方案有這麼個問題:

若是是按照這個邏輯來走的話,有一個很顯而易見的邏輯缺陷,從 IAP 支付到咱們去後臺建立訂單這個過程有蘋果支付的和咱們建立訂單的延時。如今情景是用戶 A 發起了支付,而後還未購買就退出了登陸,而後用 B 帳號登陸了,而後 IAP 支付成功,咱們將支付信息存進了以 B 的 userid 爲 key 的帳戶中,這樣就會致使咱們去後臺驗證的時候會把錢充到 B 帳戶中

做者給出的方案是:

因此咱們在用戶退出登陸的時候須要去檢查他是否有未完成交易,若是有就要給個警告。可是仍是沒辦法完全解決掉這個問題,可是考慮到這個結果是用戶的行爲致使的,並且出現這個問題的概率不大,暫時就這樣處理。若是你確實有這方面的擔憂,那就應該採用上面說的粗放式的驗證,粗放式的驗證是不存在這個問題的。

因爲完美映射方案是不記錄任何用戶id信息的,因此沒法處理帳號切換的問題,只能從產品設計上增長一些警示措施。

對於非完美映射方案,因爲原本就要持久化訂單id或者業務id,同時把用戶id綁定在一塊兒,這樣即使切換了用戶,也知道IAP交易對應的持久化數據是否和當前登陸用戶一致,一致則發起驗證,不然忽略。

固然,也有做者認爲切換帳號致使串單的狀況太過極限,不必處理,好比這篇提到:

網上博客還愛用那種切換帳號的場景舉例,A內購成功了,但用戶各類騷操做後,本身換到B帳號,而後服務器那邊把商品發到B帳號上了,等等。 這些狀況都是存在的,由於蘋果的內購機制問題,你是不能百分百保證不丟單的,不要把丟單狀況看的那麼嚴重,邏輯寫的那麼複雜。你看看全部大廠的App上都會寫充值遇到問題,點我聯繫客服 巴拉巴拉。

若是你們開發時間充足,能夠慢慢去彌補極端操做漏洞。

贊成做者說的,這確實不是個大問題,咱們的方案也沒花什麼力氣去專門解決它,只是把思路理清後得出的方案中發現這個問題正好也迎刃而解了。

完成交易時機

這裏指的是finishTransaction:的調用時機。通常有兩種作法:

  1. 當收到purchasedfailed通知時調用
  2. 當收到purchased通知時不調用,等到這筆交易完成了App服務端驗證後再調用

咱們知道,當調用finishTransaction:後,IAP纔會認爲這筆交易真正結束了。不然,每次App啓動時都會收到相應的purchased通知(若是註冊了observer的話),即使App卸載重裝之後也能收到。

按理來說,當咱們加了交易持久化等機制之後,已經能夠徹底脫離開IAP自身的事務機制來完成訂單的驗證任務了,那早早地finishTransaction:應該也沒事,作法1和2的效果在大多數狀況下是一致的。

然而有這麼一種狀況讓我最後選擇了作法2:當用戶IAP購買成功,進行後續驗證流程不太順利時(發生網絡很差或者崩潰等異常),有時會去嘗試點擊從新購買。若是是作法1,從新購買會讓用戶從新扣款,用戶就崩潰了,而作法2不會,當嘗試支付一個沒有完成的交易時,輸入密碼後會出現下面的彈窗,並不會重複扣款:

IAP免費恢復.png

利用這一特性,一旦收到掉單報障,客服還能夠引導用戶經過再次點擊購買去作補救。事實上,在我司方案實施過程當中,也確實發生過這樣的案例,後文中會提到。若是採用作法1,就無法補救了。

另外,作法1使得IAP事務機制提早結束了,整個流程中只剩下了App端本身維護的驗證任務,而作法2保留了IAP事務機制,能夠和App端驗證任務一塊兒提供雙重保證,二者是不衝突的。

重試機制

關於初次下單或驗證失敗後的重試機制,業界也是五花八門。

貝聊的方案以下圖所示:

貝聊重試驗證流程.png

這個流程和支付寶微信支付的重試機制有些相似,能夠看出隨着驗證失敗次數增長,重試間隔會愈來愈大。同時因爲重試間隔的存在,整個重試流程應該是不阻斷用戶操做界面的,從代碼中看不出是否靜默重試,或是給了用戶一些提示信息,好比「正在重試中,請耐心等待」等。若是重試一直不成功,則App會無限重試下去,以最多一分鐘一次的頻率。App每次從後臺進入前臺都會啓動這個流程,重試成功的話會彈alert

另外,這篇也提到了另外一個方案,沒有隊列的概念,側重發貨任務的狀態檢查和去重,以下圖所示:

zhangtielei重試驗證流程.png

不一樣IAP商品發貨任務互不影響,固定的重試間隔,無限次數重試,保證發貨任務惟一性。和貝聊相似的是,該方案應該也是非阻斷式重試,也沒有提到交互流程上是否靜默重試。

對比下來,發現業界重試方案都大同小異。我的更傾向於:

  1. 採用驗證隊列:能夠更好地管理App內全部驗證請求優先級
  2. 不間斷重試:由於對於用戶來說,錢扣了之後內心會比較急,等待重試期間用戶說不定已經來客訴了
  3. 最多重試3次,請求15秒超時:若是這45秒內重試始終不成功,那就大機率不是網絡的問題了,再多的重試也沒用
  4. 阻斷式非靜默重試:在重試過程當中,模態彈窗顯示一些文案來安撫用戶,同時能夠阻止用戶相似點擊重試購買等有可能會讓狀況變得更復雜的操做
  5. 增長兜底方案:當3次自動重試都失敗之後,明確告知用戶訂單不會丟失,引導用戶去訂單找回頁面繼續手動重試,提供多種途徑聯繫到客服,能夠方便地將App本地保存的加密交易信息提供給客服做爲找回訂單的依據。即使App卸載重裝,因爲存了keychain,也能高亮顯示找回訂單的入口。目的只有一個,不讓技術側收到任何掉單報障。

我司方案分析

對比完了業界方案之後,內心有個大體的優化方向了,無非是上面的六大方面都儘可能取最優解。而後把目光轉向我司的實際狀況上來,能夠從業務、現象和代碼三塊來着手分析。

業務

我公司的IAP業務形態正是上文中介紹的多對一形態,採用了先走IAP購買流程再下單的模式。

正常購買的流程以下圖所示:

我司正常購買時序圖.png

購買完成後重試流程以下圖所示:

我司重試驗證流程.png

啓動App後重試流程以下圖所示:

我司重試驗證流程_啓動後.png

結合上文的討論,光從流程的角度已經能夠看出有不少能夠優化的地方,大體的優化方向以下:

  1. 下單順序優化:因爲多對一IAP業務形態不存在訂單完美映射方案,所以維持現有的下單順序,不作重構
  2. 交易持久化:持久化到keychain,點擊購買當即持久化
  3. 訂單映射訂單非完美映射方案
  4. 用戶映射:在訂單映射同時加入用戶id信息,保證切換用戶不串單
  5. 完成交易時機:等完成驗證後再finishTransaction:
  6. 重試機制:驗證隊列 + 不間阻斷式非靜默重試3次 + 兜底方案

現象

因爲以前這塊沒有詳細打點,因此掉單用戶沒有任何相關行爲日誌可查。

同時因爲服務端也沒有任何下單的記錄,於是只能模糊判斷爲網絡緣由致使請求不可達,或者是崩潰致使沒發起請求。

不肯定的時候最好本身去試一試,感覺下用戶一樣的購買流程。

用線上App作實驗,在點擊購買後,會彈出一個模態的loading框,應該是防止用戶屢次點擊或離開頁面,猜想是爲了簡化一些程序邏輯。這個loading持續的時間會比較長,一直得等到IAP購買成功而且訂單在服務端驗證成功後才消失,遇到網絡慢的時候確實會等待比較久,而整個界面又不可點擊,失去耐心的用戶可能就會選擇殺掉App。在嘗試中我也遇到了一次,在IAP支付成功後,等待服務端驗證的時間太長了,因而就殺掉了App。重啓後App內一片祥和,像什麼都沒發生過,固然本該發貨的商品也沒收到。就這麼常規的一個小case,就把掉單測出來了。測試和開發都該打屁屁,算了,當時時間緊,畢竟IAP合規要緊,總比下架強。

好了,接下來就能夠手撕代碼了。

代碼

經過debug發現支付成功後交易加入驗證隊列時,這個隊列居然是個null。這個隊列初始化的地方只有一處:

- (instancetype)initWithCoder:(NSCoder *)coder {
    self = [super init];
    if (self) {
        _iapArray = [coder decodeObjectForKey:IAPModelIapArrayKey];
        
        if (!_iapArray) {
            _iapArray = [NSMutableArray array];
        }
    }
    return self;
}

複製代碼

而當這個類不是從Archive中恢復的時候,根本不會調用-initWithCoder:來初始化對象,而是調用-init,致使_iapArray根本沒被初始化過。

就是這麼個不起眼的地方,致使了IAP的重試機制形同虛設了,這必定是形成掉單的一個很重要緣由了。

不少嚴重的問題到最後都是一些弱智的小失誤引發的。

細節是魔鬼。

三. 堵漏洞之旅

那是否是簡單地把這bug修了就完事了呢,這不符合我一向的風格。我更但願系統化地解決問題。

固然,此次得藉助產品的力量,由技術驅動產品,從技術和產品兩方面來改造了。

產品層面

爲了零掉單的目標,本着讓用戶以爲很穩的原則,全部異常環節都得給足提示和保障,全部等待環節要及時反饋進度。

改造後的IAP購買流程以下圖所示:

產品改造_購買流程.png

改造後的App啓動補單流程以下圖所示(其中重試流程同上圖,再也不重複做圖):

版面 2.png

另外,訂單找回頁面做爲兜底方案,須要考慮怎麼可讓用戶方便地把異常訂單信息上報過來,而且後續怎麼跟進。最理想的固然是經過接口,一鍵上傳,同時提供客服聯繫方式,由於用戶掉單都比較急,急需聯繫客服。客服經過後臺幫用戶確認票據是否有效,若是有效則幫用戶手動補單。

這樣一來,就須要開發接口,以及一套供客服使用的後臺。因爲種種緣由,這方面的資源無法搞定。只能另想辦法。

上報方式決定了後續處理方式,可供選擇的有:

  1. App在線客服?只能用來聯繫上客服,因爲RSA加密過的票據信息過長,沒法發送票據信息
  2. 客服微信?先加客服微信,再微信發送票據,但票據信息很長,要考慮將文本做爲文件發送
  3. 郵件?用戶不必定配置了系統郵箱,但也能夠一試
  4. 其餘?用戶能想到的任何能夠上報的方式,App能夠一鍵拷貝票據到系統剪貼板

初期先簡陋些,能讓票據到咱們這裏就行。要不都做爲備選一併提供了,簡單粗暴 (逃:

找回訂單頁面.jpg

上圖中沒有顯示全的方法一是手動重試下單,做爲3次自動重試的補充。

雖然這個頁面只是權衡下來的一個結果,並不是最佳方案,考慮到上線後能有機會看到這個頁面的用戶不多(但願是沒有),初版能夠接受。

技術層面

因爲以前的技術方案從流程、設計理念等方面相比新方案有較多區別,在原有代碼上修修補補會很彆扭,因而就把IAP模塊徹底重構了。(重構過程省略1000字...)

重構完的代碼須要保證能經過下面的異常狀況測試用例:

  1. 點擊IAP購買,殺App,在桌面完成付款,打開App,可以啓動自動重試流程
  2. 點擊IAP購買,完成付款,斷網或切換到弱網,自動重試3次都超時,可以提示找回訂單入口,切換到正常網絡,在找回訂單頁面可以重試下單成功
  3. 在出現異常訂單後,刪除並重裝App,登陸相同用戶後還能找到這筆訂單
  4. 在出現異常訂單後,點擊購買相同的IAP商品(iap_product_id和業務id都相同),直接發起重試
  5. 在出現異常訂單後,點擊購買相同iap_product_id的另外一個商品(業務id不一樣),提示沒法購買,避免出現您已購買此App內購買項目,此項目將免費恢復的系統提示,由於一旦出現這個提示,系統是不給IAP回調的,App的模態loading就無法隱藏,用戶只能殺App

有些極端的測試用例就不考慮了,好比用戶在某臺手機掉單了,結果手機也丟了,換了臺手機來找回訂單等狀況。難不成還爲了這種case作服務端或者iCloud同步麼? Are you kidding me? 過分優化是萬惡之源,有這時間多寫點業務也好啊。

另外,因爲IAP的流程中有不少異步行爲,這中間用到的內存變量都有可能由於崩潰等緣由丟失,因此重構時把關鍵內存變量都換成了持久化存儲。

同時因爲IAP的有些問題沙盒環境是沒法測出來的,爲了方便定位線上問題,在各環節加入詳細打點,好比:

BI打點1.png

BI打點2.png

最後,爲了更好地監控線上IAP的運行狀況,用python擼了個腳本天天從打點後臺撈日誌並監控異常打點發送日報,下圖是2019-07-08這天收到的監控郵件:

監控郵件.png

監控固然也能用ELK來作,可是感受定製化不如這樣更自由一些,能夠用本身最舒服的姿式看更乾淨的數據。

經過關鍵事件的打點數,能夠看出當天運行是否平穩。

另外加了個小彩蛋,能夠看到IAP優化上線以來天天以及累計挽回的收入。計算方法很簡單:用戶訂單驗證成功時,若是此前發生太重試,那麼把這筆訂單的收入計入挽回的損失中(打點裏的iap_retry_verify_succeed事件會上報單筆訂單挽回收入)。天天看看這個項目又爲公司省了多少多少錢,幹活也頗有動力有木有。

最後爲了方便排查具體用戶的問題,把全部有過異常事件的用戶詳細日誌撈出來,按客戶端時間排好序放入excel表格,做爲郵件附件,同時搭配quicklook-csv一塊兒食用,用空格直接預覽csv內容,效果更佳。下圖是2019-07-08某用戶IAP相關詳細日誌:

某用戶監控日誌.png

過後證實,這些監控對排查線上問題幫助很大。

甚至還藉此挖出一個非IAP相關問題:某天查日誌發現有用戶重試驗證始終不成功,用戶在訂單找回頁面手動重試了若干次也都失敗了,訂單驗證API返回顯示用戶token已失效。正常狀況下App端用戶token失效會讓用戶從新去登陸,用戶是不可能丟了登陸態還繼續在App內使用的。後來發現是服務端最近新接入的登陸組件擅自改寫了返回碼,App端用來判斷登陸失效的返回碼不生效了。這個問題發生有段時間了,因爲沒有用戶報障,就差點被時間掩埋,釀成大問題。

OK,我的認爲已經穩了,上線吧。

零掉單1.0上線

2019-03-14上線。

誰料,上線之後被啪啪打臉。

客服同事找到我,說感受新版本上了之後天天的報障量不降反升了。

跟全部碼農收到bug的第一反應同樣,「不可能,必定是哪裏搞錯了」。

挑了其中一個用戶反饋,準備挖掘一番。下圖是用戶的IAP支付成功憑證:

掉單用戶支付截屏.png

能夠看出是下午2019-03-19 13:06左右支付成功的。

而後去看用戶的IAP相關行爲日誌,以下圖所示:

掉單用戶日誌.png

能夠看到從12:59開始到13:02之間,用戶在猶豫要不要購買,點擊了購買,隨後又取消,猶豫了兩次。

隨後注意13:0513:06那次,從iap_purchase_click --> iap_purchase_transaction_cancel --> iap_purchase_transaction_succeed居然先取消後又支付成功了

13:06以後用戶有點懵逼了,不斷點擊購買再取消,試圖恢復訂單,最後發現不行就過來報障了。

日誌中支付成功的時間和用戶的截屏高度吻合,能夠認爲那次確實支付成功了。可是以前那次cancel事件是怎麼回事。又查了幾個其餘掉單用戶,發現都是類似的行爲日誌:先cancelsucceed了。

App端在收到cancel事件後會把keychain中持久化的交易給清理掉,因此後續收到succeed事件時,就沒法經過iap_product_id匹配到以前的交易了,以致於無法發起後續的訂單驗證流程,這一點和用戶日誌也是高度吻合。因此掉單緣由應該就是這個cancel致使。

至於爲何新版本報障量上升了,是由於老版本不走這套邏輯,只是用臨時變量記錄了點擊購買的商品,在cancel時也不會清理,因此succeed時能夠對應上。

真的是解決了一個bug,又帶來幾個新bug。至於爲何有cancel,聯繫了幾個用戶,發現共性是IAP支付時都曾經跳出須要他們驗證Apple帳號的彈窗。網上搜了下發現也有個別開發者提到過這個問題。應該就是那次驗證彈窗致使IAP先給了cancel回調。Leo這篇也提到了另外一種因爲App Store的policy更新致使這個狀況的可能。

這樣的掉單實際上是能夠修復的,只不過稍微迂迴一些,須要用戶配合。本身的鍋,含着淚也要扛。我聯繫了幾個用戶,引導他們能夠再次點擊購買相同的商品,此時keychain會再次把商品信息持久化,同時因爲用戶已經購買過,而且沒有finishTransaction:,不會重複扣款,會收到您已購買此App內購買項目,此項目將免費恢復的提示,但因爲這個消息是沒有回調的,模態loading會一直在,此時殺掉並重進App,就能再次收到succeed,並從keychain中對應到以前的交易信息,併發起訂單驗證流程了。

有一個用戶配合我走完了整個流程並最終恢復成功,讓我驗證了以前的推斷,有驚無險,畢竟,萬一用戶再次購買又發生了扣款,那用戶的憤怒值就。。。

最簡單的方案就是在收到cancel回調時不清理kaychain中數據。惟一的問題是這些數據有可能沒有辦法被清理,即使App被刪除。但由於數據量很小,先簡單上一版hotfix,後續再想優化方案,無非是找個時機幫用戶清理一把。

零掉單1.1上線

上線後,報障量果真逐漸少下來了,一兩個禮拜後,基本趨於零。

穩定了兩個月,到了五月初,又零星收到幾個掉單報障。經過查日誌,發現是一種新的狀況:先收到了fail後收到了succeed回調。和最先的cancel同樣,fail也會清理keychain中的數據,致使後續succeed時找不到相應交易。

這點不能吐槽更多了,只能說IAPAPI設計有些反人類了。

無奈只能在收到fail回調時也不清理keychain,再上一版。

零掉單1.2上線

這個版本上線至今半年多了,線上沒有再收到過一例掉單報障。此事能夠告一段落了。

其餘

遊客模式

7月份App提審被拒,蘋果要求咱們的App支持遊客模式,即不註冊登陸也須要能夠購買IAP。這一點對現有IAP流程會有必定影響,大體改造思路以下:

  1. 未登陸時,把設備id當作用戶id,用戶發生的一切IAP購買都關聯到設備id
  2. 未登陸時購買的一切商品都不發起服務端訂單驗證,僅作本地記錄
  3. 未登陸時點擊購買的商品都提示須要登錄才能用,相似文案:「尊敬的用戶,根據相關法律法規和監管要求,全部未實名登記或身份信息不全的用戶必須進行補登記,請您登陸帳號後開始使用」
  4. 登陸後,把遊客模式購買的IAP記錄都遷移到當前用戶下,並當即發起服務端訂單驗證,此後流程與以前一致

四. 小結

至此,IAP掉單相關優化介紹完了。這一塊代碼量不會不少,思路理清便可。前期多加些打點,後期排問題會方便不少。

本文不是一篇關於純技術的文章,而是筆者項目實踐中方方面面的一個記錄,旨在還原一些作決策的過程。

做爲系列的第二篇,有了一些壓力,斷斷續續寫了挺長時間,接下來有一些新的挑戰,更新也會比較慢一些。

完。

參考連接

相關文章
相關標籤/搜索