Apple In App Purchase(IAP) 实战

之前和 Flutter 开发的同学整了很久的 IAP,坑实在是太多了,所以需要一条条的来解释,如果有和我一样的倒霉鬼,希望通过这一篇集合的文章让大家在开发阶段更加顺利。

注:本文不会涉及 Native 的开发怎么写,因为我不会写 Native,也并不负责移动端的开发。

开发之前

在开发之前,App 的前情提要配置流程请见官方文档:App 内购买项目配置流程

在配置完成之后开发就可以开始了,但是在开发之前,你可能想要知道一些 IAP 的计费规则:

  1. IAP 的定价方式:并不是以数字来区分的,而是以等级来区分,比如等级 1,就是 0.99 美元 / 6 元 RMB,他会任意映射到任意地区/货币的价格,汇率由苹果来决定。详情可见价格与销售范围
  2. 关于抽成:普通商品收益率 70%;自动订阅商品不满一年为 70%,用户订阅满一年,收益率将变成 85%。具体规则如下(详情请见自动续期订阅群组设置概述):
在订阅者使用付费服务的首年内,您的收益率为 70%。当订阅者为同一订阅群组中的订阅产品累积一年的付费服务后,您的收益率将提高至 85%。同一群组中的升级订阅、降级订阅和跨级订阅不会中断付费服务的天数。转换至不同群组的订阅将重置付费服务的天数。赚取 85% 订阅价格这一规则适用于2016年6月之后生效的订阅续期。

普通内购的开发

接下来我们会配合苹果官方文档中的图文来解释一下大致的运行和开发流程。

15886016826600.jpg

如图,整个通信流程中,由移动端去调用 StoreKit(也就是 SDK),而我们的 Server 由两个用途,之后会逐一解释:1. 由 App 调用接口进行一些读写操作 2. 与 App Store 提供的接口进行通信验证(以及接受回调)。

而至于 App 与 App Store 的交互,可以看这一张图:

15886016921612.jpg

关于移动端开发需要做的事情,在这张图中描述的很明白,这里暂不做赘述了,简要概括一下,对于一次内购而言,我们需要实现的部分是:

  1. 发起商品请求
  2. 展示 UI,实现交互
  3. 发放商品

其中 1, 2 是由客户端来实现的,3 在带服务端的产品中是由服务端实现。

接下来,我们会一步步来说明这几个步骤该怎么样按照苹果开发文档所提供的标准来实现。

获取商品列表

因为 App Store Connect 中配置的商品列表无法获取,苹果并没有提供 API,需要在 App 端或者后端配置商品列表(主要用于记录在 App Store Connect 中的商品 id),但是苹果提供了批量导出和导入 xml 的方法。

对于带服务端的程序,苹果的商品 id 可以存在服务端数据库中,由后端提供给前端一个商品列表的接口,方便动态的变更。

因此本步骤不用与苹果服务器通讯。

文档原文:

There is no runtime mechanism to fetch a list of all products configured in App Store Connect for a particular app. You are responsible for managing your app’s list of products and providing that information to your app. If you need to manage a large number of products, consider using the bulk XML upload/download feature in App Store Connect.
—— Loading In-App Product Identifiers

获得某个商品的信息

在实际显示中,会存在两个问题:

  1. 我们存储的 Apple 商品 id 可能会过期
  2. 我们的定价可能与苹果中的不一致

其中产生 2 的原因很复杂,有业务上的原因:比如前文我们说的,梯度定价可能和我们网页中的定价不匹配;也有一些苹果更新策略的原因,苹果的定价并不能实时更新上线,因此很有可能遇到定价不统一的情况。(这里需要注意,由于更新策略原因,建议尽量不要使用苹果内购进行一些整点促销的功能,而去使用一些时间段型的优惠)。

所以我们需要获取商品信息,来检验商品的有效性以及获取商品的定价。为此苹果提供了一个 SKProductsRequest 方法(一个 HTTP 请求),然而现实是残酷的,他并没有提供 Restful API,因此必须由 Native 去调用,也导致了我们后端无法用这个方法更新我们的数据。

本节见:Fetching Product Information from the App Store

发起订单请求

这一步是所有步骤里最简单的,根据文档中说的直接执行两个语句:Requesting a Payment from the App Store,这一步其实是所有步骤里最直接的。

处理交易

解决了前面的问题,终于要开始关键的部分了:「Delivering products」。

15888515074674.jpg

第一步:监听交易。这一部分是个监听队列,比较复杂。从 SDK 上来看长的跟 status callback 差不多。

第二步:更新状态。无非就是购买成功啥的,他的意思是你得显示在 UI 上,让用户明确知道自己购买成功了。

这一部分都是 Native 的操作,因此这里不做展开了:Processing a Transaction

验证收据

预期说处理交易部分是关键,不如说验证收据才是。这里我们说的验证收据实际上分为两个部分:

  1. 验证收据是否正确(不是伪造的)
  2. 发放商品

之所以不把这一步放在处理交易里来讲,是因为给客户端的返回值很有可能是伪造的,只有经过了苹果服务端校验之后才能确认其真实性。对于客户端应用来说,这一步相对比较简单,因为你从发送请求到发放完全可以在一个操作中完成;而对于服务端的应用来说,相对的要比较复杂一些:

  1. 在客户端中获取收据数据,从二进制文件转换成 base64,发给服务端。
  2. 服务端通过 verifyReceipt 接口获得解密的数据。
  3. 检测 Status 是否正确,校对客户端发来的密码(Password 字段)设置与苹果中的配置是否一致。
  4. 得到 receipt 中的内容,尤其是 Apple 的商品 id,然后对应后端存储的商品信息进行发放。

连续订阅

自动订阅和普通内购有很多地方有区别,自动续订需要:

  • 计算订阅持续的时间段
  • 检测到非续订的商品即将到期,提醒用户订阅
  • 有让用户在不同设备上恢复订阅的机制

订阅流程

15888586137123.jpg

通过上图来了解一下整个流程,如果没看懂也没什么关系,因为之后我们将会一步步的来说明怎么做。

初次订阅

初次订阅的流程同「普通内购的开发」,需要注意的是,建议将完整的收据内容都存储在数据库中,尤其是 originalTransactionId,之后所有的续期信息都以 originTransactionId 为准来进行追溯。

续期订阅

首先需要建立一个 Server-To-Server 通知。但是这和一般的 Webhook 并不一样,理论上每次续订都会收到来自 Apple 的回调,但是在 Apple Store 的通知中,只有当首次续期失败,尝试重试时才会告诉通过 Server-To-Server 通知告诉服务器,在大部分情况下,你需要通过轮询去检查是否有新的收据。而轮询就是不停的通过 verifyReceipt API 使用旧的 receipt base64 信息去获取新的回执,如果有续费信息就把更新存储下来并且执行续期操作。

而在这一步中有无数个坑等着你去踩,这里先说一下官方文档里有的需要注意的点——如何检测订阅事件:

收据接口返回的三个字段可供检测:original_purchase_date, purchase_dateexpires_date(注意,不能直接使用购买期限 + 日期的方式)

Do not calculate the subscription period by adding a subscription duration to the purchase date. That approach fails to take into account the free trial period, the marketing opt-in period, and the content made available immediately after the user purchased the subscription.
——见 Handling Subscriptions Billing

在订阅失效时,你可以通过回调可以接收原因以及变更时间。

  • 退款:会向服务器发送一个 type CANCEL 的通知。
  • 自动续期:向服务器发出 DID_RECOVER 通知。(RENEWAL 类型根据下一篇文档准备弃用)
  • 订阅变更:DID_CHANGE_RENEWAL_PREF

API 返回值

官方文档中对于 API 返回结构显示的非常不友好(尤其是数据类型),需要实际调用来验证,这里提供一个踩坑后的 Typescript 声明文件可供参考:

/**
 * 两种不同的请求有不同的返回值,请见 Response interface
 */
export declare namespace Apple {
  interface InAppItem {
    quantity: string // 自己转成 int
    product_id: string
    transaction_id: string
    original_transaction_id: string
    purchase_date: string
    purchase_date_ms: string // 时间戳
    purchase_date_pst: string
    original_purchase_date: string
    original_purchase_date_ms: string // 时间戳
    original_purchase_date_pst: string
    expires_date: string
    expires_date_ms: string // 时间戳
    expires_date_pst: string
    web_order_line_item_id: string
    is_trial_period: 'true' | 'false' // "true" or "false": 自己转换成 boolean
    is_in_intro_offer_period: 'true' | 'false' // "true" or "false": 自己转换成 boolean
    subscription_group_identifier: string // 订阅的标识符
    cancellation_date?: string
    cancellation_date_ms?: string // 时间戳
    cancellation_date_pst?: string
    cancellation_reason?: string
  }

  interface Receipt {
    receipt_type: 'Production' | 'ProductionVPP' | 'ProductionSandbox' | 'ProductionVPPSandbox'
    adam_id: number
    app_item_id: number
    bundle_id: string
    application_version: string
    download_id: number
    version_external_identifier: number
    receipt_creation_date: string
    receipt_creation_date_ms: string // 时间戳
    receipt_creation_date_pst: string
    request_date: string
    request_date_ms: string // 时间戳
    request_date_pst: string
    original_purchase_date: string
    original_purchase_date_ms: string // 时间戳
    original_purchase_date_pst: string
    in_app: InAppItem[]
  }

  interface PendingRenewalInfo {
    expiration_intent: string
    auto_renew_product_id: string
    original_transaction_id: string
    is_in_billing_retry_period: string
    product_id: string
    auto_renew_status: string
  }

  /**
   * Document: https://developer.apple.com/documentation/appstorereceipts/responsebody
   */
  interface ReceiptResponse {
    status: number
    environment: 'Sandbox' | 'Production'
    receipt: Receipt
    latest_receipt_info: InAppItem[]
    latest_receipt: string // base64
    pending_renewal_info: PendingRenewalInfo[]
  }

  interface UnifiedReceipt {
    environment: 'Sandbox' | 'Production'
    'is-tryable': boolean
    latest_receipt: string
    latest_receipt_info: InAppItem[]
    pending_renewal_info: PendingRenewalInfo[]
    receipt: Receipt
    status: number
  }

  /**
   * Document: https://developer.apple.com/documentation/appstoreservernotifications/responsebody
   */
  interface NotificationResponse {
    auto_renew_adam_id: string
    auto_renew_product_id: string
    auto_renew_status: 'true' | 'false'
    auto_renew_status_change_date: string
    auto_renew_status_change_date_ms: string // 时间戳
    auto_renew_status_change_date_pst: string
    environment: 'Sandbox' | 'PROD'
    expiration_intent: number
    latest_expired_receipt: string
    latest_expired_receipt_info: InAppItem[]
    latest_receipt: string
    latest_receipt_info: InAppItem[]
    notification_type: string
    password: string
    unified_receipt: UnifiedReceipt
  }
}

/**
 * Document: https://developer.apple.com/documentation/appstorereceipts/status
 */
export const APPLE_STATUS = {
  CANT_READ_JSON: 21000,
  MISSING_RECEIPT_DATA: 21002,
  NO_AUTHED: 21003,
  NOT_MATCH_SECRET_KEY: 21004,
  SERVER_UNAVAILABLE: 21005,
  FOR_TEST_ENV: 21007,
  FOR_PRODUCTION_ENV: 21008,
  NO_AUTHED_LIKE_NEVER_PAY: 21010,
  NO_PROBLEM: 0,
}

/**
 * Document: https://developer.apple.com/documentation/appstoreservernotifications/notification_type
 */
export const APPLE_NOTIFICATION_TYPE = {
  CANCEL: 'CANCEL',
  DID_CHANGE_RENEWAL_PREF: 'DID_CHANGE_RENEWAL_PREF',
  DID_CHANGE_RENEWAL_STATUS: 'DID_CHANGE_RENEWAL_STATUS',
  DID_FAIL_TO_RENEW: 'DID_FAIL_TO_RENEW',
  DID_RECOVER: 'DID_RECOVER',
  INITIAL_BUY: 'INITIAL_BUY',
  INTERACTIVE_RENEWAL: 'INTERACTIVE_RENEWAL',
  RENEWAL: 'RENEWAL',
}

测试

在测试之前,首先你需要配置沙盒测试员:创建一个沙盒测试员帐户,然后在 App 中使用沙盒测试员账号登录,就可以进行流程的测试了。

注意,后端校验中,沙盒环境有单独的校验服务器,校验订单 API 与正式环境不同:

  • 沙盒环境验证服务器:https://sandbox.itunes.apple.com/verifyReceipt
  • 正式环境验证服务器:https://buy.itunes.apple.com/verifyReceipt

而一个收据如何区分是沙盒还是正式环境,首先我们先去请求沙盒服务器,如果返回的是 21007,代表环境不符合,然后我们再去请求正式环境的服务器(或者相反)。

在测试时同时需要注意的是,沙盒环境对于自动续费有加速行为,同时也有自动续费次数的限制:

沙盒环境自动续费周期

我们不可能在测试时真的等一个礼拜甚至一个月去等待结果,因此苹果在沙盒环境中提供了一个内部时间机制,他的时间列表是:

15889458092501.jpg
注意:在 testflight beta 中也依旧是沙盒环境,依旧会享受加速。

续费次数限制

在测试环境中,续费行为也不会无限次的执行,他只会进行四次续费,只会就会取消续订,如果你要再次测试,使用同一账号(在同一天)也不会再次执行续费,建议是换一个沙盒测试员账号,避免沙盒的坑。

沙盒环境·坑

关于沙盒环境,其实有很多坑需要解决,本质上苹果提供的沙盒环境是极其不稳定的,之前我们就遇到了沙盒模式永远无法续费成功的情况,结果与苹果联系后发现这是一个苹果的 Bug,但是在一周前就已经有人在社区提问,却没有人去解决,好几天之后苹果终于修复了这个 Bug,我们也就没有了这个问题。

在大部分情况下,不应该过于自信沙盒环境,很有可能还有苹果的锅。

IAP 坑爹总结

  • 获取交易状态 / 收据时的延时:队列 + 刷新收据机制可以保障一下,交互上也需要保证用户不重复付款。
  • 未绑定 App Store 支付用户的特殊支付流程:用户第一次绑定后会向 App 传递支付失败的信息,然而用户绑定完成之后会继续交易流程,需要在 Native 上继续监听一段时间保证用户是否真的没有支付成功。
  • 用户因为删除 App 或者其他操作(也可能是 App 闪退)可能会导致找不到订单,把订单信息存入 keychain 中,在点击恢复购买时重新发起请求,后端要注意每个订单的操作不能重复续费,对于单一支付,transactionId 可以保证全局唯一性。
  • 越狱机型的 hack 操作:越狱机子一律不让支付
  • 订单获取信息期间用户登录了另一账号:订单与用户 ID 绑定

全文总结

问就是真的烦,真的不想再写一次了(包括这篇文章,写起来也是又臭又长)。

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: iOS

添加新评论