苹果内购:IAP的"坑"与"解"
系列三:支付篇(第4篇)
一、开篇:为什么IAP是道"必选题"
做游戏出海,iOS端绕不开一个话题——IAP(In-App Purchase,应用内购买)。
苹果的规则很简单粗暴:凡是在App内销售数字商品,必须走IAP。虚拟货币、游戏道具、会员订阅……统统归苹果管,而且要抽成30%(小企业计划可降至15%)。想绕?可以,但你的App可能随时被下架,甚至开发者账号被封禁。
这就像在商场租铺位,商场规定:你卖衣服我不管,但卖储值卡必须走我的收银台,而且每张卡我要抽三成。不乐意?那别在我这儿开店。
对游戏开发者来说,IAP不是选择题,是必答题。而这道题的坑,比你想象的要多。很多团队在测试环境跑得好好的,一上线就翻车;有的做了几年IAP,对账时才发现少了一大笔钱。
今天我们就来聊聊IAP的核心机制、常见坑点,以及如何设计一个靠谱的内购系统。
二、IAP的核心机制
2.1 产品ID:商品的"身份证"
在苹果开发者后台,你需要为每个可购买商品创建一个Product ID。这个ID看起来简单,但有几个关键点需要理解:
- 消耗型(Consumable):买了就用,用完再买。典型如游戏钻石、金币。
- 非消耗型(Non-Consumable):一次购买,永久拥有。典型如解锁关卡、去除广告。
- 自动续订订阅(Auto-Renewable Subscription):按周期自动扣款续费。典型如月卡、会员。
- 非续订订阅(Non-Renewing Subscription):订阅期内有效,不自动续费。用得较少。
这就像给商品贴标签——标签贴错了,整个购买流程都会出问题。所以设计Product ID的命名规范很重要,建议用 {商品类型}_{货币类型}_{金额} 这样的格式,便于管理和追溯。
2.2 购买流程:三步走
一个典型的IAP流程分为三个阶段:
客户端调用SKProductsRequest,向苹果查询Product ID对应的商品信息,包括价格、标题、描述等。这一步很重要,因为价格可能随时变化,不能在客户端硬编码。
// Swift 示例:请求商品信息
let productIDs: Set<String> = ["com.yourapp.coins_100", "com.yourapp.vip_monthly"]
let request = SKProductsRequest(productIdentifiers: productIDs)
request.delegate = self
request.start()
// 代理回调
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
for product in response.products {
print("商品: \(product.localizedTitle), 价格: \(product.price)")
}
// 检查无效的Product ID
for invalidID in response.invalidProductIdentifiers {
print("无效的商品ID: \(invalidID)")
}
}
拿到商品信息后,客户端创建SKPayment对象,加入支付队列。苹果弹出支付界面,用户完成Face ID/Touch ID/密码验证后,扣款成功。此时客户端会收到SKPaymentTransaction对象,里面包含了最重要的东西——收据(Receipt)。
// Swift 示例:发起支付
func purchase(product: SKProduct) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
// 监听交易状态
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// 购买成功,提取收据
if let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) {
let receiptString = receiptData.base64EncodedString()
sendReceiptToServer(receipt: receiptString, transaction: transaction)
}
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
if let error = transaction.error {
print("购买失败: \(error.localizedDescription)")
}
SKPaymentQueue.default().finishTransaction(transaction)
case .restored:
// 恢复购买
handleRestoredPurchase(transaction)
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
}
客户端把收据发给你的服务器,服务器再向苹果验证"这单是真的吗"。验证通过后,服务器发放虚拟商品,并记录订单。
2.3 收据验证机制:技术细节
收据(Receipt)是苹果发放的"购买凭证",本质是一串PKCS7格式的加密数据。理解收据的结构对于调试和排查问题至关重要。
收据的结构
收据文件(NSBundle.mainBundle().appStoreReceiptURL)是一个PKCS7签名的容器,内部包含:
ReceiptPayload {
Bundle ID // 应用包名
Application Version // 应用版本号
Original Application Version // 原始购买版本
Creation Date // 收据生成时间
Expiration Date // 过期时间(如果有)
In-App Purchase Receipts [] // 内购收据列表
...
}
In-App Purchase Receipt {
Product ID // 商品ID
Transaction ID // 交易ID(唯一标识一次购买)
Original Transaction ID // 原始交易ID(续订时用于追踪)
Purchase Date // 购买时间
Expiration Date // 过期时间(订阅型)
Quantity // 数量
...
}
验证接口详解
苹果提供了两个验证接口:
- 沙箱环境:
https://sandbox.itunes.apple.com/verifyReceipt - 生产环境:
https://buy.itunes.apple.com/verifyReceipt
请求格式(POST,JSON):
{
"receipt-data": "Base64编码的收据数据",
"password": "App专用共享密钥(订阅型商品必填)",
"exclude-old-transactions": true // 可选,排除旧交易记录
}
响应格式(成功时):
{
"status": 0,
"environment": "Production",
"receipt": {
"bundle_id": "com.yourapp.game",
"application_version": "1.0.0",
"in_app": [
{
"product_id": "com.yourapp.coins_100",
"transaction_id": "1000000123456789",
"purchase_date_ms": "1704067200000",
"quantity": "1"
}
]
},
"latest_receipt_info": [...] // 订阅型商品的最新状态
}
服务端验证示例(Go语言)
type ReceiptRequest struct {
ReceiptData string `json:"receipt-data"`
Password string `json:"password,omitempty"`
ExcludeOldTx bool `json:"exclude-old-transactions,omitempty"`
}
type ReceiptResponse struct {
Status int `json:"status"`
Environment string `json:"environment"`
Receipt map[string]interface{} `json:"receipt"`
LatestReceiptInfo []InAppPurchase `json:"latest_receipt_info"`
}
func VerifyReceipt(receiptBase64 string) (*ReceiptResponse, error) {
req := ReceiptRequest{
ReceiptData: receiptBase64,
Password: "your_app_shared_secret",
ExcludeOldTx: true,
}
body, _ := json.Marshal(req)
// 先尝试生产环境
resp, err := http.Post(
"https://buy.itunes.apple.com/verifyReceipt",
"application/json",
bytes.NewBuffer(body),
)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result ReceiptResponse
json.NewDecoder(resp.Body).Decode(&result)
// 沙箱收据发到生产环境,自动切换
if result.Status == 21007 {
return verifyWithSandbox(receiptBase64)
}
return &result, nil
}
func verifyWithSandbox(receiptBase64 string) (*ReceiptResponse, error) {
// 切换到沙箱环境重试
req := ReceiptRequest{
ReceiptData: receiptBase64,
Password: "your_app_shared_secret",
}
body, _ := json.Marshal(req)
resp, err := http.Post(
"https://sandbox.itunes.apple.com/verifyReceipt",
"application/json",
bytes.NewBuffer(body),
)
// ... 处理响应
}
问题来了:你的服务器怎么知道这个收据来自哪个环境?很多团队的坑,就从这里开始。
三、常见坑点
3.1 沙箱与生产环境的"人格分裂"
这是最常见的坑,没有之一。
- 方案一:客户端在提交收据时,带上环境标识(Debug模式=沙箱,Release模式=生产)。但这个方案不完美,因为App Review阶段是Release包但用沙箱收据。
- 方案二(推荐):服务端先用生产接口验证,如果返回21007(沙箱收据发到生产接口),则自动切换到沙箱接口重试。这样可以兼容所有场景。
3.2 收据验证失败的"万花筒"
收据验证失败的原因五花八门,苹果返回的错误码有十几种。这里列举几个最典型的:
| 错误码 | 含义 | 常见原因 |
|---|---|---|
| 21002 | 收据数据无效 | 收据格式错误、Base64编码问题、收据被截断 |
| 21003 | 收据无法认证 | 收据被篡改或伪造,或者收据已过期 |
| 21004 | Shared Secret错误 | 订阅型商品需要App专用共享密钥,配置错误或缺失 |
| 21005 | 收据服务器不可用 | 苹果服务临时故障,需要重试 |
| 21006 | 订阅已过期 | 订阅型商品已过期,需要检查过期时间,决定是否续订 |
| 21007 | 沙箱收据发到了生产接口 | 环境不匹配,需要切换到沙箱接口 |
| 21008 | 生产收据发到了沙箱接口 | 环境不匹配,需要切换到生产接口 |
- 21002/21003:这是真正的失败,告知用户购买失败,引导重新购买。
- 21005:这是临时故障,要实现重试机制,间隔递增(1秒、2秒、4秒),最多重试3次。
- 21007/21008:环境不匹配,自动切换环境重试,对上层业务透明。
- 其他错误:记录详细日志,便于后续排查。建议把完整的请求和响应都记录下来,包括时间戳、收据前缀、错误码等。
3.3 订阅型商品:续订的"黑盒"
订阅型商品(如月卡、季卡、年费会员)是最复杂的类型,没有之一。
苹果提供了两种机制来追踪订阅状态:
| 通知类型 | 含义 |
|---|---|
| INITIAL_BUY | 首次购买订阅 |
| DID_RENEW | 续订成功 |
| DID_FAIL_TO_RENEW | 续订失败(通常因支付问题) |
| DID_CHANGE_RENEWAL_STATUS | 用户更改了续订设置 |
| CANCEL | 用户取消订阅 |
| REFUND | 退款 |
| PRICE_INCREASE | 价格上涨后的用户响应 |
- 两者结合使用:以服务器通知为主,定时轮询为辅
- 在用户登录或访问会员功能时,顺便验证一下订阅状态
- 本地缓存订阅过期时间,减少不必要的验证请求
- 对于即将过期的订阅,提前几天开始频繁检查
3.4 退款通知:钱退了,货还在?
用户申请退款,苹果批准后,钱退回去了——但你游戏里的钻石已经发了。怎么办?
这是很多游戏公司的痛点。苹果的退款政策偏向用户,用户申请退款的成功率很高,尤其是首次申请。而苹果的退款通知有几个问题:
- 通知延迟:退款发生后,通知可能延迟几天才到
- 通知丢失:网络问题可能导致通知丢失
- 配置缺失:很多团队根本没配置退款通知
- 开启Server-to-Server Notifications,在App Store Connect中配置通知URL
- 收到
REFUND类型通知后,标记该笔订单为已退款 - 根据业务规则处理:
- 如果虚拟货币已消耗,根据金额决定是否封号 - 对于小额退款,可以考虑接受损失,避免用户体验太差
- 定期对账,发现异常订单
3.5 未完成交易的恢复机制
用户买了东西,支付成功了,但网络断了,你的服务器没收到验证请求——这时候钱扣了,货没发。用户肯定要投诉。
苹果提供了恢复购买(Restore Purchases)机制来解决这个问题:
- 对于非消耗型商品和订阅,客户端调用
SKPaymentQueue.default().restoreCompletedTransactions(),苹果会返回所有历史购买记录 - 对于消耗型商品,恢复机制不适用——苹果认为消耗型商品是一次性的,用完就没了
- App启动时,检查支付队列是否有未完成的交易(
SKPaymentQueue.default().transactions),如果有,重新提交验证 - 在设置页面提供手动"恢复购买"按钮,让用户可以主动触发
- 服务端要支持收据的幂等验证:同一收据多次验证,只发货一次,但要每次都返回成功,避免客户端无限重试
// App启动时检查未完成交易
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 添加交易观察者
SKPaymentQueue.default().add(self)
// 检查未完成的交易
for transaction in SKPaymentQueue.default().transactions {
if transaction.transactionState == .purchased {
// 重新提交验证
handlePurchasedTransaction(transaction)
}
}
return true
}
3.6 沙箱测试的"时间加速"陷阱
沙箱环境的订阅测试有个特殊机制:时间加速。这让测试更方便,但也容易造成困惑。
| 实际订阅时长 | 沙箱环境实际时长 | 最多续订次数 |
|---|---|---|
| 1周 | 3分钟 | 6次 |
| 1个月 | 5分钟 | 6次 |
| 2个月 | 10分钟 | 6次 |
| 3个月 | 15分钟 | 6次 |
| 6个月 | 30分钟 | 6次 |
| 1年 | 1小时 | 6次 |
- 测试时以为订阅会持续很久,结果5分钟就过期了
- 续订通知来得太频繁,以为是Bug
- 沙箱的订阅最多自动续订6次,之后会停止,这不是生产环境的问题
3.7 客户端收据丢失
iOS的收据存储在Bundle.main.appStoreReceiptURL,但这个文件可能在以下情况下丢失:
- 用户卸载重装App
- 系统升级
- 设备恢复出厂设置
- 服务端必须保存用户的购买记录,不能只依赖收据
- 用户登录时,从服务端恢复购买状态
- 对于非消耗型商品和订阅,提供"恢复购买"功能
四、解决方案
4.1 健壮的收据验证系统
一个靠谱的收据验证系统应该具备以下能力:
func ProcessReceipt(userID string, receiptBase64 string) error {
// 1. 检查是否已处理过(幂等性)
existing, _ := db.GetOrderByReceipt(receiptBase64)
if existing != nil {
// 已处理过,直接返回成功
return nil
}
// 2. 验证收据(带重试)
resp, err := verifyReceiptWithRetry(receiptBase64, 3)
if err != nil {
return err
}
// 3. 验证返回状态
if resp.Status != 0 {
logVerificationFailure(userID, receiptBase64, resp.Status)
return fmt.Errorf("receipt verification failed: %d", resp.Status)
}
// 4. 处理购买记录
for _, purchase := range resp.LatestReceiptInfo {
// 检查transaction_id是否已处理
if db.IsTransactionProcessed(purchase.TransactionID) {
continue
}
// 发放商品
err := deliverProduct(userID, purchase.ProductID, purchase.Quantity)
if err != nil {
return err
}
// 记录订单
db.CreateOrder(Order{
UserID: userID,
TransactionID: purchase.TransactionID,
ProductID: purchase.ProductID,
Receipt: receiptBase64,
Status: "completed",
CreatedAt: time.Now(),
})
}
return nil
}
func verifyReceiptWithRetry(receipt string, maxRetries int) (*ReceiptResponse, error) {
backoff := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
for i := 0; i < maxRetries; i++ {
resp, err := VerifyReceipt(receipt)
if err == nil && resp.Status != 21005 {
return resp, nil
}
// 21005 是临时故障,需要重试
if resp.Status == 21005 && i < maxRetries-1 {
time.Sleep(backoff[i])
continue
}
return resp, err
}
return nil, fmt.Errorf("max retries exceeded")
}
4.2 异常处理清单
| 异常场景 | 处理方式 |
|---|---|
| 网络超时 | 重试3次,间隔递增(1s, 2s, 4s) |
| 苹果服务不可用(21005) | 记录日志,稍后重试,不立即返回失败 |
| 收据格式错误(21002) | 返回失败,引导用户重新购买 |
| 环境不匹配(21007/21008) | 自动切换环境重试 |
| 订阅过期(21006) | 检查是否续订,更新用户权益 |
| 收据被篡改(21003) | 记录日志,标记用户可疑行为 |
| 交易已处理 | 直接返回成功(幂等性) |
4.3 对账:发现问题的最后防线
- 收据验证可能遗漏(服务器故障、网络问题)
- 退款通知可能丢失
- 业务逻辑可能有Bug
- 日对账:每天对比苹果后台(App Store Connect)的财务报告与本地订单数据
- 差异处理:发现差异后,追溯原因,补发或回收虚拟商品
- 自动化:将对账脚本化,异常自动告警
苹果的财务报告有2-3天的延迟,所以日对账实际是核对"两天前"的数据。如果需要更实时的对账,可以考虑用苹果的Sales and Trends API(需要额外权限)。
五、审核与合规要求
5.1 App Store审核要点
苹果对内购的审核非常严格,以下是最常见的被拒原因:
- 必须显示本地化价格(使用
product.price和priceLocale) - 不能显示"$0.99"这样的硬编码价格
- 必须清晰标注订阅的续订周期和价格
- 必须提供隐私政策链接
- 必须提供服务条款链接
- 必须在订阅界面清晰说明:
- 续订规则(自动续订,除非取消) - 取消方式 - 试用期结束后的收费规则
- 必须提供"恢复购买"功能
- 按钮位置要明显(通常在设置页面)
// 恢复购买的实现
@IBAction func restorePurchases(_ sender: UIButton) {
SKPaymentQueue.default().restoreCompletedTransactions()
}
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
// 恢复完成,提示用户
showAlert("恢复成功", "您的购买已恢复")
}
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
// 恢复失败
showAlert("恢复失败", error.localizedDescription)
}
5.2 地区合规要求
不同地区对内购有不同的监管要求,这是出海必须考虑的:
- 游戏版号:没有版号的游戏不能上线
- 防沉迷系统:未成年人有严格的时间和消费限制
- 虚拟货币监管:虚拟货币不能兑换法币
- 用户有权要求删除账户和所有关联数据
- 必须明确告知数据使用方式
- 需要提供数据导出功能
- 游戏内购可能需要接入本地支付渠道(如Kakao Pay)
- 需要符合韩国游戏分级委员会的要求
- 13岁以下用户的特殊保护
- 收集儿童数据需要家长同意
- 部分国家对抽卡机制有限制
- 需要披露概率(抽卡游戏)
- 可能需要移除赌博元素
5.3 隐私与数据安全
苹果对用户隐私越来越重视,内购系统需要注意:
- 如果你的App追踪用户行为用于广告归因,需要请求用户授权
- 很多用户会拒绝,要考虑这种情况下的归因方案
- 在App Store Connect中必须披露你的App收集哪些数据
- 内购相关的数据(购买记录)也属于需要披露的范围
- 敏感数据(如收据)必须加密存储
- 不能在客户端存储验证密钥
- 遵守数据保留政策,定期清理过期数据
六、游戏行业的特殊考虑
6.1 虚拟货币的合规要求
不同地区对虚拟货币的监管要求不同,这在设计内购系统时必须考虑:
- 中国:虚拟货币不能兑换法定货币,未成年人有消费限额
- 韩国:游戏内购需要接入本地支付渠道,苹果支付可能不是唯一选择
- 欧盟:需要符合GDPR数据保护要求,用户有权删除账户和关联数据
- 中东:部分国家对赌博元素敏感,抽卡机制可能需要调整
在设计内购系统时,要预留足够的灵活性,支持按地区配置不同的规则。
6.2 防沉迷与内购
对于未成年用户,很多地区要求限制消费:
- 设置单次消费上限
- 设置每日/每月消费总额上限
- 特定时段禁止消费(如中国要求未成年人22:00-次日8:00禁止游戏消费)
这些限制必须在服务端实现,不能依赖客户端。越狱设备可以绕过客户端的所有限制。
func CheckPurchaseLimit(userID string, amount float64) error {
user, _ := db.GetUser(userID)
// 检查是否未成年
if user.IsMinor {
// 检查时间段限制(中国要求)
if isRestrictedTime() {
return fmt.Errorf("未成年人当前时段禁止消费")
}
// 检查单次消费上限
if amount > config.MinorSingleLimit {
return fmt.Errorf("超过单次消费上限")
}
// 检查月消费总额
monthlyTotal := db.GetMonthlySpending(userID)
if monthlyTotal + amount > config.MinorMonthlyLimit {
return fmt.Errorf("超过月消费上限")
}
}
return nil
}
6.3 多区多服的账号同步
如果游戏支持多个区服,用户在不同区服购买的商品如何同步?
- 区服隔离:每个区服独立计算,不互通。最简单,但用户体验差。
- 账号级别:部分商品(如皮肤、角色)在账号级别生效,跨区服通用。虚拟货币通常区服隔离。
- 统一货币:虚拟货币在账号级别统一,但兑换比例可能不同。复杂度高,需要防止套利。
技术实现上,需要在用户中心层面设计统一的购买记录和权益管理,不能让每个区服各自为政。
七、总结
核心要点回顾
- IAP是iOS端的必选项,绕不开,躲不掉,提前理解规则很重要。
- 收据验证必须在服务端进行,永远不要信任客户端,越狱设备可以伪造一切。
- 沙箱与生产环境要自适应,用21007/21008错误码自动切换,避免上线后大面积失败。
- 订阅型商品要开启服务器通知,配合定时轮询,确保不错过续订事件。
- 退款要处理,否则钱退了货还在,长期累积是巨大的财务损失。
- 未完成交易要恢复,App启动时检查队列,提供手动恢复入口。
- 对账是最后的防线,定期检查,及时发现问题。
- 审核合规不能忽视,不同地区有不同要求,提前规划。
最佳实践建议
- 设计阶段就考虑异常场景,不要只关注Happy Path。
- 日志要详细,排查问题时你会感谢自己。
- 对账自动化,告警及时化。
- 保持对苹果政策变化的关注,规则会变,系统要跟着调整。
- 测试覆盖沙箱环境的所有场景,包括时间加速。
技术实现清单
- [ ] 实现完整的购买流程(请求商品、发起支付、处理结果)
- [ ] 处理所有交易状态(成功、失败、恢复)
- [ ] App启动时检查未完成交易
- [ ] 提供"恢复购买"功能
- [ ] 正确显示本地化价格
- [ ] 实现收据验证接口
- [ ] 环境自动切换(处理21007/21008)
- [ ] 幂等性保证(同一收据只发货一次)
- [ ] 订阅状态追踪(服务器通知+轮询)
- [ ] 退款处理逻辑
- [ ] 详细日志记录
- [ ] 监控告警
- [ ] 隐私政策和服务条款
- [ ] 订阅说明(周期、价格、取消方式)
- [ ] 恢复购买功能
- [ ] 地区合规检查(防沉迷、数据保护)
- 支付篇(1):支付系统的设计哲学
- 支付篇(2):第三方支付的接入与踩坑
- 支付篇(3):Google Play内购详解
- 本文:苹果内购:IAP的"坑"与"解"
💬 评论 (0)