PAYATHON 2026

iPhone Android Recurring Payments API 如何处理订阅

支付阿杰

结论

移动端只负责发起购买,并将购买凭证发送给后端。订阅状态、续费、取消、退款和权限有效期,都应由后端根据商店数据维护。

两端使用的核心凭证不同:

  • Google Play:purchaseToken
  • Apple App Store:StoreKit 2 的签名交易信息(JWS),以及服务端返回的 originalTransactionId 和 transactionId

不能仅凭客户端传来的“支付成功”更新订阅。后端需要调用 Google Play Developer API 查询订单状态,或验证 Apple 的签名数据,同时接入两家商店的服务端通知。

推荐的数据流

  1. 后端配置商品与内部套餐的映射,例如:
google: premium_monthly -> plan_premium_monthly
apple:  premium_monthly -> plan_premium_monthly
  1. App 通过 Google Play Billing Library 或 Apple StoreKit 发起购买。
  2. App 取得商店凭证后,将其发送给后端。
  3. 后端验证凭证,并核对商品、应用、订单状态和有效期。
  4. 后端创建或更新订阅,同时保存商店交易记录。
  5. Google 订单需要在规定时间内完成 acknowledgement。只有消耗型商品才使用 consume,订阅不能当作消耗型商品处理。
  6. 后端持续接收续费、取消、退款、撤销和宽限期等服务端通知。
  7. 用户登录或恢复购买时,后端再次主动查询商店,以修正遗漏的通知。

Google Play 可以获得什么

Android 客户端购买成功后,通常会取得类似以下的数据:

{
  "productId": "premium_monthly",
  "purchaseToken": "opaque-token",
  "orderId": "GPA.1234-5678-9012-34567",
  "purchaseTime": 1760000000000,
  "purchaseState": 1,
  "isAcknowledged": false,
  "isAutoRenewing": true
}

具体字段取决于 Billing Library API 和购买类型,客户端对象不能作为最终支付依据。客户端至少应向后端发送以下信息:

{
  "platform": "google_play",
  "productId": "premium_monthly",
  "purchaseToken": "opaque-token"
}

后端使用 Google Play Developer API 查询订阅。服务端响应通常包含:

  • 应用包名
  • 商品或订阅项
  • 订单及关联订单信息
  • 购买状态
  • 当前订阅有效期
  • 是否自动续订
  • 取消相关信息
  • 付款或价格相关信息
  • acknowledgement 状态
  • 暂停、宽限期、账号保留等生命周期状态
  • 套餐、base plan、offer 等信息

Google Play Developer API 更新迭代时,具体对象和字段也可能变化。新项目应采用当前官方推荐的订阅查询接口,不要按照已经废弃的旧响应结构设计数据库。

后端验证流程可以抽象为:

async function verifyGoogleSubscription(input, userId) {
  const subscription = await googlePlayApi.getSubscription({
    packageName: "com.example.app",
    purchaseToken: input.purchaseToken
  });

  if (!subscription) {
    throw new Error("Subscription not found");
  }

  const purchasedItem = subscription.lineItems?.find(
    item => item.productId === input.productId
  );

  if (!purchasedItem) {
    throw new Error("Product mismatch");
  }

  await saveStoreTransaction({
    userId,
    platform: "google_play",
    productId: input.productId,
    externalTransactionId: subscription.latestOrderId,
    purchaseToken: input.purchaseToken,
    expiresAt: purchasedItem.expiryTime,
    rawPayload: subscription
  });

  return calculateEntitlement(subscription);
}

calculateEntitlement() 不能只检查 isAutoRenewing。用户关闭自动续订后,通常仍可使用订阅,直到当前已付款周期结束。

Apple App Store 可以获得什么

使用 StoreKit 2 时,客户端可以取得经过 Apple 签名的交易数据。常见交易属性包括:

  • transactionId
  • originalTransactionId
  • productId
  • purchaseDate
  • originalPurchaseDate
  • expirationDate
  • revocationDate
  • revocationReason
  • isUpgraded
  • subscriptionGroupIdentifier
  • environment
  • appAccountToken
  • 所有权类型等信息

客户端可以发送签名交易 JWS,也可以发送足以让后端查询交易的标识:

{
  "platform": "app_store",
  "productId": "premium_monthly",
  "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs..."
}

建议在购买时设置由后端生成的 appAccountToken,用来关联 App Store 交易和站内用户。不要直接将邮箱或用户名用于该字段,通常应使用不可预测的 UUID。

后端需要完成以下处理:

  1. 验证 JWS 签名和证书链。
  2. 检查 bundleId、environment 和商品是否属于自己的应用。
  3. 检查交易是否已经撤销。
  4. 使用 originalTransactionId 关联同一条订阅链。
  5. 根据 expirationDate 和续订状态计算用户权限。
  6. 必要时通过 App Store Server API 查询交易历史或订阅状态。

伪代码如下:

async function verifyAppleSubscription(input, userId) {
  const transaction = await verifyAppleJws(
    input.signedTransactionInfo
  );

  if (transaction.bundleId !== "com.example.app") {
    throw new Error("Bundle ID mismatch");
  }

  if (transaction.productId !== input.productId) {
    throw new Error("Product mismatch");
  }

  await saveStoreTransaction({
    userId,
    platform: "app_store",
    productId: transaction.productId,
    externalTransactionId: transaction.transactionId,
    originalTransactionId: transaction.originalTransactionId,
    expiresAt: transaction.expiresDate,
    revokedAt: transaction.revocationDate,
    rawPayload: transaction
  });

  return {
    active:
      !transaction.revocationDate &&
      new Date(transaction.expiresDate) > new Date()
  };
}

实际项目应使用 Apple 提供或认可的服务端库验证 JWS,不能跳过证书链、环境或应用标识校验。

如果仍在维护基于 receipt 的旧实现,服务端也可以验证 App Store receipt。新系统更适合围绕 StoreKit 2、App Store Server API 和 App Store Server Notifications 设计,同时兼容旧交易。

取消、终止和退款是否支持

支持,但取消、终止和退款是三种不同的操作。

用户取消自动续订

用户通常需要前往系统的订阅管理页面取消:

  • Google Play:Play Store 的订阅管理页面
  • Apple:系统或 App Store 的订阅管理页面

取消自动续订通常不会立即终止服务。用户在当前计费周期内仍然拥有权限,直至 expiryTime 或 expirationDate。

App 可以打开订阅管理页面,但不能自行将跳转操作视为“取消成功”:

val intent = Intent(
    Intent.ACTION_VIEW,
    Uri.parse("https://play.google.com/store/account/subscriptions")
)
startActivity(intent)

Apple 平台可以使用 StoreKit 提供的订阅管理入口,具体调用方式取决于采用的 StoreKit API 和系统版本。

开发者终止订阅

Google Play Developer API 提供订阅管理相关的服务端操作。具体可以执行哪些操作,取决于订阅状态和当前接口,例如取消订阅或撤销已经授予的权益。

Apple 订阅通常由用户在 Apple 管理界面中取消。开发者不能只把自己后端的状态改为 canceled,就认为 App Store 订阅已经取消。如果需要停止权益,应使用 Apple 当前提供的服务端能力或后台流程,并明确区分“停止续订”和“撤销当前权益”。

退款

退款由商店支付体系处理,App 不应自行向银行卡或其他付款方式退款。

  • Google Play 可以通过 Play Console 或适用的 Developer API 处理退款、撤销等操作。
  • Apple 退款通常由 Apple 接受申请并进行审核。开发者可以向用户提供 Apple 的退款入口,再通过服务端通知获取退款或撤销结果。

退款后,商店可能发送退款或撤销通知。后端收到通知时,应记录退款交易,再根据通知的具体含义决定立即收回权益,还是将权益保留到周期结束。

“退款政策”不是 API 返回的通用配置对象。政策取决于商店规则、地区法律、商品类型和审核结果,不能通过接口设置一套跨平台通用的退款规则。

服务端通知必须接入

只在购买时验证一次无法持续维护准确的订阅状态。即使 App 没有运行,订阅仍可能发生以下变化:

  • 自动续费成功或失败
  • 用户关闭或恢复自动续订
  • 进入宽限期
  • 账号保留或暂停
  • 升级、降级或更换套餐
  • 退款、撤销或拒付
  • 订阅到期

Google Play 使用 Real-time Developer Notifications。通知通常只说明“某个购买发生了变化”,后端收到通知后,仍需根据 purchaseToken 查询最新状态。

Apple 使用 App Store Server Notifications。通知中包含签名数据,后端需要验证签名,再根据通知类型和子类型更新订阅。

通知处理必须具有幂等性:

async function handleStoreEvent(event) {
  const eventId = getStableEventId(event);

  if (await eventAlreadyProcessed(eventId)) {
    return;
  }

  await database.transaction(async tx => {
    await tx.insertStoreEvent(eventId, event);

    const verifiedState = await queryOrVerifyLatestStoreState(event);
    await tx.upsertSubscription(normalizeSubscription(verifiedState));
    await tx.upsertTransactions(normalizeTransactions(verifiedState));
  });
}

通知可能重复、延迟或乱序,不能简单地按照接收顺序覆盖状态。更新时应比较交易时间、到期时间和商店交易标识。

后端实体如何设计

Apple 和 Google 的原始对象没有必要共用完全相同的字段。更稳妥的设计是保存一层统一的业务状态,同时保留各平台的原始数据。

Subscription
- id
- user_id
- platform
- product_id
- status
- current_period_start
- current_period_end
- auto_renew_enabled
- original_transaction_id
- purchase_token
- environment
- created_at
- updated_at

StoreTransaction
- id
- subscription_id
- platform
- external_transaction_id
- external_order_id
- product_id
- transaction_type
- purchased_at
- expires_at
- revoked_at
- amount
- currency
- raw_payload
- created_at

StoreEvent
- id
- platform
- external_event_id
- event_type
- occurred_at
- raw_payload
- processed_at

金额和币种未必能从所有订阅状态接口中稳定取得。如果财务对账是硬性需求,还应保存订单数据,并使用商店提供的财务或销售报告。不要假设每条生命周期通知都会包含完整金额。

常见陷阱

  • 不要信任客户端提交的 active: true、价格、到期时间或订单状态。
  • 不要只凭商品 ID 开通权益,必须验证 purchaseToken 或签名交易。
  • 一个购买凭证只能绑定一个站内账号。绑定操作需要具备原子性,并设置唯一约束。
  • Apple 应使用 originalTransactionId 追踪订阅链,不能只保存最新的 transactionId。
  • Google 的续订订单可能产生新的订单标识,购买令牌及其替换关系需要按照接口返回处理。
  • 正确区分生产环境和沙盒环境。
  • 取消自动续订不代表订阅立即失效。
  • 支付失败也未必会让订阅立即失效,商店可能进入重试或宽限期。
  • 不要根据客户端设备时间判断订阅是否过期。
  • App 启动时可以同步购买,但服务端通知和主动查询才是最终状态来源。
  • 如果 Web、Apple 和 Google 三套订阅共享账号权益,需要明确重复订阅、跨平台取消以及升级降级规则。商店订阅通常不能由另一个支付渠道直接取消。

备注:内容仅供参考。