PAYATHON 2026

如何获取 Google Billing 已购商品及其描述

支付阿杰

结论

onPurchasesUpdated() 返回的 purchases 中已经包含实际购买的商品 ID。题目使用的是旧版 Billing API,可以调用 Purchase#getSku() 获取 SKU,再从之前查询到的 SkuDetails 列表中找到对应商品。

当前代码中的成员变量 skuDetails 没有赋值。直接调用下面的方法会触发 NullPointerException:

skuDetails.getSku();
skuDetails.getDescription();

查询商品信息时,需要保存返回的 SkuDetails,并建立一个以 SKU 为键的映射。

商品描述不适合用作积分数值,因为描述可能被修改、翻译,或者包含非数字字符。更可靠的方式是根据固定的商品 ID 确定要增加的积分。

保存查询到的商品信息

在 Activity 中添加一个映射:

private final Map<String, SkuDetails> skuDetailsBySku = new HashMap<>();

查询成功后,按 SKU 保存每个 SkuDetails:

@Override
public void onSkuDetailsResponse(
        int responseCode,
        List<SkuDetails> skuDetailsList
) {
    if (responseCode == BillingClient.BillingResponse.OK
            && skuDetailsList != null) {

        skuDetailsBySku.clear();

        for (SkuDetails details : skuDetailsList) {
            skuDetailsBySku.put(details.getSku(), details);
        }

        loadProductToRecyclerView(skuDetailsList);
    } else {
        Toast.makeText(
                ponerDineroActivity.this,
                "No logro cargarse la lista :(",
                Toast.LENGTH_SHORT
        ).show();
    }
}

购买完成后,就可以通过 Purchase#getSku() 找到对应的 SkuDetails。

在购买回调中识别商品

旧版 API 可以这样处理:

@Override
public void onPurchasesUpdated(
        int responseCode,
        @Nullable List<Purchase> purchases
) {
    if (responseCode != BillingClient.BillingResponse.OK) {
        Toast.makeText(this, "Hubo un Error", Toast.LENGTH_SHORT).show();
        return;
    }

    if (purchases == null || purchases.isEmpty()) {
        return;
    }

    for (Purchase purchase : purchases) {
        String purchasedSku = purchase.getSku();

        SkuDetails purchasedDetails = skuDetailsBySku.get(purchasedSku);

        if (purchasedDetails == null) {
            // 商品详情可能尚未查询,或者应用进程已被重新创建。
            queryDetailsAndProcessPurchase(purchase);
            continue;
        }

        String description = purchasedDetails.getDescription();

        Toast.makeText(
                this,
                "SKU: " + purchasedSku + ", description: " + description,
                Toast.LENGTH_SHORT
        ).show();

        processPurchasedProduct(purchase, purchasedDetails);
    }
}

两者的关联关系如下:

Purchase.getSku()
        ↓
Map<String, SkuDetails>
        ↓
SkuDetails.getDescription()

不要根据用户最后点击的 RecyclerView 位置判断购买结果。支付期间,Activity 可能被重建,用户也可能取消、重试或恢复尚未处理的购买。购买结果应以 Google Billing 返回的 Purchase 为准。

推荐:根据 SKU 计算积分

即使能够获取 getDescription(),也不应把它直接传给 Integer.parseInt()。例如,Google Play 后台中的描述可能是:

Recibe 100 pesos

这段文本无法直接转换为整数。更安全的做法是明确维护 SKU 与积分的对应关系:

private int getPointsForSku(String sku) {
    switch (sku) {
        case "carga_100_pesos":
            return 100;

        case "carga_200_pesos":
            return 200;

        case "carga_300_pesos":
            return 300;

        default:
            throw new IllegalArgumentException("Unknown SKU: " + sku);
    }
}

购买处理代码如下:

private void processPurchasedProduct(
        Purchase purchase,
        SkuDetails details
) {
    String sku = purchase.getSku();

    final int purchasedPoints;
    try {
        purchasedPoints = getPointsForSku(sku);
    } catch (IllegalArgumentException exception) {
        Toast.makeText(this, "Producto desconocido: " + sku, Toast.LENGTH_SHORT).show();
        return;
    }

    int currentPoints;
    try {
        currentPoints = Integer.parseInt(currentusermoney);
    } catch (NumberFormatException exception) {
        Toast.makeText(this, "Saldo actual no válido", Toast.LENGTH_SHORT).show();
        return;
    }

    int total = currentPoints + purchasedPoints;

    currentusermoney = String.valueOf(total);
    dineroactual.setText("Usted Tiene " + currentusermoney + " ARS");

    Toast.makeText(
            this,
            details.getDescription() + " - Total: " + total,
            Toast.LENGTH_SHORT
    ).show();

    // 验证并发放积分后,再确认或消费这笔购买。
}

商品描述可以显示在界面上,但积分规则应由 SKU 或服务端商品配置决定。

商品详情不在缓存时重新查询

SkuDetails 是商品目录信息,不是购买凭证。应用重启后,内存中的 skuDetailsBySku 会丢失,所以购买回调不能假定商品详情始终存在。

此时可以使用购买结果中的 SKU 重新查询:

private void queryDetailsAndProcessPurchase(Purchase purchase) {
    String sku = purchase.getSku();

    SkuDetailsParams params = SkuDetailsParams.newBuilder()
            .setSkusList(Collections.singletonList(sku))
            .setType(BillingClient.SkuType.INAPP)
            .build();

    billingClient.querySkuDetailsAsync(
            params,
            new SkuDetailsResponseListener() {
                @Override
                public void onSkuDetailsResponse(
                        int responseCode,
                        List<SkuDetails> skuDetailsList
                ) {
                    if (responseCode != BillingClient.BillingResponse.OK
                            || skuDetailsList == null
                            || skuDetailsList.isEmpty()) {
                        Toast.makeText(
                                ponerDineroActivity.this,
                                "No se encontraron los detalles del producto",
                                Toast.LENGTH_SHORT
                        ).show();
                        return;
                    }

                    SkuDetails details = skuDetailsList.get(0);
                    skuDetailsBySku.put(details.getSku(), details);
                    processPurchasedProduct(purchase, details);
                }
            }
    );
}

必须防止重复发放

积分是可以重复购买和消耗的权益。如果每次收到购买回调都直接累加,同一笔交易可能在应用重启、网络重试或恢复购买时被重复发放。

较稳妥的处理顺序是:

  1. 从 Purchase 中获取 SKU、购买令牌等购买数据。
  2. 最好把购买令牌发送到可信服务端,并通过 Google Play 验证。
  3. 将购买令牌作为唯一键,检查该订单是否已经发放过积分。
  4. 以原子方式记录订单并增加用户积分。
  5. 积分成功入账后消费该购买。如果商品不是消耗品,则应确认购买。
  6. 消费成功后,用户才能再次购买同一个消耗型商品。

不要只在客户端或通过 Firebase 客户端直接增加余额。本地 SKU 映射和客户端数据都可能被篡改。至少需要保证数据库权限、购买验证,以及基于购买令牌的幂等处理。

关于新版 Google Play Billing

题目中的代码使用旧版 SkuDetails API。较新的 Billing Library 已经改用 ProductDetails,购买对象也可能通过商品 ID 列表返回商品标识,而不是使用单个 getSku()。具体方法名取决于项目采用的 Billing Library 版本,但处理逻辑相同:

从 Purchase 取得 product ID
→ 用 product ID 查找 ProductDetails
→ 验证购买并防止重复发放
→ 按 product ID 决定积分数量
→ 确认或消费购买

对于当前代码,直接的修复方式是使用 purchase.getSku() 查询缓存的 Map<String, SkuDetails>。在生产环境中,应将 SKU 作为稳定的业务标识,不要把可以编辑和本地化的商品描述当作积分值。

备注:内容仅供参考。