升级订阅套餐时出现 In App Billing 错误 DF-DFERH-01
结论
DF-DFERH-01 是 Google Play 购买界面的通用服务端错误,Billing Library 通常会将其映射为 BillingResponseCode.SERVICE_UNAVAILABLE。如果只有 6 months -> yearly 稳定报错,其他购买和套餐切换都正常,问题通常不在设备网络或 Billing Library 初始化,而是这一转换组合不符合所选替换模式的计费条件。
首先检查 IMMEDIATE_AND_CHARGE_PRORATED_PRICE。这种模式只适用于新套餐单位时间价格更高的升级。Google Play 判断升级和降级时,不会简单比较订阅周期或订单总价,而会结合当前生效价格、目标价格、计费周期、地区货币和优惠阶段,计算单位时间价格。
因此,yearly 的周期虽然更长,总价也可能更高,但只要折算后的单位时间价格不高于 6 months,就不能使用 IMMEDIATE_AND_CHARGE_PRORATED_PRICE。部分 Play 后端或旧版购买界面不会返回明确的参数错误,而只会显示 DF-DFERH-01。
为什么只有这一组套餐失败
假设价格如下:
- 6 months:¥198
- yearly:¥328
yearly 的总价更高,但折算后分别是:
- 6 months:约 ¥33/月
- yearly:约 ¥27.33/月
在 Google Play 看来,6 months -> yearly 并没有提高单位时间价格。此时要求系统立即切换并收取剩余周期差价,可能没有正差额可收,因此不符合 IMMEDIATE_AND_CHARGE_PRORATED_PRICE 的使用条件。
不能只按 monthly、6 months 和 yearly 的周期长短来判断升级或降级,还要考虑:
- 用户当前实际支付的价格;
- 当前套餐是否仍采用旧价格或保留价格;
- 目标 base plan 和 offer 的实际价格阶段;
- 免费试用、首期优惠和分阶段价格;
- 不同国家或地区的本地价格;
- 税费、货币和 Play 自动换算后的价格。
其他组合能够正常切换,并不代表这一组配置也符合规则。Google Play 会分别校验每一种替换组合的价格和计费条件。
建议的解决方式
1. 先更换替换模式验证
对于 6 months -> yearly,建议先使用 DEFERRED。用户可以继续使用当前的 6 个月套餐,到下一个续费点再切换为 yearly:
val updateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED
)
.build()
如果业务要求立即切换,可以评估 WITH_TIME_PRORATION 或 WITHOUT_PRORATION。是否支持这些模式,取决于当前使用的 Play Billing Library 版本、套餐类型和 Google Play 的替换规则:
val updateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION
)
.build()
WITHOUT_PRORATION 会立即切换权益,但通常不会立即收费,而是在下一个续费点按新套餐收费:
val updateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION
)
.build()
如果更换替换模式后能够正常打开购买流程,基本可以确认问题出在 CHARGE_PRORATED_PRICE 与实际价格关系不兼容。
2. 使用旧版 Billing Library 时调整 proration mode
旧版 API 可能需要这样写:
val flowParams = BillingFlowParams.newBuilder()
.setSkuDetails(yearlySkuDetails)
.setOldSku(currentSixMonthSku)
.setReplaceSkusProrationMode(
BillingFlowParams.ProrationMode.DEFERRED
)
.build()
val billingResult = billingClient.launchBillingFlow(activity, flowParams)
如果需要立即切换,可以根据当前库版本尝试:
.setReplaceSkusProrationMode(
BillingFlowParams.ProrationMode.IMMEDIATE_WITH_TIME_PRORATION
)
不要对单位时间价格没有上涨的组合继续使用 IMMEDIATE_AND_CHARGE_PRORATED_PRICE。
3. 检查实际参与交易的价格
排查时不能只看 Play Console 中的默认价格,还要确认测试账号所在地区实际返回的 ProductDetails、base plan、offer 和 pricing phase。
例如,可以打印目标价格阶段:
productDetails.subscriptionOfferDetails
?.forEach { offer ->
offer.pricingPhases.pricingPhaseList.forEach { phase ->
Log.d(
"Billing",
"basePlanId=${offer.basePlanId}, " +
"offerId=${offer.offerId}, " +
"priceMicros=${phase.priceAmountMicros}, " +
"currency=${phase.priceCurrencyCode}, " +
"period=${phase.billingPeriod}, " +
"cycles=${phase.billingCycleCount}"
)
}
}
同时核对以下内容:
- 是否选择了预期的
basePlanId; - 是否误用了某个优惠的
offerToken; - yearly 是否包含首期折扣;
- 当前 6 months 用户是否仍在使用旧价格;
- 两个套餐在测试地区是否使用相同货币;
- 目标 pricing phase 是否确实是长期续费阶段。
不要直接通过 priceAmountMicros 比较两个套餐,因为它表示一个计费周期的金额,必须结合 billingPeriod 才能判断价格关系。P6M 和 P1Y 都是日历周期,也不适合按固定天数硬编码换算。
完整调用示例
下面使用现代 ProductDetails API,将 6 个月套餐切换到 yearly,并采用 DEFERRED:
val offerDetails = yearlyProductDetails.subscriptionOfferDetails
?.firstOrNull { offer ->
offer.basePlanId == yearlyBasePlanId &&
offer.offerId == null
}
?: error("Yearly base plan offer not found")
val productDetailsParams =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(yearlyProductDetails)
.setOfferToken(offerDetails.offerToken)
.build()
val updateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED
)
.build()
val flowParams =
BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.setSubscriptionUpdateParams(updateParams)
.build()
val result = billingClient.launchBillingFlow(activity, flowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(
"Billing",
"launchBillingFlow failed: " +
"code=${result.responseCode}, " +
"message=${result.debugMessage}"
)
}
oldPurchaseToken 必须来自当前仍然有效的 6 months 订阅购买记录,不能传入历史订单号、目标套餐 token 或已经失效的 purchase token。
仍然报错时的排查顺序
如果改用 DEFERRED 后仍然出现相同错误,可以按以下顺序检查:
- 在 Play Console 中确认两个 base plan 均为 Active,并且测试地区可用。
- 确认上传到测试轨道的应用版本使用了正确的 package name、签名和 Billing Library 配置。
- 确认测试账号已加入 license testing 或相应测试轨道,并且应用确实是从 Google Play 安装的。
- 重新查询
ProductDetails,不要长期缓存旧的offerToken。 - 确认传入的是当前有效订阅的
purchaseToken。 - 检查用户是否存在暂停、宽限期、账号保留、待处理交易或未确认购买。
- 清除 Google Play 商店缓存或更换测试账号,排除商店端缓存问题。
- 等待 Play Console 配置完成同步。套餐、价格或 offer 刚修改后,各地区的数据可能不会立即一致。
- 记录
BillingResult.responseCode、debugMessage、目标产品、base plan、offer、地区和测试时间,以便提交 Google Play 支持工单。
注意事项
SERVICE_UNAVAILABLE 本身无法准确说明是哪项配置有误。它也可能表示 Google Play 暂时不可用、账号状态异常或商店缓存存在问题。不过,如果只有某一个套餐组合能够稳定复现错误,其他组合始终正常,就应该先检查替换模式和实际价格关系,而不是反复重试网络请求。
业务层也不应该直接把周期变长定义为 upgrade。更稳妥的方式是为每一种允许的套餐转换单独配置替换模式:
- 在续费点切换:
DEFERRED - 立即切换并调整剩余有效期:
WITH_TIME_PRORATION - 立即切换权益,下次续费时再收费:
WITHOUT_PRORATION - 只有确认目标套餐的单位时间价格更高时,才使用
CHARGE_PRORATED_PRICE
这样可以避免 yearly 折扣后的单位价格更低,却被客户端误判为可以收取按比例差价的升级。
备注:内容仅供参考。
