如何在 Play Console 中将 3 个旧 SKU 合并为新订阅?
结论
Play Console 中已有的 SKU A、B、C 不能直接合并为一个 subscription,也不能通过 Billing Library 5.0 API 改变它们在 Google Play 中的归属关系。
自动转换后,A、B、C 仍然是三个独立的订阅商品,各有自己的 product ID、base plan、offer 和购买记录,这些数据不能互换。Billing Library 5.0 只能读取和销售 Play Console 中已有的配置,客户端无法把三个商品重新定义为同一个 Google Play subscription。
可以考虑以下两种方案:
- 如果只想让三个旧商品提供相同的应用权益,可以保留现有的 Play Console 配置,在应用和服务端将 A、B、C 映射到同一个内部 entitlement。
- 如果需要在一个 subscription 下配置两个 base plans 和三个 offers,就必须新建第四个 subscription,并重新创建相应的 base plans 和 offers。旧订阅用户不会自动迁移,系统需要继续兼容旧商品,或者让用户主动切换订阅。
为什么不能直接合并
Billing Library 5.0 将客户端购买模型从 SkuDetails 调整为 ProductDetails,并引入了以下层级:
Subscription
└── Base plan
└── Offer
subscription 的 product ID 依然存在。被弃用的是旧的 SkuDetails API,以及“一个 SKU 对应一种完整订阅方案”的旧建模方式。这并不表示客户端可以合并多个已有的 product ID。
以下数据由 Play Console 和 Google Play 后端管理:
- subscription product ID
- base plan ID
- offer ID
- 用户购买记录所属的商品
- 自动续订和结算状态
- offer 的资格条件
客户端可以将多个 product ID 映射为相同权益,但无法让 Google Play 将它们识别为同一个 subscription,也不能把已有购买记录转移到新商品名下。
方案一:只合并业务权益
如果合并的目的只是让购买 A、B、C 的用户解锁同一组功能,就不必立即调整 Play Console。可以在服务端维护统一的权益映射:
private val premiumProducts = setOf(
"sku_a",
"sku_b",
"sku_c"
)
fun grantsPremiumEntitlement(productId: String): Boolean {
return productId in premiumProducts
}
生产环境不能只根据客户端传入的 productId 发放权益。服务端需要验证 purchase token,确认订阅是否有效,以及是否已经过期、取消或撤销,再将其映射到内部权益:
sku_a ─┐
sku_b ─┼──> premium_entitlement
sku_c ─┘
这种做法可以统一应用内的业务逻辑,但不会改变以下限制:
- 查询商品时,A、B、C 仍会返回三个
ProductDetails。 - 每个商品只能使用自己配置的 base plan 和 offer。
- 后台统计、价格配置和订阅管理仍按三个商品分别处理。
- A 的 offer 不能用于 B 或 C。
- 客户端不能创建 Play Console 中不存在的组合商品。
方案二:建立新的统一订阅
如果希望今后的订阅结构真正变为“一个 subscription、两个 base plans、三个 offers”,需要在 Play Console 中创建一个新的订阅商品。
1. 创建新的 subscription
在 Play Console 的订阅管理页面中新建商品,例如:
Product ID: premium_subscription
product ID 应作为长期稳定的标识。商品创建后,通常不能通过重命名来替代另一个已有商品,因此需要提前确定名称。
2. 创建两个 base plans
按照实际结算周期创建两个 base plans,例如:
monthly
annual
每个 base plan 需要分别设置:
- 自动续订类型
- 结算周期
- 各地区价格
- 宽限期
- 账号保留期
- 重新订阅设置
base plan 对应基础结算方案。如果两个旧 SKU 的差别不在结算周期或续订方式,而只在优惠价格,它们未必需要拆成两个 base plans,也可以配置为同一个 base plan 下的不同 offers。
3. 创建三个 offers
每个 offer 都必须属于某个具体的 base plan,不能直接悬挂在 subscription 下。需要按照原有三个 SKU 的业务规则,把 offers 分配给相应的 base plans,例如:
premium_subscription
├── monthly
│ ├── monthly_free_trial
│ └── monthly_intro_price
└── annual
└── annual_intro_price
配置时还要确定:
- offer tag
- 用户资格条件
- 免费试用或优惠阶段
- 优惠持续时间
- 优惠后的续订价格
- 可用国家或地区
旧商品中的 offer 通常不能直接“移动”到新的 subscription,需要根据原有规则重新创建。也不能假定新 offer 的资格判断与旧 SKU 完全相同,应以 Play Console 中的实际配置为准进行验证。
4. 激活新配置
完成价格、地区和资格条件的设置后,需要激活 base plans 和 offers。只创建 subscription 还不能开始销售,目标 base plan 必须处于可购买状态。
5. 更新客户端商品查询
使用 Billing Library 5.0 时,应通过 ProductDetails 查询新商品:
val productList = listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId("premium_subscription")
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
return@queryProductDetailsAsync
}
val subscription = productDetailsList.firstOrNull() ?: return@queryProductDetailsAsync
val offers = subscription.subscriptionOfferDetails.orEmpty()
offers.forEach { offer ->
val basePlanId = offer.basePlanId
val offerId = offer.offerId
val offerToken = offer.offerToken
// 根据 basePlanId、offerId、价格和业务规则展示购买选项。
}
}
发起购买时,必须传入所选方案对应的 offerToken:
val productDetailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(selectedOffer.offerToken)
.build()
val billingFlowParams =
BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
不要按列表位置选择 offer。Play 返回的顺序不能作为稳定的业务标识,判断时应结合 basePlanId、offerId、offer tags 和定价阶段。
如何处理已有订阅用户
新建统一 subscription 不会改变 A、B、C 的现有购买记录。已经订阅旧商品的用户仍应被识别为有效订阅用户。
过渡期间,客户端通常需要同时查询新旧商品:
val productIds = listOf(
"sku_a",
"sku_b",
"sku_c",
"premium_subscription"
)
val products = productIds.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
}
服务端也要同时接受这些商品:
enum class Entitlement {
PREMIUM
}
fun entitlementFor(productId: String): Entitlement? =
when (productId) {
"sku_a",
"sku_b",
"sku_c",
"premium_subscription" -> Entitlement.PREMIUM
else -> null
}
在确认旧订阅者已经全部退出之前,不要删除旧商品的识别逻辑。即使旧商品不再向新用户销售,存量用户仍可能继续续订、恢复购买,也可能发生退款或撤销等状态变化。
让旧用户主动切换到新订阅
如果要让旧订阅者转到新的统一商品,可以在购买新商品时传入旧订阅的 purchase token,将这次购买声明为订阅替换:
val updateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setReplaceProrationMode(
BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION
)
.build()
val productDetailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(newProductDetails)
.setOfferToken(selectedOffer.offerToken)
.build()
val billingFlowParams =
BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.setSubscriptionUpdateParams(updateParams)
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
替换模式会影响切换时间、剩余价值的折算方式和下次扣款时间。应根据升级、降级或同级切换等场景选择合适的模式,并查阅项目所用 Billing Library 版本的文档。示例中的 IMMEDIATE_WITH_TIME_PRORATION 不能在未经计费行为验证的情况下直接固定使用。
这仍然是由用户发起的新购买或订阅替换流程,并不是后台批量修改商品归属,也不会把旧商品本身合并到新的 subscription 中。
上线时的注意事项
- 新旧商品可能同时有效,服务端需要做好去重,避免重复发放权益。
- 不要将
offerId视为全局唯一标识。理解它时,需要同时考虑 subscription 和 base plan。 - 旧用户不一定符合新 offer 的资格条件,尤其是仅面向新订阅用户的免费试用。
- 新建 offer 时,需要重新检查免费试用资格、适用地区、价格阶段和续订价格。
- 停止销售旧商品前,应先发布能够识别新商品的客户端和服务端版本。
- 移除旧商品的购买入口不等于终止现有订阅,不要影响存量用户续订。
- 服务端应保存并验证 purchase token,并正确处理续订、取消、退款、撤销和订阅替换。
- 如果只需要统一会员权益,保留三个旧 subscription 并建立内部映射通常风险更低。只有确实需要统一商品配置、购买页面和后续运营时,才需要创建新的统一 subscription。
备注:内容仅供参考。