PAYATHON 2026

Unity IAP 如何处理 Google Store 非消耗品退款撤权

支付小周

结论

Unity IAP 客户端没有可靠的“退款完成”事件,因此应用无法在 Google Play 退款后立即调用 ShowAds()。

对于 Google Play 的非消耗品,建议由服务端管理授权状态:

  1. 购买成功后,将 purchaseToken、productId 和用户账号发送给后端。
  2. 后端通过 Google Play Developer API 验证购买。
  3. 后端订阅 Google Play Real-time Developer Notifications(RTDN)。
  4. 收到退款或撤销通知后,重新查询订单状态并撤销服务端的 pro 权限。
  5. 应用在启动、登录和回到前台时向后端查询权益。如果权限已撤销,则重新启用广告。

只使用 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 复查和客户端权益同步。

备注:内容仅供参考。