苹果订阅支付服务端完整指南(PHP实战)

1 阅读5分钟

从踩坑到上线,一个PHP后端的苹果支付接入实录

📌 写在前面

公司项目需要接入苹果App内购(IAP)的订阅支付功能。刚开始我心想:微信支付都接了八百遍了,苹果还能玩出什么花?

结果一做才发现——坑比想象的多

主要问题是:网上的资料太少了,大部分都是零零散散的片段,没有一个完整的、能直接“抄作业”的教程。

于是自己踩完坑、做完之后,决定把整个过程整理出来,希望能帮到下一个被苹果支付折磨的兄弟。

🎯 本文核心内容

模块说明
业务逻辑服务端需要做什么
主动购买用户点击购买,客户端拿票据来验证
订阅续费苹果服务器自动回调,处理续费逻辑
完整代码可直接复用的PHP代码片段
踩坑记录常见问题及解决方案

一、整体业务流程

先上一张图,看懂苹果支付的核心流程:

图片

核心理解:苹果支付和微信支付最大的区别在于——苹果不会直接告诉你“支付成功了” ,而是给客户端一个票据(receipt),客户端拿到后再传给服务端,由服务端去苹果服务器验证。

二、两种支付场景

苹果IAP支付分为两种:

类型说明特点
主动购买用户点击购买,一次性付费类似买断制,立即生效
订阅购买用户订阅会员,到期自动续费需要处理苹果服务端回调

下面分别讲解。

三、场景一:主动购买(一次性付费)

3.1 业务流程

  1. App端创建订单,传给服务端保存

  2. App调用苹果SDK发起支付

  3. 苹果返回支付票据(receipt)

  4. App将票据 + 订单号传给服务端

  5. 服务端去苹果服务器验证票据

  6. 验证通过后,处理发货逻辑

💡 票据是什么?
票据就是一串很长的字符串,服务端拿着它去苹果服务器验证,苹果会返回一堆明文数据,里面包含购买信息、产品ID、交易ID等。

3.2 完整代码

① 支付验证接口

/**
 * @title 验证支付票据 完成订单接口
 */
public function ios_pay()
{
    $receipt_data = $_REQUEST['ios_billon'] ?? '';
    $order_id = $_REQUEST['order_id'] ?? '';
    
    $result = array('status' => false, 'message' => '非法参数');
    if (empty($receipt_data) || empty($order_id)) {
        return_json($result);
    }
    
    // 请求苹果服务器验证票据
    $appkey = "你的苹果共享秘钥";  // 在苹果开发者后台获取
    $html = $this->acurl($receipt_data, $appkey);
    $data = json_decode($html, true);
    
    // 状态码21007表示沙盒环境,切换到沙盒模式重新验证
    if ($data['status'] == '21007') {
        $html = $this->acurl($receipt_data, $appkey, 1);
        $data = json_decode($html, true);
        $data['sandbox'] = '1';
    }
    
    // 记录日志(方便排查问题)
    $logStr = "************** 票证信息 " . date("Y-m-d H:i:s") . " **************\n\r";
    file_put_contents("apple_pay_log.txt", $logStr . json_encode($data) . "\n\r", FILE_APPEND);
    
    // 验证是否购买成功(status=0表示成功)
    if (intval($data['status']) === 0) {
        // 校验Bundle ID和票据有效性
        if ($data['receipt']['bundle_id'] != IOS_BUNDLE_ID || empty($data['receipt']['in_app'])) {
            return_json($result);
        }
        
        // 提取票据信息
        $ks['status'] = $data['status'];
        $ks['receipt'] = $data['receipt'];
        unset($ks['receipt']['in_app']);
        $data['receipt']['in_app'][0] = $data['receipt']['latest_receipt_info'][0] ?? $data['receipt']['in_app'][0];
        
        // 合并数据
        $k = array_merge($ks, $data['receipt']['in_app'][0]);
        
        $pay_date = date('Y-m-d H:i:s', $k['purchase_date_ms'] / 1000);  // 购买时间
        $transaction_id = $k['transaction_id'];  // 交易ID
        $original_transaction_id = $k['original_transaction_id'];  // 原始交易ID
        
        // ========== 处理你的业务逻辑 ==========
        
        // 判断是否为免费试用
        if ($k['is_trial_period'] == 'true') {
            $update_data['money'] = 0;
            $update_data['ios_status'] = 1;  // 1=免费试用中
            $is_try = 1;
        }
        
        // 更新订单状态,开通会员权益...
        
        // ====================================
        
        $result = array('status' => true, 'message' => '支付成功');
    } else {
        $result = array('status' => false, 'message' => $this->err_msg[$data['status']] ?? $data['status']);
    }
    
    return_json($result);
}

② CURL请求封装(去苹果服务器验证)

/**
 * 去苹果服务器验证票据
 * @param string $receipt_data 票据字符串
 * @param string $appkey 共享秘钥
 * @param int $sandbox 是否沙盒环境(1=沙盒,0=正式)
 * @return string
 */
public function acurl($receipt_data, $appkey = "", $sandbox = 0)
{
    // 构造请求参数
    $POSTFIELDS = array("receipt-data" => $receipt_data);
    if (!empty($appkey)) {
        $POSTFIELDS = array("receipt-data" => $receipt_data, 'password' => $appkey);
    }
    $POSTFIELDS = json_encode($POSTFIELDS);
    
    // 苹果验证地址
    $url_buy = "https://buy.itunes.apple.com/verifyReceipt";      // 正式环境
    $url_sandbox = "https://sandbox.itunes.apple.com/verifyReceipt"; // 沙盒环境
    $url = $sandbox ? $url_sandbox : $url_buy;
    
    // 发起请求
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $POSTFIELDS);
    $result = curl_exec($ch);
    curl_close($ch);
    
    return $result;
}

3.3 验证前后的数据对比

验证前的票据示例:

MjdwZjYxNWEtN2QwZi00Y2VmLWFiYmUtY...

就是一串很长的字符串,看不出任何信息)

验证后的数据(status=0代表成功):

{
    "status": 0,
    "receipt": {
        "bundle_id": "com.your.app",
        "in_app": [...],
        "latest_receipt_info": [...]
    }
}

⚠️ 注意original_transaction_id 是苹果订阅的“身份证”,建议存下来,后续续费回调时会用到。

四、场景二:订阅购买(自动续费)

4.1 什么是订阅购买?

苹果订阅的特点是:

  • 用户购买后,会在Apple ID的订阅中心生成一条记录
  • 到期后自动扣款续费(除非用户手动取消)
  • 每次续费,苹果会通过服务端回调通知你

4.2 业务流程

1. 用户首次订阅购买(走主动购买的流程)
2. 苹果服务器记录订阅关系
3. 到期前:苹果自动扣款
4. 苹果服务器回调你的接口(通知续费成功)
5. 你根据回调信息,更新会员有效期

4.3 配置回调地址

这个接口需要在 苹果开发者后台 配置:

路径:App Store Connect → App → 服务端通知 → 回调URL 填入:https://你的域名/api/ios_continu_pay

4.4 完整代码

/**
 * 苹果订阅续费回调接口
 * 需要在苹果开发者后台配置
 */
public function ios_continu_pay()
{
    // 获取苹果回传的原始数据
    $input_data = trim(file_get_contents("php://input"));
    
    // 记录日志(重要!方便排查问题)
    $logStr = "************** 苹果订阅回调 " . date("Y-m-d H:i:s") . " **************\n\r";
    file_put_contents("apple_subscribe_log.txt", $logStr . $input_data . "\n\r", FILE_APPEND);
    
    $resp_str = json_decode($input_data, true);
    
    if (!empty($resp_str)) {
        $data = $resp_str['unified_receipt'];
        $notification_type = $resp_str['notification_type'];  // 通知类型
        $password = $resp_str['password'];  // 共享秘钥
        
        // 验证秘钥是否正确
        if ($password == "你的苹果共享秘钥") {
            
            // 获取最新的票据信息
            $receipt = $data['latest_receipt_info'] ?? $data['latest_expired_receipt_info'];
            $receipt = self::arraySort($receipt, 'purchase_date', 'desc');
            
            $original_transaction_id = $receipt['original_transaction_id'];  // 原始交易ID
            $transaction_id = $receipt['transaction_id'];  // 本次交易ID
            $purchaseDate = str_replace(' America/Los_Angeles', '', $receipt['purchase_date_pst']);
            
            // ========== 根据通知类型处理业务 ==========
            // INITIAL_BUY:初次购买
            // RENEWAL:自动续费成功
            // DID_RENEW:续费成功
            // INTERACTIVE_RENEWAL:用户在App Store内手动续费
            // CANCEL:用户取消订阅
            
            switch ($notification_type) {
                case 'RENEWAL':
                case 'DID_RENEW':
                case 'INTERACTIVE_RENEWAL':
                    // 续费成功,延长会员有效期
                    // 根据 original_transaction_id 找到用户,更新会员到期时间
                    break;
                case 'CANCEL':
                    // 用户取消订阅,记录订阅状态
                    break;
                default:
                    // 其他通知类型,仅记录日志,不处理业务
                    break;
            }
            // ========================================
            
        } else {
            // 秘钥不正确,记录错误日志
            // NewLog::log('订阅回调密码不正确', 'ios_pay_error');
        }
    }
}

4.5 辅助函数:数组排序(取最新一条)

/**
 * 二维数组排序,取最新的一条数据
 */
public static function arraySort($arr, $key, $type = 'asc')
{
    $keyArr = [];
    foreach ($arr as $k => $v) {
        $keyArr[$k] = $v[$key];
    }
    
    if ($type == 'asc') {
        asort($keyArr);
    } else {
        arsort($keyArr);
    }
    
    foreach ($keyArr as $k => $v) {
        $newArray[$k] = $arr[$k];
    }
    
    $newArray = array_merge($newArray);
    return $newArray[0];
}

4.6 常见的 notification_type

类型含义是否需要处理
INITIAL_BUY初次购买订阅✅ 需要(首次开通会员)
RENEWAL自动续费成功✅ 需要(延长会员)
DID_RENEW续费成功(同RENEWAL)✅ 需要
INTERACTIVE_RENEWAL用户手动续费✅ 需要
CANCEL用户取消订阅⚠️ 记录状态即可
DID_CHANGE_RENEWAL_PREF用户更改订阅计划⚠️ 可选处理
EXPIRED订阅过期⚠️ 记录状态即可

五、踩坑记录 & 避坑指南

坑1:沙盒环境和正式环境容易搞混

环境验证地址用户类型
沙盒sandbox.itunes.apple.com测试账号
正式buy.itunes.apple.com真实用户

解决方案:优先用正式环境验证,返回21007再切到沙盒。


坑2:订阅回调通知类型太多,不知道处理哪些

苹果会发很多类型的通知,不是全部都要处理。核心只需要处理续费成功的类型RENEWALDID_RENEWINTERACTIVE_RENEWAL

其他类型记录日志即可。


坑3:original_transaction_id 是唯一标识

苹果订阅中,original_transaction_id 是订阅记录的“身份证”,同一个订阅关系从首次购买到最后一次续费,这个ID都不变。

建议:把这个ID存到用户表,续费回调时用它来定位用户。


坑4:票据验证的时机

不要在客户端收到票据后立即验证。苹果的票据生成有延迟,建议:

  • 立即验证一次
  • 如果失败,延迟3-5秒再重试一次

六、最后

以上就是苹果IAP订阅支付的服务端完整实现。

核心总结:

要点说明
主动购买客户端拿票据 → 服务端去苹果验证 → 发货
订阅续费配置苹果回调地址 → 处理续费通知 → 延长会员
核心字段original_transaction_id 是订阅唯一标识
常见坑点沙盒/正式环境切换、通知类型筛选

网上的资料确实不多,希望这篇文章能帮到正在对接苹果支付的你。

如果有什么问题,欢迎留言交流。


👍 如果对你有帮助,欢迎点赞、收藏、转发!