Unity IAP 如何处理 Google Store 非消耗品退款撤权
结论
Unity IAP 客户端没有可靠的“退款完成”事件,因此应用无法在 Google Play 退款后立即调用 ShowAds()。
对于 Google Play 的非消耗品,建议由服务端管理授权状态:
- 购买成功后,将
purchaseToken、productId和用户账号发送给后端。 - 后端通过 Google Play Developer API 验证购买。
- 后端订阅 Google Play Real-time Developer Notifications(RTDN)。
- 收到退款或撤销通知后,重新查询订单状态并撤销服务端的 pro 权限。
- 应用在启动、登录和回到前台时向后端查询权益。如果权限已撤销,则重新启用广告。
只使用 Unity IAP 的本地收据校验,无法可靠判断一张原本有效的收据后来是否已经退款。
为什么现有代码检测不到退款
ProcessPurchase 表示客户端收到了一笔购买,不会通知之后发生的退款。用户退款时,应用可能根本没有运行,因此客户端事件无法保证及时送达。
CrossPlatformValidator.Validate() 主要验证收据签名、应用标识和内容是否可信,不能证明这笔交易在验证时仍未退款。一张过去合法签发的收据,退款后也不会因为本地验签而自动失效。
RestoreTransactions() 同样不适合作为退款通知机制。重新查询 Google Play 当前拥有的商品时,已撤销的商品通常不会再作为有效购买返回,但这种方式只适合客户端同步,并有以下限制:
- 应用必须启动并成功连接 Google Play。
- 不同版本的 Unity IAP 和 Google Play Billing Library,恢复行为可能有所不同。
- 如果 pro 状态已经永久写入
PlayerPrefs,购买记录消失也不会自动清除这个状态。 - 本地状态可能被删除或篡改,不能作为高价值权益的最终依据。
因此,不能只在 ProcessPurchase() 中执行一次 HideAdsPro(),随后一直信任本地标记。
推荐的处理流程
1. 购买后把 token 交给后端
从 Unity IAP 的 Google Play 收据中提取 purchaseToken。不同 Unity IAP 版本使用的收据模型和接口可能不同,下面继续使用问题代码中的 CrossPlatformValidator:
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
{
if (e.purchasedProduct.definition.id != Pro)
return PurchaseProcessingResult.Complete;
#if RECEIPT_VALIDATION
try
{
var receipts = validator.Validate(e.purchasedProduct.receipt);
var googleReceipt = receipts
.OfType<GooglePlayReceipt>()
.FirstOrDefault();
if (googleReceipt == null)
{
Debug.LogError("Google Play receipt was not found.");
return PurchaseProcessingResult.Complete;
}
StartCoroutine(VerifyPurchaseWithServer(
e.purchasedProduct.definition.storeSpecificId,
googleReceipt.purchaseToken
));
// 如果需要等待后端验证后再确认交易,可在此返回 Pending,
// 验证成功后调用 m_Controller.ConfirmPendingPurchase(product)。
return PurchaseProcessingResult.Complete;
}
catch (IAPSecurityException ex)
{
Debug.LogError("Invalid receipt: " + ex.Message);
SetProEnabled(false);
return PurchaseProcessingResult.Complete;
}
#else
Debug.LogError("Server-side purchase verification is required.");
return PurchaseProcessingResult.Complete;
#endif
}
实际项目中,最好等服务器确认购买有效后再授予永久权益。如果使用 PurchaseProcessingResult.Pending,需要保证处理过程幂等,并在服务器确认成功后调用:
m_Controller.ConfirmPendingPurchase(product);
不要为了等待网络而无限保持 pending 状态。超时、重试和重复回调都需要处理。
2. 后端调用 Google Play Developer API
后端需要使用以下信息查询非消耗品订单:
- Android package name
- Google Play 商品 ID
purchaseToken
具体接口取决于项目当前使用的 Google Play Developer API 版本。传统接口通常是 purchases.products.get,较新的项目也可能使用 Google 提供的新商品购买状态接口。实现时,应以当前官方文档和实际 API 响应字段为准。
后端至少需要校验:
- package name 是否与自己的应用一致;
- product ID 是否为预期的
pro; - purchase token 对应的购买状态是否有效;
- token 是否已绑定到另一个用户;
- 订单是否已按 Google Play 的要求完成 acknowledgement;
- 收到退款或撤销后,服务端权益是否已设为无效。
服务端可以采用类似的数据结构:
user_id
package_name
product_id
purchase_token
entitlement_active
last_verified_at
revoked_at
每个 purchaseToken 应唯一绑定到一个内部用户,授予和撤销权益的操作都需要保持幂等。
3. 使用 RTDN 接收退款和撤销消息
在 Google Play Console 中配置 Real-time Developer Notifications。Google Play 会通过 Google Cloud Pub/Sub 向后端发送购买状态变化通知。
对于退款或撤销,需要处理与作废购买有关的通知,例如 voidedPurchaseNotification。收到通知后,不要只根据通知内容直接修改权益。建议按以下顺序处理:
收到 Pub/Sub 消息
→ 验证并解析消息
→ 根据 purchaseToken 定位订单
→ 调用 Google Play Developer API 查询最终状态
→ 更新 entitlement_active
→ 记录撤销原因和处理时间
RTDN 只表示状态发生了变化,最终判断应以 Developer API 的查询结果为准。Pub/Sub 消息可能重复或乱序,因此处理程序必须幂等。
还可以定期调用 Voided Purchases API 对账,弥补通知配置错误、临时处理失败或历史消息遗漏。该 API 的查询范围和分页规则可能调整,实现时应参考当前的 Google Play 文档,不能将它当作无限期保存的完整订单数据库。
客户端如何撤销 pro
应用不必直接订阅 Google 的退款事件,只需定期向自己的服务端查询当前账号是否拥有 pro:
[Serializable]
public class EntitlementResponse
{
public bool proActive;
}
private IEnumerator RefreshEntitlement()
{
using var request =
UnityEngine.Networking.UnityWebRequest.Get(
"https://api.example.com/v1/me/entitlements");
request.SetRequestHeader(
"Authorization",
"Bearer " + GetUserAccessToken());
yield return request.SendWebRequest();
if (request.result !=
UnityEngine.Networking.UnityWebRequest.Result.Success)
{
Debug.LogWarning("Could not refresh entitlement: " + request.error);
// 网络失败时采用宽限期、保持上次状态还是暂时恢复广告,
// 应由产品策略决定。
yield break;
}
var result = JsonUtility.FromJson<EntitlementResponse>(
request.downloadHandler.text);
SetProEnabled(result.proActive);
}
private void SetProEnabled(bool enabled)
{
if (enabled)
{
AdsManager.Instance.HideAdsPro();
}
else
{
AdsManager.Instance.ShowAds();
}
// 这里只能作为缓存,不能作为最终授权来源。
PlayerPrefs.SetInt("pro_cache", enabled ? 1 : 0);
PlayerPrefs.Save();
}
建议至少在以下时机调用 RefreshEntitlement():
- 用户登录后;
- Unity IAP 初始化完成后;
- 应用从后台回到前台时;
- 距离上次同步已有一段时间时;
- 购买成功并完成服务端验证后。
退款发生后,服务端先通过 RTDN 撤销权限,应用下次与服务端同步时再恢复广告。实际能多快生效,取决于 RTDN 的处理速度和客户端的同步频率。应用离线时,任何服务器方案都无法立即改变设备上的本地状态。
没有后端时的最低限度方案
如果暂时没有后端,可以在 Unity IAP 初始化完成后,根据 Google Play 当前返回的有效购买重新计算 pro 状态,不要只在购买时把状态永久写成 true:
public void OnInitialized(
IStoreController controller,
IExtensionProvider extensions)
{
m_Controller = controller;
m_GooglePlayStoreExtensions =
extensions.GetExtension<IGooglePlayStoreExtensions>();
Product pro = controller.products.WithID(Pro);
bool currentlyOwned =
pro != null &&
pro.hasReceipt &&
!string.IsNullOrEmpty(pro.transactionID);
SetProEnabled(currentlyOwned);
}
这只是降级方案。hasReceipt 表示 Unity IAP 当前拿到了收据,并不等于服务器已经实时确认该购买没有退款。它的行为也会受到 Unity IAP 版本、缓存和商店查询结果的影响。测试时需要覆盖退款后清除应用数据、重新安装、离线启动和切换账号等场景。
代码中的其他注意事项
不要通过 localizedTitle 查找商品:
if (product.metadata.localizedTitle == Pro)
标题会被本地化,也可能在 Google Play Console 中修改。应使用稳定的商品 ID:
Product pro = m_Controller.products.WithID(Pro);
return pro?.metadata.localizedPriceString;
在 Android 上,Unity IAP 通常会在初始化时同步已有的非消耗品购买。不要把 RestoreTransactions() 的回调当作退款回调。无论是否主动恢复购买,都需要重新计算完整的权益状态,并明确执行 SetProEnabled(false)。如果只在发现购买时执行 HideAdsPro(),已经退款的用户可能仍会保留本地 pro 状态。
如果 pro 权限没有账号体系,应用完全离线,权限状态也只保存在设备上,就无法可靠、及时地在退款后撤销权益。要让退款处理可以审计,需要同时使用服务端验证、RTDN、Developer API 复查和客户端权益同步。
备注:内容仅供参考。
