Unity 之 Mac App Store 内购过程解析(恢复购买)

  • 准备工作
  • 一,具体实现
  • 1.1 场景搭建
  • 1.2 代码实现
  • 1.3 打包设置
  • 二,打包测试
  • 2.1 实现步骤说明
  • 2.2 Mac签名命令
  • 三,示例演示
  • 3.1 购买商品
  • 3.2 购买非消耗道具
  • 3.3 恢复购买
  • 四,支付验证
  • 4.1 验证返回数据
  • 4.2 状态码说明


准备工作

  1. 苹果后台设置
  2. 创建工程导入内购插件

需要详细步骤请查看:
Unity 之 接入IOS内购过程解析

Unity内购官方文档

Mac支付和IOS逻辑基本一致,这是我之前做IOS内购时的思维导图,可以看下,先有个概念:

iOS 内购 恢复购买怎么处理 苹果内购恢复购买_1024程序员节


一,具体实现

1.1 场景搭建

创建四个按钮,分别为购买道具清空日志购买非消耗道具恢复购买 ;为了方便查看日志,我还创建了一个ScrollView组件下面放了一个Text接受日志输出。

创建完成效果如下:

iOS 内购 恢复购买怎么处理 苹果内购恢复购买_内购支付_02


1.2 代码实现

完整代码如下:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.UI;

/// <summary>
/// IAP管理类
/// </summary>
public class IAPManagerTest : MonoBehaviour, IStoreListener
{
    public Text riZhiText;
    
    /// <summary>
    /// 需要换成对应游戏后台的key
    /// </summary>
    private string[] goodsList = new string[]
    {
        "com.Czhenya.zuan10",
    };
    
    /// <summary>
    /// 非消耗型道具 -- 去除广告的id
    /// </summary>
    private string removedsId = "com.Czhenya.delad";

    private bool isRestore = false;
    
    // 控制器
    private IStoreController controller;

    // 苹果扩展
    private IAppleExtensions appleExtensions;

    // 谷歌商店扩展
    private IGooglePlayStoreExtensions googlePlayStoreExtensions;
    
    private static IExtensionProvider extensionProvider;

    // 是否可以发起购买
    private bool isCanOnClickBubBtn = false;

    void Start()
    {
        Application.targetFrameRate = 60;
        Init();
    }

    /// <summary>
    /// 初始化
    /// </summary>
    private void Init()
    {
        // 没有网络,IAP会一直初始化
        if (Application.internetReachability == NetworkReachability.NotReachable)
        {
            Debug.Log("----- 用户没有连接网络 IAP不可用 ------");
            riZhiText.text += "----- 用户没有连接网络 IAP不可用 ------\n";
        }

        var module = StandardPurchasingModule.Instance();
        ConfigurationBuilder builder = ConfigurationBuilder.Instance(module);
        // builder.AddProduct("商品id1", ProductType.Consumable); 
        // ProductType :和后台说明对应
        // consumable:可消费的,如游戏中的金币,用完还可以再购买。
        // non-consumable:不可销毁的,一次购买,永久生效。比如去广告,解锁游戏关卡,这种商品只能购买一次。
        // subscription:订阅的,这种一般用于新闻、杂志、或者app里面的月卡。可以按月或者按年收费。
        for (int i = 0; i < goodsList.Length; i++)
        {
            builder.AddProduct(goodsList[i], ProductType.Consumable);
        }
        
        // 不可销毁的,一次购买,永久生效。比如去广告,解锁游戏关卡,这种商品只能购买一次。
        builder.AddProduct(removedsId, ProductType.NonConsumable);

        riZhiText.text += "----- 开始初始化... ------\n";
        // 开始初始化
        UnityPurchasing.Initialize(this, builder);
    }

    /// <summary>
    /// 初始化成功 -- 接口函数
    /// </summary>
    /// <param name="controller"></param>
    /// <param name="extensions"></param>
    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        Debug.Log("【Unity IAP】初始化成功 IAP initialize success");
        riZhiText.text += "【Unity IAP】初始化成功 IAP initialize success\n";
        isCanOnClickBubBtn = true;
        this.controller = controller;

        // 回调赋值
        extensionProvider = extensions;
        appleExtensions = extensions.GetExtension<IAppleExtensions>();
        googlePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions>();

        //登记 购买延迟 监听器
        appleExtensions.RegisterPurchaseDeferredListener(OnDeferred);
    }

    //购买延迟提示
    private void OnDeferred(Product item)
    {
        Debug.Log("【Unity IAP】 网速慢.................");
        riZhiText.text += "【Unity IAP】 网速慢.................\n";
    }

    /// <summary>
    /// 初始化失败回调 -- 接口函数
    /// </summary>
    /// <param name="error"></param>
    public void OnInitializeFailed(InitializationFailureReason error)
    {
        Debug.LogError("【Unity IAP】初始化失败 OnInitializeFailed, reason:" + error.ToString());
        riZhiText.text += "【Unity IAP】初始化失败 OnInitializeFailed, reason:" + error.ToString() + "\n";
    }

    /// <summary>
    /// 购买失败回调 -- 接口函数
    /// </summary>
    /// <param name="i"></param>
    /// <param name="p"></param>
    public void OnPurchaseFailed(Product i, PurchaseFailureReason p)
    {
        Debug.LogError("【Unity IAP】购买失败 OnPurchaseFailed,reason:" + p.ToString());
        riZhiText.text += "【Unity IAP】购买失败 OnPurchaseFailed,reason:" + p.ToString() + "\n";
        if (this.onPurchaseFailed != null)
        {
            this.onPurchaseFailed();
            this.onPurchaseFailed = null;
        }
    }

    /// <summary>
    /// 购买成功回调 -- 接口函数
    /// </summary>
    /// <param name="e"></param>
    /// <returns></returns>
    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
    {
        Debug.Log("【Unity IAP】购买成功 purchase finished, apple return receipt:" + e.purchasedProduct.receipt);
        //riZhiText.text += "【Unity IAP】购买成功 purchase finished, apple return receipt:" + e.purchasedProduct.receipt + "\n";
        riZhiText.text += "【Unity IAP】购买成功 e.purchasedProduct.definition.id:" + e.purchasedProduct.definition.id + "\n";
        riZhiText.text += "【Unity IAP】恢复购买成功 isRestore: " + isRestore + "\n";

        if (isRestore) // 恢复购买
        {
            Debug.Log("恢复购买成功 isRestore " + isRestore);
   
            // 判断是否是去除广告id
            if (removedsId.Equals(e.purchasedProduct.definition.id))
            {
                Debug.Log("恢复购买成功");
                // todo... 恢复成功回调
                isRestore = false;
            }
            else
            {
                onPurchaseFailed?.Invoke();
            }

            return PurchaseProcessingResult.Complete;
        }
        
        if (this.onPurchaseSuccess != null)
        {
            this.onPurchaseSuccess(e.purchasedProduct.receipt);
            this.onPurchaseSuccess = null;
        }

        return PurchaseProcessingResult.Complete;
    }

    /// <summary>
    /// 支付失败回调
    /// </summary>
    private Action onPurchaseFailed;

    /// <summary>
    /// 支付成功回调
    /// </summary>
    private Action<string> onPurchaseSuccess;

    /// <summary>
    /// 购买产品
    /// </summary>
    /// <param name="productId">产品ID</param>
    /// <param name="onFailed">失败回调</param>
    /// <param name="onSuccess">成功回调</param>
    public void PurchaseProduct(string productId, Action onFailed, Action<string> onSuccess)
    {
        this.onPurchaseFailed = onFailed;
        this.onPurchaseSuccess = onSuccess;

        if (controller != null)
        {
            var product = controller.products.WithID(productId);
            if (product != null && product.availableToPurchase)
            {
                Debug.Log("【Unity IAP】开始购买");
                riZhiText.text += "【Unity IAP】开始购买... \n";

                controller.InitiatePurchase(productId);
            }
            else
            {
                Debug.LogError("【Unity IAP】失败回调 no product with productId:" + productId);
                riZhiText.text += "【Unity IAP】失败回调 no product with productId:" + productId + " \n";
                if (this.onPurchaseFailed != null)
                {
                    this.onPurchaseFailed();
                }
            }
        }
        else
        {
            Debug.LogError("【Unity IAP】失败回调 controller is null,can not do purchase");
            riZhiText.text += "Unity IAP】失败回调 controller is null,can not do purchase \n";
            if (this.onPurchaseFailed != null)
            {
                this.onPurchaseFailed();
            }
        }
    }

    /// <summary>
    /// 发起购买函数  -- 商城按钮监听
    /// </summary>
    /// <param name="i"></param>
    public void OnClickPurchase(int i)
    {
        // 正式项目时需限制 -- 不允许多次点击 

        Debug.Log("【Unity IAP】发起购买函数 " + Application.internetReachability);
        riZhiText.text += "【Unity IAP】发起购买函数  "+Application.internetReachability+" \n";
        if (Application.internetReachability == NetworkReachability.NotReachable)
        {
            Debug.Log("【Unity IAP】用户没网... ");
            return;
        }

        PurchaseProduct(goodsList[0], OnBuyFailed, OnBuySuccess);
    }

    #region 购买回复非消耗道具

    /// <summary>
    /// 购买非消耗道具 -- 商城按钮监听
    /// </summary>
    public void OnClickRemoved()
    {
        // 正式项目时需限制 -- 不允许多次点击 

        Debug.Log("【Unity IAP】购买一次性道具 " + Application.internetReachability);
        riZhiText.text += "【Unity IAP】购买一次性道具  "+Application.internetReachability+" \n";
        if (Application.internetReachability == NetworkReachability.NotReachable)
        {
            Debug.Log("【Unity IAP】用户没网... ");
            return;
        }

        PurchaseProduct(removedsId, OnBuyFailed, OnBuySuccess);
    }
    
    /// <summary>
    /// 恢复购买非消耗道具 -- 商城按钮监听
    /// </summary>
    public void OnClickRecover()
    {
        // 正式项目时需限制 -- 不允许多次点击 

        Debug.Log("【Unity IAP】恢复购买 " + Application.internetReachability);
        riZhiText.text += "【Unity IAP】恢复购买  "+Application.internetReachability+" \n";
        if (Application.internetReachability == NetworkReachability.NotReachable)
        {
            Debug.Log("【Unity IAP】用户没网... ");
            return;
        }

        if (Application.platform == RuntimePlatform.IPhonePlayer || Application.platform == RuntimePlatform.OSXPlayer)
        {
            Debug.Log("发起恢复请求");
            isRestore = true;
            IAppleExtensions apple = extensionProvider.GetExtension<IAppleExtensions>();
            apple.RestoreTransactions(HandleRestored);
        }
        else
        {
            Debug.Log("恢复购买失败. 不支持这个平台. 当前平台 = " + Application.platform);
        }

    }
    
    // 恢复购买之后,会返回一个状态,如果状态为true,
    // 之前购买的非消耗物品都会回调一次购买成功(ProcessPurchase)
    // 然后在这里个回调里面进行处理
    void HandleRestored(bool result)
    {
        // 返回一个bool值,如果成功,则会多次调用支付回调,然后根据支付回调中的参数得到商品id,最后做处理(ProcessPurchase)
        Debug.Log("恢复购买继续: " + result + ". 如果没有进一步的消息,则没有可恢复的购买。");
        isRestore = result;
        riZhiText.text += "【Unity IAP】恢复购买继续  " + result + ". 如果没有进一步的消息,则没有可恢复的购买。 \n";
        if (result)
        {
            riZhiText.text += "【Unity IAP】恢复购买成功! \n";
            Debug.Log("恢复购买成功!");
        }
        else
        {
            riZhiText.text += "【Unity IAP】恢复购买失败! \n";
            Debug.Log("恢复购买失败!");
        }
        // todo...回调处理
    }

    #endregion
    
    /// <summary>
    /// 购买失败回调
    /// </summary>
    void OnBuyFailed()
    {
        Debug.Log("【Unity IAP】购买失败回调 OnBuyFailed...");
        riZhiText.text += "【Unity IAP】购买失败回调 OnBuyFailed... \n";
    }

    /// <summary>
    /// 购买成功回调
    /// </summary>
    /// <param name="str"></param>
    void OnBuySuccess(string str)
    {
        Debug.Log("【Unity IAP】购买成功回调 OnBuySuccess..." + str);
        riZhiText.text += "【Unity IAP】购买成功回调 OnBuySuccess... \n";
        riZhiText.text += "【Unity IAP】购买成功...收据: " + str + " \n";
        //会得到下面这样一个字符串
        //{"Store":"AppleAppStore",
        //"TransactionID":"1000000845663422",
        //"Payload":"MIIT8QYJKoZIhvcNAQcCoIIT4jCCE94CAQExBBMMIIBa ... 还有N多 ..."}
    }

    public void ClearRiZhi()
    {
        riZhiText.text = "清空数据\n";
    }
}

PS:此代码为上图使用的测试代码,按钮点击监听赋值,在Inspector面板下拖拽赋值。正式使用时可自行删除注释或者点击获取源码


1.3 打包设置

将包名修改为与后台一致,其他属性默认即可。若需要更多设置,可参考:Unity 之 打包参数 – Player面板属性详解

iOS 内购 恢复购买怎么处理 苹果内购恢复购买_unity_03


二,打包测试

2.1 实现步骤说明

Mac内购流程打包步骤

  1. 使用正式包名
    和苹果后台创建的对应上,直接在Unity里面设置好。
  2. 签名app并打包为pkg
    若在Unity中没有设置正确包名,也可以直接在打包处理的app,右键显示包内容,找到信息.plist文件并将CFBundleIdentifier字符串更新为应用程序的包名。
    在2.2中还有详细的签名和导出pkg的步骤
  3. 安装pkg并调用初始化内购项
    要正确安装软件包,请删除未打包的。运行新创建的软件包并安装它之前的应用程序文件。
    在3.1中有测试步骤实现过程
  4. 调用购买并尝试购买查看返回数据
    测试结果和支付验证

2.2 Mac签名命令

签名需要两个证书和一个签名文件,若之前都没搞过,则可以参考:Unity 之 上传Mac App Store过程详解
文章中有详细获取证书步骤和签名配置所需文件。

  1. 设置权限
chmod -R a+xr "/Users/Czhenya/Desktop/Mac/你的.app"
  1. 文件签名
codesign -o runtime -f --deep -s '3rd Party Mac Developer Application: 证书.' --entitlements "/Users/Czhenya/Desktop/App.entitlements" "/Users/Czhenya/Desktop/Mac/你的.app"
  1. 打包pkg
productbuild --component /Users/Czhenya/Desktop/Mac/你的.app /Applications --sign "3rd Party Mac Developer Installer: 证书." /Users/Czhenya/Desktop/Mac/你的.pkg

三,示例演示

3.1 购买商品

  1. 商品初始化成功:
  2. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_macos_04

  3. 输入沙盒账号:(首次使用会有双重认证之类的确保身份安全,可选择跳过或按照提示操作即可)
  4. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_unity_05

  5. 若是第一次登录,需要确认Apple ID 安全,点击继续:
  6. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_内购支付_06

  7. 若出现“保护您的账号”提示,选择不升级即可:
  8. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_unity_07

  9. 最后终于到了支付购买界面了,点击购买就可了:
  10. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_内购支付_08

  11. 购买完成后,会弹出操作完成提示,点击“好“即可触发支付成功回调(沙盒会稍微慢一点)
  12. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_iOS 内购 恢复购买怎么处理_09

  13. 支付成功回调:
  14. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_1024程序员节_10

  15. 取消支付回调:
  16. iOS 内购 恢复购买怎么处理 苹果内购恢复购买_内购支付_11


3.2 购买非消耗道具

  1. 初始化成功后,点击购买非消耗道具

3.3 恢复购买

  1. 购买过一次之后,再次购买会购买失败,这时需要点击恢复购买,执行恢复购买逻辑

四,支付验证

若是单机游戏无需服务器进行支付验证,则按照成功回调发放奖励跳过此步骤即可。若需要服务器验证,则将支付成功的Payload传到服务器,获取验证结果后发放奖励或提示支付失败。

4.1 验证返回数据

服务端验证返回数据
iOS发起票据验证请求后,通过处理AppStore返回数据来验单。服务验证需要注意的地方:不同iOS版本的返回数据不同,服务端验证方式也不同。

  1. iOS7及以上获取的票据返回数据:
{
    receipt =  {
        "adam_id" = 0,
        "app_item_id" = 0,
        "application_version" = 1,
        "bundle_id" = "com.Czhenya",
        "download_id" = 0,
        "in_app" = {
            {
                "is_trial_period" = false,
                "original_purchase_date" = "2022-10-24 01:00:00 Etc/GMT",
                "original_purchase_date_ms" = 1483203661000,
                "original_purchase_date_pst" = "2022-10-24 01:00:00 America/Los_Angeles",
                "original_transaction_id" = 1000000000000001,
                "product_id" = "com.Czhenya.zuan10",
                "purchase_date" = "2022-10-24 01:00:00 Etc/GMT",
                "purchase_date_ms" = 1483203661000,
                "purchase_date_pst" = "2022-10-24 01:00:00 America/Los_Angeles",
                "transaction_id" = 1000000000000001
            }
        },
        "receipt_type" = "ProductionSandbox",
        "request_date" = "2022-10-24 01:00:00 Etc/GMT",
        "request_date_ms" = 1483203661000,
        "request_date_pst" = "2022-10-24 01:00:00 America/Los_Angeles",
        "version_external_identifier" = 0,
    },
    status = 0
}
  1. iOS7以下获取的票据返回数据(不包括iOS7):
{
    receipt = {
        "bid" = "com.Czhenya",
        "bvrs" = 1,
        "item_id" = 573837050,
        "original_purchase_date" = "2022-10-24 01:00:00 Etc/GMT",
        "original_purchase_date_ms" = 1483203661000,
        "original_purchase_date_pst" = "2022-10-24 01:00:00 America/Los_Angeles",
        "original_transaction_id" = 1000000000000001,
        "product_id" = "com.Czhenya.zuan10",
        "purchase_date" = "2022-10-24 01:00:00 Etc/GMT",
        "purchase_date_ms" = 1483203661000,
        "purchase_date_pst" = "2022-10-24 01:00:00 America/Los_Angeles",
        "transaction_id" = 1000000000000001
    },
    status = 0
}

验证订单是否成功,关键看这几个数据

  1. status为 0 表示成功;其他都为失败,表示失败原因。
  2. 根据 receipt.in_app 字段判断iOS版本,验证方法也不同:
  • iOS7及以上:有in_app字段,验证 receipt.bundle_id 是否为你 App 的 bundle id,根据 in_app 处理充值的每一笔订单, 根据 product_id 判断用户充值了哪个档位,同时取出 transaction_id。
  • iOS7以下:没有in_app字段,验证 receipt.bid 是否为你 App 的 bundle id,根据 product_id 判断用户充值了哪个档位,同时取出 transaction_id。
  1. 根据 transaction_id 对比数据库历史订单判断是否已处理过,没有则认为本次充值是有效的。

4.2 状态码说明

AppStore 服务器有两个,对应测试环境(沙盒测试)和正式环境:

状态码

说明

21000

未使用HTTP POST请求方法向App Store发送请求。

21001

此状态代码不再由App Store发送。

21002

receipt-data属性中的数据格式错误,或者服务遇到了临时问题。再试一次。

21003

收据无法认证。

21004

您提供的共享密钥与您帐户的文件共享密钥不匹配。

21005

收据服务器暂时无法提供收据。再试一次。

21006

该收据有效,但订阅已过期。当此状态码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。

21007

该收据来自测试环境,但是已发送到生产环境以进行验证。

21008

该收据来自生产环境,但是已发送到测试环境以进行验证。

21009

内部数据访问错误。稍后再试。

21010:

找不到或删除了该用户帐户。