苹果手机app内购是从哪儿扣费

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了苹果手机app内购是从哪儿扣费相关的知识,希望对你有一定的参考价值。

苹果用户在新注册或登录一台设备时会做账户的初始设置,其中就包括设置付款方式,可以添加支付宝或银行卡等方式用于APP Store的app下载或者购买在线音乐等。

如果不添加付款方式是无法完成账户设置的,即使设置了也无法使用付费功能,当用户在苹果设备上进行购物后自动从绑定的付款方式中扣除相应的数额,当然前提是经你本人确认过付款密码。

可根据如下步骤设置:

1、打开设置,点击最上方的Apple ID进入账户设置。

2、进入账户设置,点击付款与送货。

3、进入付款与送货,点击付款方式,在跳出的页面中更改付款方式。

扩展资料:

苹果帐户介绍:

苹果帐户(Apple ID)是苹果公司为其产品(如iWork、iTunes Store和Apple Store)所引入的认证系统。Apple ID作为一个集全功能于一身的帐户,允许用户访问苹果的各种资源。因为一个Apple ID可以被用于由苹果提供的多个产品和服务,它也可以称为Apple账户、MobileMe账户、Mac账户、iTunes Store账户和iChat账户。

Apple ID可用于执行与 Apple 有关的所有操作(包括使用iCloud存储内容、从App Store下载应用程序以及从 iTunes Store 购买歌曲、影片和电视节目)的登录信息。

Apple ID需要一个Email邮箱地址 ,配合密码,用户就可以在iPhone上的App Store轻松下载安装和更新软件。

参考资料:百度百科-Apple ID

参考技术A

苹果公司的App Store是与Apple ID进行绑定的。可以使用Apple ID中的余额,或者绑定的信用卡进行支付。

操作流程如下:

    首先在你的ios设备上,打开自带的App Store这个应用,在精品推荐的页面底部,找到你的Apple ID,点选后,在弹出的提示框中选择查看Apple ID,而后需要输入你的Apple ID的密码。

    在成功登陆后,让我们来点击进入付款信息,而后便能看到新的付款方式「银联 UnionPay」,在选择银联后,下方输入你的银联卡号(注意,国内发行的信用卡或储蓄卡均可绑定,系统会自动识别卡片类型),而后在卡号的右上角出现一个小小的银联标志,即代表卡片已被成功识别。在下方需要填写相关的一些个人信息,当然,若你绑定的是信用卡,则需要额外填写诸如卡号、安全码和有效期等信息。

    在填写好相关信息后,我们便会收到银行发来的验证短信。注意:银行卡无需开通网上银行,但手机号必须和银行登记的一致。验证成功便代表绑定成功,不会特别出现类似“绑定已成功”的确认页面。如果你希望手动查看绑定是否成功,可再次进入付款信息页面,这时你会看到银行卡号已经变成黑点+卡号后四位的形式,即代表绑定已成功。

参考技术B 每个苹果手机用户需要注册成苹果用户,以用于在苹果软件商城appstone 中下载软件。
而这个用户名是类似于支付宝账户一样的,功能很多。当需要购买APP的时候,你需要将这个账户绑定银行卡卡号,之后才能使用付费功能。也就是现在的苹果pay功能。你可以用这个购买收费的APP,还有APP内购,还有一些电子书,音乐什么的。
苹果手机app内购也是从这里面扣钱的,当然你不绑定银行卡的话,是扣不了你的钱的。
参考技术C 内购指的是程序内购买。是从你登录设备的Apple ID的账户余额中扣除。
如果是越狱设备安装了破解版,则是直接绕过验证。
参考技术D 应该是在你的APP st0re里扣款,也能在绑定的银行卡上扣除,应该是可以选择的

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='订阅交易表'

以上是关于苹果手机app内购是从哪儿扣费的主要内容,如果未能解决你的问题,请参考以下文章

如何查看 ios 游戏内购消费订单?

苹果内购是啥意思?

[iOS]苹果审核规则

苹果手机 apple pay 一不小心支付购买了自己不要的服务

apple pay可以用手机买火车票吗

Https安全协议在手机App与后台服务通信上的应用