iOS內購自動續訂


IAP 自動續費后端接入指南

https://blog.csdn.net/theCrucian/article/details/89406203

iOS內購(IAP)自動續訂訂閱類型服務端總結

https://blog.csdn.net/qq_23564667/article/details/105512349

 

前言
使用場景
接入流程
1. 后台配置
2. 方案選擇
三種方案的對比
最終方案
3.關鍵點
3.1 續費表扣費狀態的設計
3.2 如何判斷用戶續費成功?
3.3 如何判斷用戶關閉訂閱?
3.4 如何判斷蘋果扣費失敗?
3.5 如何判斷用戶在訂閱周期內切換商品?
3.6 如何判斷用戶已退款?
3.7 server輪詢時查哪些數據?
3.8關於冪等性校驗和restore問題
3.9 用戶切換相同周期產品退款問題
3.10 如何判斷首單優惠?
3.11 如何判斷免費試用?
最后
前言

iap自動續費在線上運行了比較久的時間了, 相對穩定, 最開始開發的時候, 沒有找到一個比較完備的server端開發指南, 所以在此做一個記錄, 希望可以幫助到更多的人快速搭建自己的server端程序

使用場景

我們的場景是一個連包會員業務, ios端使用iap的自動續期訂閱類型

接入流程

1. 后台配置

后台配置比較簡單, 不在此贅述, 不會的可以參考https://www.jianshu.com/p/9e64449807ff 這片帖子

2. 方案選擇

查看apple文檔后, 總結出自動續費一共有三種

客戶端主動上報, apple每期自動扣款后, 會生成一筆新的receipt, 客戶端獲取后發送給server校驗, 成功后開通下一期會員權益

狀態變更通知 用於自動續訂訂閱的服務器到服務器通知服務, 可以在蘋果后台配置通知地址, 狀態變更時, server會收到通知, 下面是摘自蘋果官方的狀態描述

NOTIFICATION_TYPE 描述
INITIAL_BUY 初次購買訂閱。latest_receipt通過在App Store中驗證,可以隨時將您的服務器存儲在服務器上以驗證用戶的訂閱狀態。
CANCEL Apple客戶支持取消了訂閱。檢查Cancellation Date以了解訂閱取消的日期和時間。
RENEWAL 已過期訂閱的自動續訂成功。檢查Subscription Expiration Date以確定下一個續訂日期和時間。
INTERACTIVE_RENEWAL 客戶通過使用應用程序界面或在App Store中的App Store中以交互方式續訂訂閱。服務立即可用。
DID_CHANGE_RENEWAL_PREF 客戶更改了在下次續訂時生效的計划。當前的有效計划不受影響。
詳情可以查看https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Subscriptions.html#//apple_ref/doc/uid/TP40008267-CH7-SW13

server輪詢 自動續訂類型的收據, 每一期的latest_receipt_info中都會記錄所有的交易(包含歷史和新增), 可以輪詢上一期(任意一期都可以)receipt, 通過latest_receipt_info 解析出用戶最新的訂閱狀態.

具體的收據內容可以查看https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ReceiptFields.html#//apple_ref/doc/uid/TP40010573-CH106-SW2
三種方案的對比

方案 優勢 缺點
客戶端主動上報 首次購買收據只能通過這種方式獲取 1.續費收據需要用戶打開app才會上傳, 時效性不夠好 2.無法獲取關閉訂閱的行為
狀態變更通知 可以獲取到用戶取消訂閱的消息(退款) 1.不夠可靠, 可能會丟失通知(看大家評論得出, 並未親自嘗試) 2.無法獲取關閉訂閱的行為
server輪詢 只要發起輪詢, 就可以隨時獲取用戶的訂閱狀態(續費, 退款, 關閉) 1.無法獲取首次購買收據 2.成本較高, 需要對歷史收據進行輪詢
最終方案

分析了上述3種方案后, 我們決定將3種方案結合, 來實現我們的iap自動續費處理流程

使用 server輪詢, 每次使用用戶上一期的receipt調用apple的校驗接口, 調用時機有兩處:
a.當期訂閱結束時間前一天, 獲取用戶是否續費成功(只獲取續費成功的狀態, 退款和關閉對時效性要求較高)
b.用戶訪問會員首頁時進行查詢, 及時獲取用戶訂閱的關閉狀態(用戶在ituns中關閉訂閱后, 我們app需要同步更新訂閱狀態, 所以在用戶進入會員首頁時進行查詢, 如果延遲過久, 用戶會抓狂的)
接收 客戶端主動上報, 獲取用戶切換訂閱商品的行為
接收 狀態變更通知 , 獲取用戶的退款行為, 屬於逆向邏輯, 與正向的輪詢任務放在一起, 耦合過高, 所以放在回調通知中處理.
3.關鍵點

3.1 續費表扣費狀態的設計

等待扣費, 上一期扣費成功且這一期還未明確扣費狀態(成功, 關閉, 失敗), 輪詢時會查到該數據
扣費成功 , 扣費成功,輪詢時不會查到該數據
扣費失敗 , 對於扣費失敗的用戶, 蘋果仍會嘗試扣款60天, 此時應該標記為扣費失敗, 輪詢時會查到該數據
已關閉, 訂閱已經關閉, 不會再次扣費, 輪詢時不會查到該數據

3.2 如何判斷用戶續費成功?

解析出 latest_receipt_info 中最新的一筆交易, 使用 expires_date_ms (過期時間)與當前時間作比較, 如果 expires_date_ms >當前時間, 則續費成功

3.3 如何判斷用戶關閉訂閱?

解析 pending_renewal_info , 該字段是續訂狀態的說明. auto_renew_status 為0, 說明已經關閉訂閱.

3.4 如何判斷蘋果扣費失敗?

對於扣費失敗的用戶, 蘋果仍會嘗試扣款60天, 解析 pending_renewal_info , auto_renew_status 為1並且 is_in_billing_retry_period 為1, 此時用戶的狀態並不能標記為已關閉, 而應該是扣費失敗

3.5 如何判斷用戶在訂閱周期內切換商品?

解析 pending_renewal_info, 取 product_id 字段, 此字段為最新一期續訂的商品, 一定不要取 auto_renew_product_id 字段, 這是個大坑

3.6 如何判斷用戶已退款?

有兩種方式:
a. 解析latest_receipt_info中的交易, 退款后會出現cancellation_date和cancellation_reason字段, 未退款則沒有這兩個字段
b. 接收 狀態變更通知, 當 notification_type 為 CANCEL 時表明已退款, 此時再解析出 latest_expired_receipt_info 中的 transaction_id 即可與內部訂單關聯進行退款

3.7 server輪詢時查哪些數據?

查詢兩類數據:
a. 狀態為 等待扣費 並且 當前周期結束時間在當前時間之后一天的記錄, 蘋果會在訂閱到期之前的24小時內發起扣款, 所以只查詢這段時間內的數據就可以, 減少無用的輪詢
b. 所有狀態為 扣費失敗 的記錄

3.8關於冪等性校驗和restore問題

由於receipt可以多次查詢, 返回相同結果, 在我們每次處理前, 需要判斷交易是否已經處理過, 已處理過則不再處理, restore時, 同一筆交易會重新生成transactionId, 所以我們校驗的唯一key應該是, original_transaction_id(用戶唯一標識)+purchase_date_ms(訂閱周期開始時間)+expires_date_ms(訂閱周期結束時間)

3.9 用戶切換相同周期產品退款問題

當用戶切換了同一group下、周期相同的產品時(比如兩個連續包月切換), 蘋果會將上一筆交易退款, 此處可能會成為一個刷單漏洞(用戶不斷切換appId, 進行切換商品操作)需要注意一下這一點. 目前我的處理方案是, 用戶進首頁輪詢時倒序查找三條交易, 看是否退款, 未處理過的退款進行退款操作, 回收會員權益

3.10 如何判斷首單優惠?

解析交易中的is_in_intro_offer_period字段, 為true時表示享受了介紹性價格

3.11 如何判斷免費試用?

解析交易中的is_trial_period字段, 為true表示享受了免費試用

最后

有疑問的地方歡迎大家加我qq 790742549 進行交流
————————————————
版權聲明:本文為CSDN博主「CrucianLi」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/theCrucian/article/details/89406203

 

 

 

iOS內購(IAP)自動續訂訂閱類型服務端總結

IOS 后台需注意
iOS 的 App 內購類型有四種:
App 專用共享密鑰
訂閱狀態 URL
內購流程
流程簡述
服務端驗證
自動續費
調用函數方法
IOS 后台需注意

iOS 的 App 內購類型有四種:

消耗型商品:只可使用一次的產品,使用之后即失效,必須再次購買。
示例:釣魚 App 中的魚食。
非消耗型商品:只需購買一次,不會過期或隨着使用而減少的產品。
示例:游戲 App 的賽道。
自動續期訂閱:允許用戶在固定時間段內購買動態內容的產品。除非用戶選擇取消,否則此類訂閱會自動續期。
示例:每月訂閱提供流媒體服務的 App。
非續期訂閱:允許用戶購買有時限性服務的產品。此 App 內購買項目的內容可以是靜態的。此類訂閱不會自動續期。
示例:為期一年的已歸檔文章目錄訂閱。
App 專用共享密鑰

需要創建一個 “App 專用共享密鑰”,它是用於接收此 App 自動續訂訂閱收據的唯一代碼。這個秘鑰用來想蘋果服務器進行校驗票據 receipt,不僅需要傳 receipt,還需要傳這個秘鑰。
如果您需要將此 App 轉讓給其他開發人員,或者需要將主共享密鑰設置為專用,可能需要使用 App 專用共享密鑰。


訂閱狀態 URL

 

內購流程

流程簡述

先來看一下iOS內購的通用流程


用戶向蘋果服務器發起購買請求,收到購買完成的回調(購買完成后會把錢打給申請內購的銀行卡內)
購買成功流程結束后, 向服務器發起驗證憑證(app端自己也可以不依靠服務器自行驗證)
自己的服務器工作分 4 步:

1、接收 iOS 端發過來的購買憑證。
2、判斷憑證是否已經存在或驗證過,然后存儲該憑證。
3、將該憑證發送到蘋果的服務器(區分沙盒環境還是正式環境)驗證,並將驗證結果返回給客戶端。
sandbox 開發環境:https://sandbox.itunes.apple.com/verifyReceipt
prod 生產環境:https://buy.itunes.apple.com/verifyReceipt
4、修改用戶相應的會員權限或發放虛擬物品。
簡單來說就是將該購買憑證用 Base64 編碼,然后 POST 給蘋果的驗證服務器,蘋果將驗證結果以 JSON 形式返回。


服務端驗證

ios客戶端發送給服務端的數據

/**
* 訂單同步
*/
public function verify_order(){
$eventSystem = new \Freeios\Event\SystemEvent();
$request_uri = addslashes($_SERVER['REQUEST_URI']);
$resp_str = file_get_contents( "php://input");
$eventSystem->add_error('蘋果端回調-input',$request_uri,$resp_str);
$resp_str = stripslashes($resp_str);
$resp_data = json_decode($resp_str,true);

//蘋果內購的驗證收據,可以根據需要傳遞訂單或者用戶信息過來
$receipt_data = $resp_data['apple_receipt'];
$uid = $this->uid;
if (!$uid){
return_json_data(-99,'請先登錄');
}
$eventSystem->add_error('蘋果端回調-apple_receipt',$request_uri,$receipt_data);

// 驗證支付狀態
$result=$this->validate_apple_pay($receipt_data);
if(!$result['status']){ // 憑據驗證不通過
$eventSystem->add_error('蘋果端回調-result',$request_uri,'憑據驗證不通過');
return_json_data(0,'Credential verification failed');
}

$notify = $result['data'];
$transId = $notify['transaction_id']; // 交易的標識
$originalTransId = $notify['original_transaction_id']; // 原始交易ID
$transTime = $this->toTimeZone($notify['purchase_date']); // 購買時間
$transResult = $this->check_apple_trans($notify,$transId,$originalTransId,$transTime,$receipt_data);
if($transResult['status']<=0){
$eventSystem->add_error('蘋果端回調-result',$request_uri,'交易號已經出現過了');
return_json_data(0,'交易號已經出現過了');
}
// 處理訂單數據
$buyerInfo = $result['sandbox']; // 1 沙盒數據 0 正式
$productId = $notify['product_id']; // 訂單類型
$is_trial_period = $notify['is_trial_period'] == 'false' ? 0 : 1; //是否首次購買
$purchaseDate = str_replace(' America/Los_Angeles','',$notify['purchase_date_pst']);

$pay_detail = $this->pay_detail[$reward]; // 購買暢讀卡
$products = array_column($pay_detail,null,'expend_identifier');
$products = $products[$productId];
$total_fee = $products['pay']*100; // 分
$type = 3; // 蘋果內購支付
if($buyerInfo == 1){
$type = 6;//沙盒模式
}

// 寫入訂單(這個其實可以在IOS發起支付的時候請求服務端,先生成訂單,並返回訂單號)
$orderId = 'ios_a'.$this->uid.date("mdHis").rand(2000,8000);
if(!$orderId ){
$eventSystem->add_error('蘋果端回調-result',$request_uri,'訂單處理出錯');
return_json_data(0,'寫入訂單失敗');
}

// 處理訂單
$rs = 1;
if(!$rs){
$eventSystem->add_error('蘋果端回調-result',$request_uri,'更新數據錯誤失敗');
return_json_data(0,'更新數據錯誤失敗');
}
$eventSystem->add_error('蘋果端回調-result',$request_uri,'訂單處理成功');
return_json_data(1,'ok');

}
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
自動續費


/*
* 自動續費訂閱回調
* password 秘鑰: 43f37f26****adc66a1be
* */
public function renew(){
$resp_str = file_get_contents( "php://input");
if(empty($resp_str)){
$inputArr = I('','trim','');
$resp_str = '';
foreach($inputArr as $key=>$value){
$resp_str.=$key."=".$value."&";
}
}
$eventSystem = new \Freeios\Event\SystemEvent();
$eventSystem->add_error('renew','AppleAutoPay',$resp_str);

$data = json_decode($resp_str,true);
if(!empty($resp_str)) {//有時候蘋果那邊會傳空數據調用
// notification_type 幾種狀態
// NOTIFICATION_TYPE 描述
// INITIAL_BUY 初次購買訂閱。latest_receipt通過在App Store中驗證,可以隨時將您的服務器存儲在服務器上以驗證用戶的訂閱狀態。
// CANCEL Apple客戶支持取消了訂閱。檢查Cancellation Date以了解訂閱取消的日期和時間。
// RENEWAL 已過期訂閱的自動續訂成功。檢查Subscription Expiration Date以確定下一個續訂日期和時間。
// INTERACTIVE_RENEWAL 客戶通過使用應用程序界面或在App Store中的App Store中以交互方式續訂訂閱。服務立即可用。
// DID_CHANGE_RENEWAL_PREF 客戶更改了在下次續訂時生效的計划。當前的有效計划不受影響。
$notification_type = $data['notification_type'];//通知類型
$password = $data['password']; // 共享秘鑰
if ($password == "43f37f26****c66a1be") {
$receipt = isset($data['latest_receipt_info']) ? $data['latest_receipt_info'] : $data['latest_expired_receipt_info']; //latest_expired_receipt_info 好像只有更改續訂狀態才有
$product_id = $receipt['product_id']; // //商品的標識
$original_transaction_id = $receipt['original_transaction_id']; // //原始交易ID
$transaction_id = $receipt['transaction_id']; // //交易的標識
$purchaseDate = str_replace(' America/Los_Angeles','',$receipt['purchase_date_pst']);
//查詢出該apple ID最后充值過的用戶
$userid = 0; // 去數據庫查詢是否充值過
if ($notification_type == 'CANCEL') { //取消訂閱,做個記錄
if ($userid > 0) {
$eventSystem->add_error('renew','AppleAutoPay','用戶訂閱取消記錄成功');
}
} else {
//自動續訂,給用戶加時間
//排除幾種狀態不用處理,1,表示訂閱續訂狀態的更改 2,表示客戶對其訂閱計划進行了更改 3,在最初購買訂閱時發生
//if ($notification_type != "DID_CHANGE_RENEWAL_PREF" && $notification_type != "DID_CHANGE_RENEWAL_STATUS" && $notification_type != "INITIAL_BUY") {
if ($notification_type == "INTERACTIVE_RENEWAL" || $notification_type == "RENEWAL") {
$transTime = $this->toTimeZone($receipt['purchase_date']);
//查詢數據庫,該訂單是否已經處理過了
$appleTransCnt = 1; // 去數據庫查看該訂單是否處理過
if ($appleTransCnt == 0) { //沒有使用過,繼續走
$order_type = $this->products[$product_id];
$order_money = $this->product_money[$order_type];

$eventSystem->add_error('renew','AppleAutoPay','續訂成功');
} else {
$eventSystem->add_error('renew','AppleAutoPay','此次支付訂單已處理過');
}
} else {
$eventSystem->add_error('renew','AppleAutoPay','該類型通知不予處理--notification_type:' . $notification_type);
}
}
} else {
$eventSystem->add_error('renew','AppleAutoPay','該通知傳遞的密碼不正確--password:' . $password);
}
}
}

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
調用函數方法


/**
* 驗證這個交易號是否存在過了
* @param $notify
* @param $transId
* @param $totalAmount
* @param $tradeId
* @param $receipt_data
*/
public function check_apple_trans($notify,$transId,$originalTransId,$transTime,$receipt_data){
$eventOrder = new \Freeios\Event\OrderEvent();
$where = ['trade_no'=>$transId, ];
$appleTransCnt = $eventOrder->get_order_count($where);
if($appleTransCnt>0){
return ['status'=>-1,'appleTransCnt'=>$appleTransCnt];
}else{
$eventOrder->add_order_log_apple([
'trans_id'=>$transId,
'original_trans_id'=>$originalTransId,
'content'=>json_encode(['appleTransCnt'=>$appleTransCnt,'notify'=>$notify,'receipt_data'=>$receipt_data]),
]);
return ['status'=>1];
}
}

private function toTimeZone($src, $from_tz = 'Etc/GMT', $to_tz = 'Asia/Shanghai', $fm = 'Y-m-d H:i:s') {
$datetime = new \DateTime($src, new \DateTimeZone($from_tz));
$datetime->setTimezone(new \DateTimeZone($to_tz));
return $datetime->format($fm);
}

/**
* 根據語言獲取當前地區時間
* 以本地服務器時間為准
* 比美國紐約快12小時
* 比泰國,印尼快1小時
* 比葡萄牙里本斯快7小時
* @param $language
*/
private function format_time_zone($language,$is_format=true){
if($language == 1){
$f_time = strtotime('-12 hours');
}else if($language == 2 || $language == 3){
$f_time = strtotime('-1 hours');
}else{//葡萄牙語
$f_time = strtotime('-7 hours');
}
if($is_format){
$f_time = date('Y-m-d H:i:s',$f_time);
}
return $f_time;
}

private function format_to_time_zone($time_zone){
date_default_timezone_set($time_zone);//設置時區
$f_time = date('Y-m-d H:i:s');
date_default_timezone_set('Asia/Shanghai');//設置回默認的
return $f_time;
}

/**
* 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環境的驗證服務
*/
private function acurl($receipt_data, $sandbox=0){
//小票信息
$POSTFIELDS = array("receipt-data" => $receipt_data,"password"=>"43f37f26****c66a1be");
$POSTFIELDS = json_encode($POSTFIELDS);

//正式購買地址 沙盒購買地址
$url_buy = "https://buy.itunes.apple.com/verifyReceipt";
$url_sandbox = "https://sandbox.itunes.apple.com/verifyReceipt";
$url = $sandbox ? $url_sandbox : $url_buy;

//簡單的curl
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $POSTFIELDS);
curl_setopt ($ch, CURLOPT_SSL_VERIFYPEER, 0); //這兩行一定要加,不加會報SSL 錯誤
curl_setopt ($ch, CURLOPT_SSL_VERIFYHOST, 0);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}

/**
* 驗證AppStore內付
* @param string $receipt_data 付款后憑證
* @return array 驗證是否成功
*/
private function validate_apple_pay($receipt_data){
// 驗證參數
if (strlen($receipt_data)<20){
$result=array(
'status'=>false,
'message'=>' Illegal param'
);
return $result;
}
// 請求驗證
$html = $this->acurl($receipt_data);
$data = json_decode($html,true);

$data['sandbox'] = '0';
// 如果是沙盒數據 則驗證沙盒模式
if($data['status']=='21007'){
$html = $this->acurl($receipt_data, 1);
$data = json_decode($html,true);
$data['sandbox'] = '1';
}

$eventSystem = new \Freeios\Event\SystemEvent();
$eventSystem->add_error('蘋果驗證','validate_apple_pay',json_encode($data));

// 判斷是否購買成功
if(intval($data['status'])===0){ // 成功
$receipts = $data['latest_receipt_info']; // 自動續訂的訂閱項 時才會有
if(!isset($data['latest_receipt_info'])){
$receipts = $data['receipt']['in_app']; // 消費類型
}
if(count($receipts)>0){
$maxDate = '0'; //最新的日期,時間戳
$appData = null; //最新的那組數組
foreach($receipts as $k=>$app){
if($maxDate<$app['purchase_date_ms']){
$appData = $app;
$maxDate = $app['purchase_date_ms'];
}
}
$result=array(
'status'=>true,
'message'=>'Purchase success',
'data'=>$appData,
'sandbox'=>$data['sandbox'],
);
}else{
$result=array(
'status'=>false,
'message'=>'No data status:'.$data['status']
);
}
}else{ // 失敗
$result=array(
'status'=>false,
'message'=>'Failed purchase status:'.$data['status']
);
}
return $result;
}
————————————————
版權聲明:本文為CSDN博主「qq_23564667」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。
原文鏈接:https://blog.csdn.net/qq_23564667/article/details/105512349

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM