尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

iOS StoreKit2+PHP后端:内购交易验证与服务端处理实战

发布时间:2026/9/26 12:11:57

资讯中心
01
ARTICLE

iOS StoreKit2+PHP后端:内购交易验证与服务端处理实战

iOS StoreKit2+PHP后端:内购交易验证与服务端处理实战
做iOS内购开发这么久最让我头疼的从来不是客户端怎么写而是交易验证这件事怎么和后端配合。尤其是现在苹果全面推行StoreKit2很多原来熟悉的老一套本地收据验证、SKPaymentQueue回调逻辑都被新API替代了服务端要接的也不是简单的verifyReceipt了。这篇文章就围绕iOS StoreKit2 PHP后端处理这套组合把客户端接入、服务端验签、订阅通知处理、防作弊校验这些核心环节讲透给正在做或准备做内购服务端对接的同学一份可以落地参考的方案。我在实际项目里踩过不少坑比如订阅续费通知重复推送导致权益重复发放、沙盒环境和生产环境订单串号、JWS解码和签名验证方式搞错导致全部请求被拒等等。这些坑单看文档很难发现只有把整条链路跑通才会意识到服务端处理远不止接到订单就发东西这么简单。这篇文章会从选型思路开始一路讲到PHP具体实现和排查经验尽量少讲废话多给能直接抄走的东西。1. 为什么要把PHP后端拉进内购链路1.1 StoreKit2只是客户端的第一步StoreKit2是苹果在iOS 15推出的新内购框架用Swift语言特性把原来的SKPaymentQueue、SKPaymentTransaction那套老API尽量封装起来提供了Product、Transaction、Product.PurchaseResult这些更符合现代Swift开发习惯的接口。它最大的变化是弱化了本地收据Receipt的概念把交易信息变成一个用JWS格式签名的transaction对象客户端通过Transaction.currentEntitlements和Transaction.updates就能拿到历史交易和实时更新。但客户端拿到交易对象不等于这笔交易就是可信的。StoreKit2的JWS签名验证虽然客户端也可以做但一个真正靠谱的内购系统必须把信任根放在服务端。客户端代码跑在用户设备上设备环境可以被篡改、App可以被重打包、返回结果可以被hook这些都是真实存在的攻击面。服务端校验的意义就是确保客户端说买了是真的买了而且确实是这个账号买的。所以很多团队把StoreKit2接入理解成客户端调新API、服务端继续接verifyReceipt其实是不够准确的。StoreKit2对应的是苹果新推出的App Store Server API和App Store Server Notifications V2老式的/verifyReceipt端点虽然还能用但苹果明确标注它已经被API替代新项目更推荐走新链路。1.2 服务端验单到底在验什么服务端验单本质要回答三个问题交易是否真实存在、交易是否属于当前用户、交易当前是什么状态。交易是否真实存在指的是这笔交易ID必须能在苹果服务器侧查到并且里面的金额、商品ID、购买时间、应用App ID与客户端上报的完全一致。交易是否属于当前用户指的是这笔交易对应的原始ID或订单号必须与服务器上已经绑定的用户一致不能出现A用户拿着B用户的交易ID来兑换权益。交易当前是什么状态则关系到权益发放能否落库比如一笔交易可能被退款、撤销、或者订阅失效这些状态变化必须及时反馈到业务侧。传统verifyReceipt返回的是一个包含receipt、latest_receipt_info、pending_renewal_info的大JSON服务端解析字段多、历史兼容包袱重。而StoreKit2链路下服务端主要打交道的是transactionInfo、signedTransactionInfo、lastTransactions这些字段数据结构更清爽但JWS验签逻辑却成了必做的功课。1.3 整体链路设计客户端发起服务端裁决我推荐的内购整体链路是客户端调用StoreKit2完成购买拿到包含交易信息的JWS字符串把它发给自己的PHP后端PHP后端用苹果的公钥完成JWS验签并调用App Store Server API的/v1/transactions/{transactionId}接口做二次确认确认无误后PHP后端发放权益落库随后所有订阅续费、退款、失效事件由苹果的服务端通知V2推送到PHP后端处理。这个链路里客户端只负责发起购买和展示结果不承担任何信任判断。PHP后端是唯一的裁决者所有关于能不能给权益的判断都必须以服务端数据为准。这也意味着PHP后端必须接收并妥善处理通知回调因为订阅类交易在购买之后还会发生大量状态变化这些变化是客户端主动请求无法感知的只能靠服务端通知机制驱动。2. StoreKit2客户端接入的几个关键点2.1 用Transaction取代SKPaymentQueueStoreKit2最直观的变化是购买入口变了。老的SKPaymentQueue.add方式还在但在iOS 15以上新代码里我们通常直接用Product.PurchaseResult。示例逻辑大致是先通过Product.products(for:)加载商品然后调用product.purchase()拿到返回的purchaseResult。let product try await Product.products(for: [com.example.iap.coins]).first guard let product else { return } let result try await product.purchase() switch result { case .success(let verification): let transaction try verifyResult(verification) // 把这个 transaction.jwsRepresentation 发给后端 await transaction.finish() case .pending: // 等待用户去完成付款或解锁 case .userCancelled: // 用户取消 }这里值得强调的是transaction.finish()。在StoreKit2里交易完成后必须显式调用finish()来告知StoreKit你已经处理完毕。如果忘记调用交易会一直出现在Transaction.updates队列里客户端逻辑上会产生重复展示。合理的做法是客户端先把JWS字符串发给后端等后端返回成功确认后再调用finish()避免出现客户端先finish了、后端请求失败导致丢单的尴尬场景。2.2 购买结果与验证签名VerificationResultStoreKit2的PurchaseResult.success带的是一个VerificationResult里面包含jwsRepresentation和一个已经验签过的Transaction。这里的VerificationResult本身已经做了签名校验用的是苹果的根证书开发者可以通过VerificationResult的枚举值判断校验是否通过。但在服务端方案里客户端那一步验签只是第一道检查最终裁决仍然要放在后端做。jwsRepresentation是一段以eyJ开头的JWS字符串格式是Header.Payload.Signature三段。它包含了交易ID、原始ID、商品ID、购买时间、数量、App Apple ID、Bundle ID等信息。后端拿到这段字符串后要做的事情是拆出Header拿到alg和x5c证书链参数、校验证书的可信链、核对Payload里的Bundle ID和App Apple ID是否与自家应用一致、再检查交易ID是否真实存在。这个过程我会在PHP实现部分详细展开。客户端需要传给后端的数据最少是jwsRepresentation同时可以带上transactionID和productID作为冗余字段方便日志排查。但后端一定不能直接信任冗余字段必须从JWS Payload里解出真实字段再比对。2.3 订阅状态与Transaction.updates监听订阅类内购比普通一次性购买复杂的地方在于交易状态是持续变化的。StoreKit2提供了Transaction.updates这个异步序列App启动后要持续监听每当苹果检测到订阅状态变化续费成功、退款、失效、家庭共享等该序列就会吐出一个VerificationResultTransaction。很多团队在集成时只写了购买代码忘了启动Transaction.updates监听导致用户订阅到期后权益在客户端不刷新。正确的做法是在App启动早期就启动一个监听任务把收到的交易JWS也发给后端做同步更新。监听任务建议用一个生命周期覆盖App运行周期的对象来持有避免被提前释放。Task { for await update in Transaction.updates { let transaction try verifyResult(update) await sendTransactionToServer(transaction.jwsRepresentation) await transaction.finish() } }2.4 拿到transactionID之外的JWS参数我在联调时发现新手最容易忽略的一点是后端需要的很多字段并不在Transaction的Swift属性里显式暴露而是在JWS Payload原始JSON里。比如transactionId可以拿到但originalTransactionId、inAppOwnershipType、environment这些信息需要解码JWS Payload之后才能拿到。所以客户端发给后端的JWS字符串一定不能只截取其中某些字段拼接成自定义字符串。很多人在调试时为了方便把transaction.originalID单独发给后端再配一个productID结果后端拿着这三个字段去苹果查询发现数据对不上。最稳妥的方式是原封不动传整个jwsRepresentation由后端统一解码、校验、取字段。3. PHP后端处理的核心实现3.1 两种服务端校验方式选型iOS内购的服务端校验现在有两条路径。第一条是老路App Store的https://buy.itunes.apple.com/verifyReceipt端点POST一份Base64编码的收据字符串和sharedSecret苹果返回一整个JSON。走这条路的好处是参数简单、文档资料多坏处是收据字段多而杂很多字段含义模糊新项目对接时容易出理解偏差而且苹果已经在文档里建议开发者迁移到新API。第二条是新路App Store Server API。它面向StoreKit2设计提供/v1/transactions/{transactionId}查询单笔交易、/v1/subscriptions/{originalTransactionId}查询订阅状态、/v1/notifications/test发送测试通知等接口。调用时需要用一个从App Store Connect下载的.p8私钥生成JWT鉴权Token安全性更高。我强烈建议新项目直接走第二条路PHP侧只需要一个简单的JWT生成函数就能完成鉴权。老verifyReceipt可以留作兼容旧版本App的备选而不应该作为新功能的首选方案。3.2 JWS验签与Payload解包服务端拿到客户端传来的JWS字符串后第一步是拆解三段结构。Header里通常有alg、x5c、kid、typ等字段x5c是苹果证书链的Base64数组第一项是Apple根证书之后的中间证书最后一根才是信任锚。严格来说开发者应该用苹果官方的Apple Root CA证书去验证整个证书链。但实际工程里完整做证书链校验的代码量不少。我见过很多PHP项目直接省略这个步骤只解Base64后JSON_decode拿到Payload字段就发放权益。这样做风险很大因为JWS的Header和Payload是Base64编码的任何人都可以自己构造一段看起来像苹果签名的伪字符串。必须至少完成签名验证才谈得上信任里面的数据。PHP里验证ES256签名推荐用firebase/php-jwt这样的成熟库或者用OpenSSL扩展手动验签。GitHub上有很多基于web-token/jwt-framework的例子不过对一般项目来说用firebase/php-jwt加一个JWK公钥文件就够用了。需要解析苹果的JWKS地址是https://appleid.apple.com/auth/keys但那是Apple ID的密钥StoreKit2交易要用的是App Store的https://api.storekit.itunes.apple.com/...相关密钥实际上StoreKit2的JWS验证苹果在WWDC上推荐的是用Apple Root Certificate建立信任链App Store Server API文档里给出的验证方式是通过App Store Root Certificate与你用于API鉴权的.p8密钥不是一回事。实际操作中你可以把苹果提供的AppleRootCA-G3证书放到服务端用OpenSSL加载并校验签名。如果不想把工程搞得太复杂也可以用苹果官方提供的app-store-server-library-php这个官方库它内部已经封装了验签和API请求逻辑省去手写JWS校验的坑。这个库是苹果在GitHub上开源的支持JWS解析、签名验证、API JWT生成、通知V2解码强烈建议直接使用。3.3 向App Store Server API查询交易无论客户端传来的JWS验签是否通过我都建议再调一次App Store Server API做交叉验证。原因很简单JWS里包含的字段是苹果签发给客户端的证明这笔交易曾经发生过但交易是否仍然有效、是否已退款、是否被撤销只能通过服务端API查询苹果的实时状态。PHP后端调用App Store Server API需要先生成JWT。JWT的Header需要带alg为ES256、kid为App Store Connect里创建的密钥IDPayload需要带iss为Issuer ID、iat和exp为签发时间然后使用下载的.p8私钥完成ES256签名。苹果要求JWT有效期不能太长一般建议5到10分钟服务端每次请求前动态生成即可。$header [ alg ES256, kid YOUR_KEY_ID, typ JWT ]; $claims [ iss YOUR_ISSUER_ID, iat time(), exp time() 300, aud appstoreconnect-v1 ];生成鉴权Token后请求GET https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}响应体里包含signedTransactionInfo数字签名交易信息和signedRenewalInfo续期信息。再用同样的方式验签这些signed*字段就能拿到bundleId、productId、transactionId、originalTransactionId、purchaseDate、expiresDate、environment等关键字段。注意API有沙盒和生产两个域名沙盒是https://api.storekit-sandbox.itunes.apple.com生产是https://api.storekit.itunes.apple.com。联调时很容易因为域名配错导致查不到交易。我建议把环境判断逻辑写成独立函数根据JWS Payload里的environment字段动态选择API域名。3.4 与自有账号体系绑定服务端验到交易有效后就要把交易和自有账号绑定。一般做法是客户端携带userId或sessionTokenjwsRepresentation一起发给后端后端从JWS里解出originalTransactionId在自己的user_iap_subscription表里做映射。originalTransactionId在订阅交易里是一个链的锚点一次订阅从第一次购买到后续每次续费transactionId会变但originalTransactionId保持不变。因此服务端判断这个用户是否买过某个订阅应该以originalTransactionId为准而不是transactionId。每次续费事件到达时通过originalTransactionId找到归属用户再去更新到期时间。数据库表设计至少要包含original_transaction_id、transaction_id、user_id、product_id、environment、purchase_date、expires_date、last_status。其中original_transaction_id要有唯一索引防止同一个订阅被重复绑定到多个用户transaction_id也要有唯一索引杜绝重复发放。4. 服务端通知V2与订阅生命周期处理4.1 通知V2的格式和URL配置苹果的服务器通知V2和以往的V1完全不同。V1通知里是简单的notificationTypereceipt字段V2则把整个事件做成了一个signedPayload本质上又是一段JWS。开发者需要先在App Store Connect后台配置通知的URL同时也可以调用/v1/notifications/test接口发送一条测试通知来验证服务端接收能力。服务器接收通知后先检查请求体里signedPayload的存在再用与交易JWS类似的验签方式验证通知本身。验签通过后解码Payload可以拿到notificationType、subtype、data中的signedTransactionInfo和signedRenewalInfo。其中notificationType是核心事件类型常见的包括CONSUMPTION_REQUEST、DID_CHANGE_RENEWAL_STATUS、DID_FAIL_TO_RENEW、EXPIRED、REFUND、REVOKE、SUBSCRIBED、RENEWAL、INTERNAL_PURCHASE等。4.2 signedPayload验签通知里的signedPayload验证逻辑和交易JWS验证基本一致但还是有几个细节需要注意。第一通知的signedPayload在实际推送时可能不是标准的单段JWS而是以signedPayload字段承载所以接收端不要自己去猜结构直接用官方库解析最稳。第二通知Payload里有一个data.signedRenewalInfo里面包含autoRenewProductId、renewalDate、autoRenewStatus等信息续期状态判断要用到这里的数据而不是只看transactionInfo。服务端拿到通知后正确的处理顺序是验签 - 解析Payload - 提取originalTransactionId和transactionId- 查询本地映射找到用户 - 更新订阅状态 - 记录通知日志。这个顺序里验签不过的直接丢弃不要继续往下走任何业务逻辑。4.3 幂等处理与去重服务端通知有一个非常坑人的特性苹果不保证只推一次。网络抖动、服务端重启、手动重试都可能导致同一条通知重复到达。如果处理逻辑没有幂等就可能出现续费事件重复给用户加时长、退款事件重复撤销权益这类线上事故。我的做法是建一张apple_notification_log表把每次收到的notificationIdV2通知Payload里有这个字段作为唯一键写入。处理前先查重如果已经处理过就直接返回200不再执行业务逻辑。即使没有notificationId用signedTransactionInfo里的transactionId purchaseDate也能做成一个业务幂等键。另外PHP脚本执行超时也是幂等处理失败的高发场景。苹果通知服务不会因为PHP进程退出就认为消息被消费如果处理到一半数据库事务回滚、但通知日志却提前写入了后续消息就被误判为已处理。因此通知日志写入必须和业务状态更新在同一个数据库事务里要么都成功要么都失败。4.4 退款、撤销、过期与续期状态机订阅类交易的状态机我建议在服务端用显式字段维护而不要靠客户端传来的界面状态推测。比较合理的字段组合是statusACTIVE/EXPIRED/REFUNDED/REVOKED/GRACE_PERIODexpires_datelast_notification_type。收到SUBSCRIBED或RENEWAL通知时更新expires_date把状态置为ACTIVE。收到DID_FAIL_TO_RENEW通知时一般意味着订阅进入宽限期或计费重试此时不能立刻把用户权益关掉要看signedRenewalInfo里的计费重试标记。收到EXPIRED通知时把状态改为EXPIRED关闭高级权益。收到REFUND或REVOKE通知时要标记退款并回收对应权益。这里有一个容易漏掉的点退款不完全等于撤销。REFUND是用户申请退款成功REVOKE在家庭共享场景里是被家庭组织者撤销购买两种都要回收权益但原因不同运营统计时要能区分。苹果在REFUND通知里还可能带subtype比如APP_ISSUE、OTHER可以作为客服处理依据。5. 典型问题排查与安全加固实践5.1 沙盒与生产环境判定内购开发必须频繁切换沙盒环境所以服务端一定要有环境识别能力。沙盒交易和真实交易的JWS Payload里都有environment字段取值分别是Sandbox和Production。在开发联调阶段客户端如果用的是Xcode跑的真机购买走的是沙盒环境但如果用TestFlight或者App Store正式包走的是生产环境。我见过最典型的线上事故是后端拿着沙盒交易ID去生产环境API查询结果永远查不到或者是联调时没问题、上线后突然全部验单失败。排查方向很简单——先看JWS Payload里的environment再根据环境选择对应API域名。生产环境绝对不能允许沙盒交易直接发放权益但沙盒环境可以允许生产交易因为App Store审核时苹果会用沙盒账号进行审核审核环境走的是沙盒。建议在数据库表里保存environment字段同时提供后台配置项控制是否允许沙盒交易发放权益。默认关闭允许沙盒交易发放权益只允许联调阶段临时打开。5.2 常见PHP集成问题我在PHP接入过程中遇到的坑主要有这几个。第一个坑是curl请求App Store Server API时SSL证书校验失败。服务器上如果PHP的CA证书路径配置不正确https://api.storekit.itunes.apple.com的请求就会抛SSL错误。解决办法是更新服务器的ca-bundle.crt或者把cacert.pem放在项目目录并通过CURLOPT_CAINFO指定。第二个坑是ES256签名用的.p8私钥格式问题。App Store Connect下载的是PKCS#8格式的PEM文件PHP的OpenSSL扩展读取时如果报key format not supported之类的错要确认私钥内容没有被错误换行或截断。建议下载后不要用记事本修改原样保存到服务端非公开目录。第三个坑是通知回调返回的HTTP状态码。苹果对于通知端点只要收到2xx就认为投递成功如果返回非2xx会重试。有些人处理失败时直接返回500导致苹果短时间内反复推送服务端日志全是重复处理记录。我建议接收通知后先落日志、马上返回200然后异步处理业务。这样即使后续处理失败也可以靠日志追溯重放。5.3 防篡改与防重放设计服务端校验存在的最重要原因就是客户端不可信。哪怕客户端代码是用SwiftUI StoreKit2写的攻击者依然可以通过非正常手段修改App二进制、hook函数返回值、篡改网络请求。所以服务端要把所有客户端传参都当作不可信数据。防篡改的第一层是JWS验签第二层是调用App Store Server API核验交易状态第三层是价格与商品一致性校验。所谓价格一致性校验就是后端事先在配置里维护一份product_id - 苹果后台配置的金额映射每笔交易核验通过后再检查扣款金额是否匹配。攻击者最常用的手法是购买一个低价商品然后篡改Product ID声称买了高价商品。如果服务端只验JWS、不看金额这种攻击就能得逞。防重放方面除了transaction_id的唯一索引外还要注意同一笔交易不能给多个用户使用。攻击者可以买一笔交易然后拿同一段JWS给多个账号使用。因此服务端绑定关系必须在事务里完成先查transaction_id是否已被其他用户绑定未绑定才允许建立当前用户的权益记录。这个校验不能只放在应用层要给数据库加唯一索引从根上兜底。服务端日志同样重要。每笔交易的关键信息至少保留时间、用户ID、交易ID、原始交易ID、商品ID、环境、状态码。万一出现客诉可以快速定位这笔交易当时被哪个用户消费、后端判定结果如何。这个习惯在排查用户说扣钱了为什么没到账的问题时能省下大量扯皮时间。6. 实际操作中的几个小经验和建议如果按照这套方案从头做一遍我的建议是先不要急着写全业务代码而是先把苹果官方app-store-server-library-php库引入项目跑通一个最简流程客户端购买 - 后端收JWS - 官方库验签 - 调API查询单笔交易 - 返回成功。把这条路跑通后面再补订阅通知和状态机就顺很多。官方库虽然帮你封装了大量细节但有两个地方还是要自己补。第一是通知日志与幂等表官方库本身不负责业务幂等第二是自有用户体系与交易记录的映射逻辑这部分完全依赖你的业务表结构。所以不要把官方库当成完整后端来用它只是帮你省去了密码学方面的重复劳动。在订阅类权益发放逻辑上我习惯不直接依赖通知推送这一条路径。App Store Server API里有一个接口叫/v1/subscriptions/{originalTransactionId}可以主动查询某个订阅的最新状态。当用户启动App或者进入某个需要校验的页面时后端可以缓存这个接口的结果以它作为最终状态来源。这样即使某条通知因为网络问题漏掉了下一次主动查询也能把状态修正过来。最后记得在项目初期就规划好回调接口的日志链路。苹果的服务器通知是没有控制台可查的出了问题只能靠服务端日志反查。我一般会把原始通知体原样存储一份定期清理。这样无论后续排查还是做数据回放都有据可依。这套方案做完之后你会发现内购服务端的复杂度其实集中在两件事一是把苹果的签名体系和状态机理解透彻二是让自己的数据库表结构具备幂等和可追溯能力。这两件事做好了无论以后StoreKit2怎么升级服务端都稳得住。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。