PHP (Laravel) 实现 iOS 内购服务端验证

IOS内购的流程大致如下:

  1. 通过产品ID获取产品信息列表(客户端的事,我们不用管)
  2. 添加监听(客户端的事,我们不用管)
  3. 把产品包装成SKPayment(支付)发送给苹果服务器(客户端的事,我们不用管)
  4. 苹果服务器购买成功后会回调监听方法,根据苹果服务器返回信息判断是否购买成功。
  5. 购买失败或已经购买过该商品则注销交易。如果购买成功,此时可以向自家服务器发送购买成功的消息,并通过后台向苹果服务器发送验证,然后注销交易。(这一步,客户端会得到苹果返回的支付凭证数据,然后用此数据发到我们服务端,服务端进行验证)

代码如下

/**
 * @Author woann <www.woann.cn>
 * @param Request $request
 * @return \Illuminate\Http\JsonResponse
 * @des ios内购支付回调
 */
public function iosNotify(Request $request)
{
    $this->validate(
        $request,
        [
            'order_id'           =>  'required|digits_between:1,9|integer',
            'apple_receipt'           =>  'required|min:20',
        ]
    );
    //苹果内购的验证收据
    $receipt_data = $request->input('apple_receipt');
    $order_id = $request->input('order_id');
    $order = LevelOrder::find($order_id);
    if (!$order || $order->state != 0) {
        return returnApi(500,'订单状态异常');
    }
    $ios_sandBox = env('IOS_SANDBOX',true);//判断生产环境,开发环境
    if(empty($receipt_data)){
        return json(500, '参数不正确');
    }
    // 获取校验结果
    $result=$this->validate_apple_pay($receipt_data,$ios_sandBox);
    if (!$result || !is_array($result) || !isset($result['status'])) {
        return returnApi(500,'获取数据失败,请重试');
    }
    //如果校验失败
    if ($result['status'] != 0) {
        return returnApi(500,'miss [apple_receipt]');
    }
    if ($result['status'] == 0) {
        $transaction_ids = [];//用来收集需要关闭的transaction_id
        //遍历未结束交易的列表(in_app)
        foreach ($result['receipt']['in_app'] as $v){
            $transaction_ids[] = $v['transaction_id'];//不管当前交易是否已经处理过,都要返回给客户端结束交易
            $is_exist = DB::table('ios_transaction_id')->where('transaction_id', $v['transaction_id'])->first();
            if ($is_exist) {
                //如果已经处理过该订单 直接跳过
                continue;
            }
            //否则在此处理业务逻辑
            //...
            //然后记录下此transaction_id标记为已处理过
            DB::table('ios_transaction_id')->insert(['transaction_id' => $v['transaction_id']]);
        }
        //返回给客户端需要结束交易的transaction_id列表
        return returnApi(200,'SUCCESS',['transaction_ids' => $transaction_ids]);
    }
}


/**
 * 验证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 = httpRequest('https://sandbox.itunes.apple.com/verifyReceipt', $POSTFIELDS);
    }else{
        // 请求验证
        $data = httpRequest('https://buy.itunes.apple.com/verifyReceipt', $POSTFIELDS);
    }
    return $data;
}


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);
    $info = curl_getinfo($ch);
    $data = curl_exec($ch);
    curl_close($ch);
    if ($json) {
        return json_decode($data, true);
    } else {
        return $data;
    }
}    

```

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 15

这么简单的判断是认真的吗 :joy:

3年前 评论

@woann 最简单的,人家伪造支付凭证,上传一个支付了一分钱的凭证,又或者上传2018年支付的支付凭证,不就GG了吗?破解前端的接口是分分钟的事情

3年前 评论

确实,苹果票据是可以伪造的,或者说劫持代充等

3年前 评论

@伍同学 修改了,看了下相关资料,确实需要增加校验拦截,增加了对transaction_id重复处理的判断(插入数据库,回调前先判断是否存在),并增加了给客户端返回transaction_id,让客户端结束对应交易,不然交易列表一直累计。

3年前 评论

@wind 修改了,看了下相关资料,确实需要增加校验拦截,增加了对transaction_id重复处理的判断(插入数据库,回调前先判断是否存在),并增加了给客户端返回transaction_id,让客户端结束对应交易,不然交易列表一直累计。

3年前 评论
jiangjun

苹果服务器调用的接口处理可以分享一下吗

3年前 评论

    private $appleBuyUrl = "https://buy.itunes.apple.com/verifyReceipt"; //正式购买地址
    private $appleBuySandbox = "https://sandbox.itunes.apple.com/verifyReceipt"; //沙盒购买地址
$url = $sandbox ? $this->appleBuySandbox : $this->appleBuyUrl;
        //todo 这里不能用 json_encode 为转义字符串
        $sendData = '{"receipt-data":"' . $receipt_data . '"}';
        try {
            $client = new Client();
            $result = $client->request('post', $url, [
                'body' => $sendData
            ])->getBody()->getContents();

            $data = json_decode($result, true);

            // 判断是否购买成功
            if ($data['status'] == 0) {
                return $data;
            } else {
                return false;
            }
        } catch (RequestException $e) {
            //网络请求异常 重新处理
            if ($this->verify <= 10) {
                \Log::warning('Apple 验证支付失败,重试' . $this->verify . '次');
                $this->verify += 1;
                $this->applePayVerify($receipt_data, $sandbox);
            }
        } catch (\Exception $e) {
            \Log::error('验证 receipt-data 失败' . $e->getMessage());
            return false;
        }

可以简化很多的 写过相同的功能

3年前 评论

@Rekkles 我之前就是差不多这样,这样是不行的,你只校验status的值,而不校验in_app里的交易列表是否已经处理过,会被利用这点重复刷单。

3年前 评论

@woann receipt-data 是黑名单处理的 而且你接口算上加密和签名 基本上没啥重放攻击的可能性 而一般来说内购在上线之后都是关闭状态 :joy:

3年前 评论

ios内购支付成功后,ios不会异步回调来通知我们服务器吗?只能拿客户端返回的凭证来检测是否支付成功吗?

2年前 评论

@Potato1 是的,跟国内的微信和支付宝不一样,很恶心的。

2年前 评论
Potato1 2年前

意思一客户端请求一次要处理所有的订单么

2年前 评论

in_app 里面是多条信息 只有一个product_id 只知道是哪一个产品 不知道对应的是哪一个订单呀?怎么解决呀

2年前 评论

苹果是不是会出现验证时返回空,然后在多验证几次就可以拿到数据了

3周前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!