PAYATHON 2026

Android in-app billing 取消订阅后的状态变化

支付小周

结论

取消订阅、卸载应用和订阅到期是三个互不相同的事件:

  • 用户取消订阅,只会关闭自动续订。当前计费周期结束前,订阅权益通常仍然有效。
  • 卸载或重新安装应用不会让订阅提前到期,也不会让用户自动获得再次购买同一订阅的资格。
  • 重新安装不一定会触发 onPurchaseStateChange。应用启动后应主动查询当前购买记录,并根据到期时间和续订状态恢复权益。
  • Google Play Developer API 可以查询订阅状态,但必须由可信服务器调用,不能把服务账号凭据放进 Android 应用。
  • API 可能显示订阅已取消或不再自动续订,同时返回一个未来的到期时间。这表示用户已经取消续订,但当前权益尚未失效。

因此,业务代码不能只判断 CANCELED,还要结合订阅状态、到期时间,以及宽限期、账号保留期等状态判断权益。

取消订阅后发生了什么

用户在有效计费周期内取消订阅后,通常会发生以下变化:

  1. 自动续订关闭。
  2. 当前周期已经支付的权益继续有效。
  3. 如果用户没有恢复订阅或再次续订,权益会在 expiryTime 到达后结束。
  4. Google Play 不一定会通过旧版购买状态回调立即通知应用。
  5. 服务端查询或实时开发者通知可能会更早显示自动续订已经关闭。

可以从两个维度理解订阅状态:

维度取消后的典型结果
当前是否有使用权益到期前通常为“是”
到期后是否自动续费“否”

“已取消”并不等于“当前无权使用”。

卸载并重新安装是否触发回调

不要依赖重新安装来触发 onPurchaseStateChange。

onPurchaseStateChange 是旧版 Google Play Billing 的事件式处理方式,通常用于接收应用进程运行期间发生的购买变化,不能作为可靠的订阅恢复机制。应用卸载后无法接收回调,重新安装时,Google Play 也不保证通过同样的回调重放历史事件。

应用应在启动、登录成功和从后台恢复时主动查询购买记录。使用现代 Google Play Billing Library 时,可以调用 queryPurchasesAsync():

billingClient.queryPurchasesAsync(
    QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build()
) { billingResult, purchases ->

    if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
        // 查询失败:不要直接撤销用户权益,应重试或使用服务端缓存状态
        return@queryPurchasesAsync
    }

    purchases.forEach { purchase ->
        if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
            // 将 purchaseToken 发送到自己的服务器进行校验
            verifySubscriptionOnServer(
                productIds = purchase.products,
                purchaseToken = purchase.purchaseToken
            )
        }
    }
}

客户端查询可以发现当前账号下的购买记录,但是否授予订阅权益,最好由服务器根据 Google Play Developer API 的查询结果决定。

用户能否重新购买

卸载应用不会清除 Google Play 账号上的订阅记录。重新安装后,只要用户仍使用购买订阅时的 Google 账号,Google Play 就知道该订阅尚未到期。

对于同一个订阅商品,用户通常不能因为重新安装应用就创建一份完全独立的新购买。购买页面可能提供以下操作:

  • 恢复已经取消但尚未到期的订阅;
  • 重新开启自动续订;
  • 切换基础方案或优惠;
  • 按订阅替换规则升级或降级。

具体能执行哪些操作,取决于 Billing Library 版本、订阅商品配置、基础方案、优惠资格和替换模式。购买界面能够打开,不代表用户可以重复持有同一订阅。最终应以 Google Play 返回的 BillingResponseCode 和服务端订阅状态为准。

HTTP API 应该怎样查询

Google Play Developer API 应由应用服务器调用,基本流程如下:

  1. Android 客户端取得 purchaseToken。
  2. 客户端把 purchaseToken、商品标识和当前登录用户标识发送给服务器。
  3. 服务器使用受保护的凭据调用 Google Play Developer API。
  4. 服务器检查订阅状态和每个订阅项的到期时间。
  5. 服务器把计算后的权益状态返回客户端。

不要在 APK 中保存服务账号私钥或其他能够调用 Developer API 的长期凭据。APK 可以被反编译,一旦这类凭据泄露,攻击者可能访问开发者账号下的购买数据。

现代接口通常使用 purchases.subscriptionsv2.get 查询订阅:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
Authorization: Bearer {server_access_token}

服务器端的判断逻辑可以概括为:

if subscription is expired:
    deny entitlement
else if subscription is active:
    grant entitlement
else if subscription is canceled and expiryTime is in the future:
    grant entitlement until expiryTime
else if subscription is in grace period:
    apply the application's grace-period policy
else if subscription is on hold or paused:
    apply the corresponding entitlement policy
else:
    require further verification

不要直接信任客户端传来的包名、商品 ID、用户 ID 或购买状态。服务器还应验证购买令牌所对应的应用、订阅项和账号绑定关系,防止同一个令牌被绑定到多个业务账号。

API 会不会返回 CANCELED

这取决于调用的接口版本和查看的字段。

使用较新的订阅 API 时,用户关闭自动续订后,接口可能把订阅生命周期状态标记为类似 SUBSCRIPTION_STATE_CANCELED,同时在 lineItems 中返回未来的 expiryTime。这表示:

订阅不会继续续费,但在 expiryTime 到达前,已支付周期的权益仍可能有效。

使用旧版订阅查询接口时,常见结果包括:

  • autoRenewing 变为 false;
  • 返回取消原因或其他取消信息;
  • expiryTimeMillis 仍然晚于当前时间。

旧接口中的 purchaseState、取消字段和通知时机,不能直接套用到新版 subscriptionState。应按照实际接口的字段定义处理,不能因为状态名称中包含 CANCELED 就立即撤销权益。

服务端可以把原始状态转换为业务状态:

enum class EntitlementState {
    ACTIVE_RENEWING,
    ACTIVE_UNTIL_EXPIRY,
    GRACE_PERIOD,
    ON_HOLD,
    EXPIRED,
    UNKNOWN
}

其中:

  • ACTIVE_RENEWING:当前权益有效,并会自动续订;
  • ACTIVE_UNTIL_EXPIRY:自动续订已经取消,但权益会保留到到期时间;
  • EXPIRED:订阅已经到期,且没有后续有效订阅项。

推荐的状态处理方案

较为可靠的实现需要组合使用以下机制:

  • Billing Library:发起购买,并在客户端查询当前购买记录;
  • Google Play Developer API:在服务器校验购买令牌和订阅生命周期;
  • Real-time Developer Notifications:及时接收续订、取消、到期、暂停和进入宽限期等变化;
  • 定期校验:弥补通知丢失、延迟或服务器暂时不可用造成的状态缺口;
  • 服务端保存权益到期时间:客户端短暂离线时,可以按照业务策略使用缓存结果。

不要使用下面这种处理方式:

// 不推荐:CANCELED 可能只是停止自动续订,当前周期仍然有效
if (subscriptionState == "CANCELED") {
    revokeAccessImmediately()
}

更合理的判断方式是:

val now = Instant.now()

val hasEntitlement =
    expiryTime.isAfter(now) &&
    subscriptionState in setOf(
        "SUBSCRIPTION_STATE_ACTIVE",
        "SUBSCRIPTION_STATE_CANCELED",
        "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"
    )

这只是简化示例。生产环境还要分别处理暂停、账号保留、待处理交易、订阅替换、退款和撤销等情况,并以当前 Google Play Developer API 对各状态的定义为准。

取消订阅决定后续是否续费,到期时间决定当前周期的权益是否结束。卸载或重新安装应用都不会改变这两个事实。

备注:内容仅供参考。