PAYATHON 2026

升级订阅套餐时出现 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 后仍然出现相同错误,可以按以下顺序检查:

  1. 在 Play Console 中确认两个 base plan 均为 Active,并且测试地区可用。
  2. 确认上传到测试轨道的应用版本使用了正确的 package name、签名和 Billing Library 配置。
  3. 确认测试账号已加入 license testing 或相应测试轨道,并且应用确实是从 Google Play 安装的。
  4. 重新查询 ProductDetails,不要长期缓存旧的 offerToken。
  5. 确认传入的是当前有效订阅的 purchaseToken。
  6. 检查用户是否存在暂停、宽限期、账号保留、待处理交易或未确认购买。
  7. 清除 Google Play 商店缓存或更换测试账号,排除商店端缓存问题。
  8. 等待 Play Console 配置完成同步。套餐、价格或 offer 刚修改后,各地区的数据可能不会立即一致。
  9. 记录 BillingResult.responseCode、debugMessage、目标产品、base plan、offer、地区和测试时间,以便提交 Google Play 支持工单。

注意事项

SERVICE_UNAVAILABLE 本身无法准确说明是哪项配置有误。它也可能表示 Google Play 暂时不可用、账号状态异常或商店缓存存在问题。不过,如果只有某一个套餐组合能够稳定复现错误,其他组合始终正常,就应该先检查替换模式和实际价格关系,而不是反复重试网络请求。

业务层也不应该直接把周期变长定义为 upgrade。更稳妥的方式是为每一种允许的套餐转换单独配置替换模式:

  • 在续费点切换:DEFERRED
  • 立即切换并调整剩余有效期:WITH_TIME_PRORATION
  • 立即切换权益,下次续费时再收费:WITHOUT_PRORATION
  • 只有确认目标套餐的单位时间价格更高时,才使用 CHARGE_PRORATED_PRICE

这样可以避免 yearly 折扣后的单位价格更低,却被客户端误判为可以收取按比例差价的升级。

备注:内容仅供参考。