尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

苹果内购(IAP)完整指南:从交易模型到服务端校验与审核避坑

苹果内购(IAP)完整指南:从交易模型到服务端校验与审核避坑 简介一份面向 iOS 开发者的苹果内购IAPSwift 实现示例解决应用内商品购买、交易回调与恢复购买等常见问题。资源以 Xcode 工程形式组织共 31 个文件压缩包仅 66KB核心代码集中在 9 个 Swift 文件中另有 plist 配置、Objective-C 桥接头文件、Json 数据及 storyboard 界面文件方便直接查看业务层与 StoreKit 接入逻辑。已有 1701 人学习下载。Demo 包含完整 InAppPurchaseManager 实现通过 SKProductsRequest 拉取商品、SKPaymentQueue 处理支付与恢复交易并覆盖失败回调、订阅管理等关键分支同时提供 ATS 网络配置与收据验证思路适合需要快速接入内购功能的中初级 iOS 开发者参考。 苹果内购这个东西属于典型的看着简单、做起来全是细节的模块。很多人以为就是加一个购买按钮、收到回调之后发个货结果等到提审被拒、用户反馈扣了款没到账、或者审核周期被无限拉长才开始后悔没把底层逻辑吃透。我用 Swift 做过几轮完整的苹果内购支付工具包括消耗型商品、非消耗型解锁、自动续期订阅都上线跑过今天把这套东西从交易模型到服务端校验、再到审核避坑一次性讲清楚。这篇文章适合正要接入内购、或者已经接了一半正在排雷的 iOS 开发者。1. 苹果内购的交易模型和普通支付根本不是一回事1.1 四种产品类型先想清楚你的 App 属于哪一种苹果内购In-App PurchaseIAP在 App Store Connect 后台分成四种类型消耗型项目、非消耗型项目、自动续期订阅、非续期订阅。消耗型项目好理解就是游戏币、钻石、一次性道具这种买了就没了、用完了再买的东西非消耗型项目对应的是解锁永久功能比如去广告、专业版解锁自动续期订阅是现在最主流的变现方式比如会员月卡年卡非续期订阅比较特殊买一次可以用一段时间但不会自动扣费比如一个季度的离线包。这个分类不是随便选的它直接决定了你交易处理的逻辑。消耗型和非消耗型在客户端的状态机处理上几乎一样只是非消耗型必须做恢复购买自动续期订阅则多了续期、退款、订阅状态同步这些更复杂的服务端逻辑。我在做第一版的时候没太在意分类把会员做成了消耗型结果后面要迁移到订阅制后台商品类型改不了只能下架重新上特别被动。所以开工前第一件事就是明确你的商业模式对应哪种产品类型这个决策后期很难回头。1.2 交易状态机为什么一个购买会触发这么多回调普通支付通常是发起 - 回调 - 发货但苹果内购的交易走的是一个状态机。在 StoreKit 1 里一次购买对应一个SKPaymentTransaction它可能经历.purchasing、.purchased、.failed、.restored、.deferred这几个状态。很多人第一次接手的时候会困惑为什么我这里点了购买回调不立刻来或者来了好几次。原因就在.deferred状态——这是苹果的 Ask to Buy购买前询问机制儿童账号购买时需要家长审批这个状态下交易挂起可能几分钟也可能几天后才回结果。你不能把挂起状态当成失败更不能提示购买失败。另一个容易翻车的是.failed状态里的取消分支。用户取消购买时系统会返回一个错误这个不是真正的失败不应该上报统计更不应该弹窗提示支付失败。正确处理是根据下发的SKError判断是不是.paymentCancelled是就直接静默处理不打扰用户。这些细节对不上后面测试的时候就特别容易误判。2. 商品注册与沙盒账号把 App Store Connect 前期配置做扎实2.1 付费协议和税务信息没签内购接口直接报错很多新手把代码写完了一测发现请求商品列表一直为空或者直接报“无法连接到 App Store”第一反应是查代码实际上问题经常在后台。在 App Store Connect 里App 内购要正常工作需要先在“商业与隐私”里完成付费协议的签署和税务信息的填写并且确认 App 的“货币或付款方式”状态是有效。这一步不做所有内购请求都会异常。我记得第一次接内购时为了等协议生效多花了一天时间所以建议在项目启动时就先把后台这些信息全部填好别等代码开发完再补。2.2 商品 ID 注册的细节和沙盒账号的正确打开方式商品 ID 是在 App Store Connect -“App 内购买项目”里注册的它必须和代码里请求的productIdentifier完全一致。我给自己的项目定的规范是域名反写 业务模块 商品名比如com.example.app.coin_100、com.example.app.monthly_vip一套规则管到底不然商品多了以后后台和代码对不上光排查就能耗掉半天。沙盒账号的配置也是一个高频坑区。很多开发者误以为用 App Store 的正式账号就能测试内购实际上沙盒环境只能用专门的沙盒测试账号这种账号在 App Store Connect -“用户和访问” -“沙盒”里创建邮箱可以随便填因为不用真的收验证邮件。测试的正确流程是真机上先在“设置 - App Store”退出正式账号打开 App点击购买弹出苹果登录框时用沙盒账号登录。如果你在“设置”里直接登录沙盒账号会提示此 Apple ID 未能用于 App Store这是正常的不用慌。沙盒账号的密码没有大小写限制但登录时苹果经常要求二次验证这个流程要提前走一遍不然提审时你提供的测试步骤里都是坑。2.3 订阅产品必须搞清楚的订阅组自动续期订阅还多出一个订阅组的概念。同一组里用户可以升级、降级、同级切换但同一时间一个组里只能有一个有效订阅不同组的订阅可以同时生效。订阅组在后台的配置直接关系到升级降级的自动处理逻辑。如果你不设置订阅组所有订阅都是各自独立用户买了一个月VIP再买一年VIP两个都生效这在商业上是冲突的。我把这里吃透之后才明白为什么苹果在审核时要求 App 内必须提供管理订阅入口因为它要确保用户能自主操作这些订阅关系。所以我建议在后台配置时把同类型、可互相切换的订阅放进一个组不同权益体系的分开建组。费率表、本地化显示名称、促销文案这些也要在后台填好因为 StoreKit 返回的SKProduct里的localizedTitle、localizedDescription、price全都来自后台你在代码里写死价格是大忌。3. IAPManager 的完整封装商品请求、购买下发与交易监听3.1 交易监听器的注册时机比你想的更早苹果内购和普通支付最大的区别之一就是交易会发生在你不主动调用的时候。比如用户正在下单App 被系统杀掉交易在后台继续完成下次冷启动时系统会把交易重新交给你的监听器。所以SKPaymentQueue.default().add(observer)的注册时机应该是 App 启动最早期最好在AppDelegate或SceneDelegate的初始化阶段就完成而不是等用户点了购买才注册。另一个细节是监听器的生命周期。SKPaymentQueue对 observer 是弱引用如果你在某个 ViewController 里添加 observer等这个页面释放了监听器就没了。我见过几次线上漏单最后定位下来都是这个原因。我的做法是封装一个IAPManager单例在单例初始化时注册 observerApp 整个生命周期内都持有它。这个单例对外提供统一的商品请求、购买、恢复购买接口对内维护交易回调再通过闭包或 delegate 分发给业务层。这样不管用户在哪个页面触发购买交易处理的逻辑都只有一个入口排查问题方便得多。3.2 商品请求与发起购买的核心代码商品信息请求在 StoreKit 1 里用SKProductsRequest。这里有个细节SKProductsRequest一定要用属性强引用着否则发出去了delegate 回调还没回来对象就被释放了结果就是请求没有任何反应。final class IAPManager: NSObject { static let shared IAPManager() private var productsRequest: SKProductsRequest? private var completion: ((Result[SKProduct], Error) - Void)? /// 请求所有商品信息用于刷新价格和展示 func fetchProducts(identifiers: SetString, completion: escaping (Result[SKProduct], Error) - Void) { productsRequest SKProductsRequest(productIdentifiers: identifiers) productsRequest?.delegate self self.completion completion productsRequest?.start() } } extension IAPManager: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { let products response.products let invalidIdentifiers response.invalidProductIdentifiers // invalidProductIdentifiers 非空时说明后台商品 ID 没配好或没有通过审核 completion?(.success(products)) productsRequest nil } }发起购买时把用户选中的SKProduct转成SKPayment然后加入支付队列。这一步不需要你做任何弹窗系统自己会处理。func purchase(_ product: SKProduct) { guard SKPaymentQueue.canMakePayments() else { // 用户在设置里禁用了内购 return } let payment SKPayment(product: product) SKPaymentQueue.default().add(payment) }3.3 交易收尾的顺序为什么不能拿到交易就立刻 finishTransaction这是内购开发里最要命的一个顺序问题。交易回调到了.purchased之后你已经拿到了交易对象但如果这时候直接finishTransaction就等于告诉苹果这笔交易我处理好了苹果会认为发货流程已经完成。可如果你客户端发货失败、服务器没记录到订单这笔钱就永远追不回来了。正确顺序是收到.purchased回调先把交易对象缓存下来不要马上 finish从Bundle.main.appStoreReceiptURL读取 receipt如果为空就尝试刷新票据把 receipt 发给自己的服务器由服务器向苹果验证并同步发货服务器确认成功之后客户端再调用finishTransaction。如果服务器都验证通过了客户端却不 finish苹果每次启动都会把这笔交易重新抛给你直到你确认。这也是为什么很多开发者说苹果内购有兜底机制——前提是你没乱 finish。func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchased: // 交给服务端验证验证成功后调用 confirmTransaction pendingTransactions.append(transaction) verifyReceiptOnServer(transaction: transaction) case .failed: if let error transaction.error as? SKError, error.code .paymentCancelled { // 用户主动取消直接静默处理 } SKPaymentQueue.default().finishTransaction(transaction) case .restored: // 恢复购买也需要走服务端验票流程 pendingTransactions.append(transaction) verifyReceiptOnServer(transaction: transaction) default: break } } } func confirmTransaction(_ transaction: SKPaymentTransaction) { SKPaymentQueue.default().finishTransaction(transaction) }4. 票据校验客户端验票只是减速带服务端才是防刷底线4.1 两种校验方式各自能挡住谁苹果的验证方式分两种客户端本地校验和服务端二次校验。严格来说客户端校验是可以被绕过和伪造的它只能挡一挡什么工具都不会用的普通用户。但你的 App 一旦稍微有点量就会有人去改包、重签名、模拟假票据这些操作客户端校验完全拦不住。所以我的结论很明确客户端最多拿 receipt 做辅助判断真正的发货依据必须来自服务端校验结果。你别嫌服务端多一步麻烦等你遇到刷单退款的时候就知道这一步值多少钱。4.2 verifyReceipt 接口的调用细节和常见返回码服务端收到客户端上传的 receipt 之后向苹果的验证接口发起请求。这里有两个环境地址生产环境是https://buy.itunes.apple.com/verifyReceipt沙盒环境是https://sandbox.itunes.apple.com/verifyReceipt。对于用哪个地址的问题我的生产服务端处理逻辑是先请求生产环境如果返回21007receipt 是沙盒的就自动再请求一次沙盒环境。因为TestFlight 和审核期间的 sandbox 环境苹果用的是沙盒票据如果你只有一个地址就永远验不过。POST https://buy.itunes.apple.com/verifyReceipt { receipt-data: base64 字符串, password: App 专用共享密钥, exclude-old-transactions: true }共享密钥在 App Store Connect - App -“App 内购买项目” -“App 专用共享密钥”里生成。服务端校验通过之后返回体里会有receipt和latest_receipt_info字段。对订阅来说要从latest_receipt_info里找expires_date来判断订阅是否还在有效期内。这里再说一个容易踩的坑状态码 21002 表示 receipt 数据有误很多客户端在读取 receipt 后没做 base64 编码或者传成了字符串服务端就会收到这个错误21004 是共享密钥不对21005 是 receipt 服务器不可用21008 是生产环境的 receipt 被发到了沙盒地址。这些状态码对应的排查方向很明确直接按表查就行不要瞎猜。4.3 App Store Server Notifications V2订阅的退款和续订状态都靠它如果你只是做一次性购买服务端验票可能就够了。但做订阅制必须接入App Store Server Notifications V2。它负责在订阅续期成功、续期失败、退款、用户主动取消订阅等事件发生时主动推一个通知到你的服务器。为什么一定要接因为订阅是持续发生的交易用户今天买了月卡下个月系统自动续费时你的服务器可能根本没收到请求。如果只靠用户打开 App 时的验票去同步状态那些续费了却从不开 App 的用户你永远不知道他们其实是付费用户。Notification V2 有个明显的变化推送内容从明文 JSON 变成了带签名的signedPayload服务器需要用苹果的证书链校验签名后才能解析里面的事件。这个签名验证逻辑看着麻烦但它是防止伪造通知的关键。至少要实现SUBSCRIBED新订阅、DID_RENEW续期成功、DID_FAIL_TO_RENEW续期失败、REFUND退款、EXPIRED订阅过期这几类事件的处理。尤其是退款事件苹果退款了你还继续给人发会员权益这是经营事故。5. 沙盒、审核与上线三个阶段我踩过的坑5.1 沙盒测试的四个常见现象见到不要慌沙盒环境和生产环境的差异第一次接触的人往往要花很长时间适应。我整理了一下自己遇到的四类现象沙盒环境请求特别卡苹果沙盒服务器响应慢尤其是第一次购买时转圈十几秒都是正常的别急着判断超时。商品请求返回空或 invalidProductIdentifiers九成是后台协议没签、商品状态不是准备提交或已批准。自动续期订阅续期非常快沙盒环境把订阅周期压缩了一个月会员可能在几分钟内自动续期这是为了方便你测试续期逻辑不是 bug。沙盒账号登录冲突某个测试号在多个设备上反复登录偶尔会出现无法购买的提示换个账号或者重启 App 就好。5.2 审核被拒的高频理由提前自查苹果审核对 IAP 的重视程度非常高我见过太多次因为内购细节被拒的。最高频的几类引导用户到 App 外支付文案里出现去官网购买支付宝/微信字样或者 App 内宣传外部支付渠道直接违反审核准则。没有提供恢复购买入口非消耗型项目必须有恢复购买按钮审核人员会专门测试这个功能。审核人员无法完成内购测试很多团队没在提审备注里提供 TestFlight 测试账号和沙盒账号说明导致审核员卡在购买流程上。我的做法是每次提审时都在“App 审核信息”里详细写清楚测试步骤用什么账号、点哪个按钮、看到什么结果算成功。5.3 上线后的漏单复盘原因往往不简单App 上线跑了一段时间之后漏单问题开始浮出水面。我复盘过几次典型的漏单根因基本落在三块第一交易监听器没有在启动时注册。用户从购买到交易完成的整个链路里App 可能被杀掉如果监听器注册时机太晚交易不会被正确处理。第二服务端发货和客户端 finish 的顺序搞反了。先 finish 后发货发货失败时钱已经扣了用户拿不到货客诉全部涌进来。第三没有做交易持久化。客户端内存里保存的交易App 一重启就丢了。正确做法是收到未完成的交易后先落盘服务端验票通过后再按事务 ID 幂等处理。这样做能保证即使服务端偶发超时客户端重试时也不会给用户重复发货。最后分享一点我的实际体会苹果内购这套东西技术上不难难在你想不到的地方太多了。做了一年多内购之后我的体感是想省事就要在最开始把服务端验票、事务 ID 幂等、订阅通知这三件事一次性做对后面维护成本会低很多。客户端代码反而相对稳定我后来基本不再动支付相关的页面。每次发版前我都会用 TestFlight 配合沙盒账号完整跑一遍购买、恢复购买、取消订阅三种流程再检查服务端日志和订单状态。这套动作看起来笨但确实帮我挡住了好几次审核被拒。如果你正在做苹果内购建议把上面几个关键链路逐条对照自己的代码过一遍比上线后被用户和审核两头发难要轻松太多。本文还有配套的精品资源点击获取
返回列表