上线苹果自动续期订阅IAP注意事项
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了上线苹果自动续期订阅IAP注意事项相关的知识,希望对你有一定的参考价值。
参考技术A 苹果App内购项目分为4类:消耗型项目、非消耗型项目、非续期订阅、自动续期订阅。
自动续期订单在用户提交订阅后,苹果会在每个订阅周期结束前24小时进行自动扣费。这种方式能为开发者带来持续性更强的收益,但同时, 苹果对上架自动续期订单的要求也更高 。具体如下:
开发者需要在App中提供使用条款与隐私协议,并在App专业版购买界面提供相应链接。同时应该提供关于自动续期订单的订购说明。如下:
Once you confirm your purchase, you will be charged to your Apple ID account. If purchase the auto-renewing subscription, unless you cancel the order at least 24 hours before the end of the current billing cycle, the subscription will automatically renew, and your account will charge the renewal fee within 24 hours before the end of the current billing cycle. You can unsubscribe from account settings in the app store. \\r\\nPrivacy Policy & Terms of Use
确认购买后,将向您的Apple ID账户收款。购买连续包年项目,除非您在当前计费周期结束前至少24小时取消订单,否则项目会自动续订,您的账户将在当前计费周期结束前24小时内收取续订费用。您可在App Store的账户设置中取消订阅。\\r\\n隐私政策和使用条款
http://www.renmai001.com/Others/policy.htm
注:使用条款一定要包括类似以下的内容(即以上链接中的黑体字),这是苹果与开发者的“付费App”协议中第3.8(b)条款的要求。
协议地址: https://appstoreconnect.apple.com/agreements/#/?section=detail&id=92abe351-1575-4562-a121-c6577f6d58c5
会员订阅服务的内容
领取或经付费开启会员订阅服务后,你可以在相应服务期限内持续使用应用的专业版功能。
1、单次订阅服务
订阅单次订阅服务后,您的专业版权限将开通,并在之前期限的基础上往后顺延。
2、连续包年服务
(1)、连续包年服务是在你已开启会员订阅服务的前提下,向你提供的自动续费服务。如你开通连续包年服务,则视为你授权本应用在你已开通的会员订阅服务即将到期时,从你的第三方支付账户、银行卡、通讯账号、iTunes账户(简称“账户”)等代扣下一个计费周期的年度服务费用。扣费时间为计费周期到期前24小时内。
(2)、你可通过下述途径取消已开通的连续包年服务:
苹果手机在 iOS 设备“设置” → “iTunes Store 与 App Store” → 选择“Apple ID” → 点击“查看 Apple ID” → 在账户设置页面点击“订阅” → 取消订阅。
付费服务
1、你理解并同意,会员订阅服务为付费服务,本应用将收取互联网增值服务使用费;你可以在相关服务页面查阅会员订阅服务具体期限及对应费用并自行选择服务的种类。开启会员订阅功能后,无论你是否在相应服务期限内实际使用该服务或访问电子内容,已支付的费用不支持退款。
2、你理解并同意,本应用有权自行决定并不时修订会员订阅服务相关的收费标准和规则,该类标准及规则一经发布即生效,并构成本条款的有效组成部分。如你不同意前述标准、规则及其修订,你有权停止使用会员订阅服务。你继续使用会员订阅服务即视为你同意相关内容。
http://www.renmai001.com/Others/policy.htm
在App的描述中一定要添加使用条款与隐私协议的链接,如下:
PHP苹果支付以及事件通知-周期订阅实现
介绍参考链接
https://blog.csdn.net/qq_23564667/article/details/105512349
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 形式返回。
状态码 - 详情
0 校验成功
21000 未使用HTTP POST请求方法向App Store发送请求。
21001 此状态代码不再由App Store发送。
21002 receipt-data属性中的数据格式错误或丢失。
21003 收据无法认证。
21004 您提供的共享密码与您帐户的文件共享密码不匹配。
21005 收据服务器当前不可用。
21006 该收据有效,但订阅已过期。当此状态代码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。
21007 该收据来自测试环境,但已发送到生产环境以进行验证。
21008 该收据来自生产环境,但是已发送到测试环境以进行验证。
21009 内部数据访问错误。稍后再试。
21010 找不到或删除了该用户帐户。
5.关于续订
针对自动续期订阅类型,App Store会在订阅时间快到期之前,自动扣费帮助用户续订该服务。
server to server的校验方式,也是苹果推荐的校验方式 ,由苹果主动告知我们状态。 服务器需要接收苹果服务器发送过来的回调消息,根据消息类型进行续订,取消订阅,退订等操作。
6.配置接收通知地址 ----就是处理苹不同的回调事件
需要在App Store connect后台配置订阅状态URL ,用于接收 App Store 服务器回调通知的网址
官方文档: https://help.apple.com/app-store-connect/#/dev0067a330b
那我们首先来实现客户端+服务端支付功能
客户端实现自行实现
服务端需要编写
创建订单接口-给客户端订单号
客户端支付成功后请求通知接口用于发放奖励
恢复内购接口 -玩家可以恢复购买
苹果平台配置通知回调接口--处理回调事件
比如订阅、续费、升级 比如我们是周期扣款 其中订阅就是客户端付款的那一笔
续费以及升级需要我们在创建订单来发送奖励、退款收回奖励
关于用户如何与通知事件记录绑定
客户端发起付款我们会生成订单其中有用户ID以及初始化订单信息
客户端扣款成功我们解密回传数据可以得到
originalTransactionId -- 苹果用户与我们生成的唯一ID 以后都不会变
苹果通知事件中会存在originalTransactionId 我们与订单中的用户ID建立绑定就好
如果产品存在多用户请自行处理相关逻辑
如何保证回传事件是否同一笔订单
transactionId、webOrderLineItemId 每次交易都会生成唯一订单ID 这里使用的是originalTransactionId +webOrderLineItemId 来确定事件订单
具体代码实现
路由
Route::any('ios/notify', 'IosController@notify');//IOS支付回调
Route::any('renew/notify', 'IosController@iosNotify');//IOS事件通知
Route::post('ios/payment', 'IosController@payment');//IOS支付
Route::post('ios/repay', 'IosController@repay');//IOS恢复内购
控制器
<?php
namespace App\\Http\\Controllers;
use Illuminate\\Support\\Facades\\Log;
use App\\Libs\\DingTalk;
use App\\Models\\User;
use App\\Models\\Order;
use App\\Models\\VipSetmeal;
use App\\Models\\Transaction;
use App\\Models\\KuaishouAds;
use App\\Models\\TranslationNotify;
class IosController extends Controller
protected $apple_url = '';
public function __construct()
// $this->middleware(['api']);
/**
* 苹果校验接口
*
* @param $san_box
* @return string
*/
private function _getAppleUrl($san_box)
return $san_box ? 'https://sandbox.itunes.apple.com/verifyReceipt' : 'https://buy.itunes.apple.com/verifyReceipt';
/**
* 请求苹果接口
*
* @param $order_id
* @param $url
* @param $params
* @return bool|string
*/
private function _requestAppleUrl($order_id, $url, $params)
try
$result = $this->http_post($url, json_encode($params));
catch (\\Throwable $e)
$result = $e->getMessage();
try
// 请求苹果记录入库
db('orders_apple_request_log')->insert([
'order_id' => $order_id,
'params' => json_encode($params, JSON_UNESCAPED_UNICODE),
'response' => $result,
]);
catch (\\Throwable $e)
Log::channel('orders')->error('苹果请求error:' . $e->getMessage());
return $result;
/**
* 订单异常钉钉推送
*
* @param $order_info
* @param $at_phones
*/
private function _errorSendDingMsg($order_info, $at_phones = '')
$channel_desc = [
1 => '支付宝',
2 => '微信',
3 => 'apple',
];
$error_msg = "【支付-回调异常】\\n\\n【平台】:$channel_desc[$order_info['channel']]\\n【订单】:$order_info['order_id']\\n【原因】:$order_info['msg']\\n【链接】:www.test.com'
DingTalk::ding(1, $error_msg, 'text', $at_phones);
/**
* IOS支付
* @return \\Illuminate\\Http\\JsonResponse
*/
public function payment()
//timbao add 支付埋点
Log::info('PPPPP:有人准备支付啦!');
$user = getApiUser();
//timbao add
//在支付api处埋点,看看用户是否有支付不成功的情况(目前有用户反馈此问题)
Log::info('PPPPP:userid' . $user['id'] . '发起支付');
$setmeal_id = request("setmeal_id");// 套餐id
$setmeal = VipSetmeal::query()->where('setmealid', $setmeal_id)->first();
if (!$setmeal)
//timbao add 支付埋点
Log::info('支付失败,套餐id错误');
return json(4001, '参数错误,套餐不存在');
// $order = Order::query()->where([
// "user_id" => $user['id'],
// "status" => 1
// ])->where('created_at','>',now()->subDays(1)->toDateTimeString())->first();
// if(!isset($order))
$price = $setmeal['money'];//套餐金额
$num = $setmeal['num'];//数量
$title = $setmeal['title'];//标题
$title = $user['name'] . "购买" . $title;
switch ($setmeal['date_type'])
case 1:
$date = 'week';
break;
case 2:
$date = 'onemonth';
break;
case 3:
$date = 'month';
break;
case 4:
$date = 'year';
break;
case 5:
$date = 'oneyear';
break;
case 6:
$date = 'perpetual';
break;
default:
$date = 'week';
break;
$orderno = date("YmdHis") . rand(1111, 9999);
$other['num'] = $num;
$other['price'] = $price;
$other['type'] = 'setmeal';
$other['date'] = $date;
$other['setmealid'] = $setmeal['setmealid'];
// 保存订单信息
$order = Order::create([
"user_id" => $user['id'],
"title" => $title,
"ordernum" => $orderno,
"prepay_id" => null,
"remark" => request('remark'),
"money" => $price,
"channel" => 3,
"status" => 1,
"other" => $other,
'setmealid' => $setmeal['setmealid']
]);
//
//timbao add 支付信息埋点
Log::info('PPPPP:支付请求信息成功返回!userid=' . $user['id']);
return json(1001, '支付信息请求成功', $order);
/**
* 支付回调
* @return \\Illuminate\\Http\\JsonResponse
*/
public function notify()
$contentArr = request()->json()->all();
// 参数验证
if (empty($contentArr['order_id']) || empty($contentArr['apple_receipt']))
return json(4001, '参数异常,请重试');
$order_id = $contentArr['order_id'];
try
// 回调记录日志入库
db('orders_notify_log')->insert([
'order_id' => $order_id,
'channel' => 3,
'notify_params' => json_encode($contentArr, JSON_UNESCAPED_UNICODE)
]);
catch (\\Throwable $e)
Log::channel('orders')->error(json_encode($contentArr, JSON_UNESCAPED_UNICODE));
Log::channel('orders')->error('回调入库异常:' . $e->getMessage());
$arr['password'] = '2160564429*******9749c06e7f9';
$arr['receipt-data'] = $contentArr['apple_receipt'];
//苹果内购的验证收据
$order = Order::query()->where('ordernum', $order_id)->first();
if (!$order || $order['status'] == 2)
//timbao add 支付信息埋点
Log::channel('orders')->info("apple:order_id:$order_id:msg:支付失败,支付订单在数据库没有");
$this->_errorSendDingMsg(['order_id' => $order_id, 'channel' => 3, 'msg' => '支付订单在数据库没有!']);
return json(4001, '订单状态异常');
$url = $this->_getAppleUrl(config('pay.apple.san_box'));
$result = $this->_requestAppleUrl($order_id, $url, $arr);
$result = json_decode($result);
if (!$result || !isset($result->status))
//timbao add 支付信息埋点
Log::channel('orders')->info("apple:order_id:$order_id:msg:支付失败,苹果校验支付信息失败 result为空");
$this->_errorSendDingMsg(['order_id' => $order_id, 'channel' => 3, 'msg' => '苹果校验支付信息失败 result为空']);
return json(4001, '获取数据失败,请重试');
//如果校验失败
if ($result->status != 0)
// 钉钉推送
Log::channel('orders')->info("apple:order_id:$order_id:msg:支付失败,苹果校验支付状态值异常 status=$result->status");
$this->_errorSendDingMsg(['order_id' => $order_id, 'channel' => 3, 'msg' => '苹果校验支付状态值异常 status=' . $result->status]);
// receipt是Sandbox receipt,但却发送至生产系统的验证服务 - 沙箱接口重试
if ($result->status == 21007)
$url = $this->_getAppleUrl(true);
$result = $this->_requestAppleUrl($order_id, $url, $arr);
$result = json_decode($result);
if (!$result || !isset($result->status))
//timbao add 支付信息埋点
Log::channel('orders')->info("apple:order_id:$order_id:msg:支付失败,苹果校验二次重试支付信息失败 result为空");
$this->_errorSendDingMsg(['order_id' => $order_id, 'channel' => 3, 'msg' => '苹果校验二次重试支付信息失败 result为空']);
return json(4001, '获取数据失败,请重试');
if ($result->status != 0)
//timbao add 支付信息埋点
Log::channel('orders')->info("apple:order_id:$order_id:msg:支付失败,苹果校验二次支付状态值异常 status=$result->status");
$this->_errorSendDingMsg(['order_id' => $order_id, 'channel' => 3, 'msg' => '苹果校验二次支付状态值异常 status=' . $result->status]);
return json(4001, '校验失败');
else
return json(4001, '校验失败');
// 校验成功
if ($result->status == 0)
Log::channel('orders')->info("apple:order_id:$order_id:msg:校验成功状态值正常");
//在此处理业务逻辑
$product = $result->pending_renewal_info;
// $order->original_transaction_id = $product[0]->original_transaction_id;
// $order->save(); // 保存订单
// $orders = db('orders')
// ->where('original_transaction_id',$product[0]->original_transaction_id)
// ->where('created_at','>',now()->subSeconds(5)->toDateTimeString())
// ->where('setmealid',$order->other['setmealid'])
// ->first();
// if(isset($orders))
// return json(4001,'该订单已经买过了');
//
$order->pay_time = now()->toDateTimeString(); // 更新支付时间为当前时间
$order->status = 2;
$order->original_transaction_id = $product[0]->original_transaction_id;
$order->save(); // 保存订单
if ($product[0]->auto_renew_product_id != $order->other['setmealid'])
//timbao add 支付信息埋点
Log::channel('orders')->info("apple:order_id:$order_id:msg:支付失败,套餐id对不上 check:$product[0]->auto_renew_product_id-order:$order->other['setmealid']");
$this->_errorSendDingMsg(['order_id' => $order_id, 'channel_id' => 3, 'msg' => "支付失败,套餐id对不上 check:$product[0]->auto_renew_product_id-order:$order->other['setmealid']"]);
return json(4001, '验证失败!');
try
$kuaishou = new KuaishouAds();
$kuaishou->reportEvent($order->user_id, 3, ['amount' => $order->money]);
catch (\\Throwable $e)
//timbao modify
//针对快手增加了userid log, 目前有error log : Call to a member function toArray() on null
Log::channel('orders')->info("kuaishou_apple:order_id:$order_id:msg:$e->getMessage():user_id=$order->user_id");
//更新用户到期时间,剩余次数
User::saveVip($order);
//timbao add 支付信息埋点
Log::channel('orders')->info("apple:order_id:$order_id:msg:用户支付成功:user_id=$order->user_id");
//返回给客户端需要结束交易的transaction_id列表
return json(1001, 'SUCCESS');
/**
* 验证AppStore内付
* @param string $receipt_data 付款后凭证
* @return array 验证是否成功
*/
protected function validate_apple_pay($receipt_data, $ios_sandBox)
/**
* 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环境的验证服务
*/
$POSTFIELDS = '"receipt-data":"' . $receipt_data . '"';
if ($ios_sandBox)
// 请求验证
$data = $this->httpRequest('https://sandbox.itunes.apple.com/verifyReceipt', $POSTFIELDS);
else
// 请求验证
$data = $this->httpRequest('https://buy.itunes.apple.com/verifyReceipt', $POSTFIELDS);
return $data;
/**
* POST请求
* @param $url
* @param array $postData
* @param bool $json
* @return bool|mixed|string
*/
protected function httpRequest($url, $postData = array(), $json = true)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($postData)
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 0);
$data = curl_exec($ch);
curl_close($ch);
if ($json)
return json_decode($data, true);
else
return $data;
/**
* POST请求
* @param $url
* @param $data_string
* @return bool|string
*/
public function http_post($url, $data_string)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'X-AjaxPro-Method:ShowList',
'Content-Type: application/json; charset=utf-8',
'Content-Length: ' . strlen($data_string))
);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string);
$data = curl_exec($ch);
curl_close($ch);
return $data;
/**
* 恢复内购
* @return \\Illuminate\\Http\\JsonResponse
*/
public function repay()
try
$contentArr = request()->json()->all();
$arr['password'] = '2160564429*******9749c06e7f9';
$arr['receipt-data'] = $contentArr['apple_receipt'];
$ios_sandBox = env('IOS_SANDBOX', true);//判断生产环境,开发环境
if ($ios_sandBox)
$url = 'https://sandbox.itunes.apple.com/verifyReceipt';
else
$url = 'https://buy.itunes.apple.com/verifyReceipt';
$result = $this->http_post($url, json_encode($arr));
// Log::info('复购'.$result);
$result = json_decode($result);
if (!$result || !isset($result->status))
return json(4001, '获取数据失败,请重试');
//如果校验失败
if ($result->status != 0)
return json(4001, '校验失败');
if ($result->status == 0)
//在此处理业务逻辑
$product = $result->pending_renewal_info;
$orders = db('orders')->where('original_transaction_id', $product[0]->original_transaction_id)->first();
if (isset($orders))
return json(4001, '该订单已经买过了');
$setmeal_id = $product[0]->auto_renew_product_id;
$setmeal = VipSetmeal::query()->where('setmealid', $setmeal_id)->first();
if (!$setmeal)
return json(4001, '参数错误,套餐不存在');
$user = getApiUser();
$price = $setmeal['money'];//套餐金额
$num = $setmeal['num'];//数量
$title = $setmeal['title'];//标题
$title = $user['name'] . "购买" . $title;
switch ($setmeal['date_type'])
case 1:
$date = 'week';
break;
case 2:
$date = 'onemonth';
break;
case 3:
$date = 'month';
break;
case 4:
$date = 'year';
break;
case 5:
$date = 'oneyear';
break;
case 6:
$date = 'perpetual';
break;
default:
$date = 'week';
break;
$orderno = date("YmdHis") . rand(1111, 9999);
$key = md5($setmeal_id . $user['id']);
$orders = db('orders')->where(['onlykey' => $key])->first();
if (isset($orders))
return json(4001, '已经恢复购买过了');
$other['num'] = $num;
$other['price'] = $price;
$other['type'] = 'setmeal';
$other['date'] = $date;
$other['setmealid'] = $setmeal_id;
// 保存订单信息
$re = Order::create([
"user_id" => $user['id'],
"title" => $title,
"ordernum" => $orderno,
"prepay_id" => null,
"remark" => request('remark'),
"money" => $price,
"channel" => 3,
"status" => 2,
'original_transaction_id' => $product[0]->original_transaction_id,
"onlykey" => $key,
"other" => $other,
]);
$order = Order::query()->where('id', $re['id'])->first();
//更新用户到期时间,剩余次数
// User::saveNewVip($order);
User::saveVip($order);
//返回给客户端需要结束交易的transaction_id列表
return json(1001, '恢复购买');
return json(4001, '校验失败');
catch (\\Exception $e)
return json(5001, $e->getMessage());
/**
* 记录通知信息
*
* @param $data
*/
private function _saveNotify($data)
try
$notify_info = TranslationNotify::query()->where(['notification_uuid' => $data['notificationUUID']])->first();
$notify_info = objectToArray($notify_info);
if ($notify_info)
return;
$transaction_info = verifyAppleToken($data['data']['signedTransactionInfo']);
$renewal_info = verifyAppleToken($data['data']['signedRenewalInfo']);
TranslationNotify::create([
'notification_uuid' => $data['notificationUUID'],
'notification_type' => $data['notificationType'],
'sub_type' => $data['subtype'] ?? '',
'notification_version' => $data['notificationVersion'] ?? '',
'app_apple_id' => $data['data']['appAppleId'] ?? '',
'bundle_id' => $data['data']['bundleId'],
'bundle_version' => $data['data']['bundleVersion'],
'environment' => $data['data']['environment'],
'transaction_id' => $transaction_info['transactionId'],
'original_transaction_id' => $transaction_info['originalTransactionId'],
'web_order_line_item_id' => $transaction_info['webOrderLineItemId'],
'signed_renewal_info' => json_encode($renewal_info, JSON_UNESCAPED_UNICODE),
'signed_transaction_info' => json_encode($transaction_info, JSON_UNESCAPED_UNICODE),
]);
catch (\\Throwable $t)
Log::channel('transaction')->error($data['notificationUUID'] . '入库失败 error:' . $t->getMessage());
/**
* ios 事件通知
* @return bool
*/
public function iosNotify()
// 通知事件
// 订阅类型-入库 首次订阅 重新订阅 续费 套餐升级
// 自动续费状态 自动订阅状态 1开启 2-关闭
// 自动续费结果 0默认 1成功 2失败 3过期
// 是否退款
// 是否升级
try
$raw = request()->json()->all();
Log::channel('transaction')->info('notify-' . json_encode($raw));
$token = $raw['signedPayload'] ?? '';
$data = verifyAppleToken($token);
if (empty($data))
//timbao add
//记录苹果通知逻辑中的bug
Log::channel('transaction')->info('解析苹果通知异常,data为空', $raw['signedPayload']);
return true;
// 记录通知信息
$this->_saveNotify($data);
$transaction_info = verifyAppleToken($data['data']['signedTransactionInfo']);
switch ($data['notificationType'])
case 'DID_RENEW': // 续订
Transaction::autoRenew($transaction_info);
break;
case 'REFUND': // 退款
Transaction::refund($transaction_info);
break;
case 'DID_CHANGE_RENEWAL_STATUS': // 连续续订状态 变更
Transaction::changeRenewStatus($transaction_info, $data['subtype']);
break;
case 'EXPIRED': // 过期通知
Transaction::updateExpiredTrans($transaction_info);
break;
case 'SUBSCRIBED': // 订阅通知
Transaction::updateSubscribed($transaction_info, $data['subtype']);
break;
case 'DID_FAIL_TO_RENEW': // 续订失败
Transaction::updateRenewFailTrans($transaction_info);
break;
case 'DID_CHANGE_RENEWAL_PREF': //升级 降级
if ($data['subtype'] == 'UPGRADE')
Transaction::autoRenew($transaction_info, true);
break;
return true;
catch (\\Throwable $t)
//timbao add
//记录自动续费逻辑中的bug
Log::channel('transaction')->warning('解析苹果通知过程中异常,msg=' . $t->getMessage());
return false;
3 . 模型
<?php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Support\\Facades\\Log;
use DateTimeInterface;
use Prettus\\Repository\\Contracts\\Transformable;
use Prettus\\Repository\\Traits\\TransformableTrait;
/**
* Class Transaction.
*
* @package namespace App\\Models;
*/
class Transaction extends Model implements Transformable
use TransformableTrait;
protected $table = 'transaction';
protected $primaryKey = 'id';
protected $guarded = [];
protected function serializeDate(DateTimeInterface $date)
return $date->format('Y-m-d H:i:s');
/**
* 续费or升级
*
* @param $transaction_info
* @param bool $is_upgrade
* @return bool
*/
public static function autoRenew($transaction_info, $is_upgrade = false)
$original_transaction_id = $transaction_info['originalTransactionId']; // 订阅ID
$web_order_line_item_id = $transaction_info['webOrderLineItemId']; // 交易ID
$transaction_id = $transaction_info['transactionId']; // 苹果订单号
$setmeal_id = $transaction_info['productId'];
$sub_type_desc = $is_upgrade == true ? '升级' : '续费';
$base_msg = self::_getBaseMsg($original_transaction_id, $web_order_line_item_id) . "- $sub_type_desc:";
// 检测订阅
$check_result = self::_checkTransaction($transaction_info, $base_msg);
if ($check_result['check'] === false)
return false;
$order_info = $check_result['data']['order_info'];
$setmeal = $check_result['data']['setmeal'];
// 4.订阅记录入库
$user_id = $order_info->user_id;
$data = self::_getAddData($user_id, $transaction_info);
// 升级新增参数
if ($is_upgrade == true)
$data['sub_type'] = 'UPGRADE';
$data['is_upgrade'] = 1;
$data['upgrade_time'] = date('Y-m-d H:i:s');
Transaction::query()->create($data);
// 订单入库
$user = User::query()->find($user_id);
$price = $setmeal['money'];//套餐金额
$num = $setmeal['num'];//数量
$title = $setmeal['title'];//标题
$title = $user['name'] . "续费" . $title;
$other['num'] = $num;
$other['price'] = $price;
$other['type'] = 'setmeal';
$other['date'] = VipSetmeal::getDate($setmeal['date_type']);
$other['setmealid'] = $setmeal_id;
// 保存订单信息
$res = Order::query()->create([
"user_id" => $user_id,
"title" => $title,
"ordernum" => order::getOrderNum(),
"prepay_id" => null,
"remark" => request('remark'),
"money" => $price,
"channel" => 3,
"status" => 2,
"transaction_id" => $transaction_id,
'original_transaction_id' => $original_transaction_id,
"onlykey" => Order::getOnlykey($transaction_id, $user_id),
"other" => $other,
'setmealid' => $setmeal_id
]);
$order = Order::query()->where('id', $res['id'])->first();
//更新用户到期时间,剩余次数
User::saveVip($order);
return true;
/**
* 检测交易
*
* @param $transaction_info
* @param $base_msg
* @return array
*/
private static function _checkTransaction($transaction_info, $base_msg)
$ret = [
'check' => false,
'data' => [],
];
// 1.判断是否存在订阅ID订单
$order_info = Order::query()
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->where('channel', '=', 3)
->where('status', '=', 2)
->orderBy('id')
->first();
if (!$order_info)
Log::channel('transaction')->warning($base_msg . 'not find order info');
return $ret;
// 2.判断交易ID是否已经入库过
$is_exist = Transaction::query()
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->where('web_order_line_item_id', '=', $transaction_info['webOrderLineItemId'])
->count();
if ($is_exist)
Log::channel('transaction')->warning($base_msg . " 交易ID存在");
return $ret;
// 3.判断套餐是否存在
$transaction_id = $transaction_info['transactionId']; // 苹果订单号
$setmeal_id = $transaction_info['productId'];
$setmeal = VipSetmeal::query()
->where('setmealid', $setmeal_id)
->first();
if (!$setmeal)
Log::channel('transaction')->warning($base_msg . ' 自动续费没找到对应套餐,transaction_id=' . $transaction_id . ' and setmeal_id=' . $setmeal_id);
return $ret;
$ret['check'] = 'success';
$ret['data'] = compact('setmeal', 'order_info');
return $ret;
/**
* 退款
*
* @param $transaction_info
* @return bool
*/
public static function refund($transaction_info)
$original_transaction_id = $transaction_info['originalTransactionId']; // 订阅ID
$web_order_line_item_id = $transaction_info['webOrderLineItemId']; // 交易ID
$base_msg = self::_getBaseMsg($original_transaction_id, $web_order_line_item_id) . '- 退款:';
$transaction_info_data = Transaction::query()
->where('web_order_line_item_id', '=', $transaction_info['webOrderLineItemId'])
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->first();
if (!$transaction_info_data)
Log::channel('transaction')->warning($base_msg . ' not find transaction info');
return false;
// 修改退款时间以及状态
Transaction::query()
->where('web_order_line_item_id', '=', $transaction_info['webOrderLineItemId'])
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->update([
'cancellation_date_ms' => date('Y-m-d H:i:s', $transaction_info['revocationDate'] / 1000),
'is_cancellation' => 1,
]);
// 修改用户会员为到期
User::saveExpireVip($transaction_info_data->user_id);
return true;
/**
* 订阅
*
* @param $transaction_info
* @param $subtype
* @return bool
*/
public static function updateSubscribed($transaction_info, $subtype)
if (!in_array($subtype, ['INITIAL_BUY', 'RESUBSCRIBE']))
return false;
$original_transaction_id = $transaction_info['originalTransactionId']; // 订阅ID
$web_order_line_item_id = $transaction_info['webOrderLineItemId']; // 交易ID
$base_msg = self::_getBaseMsg($original_transaction_id, $web_order_line_item_id) . '- 订阅:';
// 检测订阅
$check_result = self::_checkTransaction($transaction_info, $base_msg);
if ($check_result['check'] === false)
return false;
$order_info = $check_result['data']['order_info'];
$user_id = $order_info->user_id;
// 4.订阅记录入库
$data = self::_getAddData($user_id, $transaction_info);
$data['sub_type'] = "SUBSCRIBED-$subtype";
Transaction::query()->create($data);
return true;
/**
* 续订失败
*
* @param $transaction_info
* @return bool
*/
public static function updateExpiredTrans($transaction_info): bool
return Transaction::query()
->where('web_order_line_item_id', '=', $transaction_info['webOrderLineItemId'])
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->update([
'auto_renew_result' => 3,
]);
/**
* 续订失败
*
* @param $transaction_info
* @return bool
*/
public static function updateRenewFailTrans($transaction_info): bool
return Transaction::query()
->where('web_order_line_item_id', '=', $transaction_info['webOrderLineItemId'])
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->update([
'auto_renew_status' => 2,
]);
/**
* 自动续费状态修改
*
* @param $transaction_info
* @param $subtype
* @return bool
*/
public static function changeRenewStatus($transaction_info, $subtype): bool
switch ($subtype)
case 'AUTO_RENEW_ENABLED':
$auto_renew_status = 1;
break;
case 'AUTO_RENEW_DISABLED':
$auto_renew_status = 2;
break;
default:
return false;
return Transaction::query()
->where('web_order_line_item_id', '=', $transaction_info['webOrderLineItemId'])
->where('original_transaction_id', '=', $transaction_info['originalTransactionId'])
->update([
'auto_renew_status' => $auto_renew_status,
]);
/**
* 获取日志
*
* @param $original_transaction_id
* @param $web_order_line_item_id
* @return string
*/
private static function _getBaseMsg($original_transaction_id, $web_order_line_item_id)
return "notify-$original_transaction_id-$web_order_line_item_id ";
/**
* 获取入库信息
*
* @param $user_id
* @param $transaction_info
* @return array
*/
private static function _getAddData($user_id, $transaction_info)
return [
'user_id' => $user_id, // fixme 此处不考虑用户ID切换
'transaction_id' => $transaction_info['transactionId'],
'product_id' => $transaction_info['productId'],
'web_order_line_item_id' => $transaction_info['webOrderLineItemId'],
'original_transaction_id' => $transaction_info['originalTransactionId'],
'original_purchase_date_ms' => date('Y-m-d H:i:s', $transaction_info['originalPurchaseDate'] / 1000), //首次订阅时间
'purchase_date_ms' => date('Y-m-d H:i:s', $transaction_info['purchaseDate'] / 1000), // 购买时间
'expires_date_ms' => date('Y-m-d H:i:s', $transaction_info['expiresDate'] / 1000), // 过期时间
'subscription_group_identifier' => $transaction_info['subscriptionGroupIdentifier'] ?? '',
'in_app_ownership_type' => $transaction_info['inAppOwnershipType'],
'environment' => $transaction_info['environment'] ?? '',
'sub_type' => 'DID_RENEW', // DID_RENEW UPGRADE SUBSCRIBED-INITIAL_BUY SUBSCRIBED-RESUBSCRIBE
'auto_renew_result' => 1, // 自动续费结果 0默认 1成功 2失败 3过期
'auto_renew_status' => 1, // 自动订阅状态 1开启 2-关闭
];
苹果秘钥解密
if (!function_exists('verifyAppleToken'))
/**
* 苹果token解密
*
* @param $token
* @return array|bool|mixed|string
*/
function verifyAppleToken($token)
$arr = explode('.',$token);
if(count($arr) != 3)
return [];
$header = $arr[0];
$header = base64_decode($header);
$header = json_decode($header,true);
if(empty($header))
return [];
if($header['alg'] != 'ES256')
return [];
$data = $arr[1];
$data = base64_decode($data);
$data = json_decode($data,true);
if(empty($data))
return [];
$sign = $arr[2];
if(empty($sign))
return [];
return $data;
相关表设计
#订单表
CREATE TABLE `orders` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`ordernum` varchar(100) DEFAULT '' COMMENT '系统订单号',
`title` varchar(255) DEFAULT '' COMMENT '套餐名称',
`user_id` varchar(20) NOT NULL COMMENT '用户id',
`money` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '支付金额',
`pay_time` datetime DEFAULT NULL COMMENT '支付时间',
`channel` tinyint(1) DEFAULT NULL COMMENT '支付渠道:1支付宝 2微信 3apple',
`transaction_id` varchar(30) CHARACTER SET utf8 DEFAULT '' COMMENT '微信支付交易号',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '支付结果:1待支付 2已支付 3已关闭 4支付失败',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`other` varchar(255) DEFAULT NULL,
`prepay_id` varchar(50) DEFAULT NULL COMMENT '微信支付',
`original_transaction_id` varchar(30) DEFAULT NULL,
`onlykey` varchar(35) DEFAULT NULL COMMENT '唯一字符串',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`setmealid` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `ordernum` (`ordernum`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT COMMENT='订单表'
#事件日志表
CREATE TABLE `translation_notify` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`notification_uuid` varchar(255) DEFAULT NULL COMMENT '通知唯一id',
`notification_type` varchar(255) DEFAULT NULL COMMENT '通知类型',
`sub_type` varchar(255) DEFAULT NULL COMMENT '通知子类型',
`notification_version` varchar(255) DEFAULT NULL COMMENT '通知版本',
`app_apple_id` varchar(255) DEFAULT NULL COMMENT '应用苹果id',
`bundle_id` varchar(255) DEFAULT NULL COMMENT '包id',
`bundle_version` varchar(255) DEFAULT NULL COMMENT '包版本',
`environment` varchar(255) DEFAULT NULL COMMENT '环境',
`signed_renewal_info` varchar(1024) DEFAULT NULL COMMENT '签名数据',
`signed_transaction_info` varchar(1024) DEFAULT NULL COMMENT '数据',
`transaction_id` varchar(255) NOT NULL DEFAULT '' COMMENT '订单号',
`original_transaction_id` varchar(255) NOT NULL DEFAULT '' COMMENT '原始订单号',
`web_order_line_item_id` varchar(255) NOT NULL DEFAULT '' COMMENT '目测唯一订单号',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `idx_uuid` (`notification_uuid`) USING BTREE,
KEY `idx_oid_wid` (`original_transaction_id`,`web_order_line_item_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='事件日志表'
#订阅交易表
CREATE TABLE `transaction` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'id',
`user_id` int(11) NOT NULL DEFAULT '0' COMMENT '用户id',
`transaction_id` varchar(255) NOT NULL DEFAULT '' COMMENT '订单号',
`original_transaction_id` varchar(255) NOT NULL DEFAULT '' COMMENT '苹果唯一ID',
`original_purchase_date_ms` datetime DEFAULT NULL COMMENT '订阅时间',
`purchase_date_ms` datetime DEFAULT NULL COMMENT '购买时间',
`expires_date_ms` datetime DEFAULT NULL COMMENT '过期时间',
`cancellation_date_ms` datetime DEFAULT NULL COMMENT '退款时间',
`web_order_line_item_id` varchar(255) NOT NULL,
`is_trial_period` int(11) NOT NULL DEFAULT '0',
`is_in_intro_offer_period` int(11) NOT NULL DEFAULT '0',
`in_app_ownership_type` varchar(255) NOT NULL,
`product_id` varchar(255) NOT NULL DEFAULT '' COMMENT '产品id',
`subscription_group_identifier` varchar(255) NOT NULL DEFAULT '',
`environment` varchar(20) NOT NULL DEFAULT '' COMMENT '环境',
`sub_type` varchar(50) NOT NULL DEFAULT '' COMMENT '订阅类型',
`is_upgrade` tinyint(1) NOT NULL DEFAULT '2' COMMENT '是否升级 1-是 2-否 ',
`is_cancellation` tinyint(1) NOT NULL DEFAULT '2' COMMENT '是否退款 1-是 2-否 ',
`auto_renew_status` tinyint(1) NOT NULL DEFAULT '2' COMMENT '自动订阅状态 1开启 2-关闭',
`auto_renew_result` tinyint(1) NOT NULL DEFAULT '0' COMMENT '自动续费结果 0默认 1成功 2失败 3过期',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`upgrade_time` datetime DEFAULT NULL COMMENT '升级时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_trans_id` (`transaction_id`) USING BTREE,
KEY `idx_web_order_id` (`web_order_line_item_id`) USING BTREE,
KEY `idx_origin_order_id` (`original_transaction_id`) USING BTREE,
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅交易表'
以上是关于上线苹果自动续期订阅IAP注意事项的主要内容,如果未能解决你的问题,请参考以下文章