一.前言
很多游戏需要接入内购IAP,对于苹果端,我们直接对接苹果就行了,但是android平台太多,国内,我们需要接入支付宝,微信,或者华为支付,小米支付等。国外,我们需要接入谷歌支付,亚马逊等等,相对来说都是比较麻烦的,所以,一般我们使用聚合的支付SDK,会省很多力气。
二.什么是UnityIAP
Unity IAP 是Unity官方出的一个支付插件,可让我们轻松地在Unity中接入内购
Unity IAP 支持的商店如下所示:
商店名称 | 系统平台 | 版本 | 网站 |
---|---|---|---|
Google支付 | Android | 3.0.3 | Google ReleaseNotes |
亚马逊应用商店 | Android | 2.0.76 | Amazon SDK |
Unity分发平台 | Android | 2.0.0 and higher | UDP |
IOS应用商店 | MacOS/iOS/tvOS | Store Kit v1 | Apple Store Kit |
微软应用商店 | Windows | Microsoft SDK |
根据上表,我们可以知道,UnityIAP主要还是支持海外的应用内购,对国内的众多手机品牌内购暂不支持。
不过对于我们发海外的游戏来说,已经足够了。
下面我们来以Apple和Goolgle为例,说一下IAP的接入
三.导入SDK
有的教程包括官方的一些老教程,会引导我们打开Services面板,打开In-AppPurchasing 的开关,unity会自动导入IAP的插件,但是这个流程卡顿不说,团队协作时,每个人都需要打开Services的开关,否则会报错。
其实从Unity2019+的版本开始,我们就不需要从Services这里导入sdk了,直接走PackageManager就行。打开Unity的工具包管理PackageManager,搜索找到In App Purchasing
插件,并导入到工程
四.定义产品
产品编号设置
输入跨平台唯一标识符,作为产品与应用商店通信时的默认 ID。
重要提示:ID 只能包含小写字母、数字、下划线或句点。
产品类别设置
每个产品必须是以下类型之一:
类型 | 描述 | 例子 |
---|---|---|
消耗品 | 用户可以重复购买产品。消耗品无法恢复。 | 虚拟货币 健康药水 临时加电。 |
非消耗品 | 用户只能购买一次产品。非消耗品可以恢复。 | 武器或盔甲 访问额外内容 无广告 |
订阅 | 用户可以在有限的时间内访问产品。订阅产品可以恢复。 | 每月访问在线游戏 VIP 身份授予每日奖金 免费试用 |
产品元数据设置
本部分定义了与您的产品相关联的元数据,以便在游戏内商店中使用。
说明:使用以下字段为您的产品添加描述性文本:
场地 | 数据类型 | 描述 | 例子 |
---|---|---|---|
产品区域设置 | 枚举 | 确定您所在地区可用的应用商店。 | 英语(美国)(Google Play、Apple) |
产品名称 | string | 您的产品在应用商店中显示的名称。 | “健康药水” |
产品描述 | string | 您的产品在应用商店中出现的描述性文本,通常是对产品是什么的解释。 | “恢复 50 点生命值。” |
支出设置
支出设置是我们展示给购买者的内容,通过使用名称和数量标记产品,我们可以在购买时快速调整某些项目类型(例如,硬币或宝石)的游戏内数量。
场地 | 数据类型 | 描述 | 例子 |
---|---|---|---|
支付类型 | 枚举 | 定义购买者收到的内容类别。有四种可能的类型。 | 货币 项目 资源 其他 |
支付子类型 | string | 为内容类别提供粒度级别。 | 货币类型的“金”和“银”子类型 物品类型的“药水”和“助推”子类型 |
数量 | int | 指定购买者在付款中收到的项目数、货币等。 | 1 >25 100 |
数据 | 以任何您喜欢的方式使用此字段作为在代码中引用的属性。 | UI 元素的标志 物品稀有度 |
五.接入UnityIAP
1.初始化
新建一个类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;
}
/// 初始化失败回调
/// 当 Unity IAP 遇到不可恢复的初始化错误时调用。
/// 请注意,如果网络不可用,不会调用此方法;unityIap将后台重试,直到可用为止。
public void OnInitializeFailed (InitializationFailureReason error)
{
}
/// 购买完成回调
/// 初始化成功后,随时可能会调用
public PurchaseProcessingResult ProcessPurchase (PurchaseEventArgs e)
{
return PurchaseProcessingResult.Complete;
}
/// 购买失败
public void OnPurchaseFailed (Product i, PurchaseFailureReason p)
{
}
}
2.发起支付
当用户想要购买产品时,调用IStoreController.InitiatePurchase
方法
// 当用户点击购买按钮,进入支付流程
public void OnPurchaseClicked(string productId) {
controller.InitiatePurchase(productId);
}
发起支付后,无论是调用ProcessPurchase成功购买,还是OnPurchaseFailed失败。都将被异步通知结果.
3.支付回调
购买完成时会调用商店监听器的 ProcessPurchase()
函数。并且函数需要我们返回一个结果,来告诉IAP程序是否已完成对购买的处理,
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
//商品定义信息,包括id,类型等
public ProductDefinition definition { get; private set; }
//商品元数据,包括价格,描述等
public ProductMetadata metadata { get; internal set; }
//唯一的交易id,作为一次购买的唯一id
public string transactionID { get; internal set; }
//交易订单的收据,很长很长的一个Base64加密的字符串,用来验证订单合法性
public string receipt { get; internal set; }
}
函数返回值
结果 | 描述 |
---|---|
PurchaseProcessingResult.Complete | 应用程序已完成对购买的处理,不应再次向应用程序通知此事。 |
PurchaseProcessingResult.Pending | 应用程序仍在处理购买,除非调用 IStoreController 的 ConfirmPendingPurchase 函数,否则将在下一次应用程序启动时再次调用 ProcessPurchase。 |
请注意,如果应用程序在 ProcessPurchase 处理程序执行过程中崩溃,那么在 Unity IAP 下次初始化时会再次调用它,因此我们需要重复数据删除功能。另外在初始化成功后,随时可能调用 ProcessPurchase。
Unity IAP 要求返回确认购买,以确保在网络中断或应用程序崩溃的情况下可靠地完成购买。在应用程序离线时完成的任何购买都将在下次初始化时发送给应用程序。
六.内购二次验证
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 方法。
/// <summary>
/// IStoreListener 对 OnInitialized 的实现。
/// </summary>
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
{
// 从 IStoreListener 派生 Purchaser 类使其能够接收来自 Unity Purchasing 的消息。
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; // Unity 采购系统
private IExtensionProvider m_StoreExtensionProvider; // 商店特定的采购子系统.
private string purchasingProductId; //正在支付支付中的productId
private const string PendingPrefs = "PendingPrefs";
private Dictionary<string, ProductData> pendingProducts = new Dictionary<string, ProductData>();
public void Initialize(List<ProductDefinition> products)
{
if (IsInitialized())
{
return;
}
// Create a builder, first passing in a suite of Unity provided stores.
var module = StandardPurchasingModule.Instance();
module.useFakeStoreUIMode = FakeStoreUIMode.StandardUser;
var builder = ConfigurationBuilder.Instance(module);
builder.AddProducts(products);
//调用 UnityPurchasing.Initialize 方法可启动初始化过程,从而提供监听器的实现和配置。
//请注意,如果网络不可用,初始化不会失败;Unity IAP 将继续尝试在后台初始化。仅在 Unity IAP 遇到无法恢复的问题(例如配置错误或在设备设置中禁用 IAP)时,初始化才会失败。
//因此,Unity IAP 所需的初始化时间量可能是任意的;如果用户处于飞行模式,则会是无限期的时间。您应该相应地设计您的应用商店,防止用户在初始化未成功完成时尝试购物。
UnityPurchasing.Initialize(this, builder);
InitPendingOrder();
}
private bool IsInitialized()
{
// Only say we are initialized if both the Purchasing references are set.
return m_StoreController != null && m_StoreExtensionProvider != null;
}
// Notice how we use the general product identifier in spite of this ID being mapped to
// custom store-specific identifiers above.
public void BuyProduct(string productId)
{
// If Purchasing has been initialized ...
if (IsInitialized())
{
// ... look up the Product reference with the general product identifier and the Purchasing
// system's products collection.
Product product = m_StoreController.products.WithID(productId);
// If the look up found a product for this device's store and that product is ready to be sold ...
if (product != null && product.availableToPurchase)
{
Debug.Log(string.Format("Purchasing product asychronously: '{0}'", product.definition.id));
// ... buy the product. Expect a response either through ProcessPurchase or OnPurchaseFailed asynchronously.
m_StoreController.InitiatePurchase(product);
}
// Otherwise ...
else
{
// ... report the product look-up failure situation
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
{
// ... report the fact Purchasing has not succeeded initializing yet. Consider waiting longer or
// retrying initiailization.
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;
}
}
}
// Restore purchases previously made by this customer. Some platforms automatically restore purchases, like Google.
// Apple currently requires explicit purchase restoration for IAP, conditionally displaying a password prompt.
public void RestorePurchases()
{
// If Purchasing has not yet been set up ...
if (!IsInitialized())
{
// ... report the situation and stop restoring. Consider either waiting longer, or retrying initialization.
Debug.Log("RestorePurchases FAIL. Not initialized.");
OnPurchaseFailedEvent?.Invoke((int)PurchaseFailureReason.PurchasingUnavailable,null);
return;
}
// If we are running on an Apple device ...
if (Application.platform == RuntimePlatform.IPhonePlayer ||
Application.platform == RuntimePlatform.OSXPlayer)
{
// ... begin restoring purchases
Debug.Log("RestorePurchases started ...");
// Fetch the Apple store-specific subsystem.
var apple = m_StoreExtensionProvider.GetExtension<IAppleExtensions>();
// Begin the asynchronous process of restoring purchases. Expect a confirmation response in
// the Action<bool> below, and ProcessPurchase if there are previously purchased products to restore.
apple.RestoreTransactions((result) =>
{
// The first phase of restoration. If no more responses are received on ProcessPurchase then
// no purchases are available to be restored.
Debug.Log("RestorePurchases continuing: " + result +
". If no further messages, no purchases available to restore.");
});
}
// Otherwise ...
else
{
// We are not running on an Apple device. No work is necessary to restore purchases.
Debug.Log("RestorePurchases FAIL. Not supported on this platform. Current = " + Application.platform);
}
}
#region IStoreListener
/// <summary>
/// 初始化成功
/// </summary>
/// <param name="controller"></param>
/// <param name="extensions"></param>
public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
{
// Purchasing has succeeded initializing. Collect our Purchasing references.
Debug.Log("OnInitialized: PASS");
// Overall Purchasing system, configured with products for this application.
m_StoreController = controller;
// Store specific subsystem, for accessing device-specific store features.
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
}
/// <summary>
/// 初始化失败
/// </summary>
/// <param name="error"></param>
public void OnInitializeFailed(InitializationFailureReason error)
{
// Purchasing set-up has not succeeded. Check error for reason. Consider sharing this reason with the user.
Debug.Log("OnInitializeFailed InitializationFailureReason:" + error);
purchasingProductId = null;
OnInitializeFailedEvent?.Invoke((int) error);
}
/// <summary>
/// 购买成功
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs args)
{
purchasingProductId = null;
var pdata = ProductData.FromProduct(args.purchasedProduct);
// A product has been purchased by this user.
OnPurchaseSuccessEvent?.Invoke(pdata);
AddPendingOrder(pdata);
// Return a flag indicating whether this product has completely been received, or if the application needs
// to be reminded of this purchase at next app launch. Use PurchaseProcessingResult.Pending when still
// saving purchased products to the cloud, and when that save is delayed.
return PurchaseProcessingResult.Complete;
}
/// <summary>
/// 购买失败
/// </summary>
/// <param name="product"></param>
/// <param name="failureReason"></param>
public void OnPurchaseFailed(Product product, PurchaseFailureReason failureReason)
{
// A product purchase attempt did not succeed. Check failureReason for more detail. Consider sharing
// this reason with the user to guide their troubleshooting actions.
Debug.Log(string.Format("OnPurchaseFailed: FAIL. Product: '{0}', PurchaseFailureReason: {1}",
product.definition.storeSpecificId, failureReason));
OnPurchaseFailedEvent?.Invoke((int) failureReason, ProductData.FromProduct(product));
}
#endregion
#region 二次验证
/// <summary>
/// 初始化未完成订单
/// </summary>
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);
}
}
/// <summary>
/// 添加未完成订单,等待服务器验证
/// </summary>
/// <param name="product"></param>
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);
}
}
/// <summary>
/// 已完成验证的删除订单
/// </summary>
/// <param name="product"></param>
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;
}
/// <summary>
/// 通知server进行订单收据的二次验证
/// </summary>
/// <param name="userID">用户id</param>
/// <param name="product">商品</param>
/// <param name="callback">回调</param>
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);
}
/// <summary>
/// 检测待办订单列表
/// </summary>
/// <param name="userID">用户id</param>
/// <param name="callback">回调</param>
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