一.前言
很多游戏需要接入内购IAP,对于苹果端,我们直接对接苹果就行了,但是android平台太多,国内,我们需要接入支付宝,微信,或者华为支付,小米支付等。国外,我们需要接入谷歌支付,亚马逊等等,相对来说都是比较麻烦的,所以,一般我们使用聚合的支付SDK,会省很多力气。
二.什么是UnityIAP
Unity IAP 是Unity官方出的一个支付插件,可让我们轻松地在Unity中接入内购 Unity IAP 支持的商店如下所示:
根据上表,我们可以知道,UnityIAP主要还是支持海外的应用内购,对国内的众多手机品牌内购暂不支持。 不过对于我们发海外的游戏来说,已经足够了。 下面我们来以Apple和Goolgle为例,说一下IAP的接入
三.接入UnityIAP
1.导入SDK
有的教程包括官方的一些老教程,会引导我们打开Services面板,打开In-AppPurchasing 的开关,unity会自动导入IAP的插件,但是这个流程卡顿不说,团队协作时,每个人都需要打开Services的开关,否则会报错。 其实从Unity2019+的版本开始,我们就不需要从Services这里导入sdk了,直接走PackageManager就行。打开Unity的工具包管理PackageManager,搜索找到In App Purchasing 插件,并导入到工程
2.定义产品
产品编号设置
输入跨平台唯一标识符,作为产品与应用商店通信时的默认 ID。 重要提示:ID 只能包含小写字母、数字、下划线或句点。
产品类别设置
每个产品必须是以下类型之一:
类型 | 描述 | 例子 |
---|
消耗品 | 用户可以重复购买产品。消耗品无法恢复。 | 虚拟货币 健康药水 临时加电。 | 非消耗品 | 用户只能购买一次产品。非消耗品可以恢复。 | 武器或盔甲 访问额外内容 无广告 | 订阅 | 用户可以在有限的时间内访问产品。订阅产品可以恢复。 | 每月访问在线游戏 VIP 身份授予每日奖金 免费试用 |
产品元数据设置
本部分定义了与您的产品相关联的元数据,以便在游戏内商店中使用。 说明:使用以下字段为您的产品添加描述性文本:
场地 | 数据类型 | 描述 | 例子 |
---|
产品区域设置 | 枚举 | 确定您所在地区可用的应用商店。 | 英语(美国)(Google Play、Apple) | 产品名称 | string | 您的产品在应用商店中显示的名称。 | “健康药水” | 产品描述 | string | 您的产品在应用商店中出现的描述性文本,通常是对产品是什么的解释。 | “恢复 50 点生命值。” |
支出设置
支出设置是我们展示给购买者的内容,通过使用名称和数量标记产品,我们可以在购买时快速调整某些项目类型(例如,硬币或宝石)的游戏内数量。
场地 | 数据类型 | 描述 | 例子 |
---|
支付类型 | 枚举 | 定义购买者收到的内容类别。有四种可能的类型。 | 货币 项目 资源 其他 | 支付子类型 | string | 为内容类别提供粒度级别。 | 货币类型的“金”和“银”子类型 物品类型的“药水”和“助推”子类型 | 数量 | int | 指定购买者在付款中收到的项目数、货币等。 | 1 >25 100 | 数据 | | 以任何您喜欢的方式使用此字段作为在代码中引用的属性。 | UI 元素的标志 物品稀有度 |
3.IAP编程
初始化
新建一个类IAPManager,必须继承IStoreListener接口,UnityIAP内购事件通过此接口来通知我们。 调用UnityPurchasing.Initialize 方法初始化IAP,我们需要传入相应的配置和商品信息进入。
注意:如果网络不可用,初始化不会失败;Unity IAP 将继续尝试在后台初始化。仅当 Unity IAP 遇到不可恢复的问题(例如配置错误或在设备设置中禁用 IAP)时,初始化才会失败。 因此 Unity IAP 可能需要任意时间来初始化;如果用户处于飞行模式,则无限期。如果初始化未成功完成,您应该通过防止用户尝试购买来相应地设计您的商店。
示例代码如下:
using UnityEngine;
using UnityEngine.Purchasing;
public class MyIAPManager : IStoreListener {
private IStoreController controller;
private IExtensionProvider extensions;
public MyIAPManager () {
var module = StandardPurchasingModule.Instance();
var builder = ConfigurationBuilder.Instance(module);
builder.AddProduct("100_gold_coins", ProductType.Consumable, new IDs
{
{"100_gold_coins_google", GooglePlay.Name},
{"100_gold_coins_mac", MacAppStore.Name}
});
UnityPurchasing.Initialize (this, builder);
}
public void OnInitialized (IStoreController controller, IExtensionProvider extensions)
{
this.controller = controller;
this.extensions = extensions;
}
public void OnInitializeFailed (InitializationFailureReason error)
{
}
public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs e)
{
return PurchaseProcessingResult.Complete;
}
public void OnPurchaseFailed (Product i, PurchaseFailureReason p)
{
}
}
发起支付
当用户想要购买产品时,调用IStoreController.InitiatePurchase 方法
public void OnPurchaseClicked(string productId) {
controller.InitiatePurchase(productId);
}
发起支付后,无论是调用ProcessPurchase成功购买,还是OnPurchaseFailed失败。都将被异步通知结果.
支付回调
购买完成时会调用商店监听器的 ProcessPurchase() 函数。并且函数需要我们返回一个结果,来告诉IAP程序是否已完成对购买的处理,
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
public ProductDefinition definition { get; private set; }
public ProductMetadata metadata { get; internal set; }
public string transactionID { get; internal set; }
public string receipt { get; internal set; }
}
函数返回值
结果 | 描述 |
---|
PurchaseProcessingResult.Complete | 应用程序已完成对购买的处理,不应再次向应用程序通知此事。 | PurchaseProcessingResult.Pending | 应用程序仍在处理购买,除非调用 IStoreController 的 ConfirmPendingPurchase 函数,否则将在下一次应用程序启动时再次调用 ProcessPurchase。 |
请注意,如果应用程序在 ProcessPurchase 处理程序执行过程中崩溃,那么在 Unity IAP 下次初始化时会再次调用它,因此我们需要重复数据删除功能。另外在初始化成功后,随时可能调用 ProcessPurchase。
Unity IAP 要求返回确认购买,以确保在网络中断或应用程序崩溃的情况下可靠地完成购买。在应用程序离线时完成的任何购买都将在下次初始化时发送给应用程序。
4.完成内购流程
1.立即完成购买
返回 PurchaseProcessingResult.Complete 时,Unity IAP 立即完成交易(如下图所示)。 如果我们的游戏需要服务器验证订单,并分发奖励(例如,在网络游戏中提供游戏币),那么我们就不能返回 PurchaseProcessingResult.Complete。
否则,如果在保存到云端之前卸载应用程序,则购买的消耗品将面临丢失的风险。
2.将购买保存到云端
如果要将消耗品购买交易保存到云端,我们必须返回 PurchaseProcessingResult.Pending,并且仅在成功的二次验证订单成功后,才返回 ConfirmPendingPurchase。
返回 Pending 时,Unity IAP 会在底层商店中保持交易为未结 (open) 状态,直至确认为已处理为止,因此确保了即使在消耗品处于此待处理状态时用户重新安装您的应用程序,消耗品购买交易也不会丢失。
3.收据验证
在函数PurchaseProcessing中返回Pending状态后,我们需要想苹果/谷歌的商店后台发送订单收据进行二次验证,即:VerifyReceipt
订单二次验证有两种方式: 1.客户端直接发送receipt收据到苹果后台,如果成功,直接发放商品 2.客户端发送receipt到server,由server发送receipt收据到苹果后台,成功后返回客户端并发放商品 按照安全性原则,客户端的所有信息都是不可信的,而且支付业务是游戏的核心模块,所以最好选择第二种方式。
ios的收据验证流程 验证服务器地址 1.沙盒测试服务器地址(https://sandbox.itunes.apple.com/verifyReceipt) 2.正式服务器地址(https://buy.itunes.apple.com/verifyReceipt)
客户端拿到receipt,并发送给server,server拿到receipt后,先向苹果正式服务器验证,如果苹果返回state 21007.则代表是沙盒测试环境,然后再向测试服务器进行验证。
4.常见攻击手段
说到支付安全,有些人对此不以为然,下面我给大家罗列一下常用的支付攻击手段
1、劫持apple server攻击 => 通过dns污染,让客户端支付走到假的apple_server,并返回验证成功的response。 这个主要针对支付方式一 如果是支付方式二 就无效。 2、重复验证攻击 => 一个receipt重复使用多次 3、跨app攻击 => 别的app的receipt用到我们app中来 4、换价格攻击 => 低价商品代替高价商品 5、中间人攻击 => 伪造apple_server,如果用户支付就将
5.恢复交易
恢复交易只用于非消耗品或可续订的订阅商品,如果用户卸载冲撞了应用程序时,我们用改给用户恢复这些商品,应用商店给每个用户提供了一条可供UnityIAP检索的永久记录,
在支持交易恢复功能的平台上(例如 Google Play 和通用 Windows 应用程序),Unity IAP 会在重新安装后的第一次初始化期间自动恢复用户拥有的任何商品;系统将为每项拥有的商品调用 IStoreListener 的 ProcessPurchase 方法。
在 Apple 平台上,用户必须输入密码才能检索以前的交易,因此您的应用程序必须为用户提供一个按钮来输入密码。此过程中将会针对用户已拥有的任何商品调用 IStoreListener 的 ProcessPurchase 方法。
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
extensions.GetExtension<IAppleExtensions>().RestoreTransactions (result => {
if (result) {
} else {
}
});
}
四.源代码
最后附上我的完整代码
using System;
using System.Collections.Generic;
using System.Text;
using Common;
using LitJson;
using UnityEngine;
using UnityEngine.Purchasing;
using XLua;
namespace IAP
{
public class PurchaseManager : MonoSingleton<PurchaseManager>, IStoreListener
{
#if UNITY_IOS
private string VerifyURL = URLSetting.BASE_URL + "/charge";
#elif UNITY_ANDROID
private string VerifyURL = URLSetting.BASE_URL + "/gp_charge";
#else
private string VerifyURL = URLSetting.BASE_URL + "/charge";
#endif
[CSharpCallLua] public static event Action<Product[]> OnInitializedEvent;
[CSharpCallLua] public static event Action<int> OnInitializeFailedEvent;
[CSharpCallLua] public static event Action<ProductData> OnPurchaseSuccessEvent;
[CSharpCallLua] public static event Action<int, ProductData> OnPurchaseFailedEvent;
private IStoreController m_StoreController;
private IExtensionProvider m_StoreExtensionProvider;
private string purchasingProductId;
private const string PendingPrefs = "PendingPrefs";
private Dictionary<string, ProductData> pendingProducts = new Dictionary<string, ProductData>();
public void Initialize(List<ProductDefinition> products)
{
if (IsInitialized())
{
return;
}
var module = StandardPurchasingModule.Instance();
module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
var builder = ConfigurationBuilder.Instance(module);
builder.AddProducts(products);
UnityPurchasing.Initialize(this, builder);
InitPendingOrder();
}
private bool IsInitialized()
{
return m_StoreController != null && m_StoreExtensionProvider != null;
}
public void BuyProduct(string productId)
{
if (IsInitialized())
{
Product product = m_StoreController.products.WithID(productId);
if (product != null && product.availableToPurchase)
{
Debug.Log(string.Format("Purchasing product asychronously: '{0}'", product.definition.id));
m_StoreController.InitiatePurchase(product);
}
else
{
Debug.Log(
"BuyProductID: FAIL. Not purchasing product, either is not found or is not available for purchase");
OnPurchaseFailedEvent?.Invoke((int)PurchaseFailureReason.ProductUnavailable, ProductData.FromProduct(product));
}
}
else
{
Debug.Log("BuyProductID FAIL. Not initialized.");
OnPurchaseFailedEvent?.Invoke((int)PurchaseFailureReason.PurchasingUnavailable,null);
}
}
public void CancelPurchase()
{
if (!string.IsNullOrEmpty(purchasingProductId))
{
Product product = m_StoreController.products.WithID(purchasingProductId);
if (product != null && product.availableToPurchase)
{
m_StoreController.ConfirmPendingPurchase(product);
purchasingProductId = null;
}
}
}
public void RestorePurchases()
{
if (!IsInitialized())
{
Debug.Log("RestorePurchases FAIL. Not initialized.");
OnPurchaseFailedEvent?.Invoke((int)PurchaseFailureReason.PurchasingUnavailable,null);
return;
}
if (Application.platform == RuntimePlatform.IPhonePlayer ||
Application.platform == RuntimePlatform.OSXPlayer)
{
Debug.Log("RestorePurchases started ...");
var apple = m_StoreExtensionProvider.GetExtension<IAppleExtensions>();
apple.RestoreTransactions((result) =>
{
Debug.Log("RestorePurchases continuing: " + result +
". If no further messages, no purchases available to restore.");
});
}
else
{
Debug.Log("RestorePurchases FAIL. Not supported on this platform. Current = " + Application.platform);
}
}
#region IStoreListener
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
Debug.Log("OnInitialized: PASS");
m_StoreController = controller;
m_StoreExtensionProvider = extensions;
OnInitializedEvent?.Invoke(controller.products.all);
#if LOGGER_ON
StringBuilder sb = new StringBuilder();
sb.Append("内购列表展示 -> count:" + controller.products.all.Length + "\n");
foreach (var item in controller.products.all)
{
if (item.availableToPurchase)
{
sb.Append("localizedPriceString :" + item.metadata.localizedPriceString + "\n" +
"localizedTitle :" + item.metadata.localizedTitle + "\n" +
"localizedDescription :" + item.metadata.localizedDescription + "\n" +
"isoCurrencyCode :" + item.metadata.isoCurrencyCode + "\n" +
"localizedPrice :" + item.metadata.localizedPrice + "\n" +
"type :" + item.definition.type + "\n" +
"receipt :" + item.receipt + "\n" +
"enabled :" + (item.definition.enabled ? "enabled" : "disabled") + "\n \n");
}
}
Debug.Log(sb.ToString());
#endif
}
public void OnInitializeFailed(InitializationFailureReason error)
{
Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
purchasingProductId = null;
OnInitializeFailedEvent?.Invoke((int) error);
}
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
purchasingProductId = null;
var pdata = ProductData.FromProduct(args.purchasedProduct);
OnPurchaseSuccessEvent?.Invoke(pdata);
AddPendingOrder(pdata);
return PurchaseProcessingResult.Complete;
}
public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
{
Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}",
product.definition.storeSpecificId, failureReason));
OnPurchaseFailedEvent?.Invoke((int) failureReason, ProductData.FromProduct(product));
}
#endregion
#region 二次验证
private void InitPendingOrder()
{
string encrypt = PlayerPrefs.GetString(PendingPrefs);
if (!string.IsNullOrEmpty(encrypt))
{
string json = EncryptUtility.Decrypt(encrypt);
pendingProducts = JsonMapper.ToObject<Dictionary<string, ProductData>>(json);
Logger.Log("[IAP] InitPendingOrder:" + json);
Logger.Log("[IAP] pendingProducts.Count:" + pendingProducts.Count);
}
}
private void AddPendingOrder(ProductData product)
{
if (!pendingProducts.ContainsKey(product.transactionID))
{
pendingProducts.Add(product.transactionID, product);
string json = JsonMapper.ToJson(pendingProducts);
string encrypt = EncryptUtility.Encrypt(json);
PlayerPrefs.SetString(PendingPrefs, encrypt);
}
}
private void RemovePendingOrder(ProductData product)
{
if (pendingProducts.ContainsKey(product.transactionID))
{
pendingProducts.Remove(product.transactionID);
string json = JsonMapper.ToJson(pendingProducts);
string encrypt = EncryptUtility.Encrypt(json);
PlayerPrefs.SetString(PendingPrefs, encrypt);
}
}
public int GetPendingOrderCount()
{
return pendingProducts.Count;
}
public void ReceiptVerify(string userID, ProductData product, Action<int> callback)
{
Dictionary<string, string> dic = new Dictionary<string, string>();
dic.Add("userID", userID);
dic.Add("receipt", product.receipt);
string args = JsonMapper.ToJson(dic);
Debug.LogWarning($"[IAP.Req]: {VerifyURL}?{args}");
NetworkHttp.Instance.Post(VerifyURL, null, ByteUtility.StringToBytes(args), (response) =>
{
if (response == null)
{
callback?.Invoke(408);
return;
}
var result = JsonMapper.ToObject(response);
if (result == null || !result.ContainsKey("code"))
{
callback?.Invoke(408);
Logger.LogError("[IAP.Verify] with err : result is null!");
return;
}
var code = Convert.ToInt32(result["code"].ToString());
if (code != 0)
{
callback?.Invoke(code);
Logger.LogError("[IAP.Verify] with err : {0}", result["msg"]);
return;
}
RemovePendingOrder(product);
callback?.Invoke(200);
}, 15);
}
public void CheckPendingOrder(string userID, Action<int, ProductData> callback)
{
var count = pendingProducts.Count;
if (count == 0)
{
return;
}
foreach (var pair in pendingProducts)
{
ReceiptVerify(userID, pair.Value, code => { callback?.Invoke(code, pair.Value); });
break;
}
}
#endregion
}
}
五.参考链接:
https://learn.unity.com/tutorial/unity-iap https://docs.unity3d.com/cn/2020.3/Manual/UnityIAPInitialization.html https://developer.apple.com/documentation/appstorereceipts
|