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

那我们首先来实现客户端+服务端支付功能

客户端实现自行实现

服务端需要编写

  1. 创建订单接口-给客户端订单号

  1. 客户端支付成功后请求通知接口用于发放奖励

  1. 恢复内购接口 -玩家可以恢复购买

  1. 苹果平台配置通知回调接口--处理回调事件

比如订阅、续费、升级 比如我们是周期扣款 其中订阅就是客户端付款的那一笔

续费以及升级需要我们在创建订单来发送奖励、退款收回奖励

关于用户如何与通知事件记录绑定

  1. 客户端发起付款我们会生成订单其中有用户ID以及初始化订单信息

  1. 客户端扣款成功我们解密回传数据可以得到

originalTransactionId -- 苹果用户与我们生成的唯一ID 以后都不会变

  1. 苹果通知事件中会存在originalTransactionId 我们与订单中的用户ID建立绑定就好

如果产品存在多用户请自行处理相关逻辑

  1. 如何保证回传事件是否同一笔订单

transactionId、webOrderLineItemId 每次交易都会生成唯一订单ID 这里使用的是originalTransactionId +webOrderLineItemId 来确定事件订单

具体代码实现

  1. 路由

  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恢复内购

  1. 控制器

<?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苹果支付以及事件通知-周期订阅实现的主要内容,如果未能解决你的问题,请参考以下文章

苹果支付的这些漏洞,你都堵上了吗?

iOS 自动更新订阅:收据验证流程

php [Membership 2 Pro] - 向尚未支付订阅费用的会员添加付款通知

验证服务器上的自动续订订阅收据

在事件驱动的世界中处理异常

如何在 PHP 中使用 IPN(即时付款通知)在 Paypal 中配置定期付款