苹果内购:IAP的"坑"与"解"

系列三:支付篇(第4篇)


一、开篇:为什么IAP是道"必选题"

做游戏出海,iOS端绕不开一个话题——IAP(In-App Purchase,应用内购买)。

苹果的规则很简单粗暴:凡是在App内销售数字商品,必须走IAP。虚拟货币、游戏道具、会员订阅……统统归苹果管,而且要抽成30%(小企业计划可降至15%)。想绕?可以,但你的App可能随时被下架,甚至开发者账号被封禁。

这就像在商场租铺位,商场规定:你卖衣服我不管,但卖储值卡必须走我的收银台,而且每张卡我要抽三成。不乐意?那别在我这儿开店。

对游戏开发者来说,IAP不是选择题,是必答题。而这道题的坑,比你想象的要多。很多团队在测试环境跑得好好的,一上线就翻车;有的做了几年IAP,对账时才发现少了一大笔钱。

今天我们就来聊聊IAP的核心机制、常见坑点,以及如何设计一个靠谱的内购系统。


二、IAP的核心机制

2.1 产品ID:商品的"身份证"

在苹果开发者后台,你需要为每个可购买商品创建一个Product ID。这个ID看起来简单,但有几个关键点需要理解:

这就像给商品贴标签——标签贴错了,整个购买流程都会出问题。所以设计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                // 数量
    ...
}

验证接口详解

苹果提供了两个验证接口:

请求格式(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 沙箱与生产环境的"人格分裂"

这是最常见的坑,没有之一。

3.2 收据验证失败的"万花筒"

收据验证失败的原因五花八门,苹果返回的错误码有十几种。这里列举几个最典型的:

错误码 含义 常见原因
21002 收据数据无效 收据格式错误、Base64编码问题、收据被截断
21003 收据无法认证 收据被篡改或伪造,或者收据已过期
21004 Shared Secret错误 订阅型商品需要App专用共享密钥,配置错误或缺失
21005 收据服务器不可用 苹果服务临时故障,需要重试
21006 订阅已过期 订阅型商品已过期,需要检查过期时间,决定是否续订
21007 沙箱收据发到了生产接口 环境不匹配,需要切换到沙箱接口
21008 生产收据发到了沙箱接口 环境不匹配,需要切换到生产接口

3.3 订阅型商品:续订的"黑盒"

订阅型商品(如月卡、季卡、年费会员)是最复杂的类型,没有之一。

苹果提供了两种机制来追踪订阅状态:

通知类型 含义
INITIAL_BUY 首次购买订阅
DID_RENEW 续订成功
DID_FAIL_TO_RENEW 续订失败(通常因支付问题)
DID_CHANGE_RENEWAL_STATUS 用户更改了续订设置
CANCEL 用户取消订阅
REFUND 退款
PRICE_INCREASE 价格上涨后的用户响应

3.4 退款通知:钱退了,货还在?

用户申请退款,苹果批准后,钱退回去了——但你游戏里的钻石已经发了。怎么办?

这是很多游戏公司的痛点。苹果的退款政策偏向用户,用户申请退款的成功率很高,尤其是首次申请。而苹果的退款通知有几个问题:

- 如果用户账户还有足够的虚拟货币,扣回相应数量

- 如果虚拟货币已消耗,根据金额决定是否封号 - 对于小额退款,可以考虑接受损失,避免用户体验太差

3.5 未完成交易的恢复机制

用户买了东西,支付成功了,但网络断了,你的服务器没收到验证请求——这时候钱扣了,货没发。用户肯定要投诉。

苹果提供了恢复购买(Restore Purchases)机制来解决这个问题:

// 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次

3.7 客户端收据丢失

iOS的收据存储在Bundle.main.appStoreReceiptURL,但这个文件可能在以下情况下丢失:


四、解决方案

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 对账:发现问题的最后防线

苹果的财务报告有2-3天的延迟,所以日对账实际是核对"两天前"的数据。如果需要更实时的对账,可以考虑用苹果的Sales and Trends API(需要额外权限)。


五、审核与合规要求

5.1 App Store审核要点

苹果对内购的审核非常严格,以下是最常见的被拒原因:

- 订阅周期和价格

- 续订规则(自动续订,除非取消) - 取消方式 - 试用期结束后的收费规则

// 恢复购买的实现
@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 地区合规要求

不同地区对内购有不同的监管要求,这是出海必须考虑的:

5.3 隐私与数据安全

苹果对用户隐私越来越重视,内购系统需要注意:


六、游戏行业的特殊考虑

6.1 虚拟货币的合规要求

不同地区对虚拟货币的监管要求不同,这在设计内购系统时必须考虑:

在设计内购系统时,要预留足够的灵活性,支持按地区配置不同的规则。

6.2 防沉迷与内购

对于未成年用户,很多地区要求限制消费:

这些限制必须在服务端实现,不能依赖客户端。越狱设备可以绕过客户端的所有限制。

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 多区多服的账号同步

如果游戏支持多个区服,用户在不同区服购买的商品如何同步?

技术实现上,需要在用户中心层面设计统一的购买记录和权益管理,不能让每个区服各自为政。


七、总结

核心要点回顾

  1. IAP是iOS端的必选项,绕不开,躲不掉,提前理解规则很重要。
  2. 收据验证必须在服务端进行,永远不要信任客户端,越狱设备可以伪造一切。
  3. 沙箱与生产环境要自适应,用21007/21008错误码自动切换,避免上线后大面积失败。
  4. 订阅型商品要开启服务器通知,配合定时轮询,确保不错过续订事件。
  5. 退款要处理,否则钱退了货还在,长期累积是巨大的财务损失。
  6. 未完成交易要恢复,App启动时检查队列,提供手动恢复入口。
  7. 对账是最后的防线,定期检查,及时发现问题。
  8. 审核合规不能忽视,不同地区有不同要求,提前规划。

最佳实践建议

技术实现清单




💬 评论 (0)

0/500
排序: