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 状态码说明
准备工作
- 苹果后台设置
- 创建工程导入内购插件
需要详细步骤请查看:
Unity 之 接入IOS内购过程解析
Mac支付和IOS逻辑基本一致,这是我之前做IOS内购时的思维导图,可以看下,先有个概念:
一,具体实现
1.1 场景搭建
创建四个按钮,分别为购买道具
,清空日志
,购买非消耗道具
,恢复购买
;为了方便查看日志,我还创建了一个ScrollView
组件下面放了一个Text接受日志输出。
创建完成效果如下:
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面板属性详解
二,打包测试
2.1 实现步骤说明
Mac内购流程打包步骤
- 使用正式包名
和苹果后台创建的对应上,直接在Unity里面设置好。 - 签名app并打包为pkg
若在Unity中没有设置正确包名,也可以直接在打包处理的app,右键显示包内容,找到信息.plist文件并将CFBundleIdentifier字符串更新为应用程序的包名。
在2.2中还有详细的签名和导出pkg的步骤 - 安装pkg并调用初始化内购项
要正确安装软件包,请删除未打包的。运行新创建的软件包并安装它之前的应用程序文件。
在3.1中有测试步骤实现过程 - 调用购买并尝试购买查看返回数据
测试结果和支付验证
2.2 Mac签名命令
签名需要两个证书和一个签名文件,若之前都没搞过,则可以参考:Unity 之 上传Mac App Store过程详解
文章中有详细获取证书步骤和签名配置所需文件。
- 设置权限
chmod -R a+xr "/Users/Czhenya/Desktop/Mac/你的.app"
- 文件签名
codesign -o runtime -f --deep -s '3rd Party Mac Developer Application: 证书.' --entitlements "/Users/Czhenya/Desktop/App.entitlements" "/Users/Czhenya/Desktop/Mac/你的.app"
- 打包pkg
productbuild --component /Users/Czhenya/Desktop/Mac/你的.app /Applications --sign "3rd Party Mac Developer Installer: 证书." /Users/Czhenya/Desktop/Mac/你的.pkg
三,示例演示
3.1 购买商品
- 商品初始化成功:
- 输入沙盒账号:(首次使用会有双重认证之类的确保身份安全,可选择跳过或按照提示操作即可)
- 若是第一次登录,需要确认Apple ID 安全,点击继续:
- 若出现“保护您的账号”提示,选择不升级即可:
- 最后终于到了支付购买界面了,点击购买就可了:
- 购买完成后,会弹出操作完成提示,点击“好“即可触发支付成功回调(沙盒会稍微慢一点)
- 支付成功回调:
- 取消支付回调:
3.2 购买非消耗道具
- 初始化成功后,点击购买非消耗道具
3.3 恢复购买
- 购买过一次之后,再次购买会购买失败,这时需要点击恢复购买,执行恢复购买逻辑
四,支付验证
若是单机游戏无需服务器进行支付验证,则按照成功回调发放奖励跳过此步骤即可。若需要服务器验证,则将支付成功的Payload传到服务器,获取验证结果后发放奖励或提示支付失败。
4.1 验证返回数据
服务端验证返回数据
iOS发起票据验证请求后,通过处理AppStore返回数据来验单。服务验证需要注意的地方:不同iOS版本的返回数据不同,服务端验证方式也不同。
- 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
}
- 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
}
验证订单是否成功,关键看这几个数据:
- status为 0 表示成功;其他都为失败,表示失败原因。
- 根据 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。
- 根据 transaction_id 对比数据库历史订单判断是否已处理过,没有则认为本次充值是有效的。
4.2 状态码说明
AppStore 服务器有两个,对应测试环境(沙盒测试)和正式环境:
- 沙盒验证地址:https://sandbox.itunes.apple.com/verifyReceipt
- 正式验证地址:https://buy.itunes.apple.com/verifyReceipt
状态码 | 说明 |
21000 | 未使用HTTP POST请求方法向App Store发送请求。 |
21001 | 此状态代码不再由App Store发送。 |
21002 | receipt-data属性中的数据格式错误,或者服务遇到了临时问题。再试一次。 |
21003 | 收据无法认证。 |
21004 | 您提供的共享密钥与您帐户的文件共享密钥不匹配。 |
21005 | 收据服务器暂时无法提供收据。再试一次。 |
21006 | 该收据有效,但订阅已过期。当此状态码返回到您的服务器时,收据数据也会被解码并作为响应的一部分返回。仅针对自动续订的iOS 6样式的交易收据返回。 |
21007 | 该收据来自测试环境,但是已发送到生产环境以进行验证。 |
21008 | 该收据来自生产环境,但是已发送到测试环境以进行验证。 |
21009 | 内部数据访问错误。稍后再试。 |
21010: | 找不到或删除了该用户帐户。 |