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 FreeiosEventSystemEvent();
$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 FreeiosEventSystemEvent();
$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 FreeiosEventOrderEvent();
$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 FreeiosEventSystemEvent();
$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