PAYATHON 2026

如何捕获 Google Play Subscriptions 支付成功事件

支付阿杰

结论

可以通过 onPurchasesUpdated() 接收用户完成购买后的结果。这个方法不需要手动调用。当购买状态发生变化时,Google Play Billing 会自动触发回调。

如果当前代码中的回调没有执行,通常是因为创建 BillingClient 时没有通过 .setListener(...) 注册当前的 PurchasesUpdatedListener,或者注册的监听器不是包含该方法的对象。

客户端收到购买成功的结果后,需要把 purchaseToken、商品 ID 和应用包名发送给 PHP 服务端。服务端调用 Google Play Developer API 验证购买,并在验证通过后开通订阅权益。不能只根据客户端传来的金额或成功状态发放权益。

正确注册购买监听器

假设当前类负责处理 Billing,该类需要实现 PurchasesUpdatedListener:

public class BillingManager implements PurchasesUpdatedListener {

    private final Context context;
    private BillingClient billingClient;

    public BillingManager(Context context) {
        this.context = context.getApplicationContext();

        billingClient = BillingClient.newBuilder(this.context)
                .setListener(this)
                .enablePendingPurchases()
                .build();

        connectBillingClient();
    }

    private void connectBillingClient() {
        billingClient.startConnection(new BillingClientStateListener() {
            @Override
            public void onBillingSetupFinished(BillingResult billingResult) {
                if (billingResult.getResponseCode()
                        == BillingClient.BillingResponseCode.OK) {
                    // BillingClient 已连接,可以查询商品和发起购买。
                }
            }

            @Override
            public void onBillingServiceDisconnected() {
                // 连接可能恢复,也可以在需要时重新调用 startConnection()。
            }
        });
    }

    @Override
    public void onPurchasesUpdated(
            BillingResult billingResult,
            @Nullable List<Purchase> purchases
    ) {
        int responseCode = billingResult.getResponseCode();

        if (responseCode == BillingClient.BillingResponseCode.OK
                && purchases != null) {
            for (Purchase purchase : purchases) {
                handlePurchase(purchase);
            }
        } else if (responseCode
                == BillingClient.BillingResponseCode.USER_CANCELED) {
            // 用户取消了付款。
        } else {
            Log.e(
                    "Billing",
                    "Purchase failed: "
                            + responseCode
                            + ", "
                            + billingResult.getDebugMessage()
            );
        }
    }
}

其中最重要的是:

.setListener(this)

这里的 this 必须指向实现了 PurchasesUpdatedListener 的对象。如果监听方法写在 MainActivity 中,可以这样初始化:

public class MainActivity extends AppCompatActivity
        implements PurchasesUpdatedListener {

    private BillingClient billingClient;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        billingClient = BillingClient.newBuilder(this)
                .setListener(this)
                .enablePendingPurchases()
                .build();
    }

    @Override
    public void onPurchasesUpdated(
            BillingResult billingResult,
            @Nullable List<Purchase> purchases
    ) {
        // 处理结果。
    }
}

只重写 onPurchasesUpdated() 并不会自动建立监听关系。发起购买时,必须使用同一个已经注册监听器并成功连接的 BillingClient 实例。

处理成功购买

收到 OK 后不能马上永久开通权益。还要检查购买状态、完成服务端验证,并对购买执行 acknowledge:

private void handlePurchase(Purchase purchase) {
    if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) {
        // PENDING 等状态不能按付款成功处理。
        return;
    }

    String purchaseToken = purchase.getPurchaseToken();
    String orderId = purchase.getOrderId();

    List<String> productIds = purchase.getProducts();
    String productId = productIds.isEmpty() ? null : productIds.get(0);

    if (productId == null || purchaseToken == null) {
        return;
    }

    // 先把购买凭证发送到服务端验证。
    sendPurchaseToServer(
            context.getPackageName(),
            productId,
            purchaseToken,
            orderId
    );
}

发送给 PHP API 的数据至少应包括:

{
  "packageName": "com.yeniasya.enewspaper",
  "productId": "com.yeniasya.enewspaper.subscription.onemonth",
  "purchaseToken": "token returned by Google Play",
  "orderId": "optional order id"
}

服务端还要根据当前登录用户确定订阅归属,不能接受客户端随意提交的用户 ID。更稳妥的方式是在发起购买时设置经过混淆处理的账号 ID,并在验证结果中进行核对。

服务端验证完成后再确认购买

PHP 服务端应使用服务账号凭据调用 Google Play Developer API,并验证以下信息:

  • packageName 是否属于当前应用;
  • purchaseToken 是否有效;
  • 商品或 base plan 是否与请求一致;
  • 订阅是否有效、暂停、取消或过期;
  • token 是否已经绑定到其他用户;
  • 当前到期时间以及自动续订状态。

服务端验证通过并成功保存记录后,客户端再确认购买:

private void acknowledgePurchase(Purchase purchase) {
    if (purchase.isAcknowledged()) {
        return;
    }

    AcknowledgePurchaseParams params =
            AcknowledgePurchaseParams.newBuilder()
                    .setPurchaseToken(purchase.getPurchaseToken())
                    .build();

    billingClient.acknowledgePurchase(
            params,
            billingResult -> {
                if (billingResult.getResponseCode()
                        == BillingClient.BillingResponseCode.OK) {
                    Log.d("Billing", "Purchase acknowledged");
                } else {
                    Log.e(
                            "Billing",
                            "Acknowledge failed: "
                                    + billingResult.getDebugMessage()
                    );
                }
            }
    );
}

如果 acknowledge 已由服务端负责,客户端不应重复处理。购买需要在验证成功后及时确认,否则 Google Play 可能会自动退还未确认的购买。

金额和订阅周期从哪里获取

Purchase 主要包含购买凭证和状态,并不是完整的商品价格对象。金额、币种和订阅周期需要从商品配置中读取。

使用旧版 Billing Library 时,可以从对应的 SkuDetails 获取:

String formattedPrice = skuDetails.getPrice();
long priceAmountMicros = skuDetails.getPriceAmountMicros();
String currencyCode = skuDetails.getPriceCurrencyCode();
String subscriptionPeriod = skuDetails.getSubscriptionPeriod();

subscriptionPeriod 通常使用 ISO 8601 周期格式,例如:

P1M
P3M
P6M
P1Y

P1M、P1Y 只能表示订阅周期,不能证明付款成功。客户端提交的金额也不能直接写入财务记录,因为价格可能受国家、币种、优惠、试用期和价格阶段影响。

在较新的 Billing Library 中,SkuDetails 已由 ProductDetails、base plan、offer 和 pricing phase 模型取代。周期和价格需要从用户实际选择的 SubscriptionOfferDetails 及其 PricingPhase 中读取。具体方法名取决于项目使用的 Billing Library 版本,新旧两套 API 不能混用。

服务端最好维护以下映射:

productId/basePlanId -> 业务套餐 -> 权益期限

例如:

onemonth  -> monthly
threemonth -> three_months
sixmonth -> six_months
oneyear -> yearly

最终套餐应结合 Google Play 的验证结果和服务端配置确定,不能由客户端提交的 "yearly" 字符串直接决定。

修正商品列表处理

querySkuDetailsAsync() 返回的商品顺序不一定与 skuList 中的请求顺序相同。下面的写法可能选错套餐或显示错误的价格:

s[0] = skuDetailsList.get(0);
s[1] = skuDetailsList.get(1);

应该根据商品 ID 建立映射:

Map<String, SkuDetails> detailsBySku = new HashMap<>();

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

SkuDetails oneMonth = detailsBySku.get(
        "com.yeniasya.enewspaper.subscription.onemonth"
);

SkuDetails threeMonths = detailsBySku.get(
        "com.yeniasya.enewspaper.subscription.threemonth"
);

SkuDetails sixMonths = detailsBySku.get(
        "com.yeniasya.enewspaper.subscription.sixmonth"
);

SkuDetails oneYear = detailsBySku.get(
        "com.yeniasya.enewspaper.subscription.oneyear"
);

if (oneMonth != null) {
    tv1MonthMoney.setText(oneMonth.getPrice());
}

if (threeMonths != null) {
    tv3MonthsMoney.setText(threeMonths.getPrice());
}

if (sixMonths != null) {
    tv6MonthsMoney.setText(sixMonths.getPrice());
}

if (oneYear != null) {
    tv1YearMoney.setText(oneYear.getPrice());
}

发起购买前,还要确认 SkuDetails 不为空,并检查 launchBillingFlow() 的返回结果:

private void takeMoney(SkuDetails skuDetails) {
    if (skuDetails == null) {
        Log.e("Billing", "SkuDetails is not available");
        return;
    }

    BillingFlowParams flowParams =
            BillingFlowParams.newBuilder()
                    .setSkuDetails(skuDetails)
                    .build();

    BillingResult result = billingClient.launchBillingFlow(
            MainActivity.mActivity,
            flowParams
    );

    if (result.getResponseCode()
            != BillingClient.BillingResponseCode.OK) {
        Log.e(
                "Billing",
                "Unable to launch billing flow: "
                        + result.getDebugMessage()
        );
    }
}

不要只依赖 onPurchasesUpdated()

这个回调主要处理当前客户端发起的购买流程,无法可靠覆盖以下情况:

  • 应用关闭时发生的自动续订;
  • 用户在其他设备上购买;
  • 订阅续订失败、恢复或过期;
  • 退款、撤销和拒付;
  • 应用在回调完成前退出;
  • 网络中断导致客户端没有成功上传 token。

因此,Billing 连接成功后还需要查询现有订阅,恢复可能遗漏的购买。旧版 API 可以使用 queryPurchasesAsync(),较新版本则需要通过 QueryPurchasesParams 查询 ProductType.SUBS。

服务端需要使用 Google Play 的实时开发者通知监控续订、取消和退款等生命周期事件。收到通知后,再调用 Google Play Developer API 获取权威状态。实时通知只说明状态发生了变化,具体订阅状态仍需通过 API 查询。

完整流程如下:

启动购买
    ↓
onPurchasesUpdated()
    ↓
检查 PurchaseState.PURCHASED
    ↓
上传 productId + purchaseToken + packageName
    ↓
PHP 服务端向 Google Play 验证
    ↓
保存 token、用户和订阅状态
    ↓
acknowledge purchase
    ↓
开通或更新用户权益

这套流程既能处理当前付款结果,也能防止客户端伪造金额、重复使用 token,并减少遗漏后续自动续订事件的风险。

备注:内容仅供参考。