PHP苹果支付以及事件通知-周期订阅实现
Posted woshihaiyong168
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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='订阅交易表'
以上是关于PHP苹果支付以及事件通知-周期订阅实现的主要内容,如果未能解决你的问题,请参考以下文章