一次性购买迁移至 Google Play Billing 订阅的用户管理
结论
Google Play Billing 不提供将一次性商品自动转换为订阅、迁移购买凭证,或为老用户统一开启“永久 grandfathering”的 API。
通常更适合采用“保留旧权益,将新权益订阅化”的方案:
- 已购买一次性非消耗型商品的用户,继续使用购买时承诺的功能。
- 新增的持续性服务、内容更新或云端能力可以纳入订阅。
- 如果必须统一到订阅体系,可以为老用户提供免费过渡期、专属折扣或长期优惠,但应用后端需要负责资格判断和权益管理。
- 不要消耗或撤销原有的一次性购买,也不要只根据本地缓存判断老用户资格。
Google Play 负责处理交易和购买状态,应用自己的 entitlement 系统则负责决定用户最终可以使用哪些功能。
推荐的迁移方案
方案一:永久保留老用户的原有权益
这种方案风险最低,也最容易被用户接受。
假设旧的一次性购买包含 A、B、C 三项功能,老用户可以继续永久使用这些功能。新订阅则可以包含:
- A、B、C;
- 后续开发的新功能;
- 云同步、在线存储等会持续产生成本的服务;
- 高级内容、跨设备能力或持续更新服务。
这样既保留了已购用户的权益,也让订阅拥有独立价值。
这里的“永久”通常是指继续提供用户购买时承诺的权益,并不等于承诺应用、服务器或第三方服务永远存在。具体边界还应与原购买页面、服务条款和当地消费者保护法规保持一致。
方案二:为老用户提供专属订阅条件
如果订阅提供的服务明显多于旧版,可以让老用户自行选择是否升级,例如:
- 免费试用一段时间;
- 首年折扣;
- 长期优惠价格;
- 免费获得某个基础订阅层级;
- 使用一次性购买金额抵扣订阅价值。
Google Play 可以承载订阅基础方案和优惠,但开发者通常仍需根据历史购买记录判断谁属于老用户。不要假设 Play Console 中有一个开关,可以自动把所有一次性购买者转换成 grandfathered 订阅者。
优惠形式和资格限制可能随着 Play Console、Billing Library 及 Google Play 政策发生变化。实施前,应以当时的官方文档和控制台实际配置为准。
方案三:将旧版功能与订阅功能分层
可以按下面的方式划分用户权益:
| 用户类型 | 原有功能 | 新增订阅功能 |
|---|---|---|
| 未购买用户 | 否 | 订阅后可用 |
| 一次性购买老用户 | 永久保留 | 需要订阅或享受专属优惠 |
| 新订阅用户 | 订阅有效时可用 | 订阅有效时可用 |
与强制老用户重新订阅相比,这种设计更容易解释,也能减少退款、差评和合规风险。
权益系统的实现方式
不要把“是否拥有某个商品”的判断分散在多个客户端逻辑中。建议在服务端建立统一的权益模型,例如:
legacy_lifetime_access
subscription_basic
subscription_premium
cloud_sync
premium_content
再分别将一次性购买和订阅映射到这些权益:
legacy_one_time_product
-> legacy_lifetime_access
basic_subscription
-> subscription_basic
-> cloud_sync
premium_subscription
-> subscription_premium
-> cloud_sync
-> premium_content
最终的功能判断可以写成:
val canUseLegacyFeature =
entitlements.contains("legacy_lifetime_access") ||
entitlements.contains("subscription_basic") ||
entitlements.contains("subscription_premium")
以后即使更换商品 ID、基础方案或订阅层级,也不必重写整套业务逻辑。
在客户端识别一次性购买用户
如果原来销售的是非消耗型一次性应用内商品,可以通过 queryPurchasesAsync() 查询当前账号仍然拥有的 INAPP 商品:
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
return@queryPurchasesAsync
}
purchases
.filter { purchase ->
purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
purchase.products.contains("legacy_one_time_product")
}
.forEach { purchase ->
sendPurchaseTokenToBackend(
productId = "legacy_one_time_product",
purchaseToken = purchase.purchaseToken
)
}
}
这段逻辑只能用来发现和同步资格,不能作为服务端最终授权的依据。客户端数据可能过期或遭到篡改,因此应将购买凭证发送到后端,再由后端调用 Google Play Developer API 验证。
验证成功后,可以把老用户资格绑定到应用自己的账号:
{
"accountId": "internal-user-123",
"entitlements": [
{
"name": "legacy_lifetime_access",
"source": "google_play_one_time_purchase",
"productId": "legacy_one_time_product",
"status": "active"
}
]
}
后端还需要保存 purchaseToken 的绑定记录,并限制重复绑定,防止同一购买凭证被多个应用账号重复领取。
恢复购买与账号绑定
一次性非消耗型商品一般可以在同一个 Google Play 账号下恢复,但迁移时还要考虑一个问题:Google Play 账号和应用账号并不是同一种身份。
推荐使用以下流程:
- 用户登录应用账号。
- 应用连接
BillingClient。 - 查询
INAPP类型下当前拥有的一次性商品。 - 将
purchaseToken发送到后端。 - 后端验证购买真实性和商品 ID。
- 后端把旧权益绑定到应用账号。
- 用户以后在其他设备上直接从后端读取 entitlement。
如果应用允许切换账号,需要明确购买绑定和迁移规则,否则可能出现共享购买、重复领取或用户无法找回权益等问题。对于没有应用账号的旧版本,可以提供一次性的“认领旧版权益”流程。
不要对用于永久授权的旧商品调用 consumeAsync()。商品被消耗后通常可以再次购买,其持有状态也可能无法按预期恢复。
订阅购买与老用户优惠
订阅仍需通过新的购买流程完成。老用户购买订阅后会获得新的订阅购买记录和 purchaseToken,不会继续使用原一次性商品的购买凭证。
示意代码如下:
val productDetailsParamsList = listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_subscription")
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productDetailsParamsList)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, result ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
return@queryProductDetailsAsync
}
val productDetails = result.productDetailsList.firstOrNull()
?: return@queryProductDetailsAsync
val selectedOffer = productDetails.subscriptionOfferDetails
?.firstOrNull { offer ->
offer.offerTags.contains("legacy-user-offer")
}
?: return@queryProductDetailsAsync
val productDetailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(selectedOffer.offerToken)
.build()
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
billingClient.launchBillingFlow(activity, flowParams)
}
legacy-user-offer 只是示例标签,并不是 Google Play 预定义的 grandfathering 功能。展示这项优惠之前,应先由后端确认用户是否具备老用户资格。用户最终能否购买,还会受到 Play Console 优惠配置和 Google Play 资格规则的限制。
不能只通过隐藏客户端按钮来保护专属优惠,因为客户端逻辑可能被修改。服务端仍需验证购买结果,以及用户实际购买的商品、基础方案和优惠信息。
Google Play Billing 能做什么
在这个场景中,Google Play Billing 主要可以:
- 查询用户当前拥有的一次性应用内商品;
- 查询订阅商品、基础方案和可用优惠;
- 发起新的订阅购买;
- 返回一次性购买和订阅的购买凭证;
- 配合 Google Play Developer API,在服务端验证购买状态;
- 配合 Real-time Developer Notifications,跟踪订阅续费、取消、暂停和宽限期等状态变化。
但它通常不能:
- 将一次性商品的购买记录转换为订阅记录;
- 将旧
purchaseToken改造成订阅purchaseToken; - 自动为一次性购买者创建免费订阅;
- 自动维护应用层面的永久 grandfathered 权益;
- 自动将 Google Play 购买身份映射到应用自己的账号体系。
因此,grandfathering 本质上是应用的授权策略,不是一种特殊的 Billing 购买类型。
特殊情况
如果旧模式不是应用内商品,而是 Google Play 上的付费应用,处理起来会更复杂。Billing Library 不会把“曾经购买过付费应用”作为普通 INAPP 商品返回,因此不能假设 queryPurchasesAsync() 能够恢复这类购买记录。
常见的处理方式包括:
- 在仍能识别旧版本用户的版本中签发迁移凭证;
- 通过已有的后端账号、历史订单或其他可信记录确认资格;
- 保留旧付费应用,另行发布采用订阅模式的新应用;
- 设置限时迁移窗口,并提供人工申诉或权益恢复渠道。
仅根据安装时间、应用版本、SharedPreferences 或本地数据库授予永久权益并不可靠,因为这些信息可能丢失、被复制或被修改。
上线前注意事项
- 先确认旧用户购买时实际获得了哪些承诺,避免订阅上线后收回用户已经付费购买的功能。
- 保留旧商品 ID,不要为了整理界面而删除识别旧购买所需的映射。
- 客户端负责发现购买,后端负责验证并发放权益。
- 同时查询
INAPP和SUBS,不要因为启用了订阅就停止恢复旧的一次性购买。 - 为换机、重装、Google Play 账号变化和应用账号切换设计恢复流程。
- 记录每项权益的来源,避免把永久权益错误地绑定到订阅有效期。
- 测试退款、撤销、待处理交易、重复绑定和离线启动等边界情况。
- 在购买页清楚说明老用户保留哪些功能、订阅增加哪些功能,以及取消订阅后权益会如何变化。
- 如果涉及撤回既有权益、价格抵扣或长期服务承诺,应结合目标市场的消费者保护规则进行审核。
实践中,较稳妥的做法是保留老用户已经购买的功能,再把持续新增或持续产生成本的服务纳入订阅。这样不需要迁移原购买记录,也能让新订阅拥有清晰且可持续的价值。
备注:内容仅供参考。
