内购失败的问题分析与原因总结

Date
Created
Feb 13, 2023 04:42 AM
Descrption
内购相关的疑难点
Tags
iOS
内购
notion image
提要:本篇主要涉及到内购中的疑难杂证,不是基础内购接入流程
最近公司上线了一款社交类应用,这是一款采用了flutter技术开发的应用,对于该项目还是很满意的,收入在同行算中等,重要的是对于flutter技术的掌握也更近一步,话不多说回归正题。
App想要变现的话一定就要接入内购,在此之中遇到了一些问题,以下是遇到问题的汇总与分析:

1.接入内购时的后端票据验证:

票据验证涉及到两个接口:
这里有个坑是:当App审核的时候,实际上苹果审核人员使用的是沙盒支付环境,所以后端应该在这里做一下处理,有三种处理方式:1.当你的环境切为正式环境提交审核时,假如验证票据是21007,那么后端应该补调一下沙盒的验证接口;2.或者就是加一个审核态的标识,如果审核态时,使用沙盒的接口;3.后端实际上不用前端传任何东西,拿到票据时,自己判断是沙盒还是生产环境(我们采用此种做法)
(有很多开发者因为在审核态时,用生产接口验证票据被拒)
以下是票据验证的报错以及原因
21000 App Store无法读取你提供的JSON数据21002 收据数据不符合格式21003 收据无法被验证21004 你提供的共享密钥和账户的共享密钥不一致21005 收据服务器当前不可用21006 收据是有效的,但订阅服务已经过期。当收到这个信息时,解码后的收据信息也包含在返回内容中21007 收据信息是测试用(sandbox),但却被发送到产品环境中验证21008 收据信息是产品环境中使用,但却被发送到测试环境中验证
还有个坑是:对于订阅类的产品,验证票据需要加password字段,这个是你App Store Connect上面创建的共享密钥,把这个密钥给后端就好
notion image
 

2.接入内购时是否包含自动续订类产品:

如果包含自动续订类的产品,那么一定切记要在订阅产品购买页加上会员服务协议,苹果要求这个协议必须要有,同时你还应该在协议里描述清楚你的续订类产品的详细信息以及续订规则,取消规则,请为用户负责。
notion image
 
 
如果可能,最好把会员服务协议链接和续订类产品的详细信息以及续订规则,取消规则写在商店宣传文案下面。
notion image
 
(有很多开发者因为没有任何协议导致被拒)

3.内购的报错原因最好详细展开有助于分析:

我们的内购流程有详细的埋点跟踪用户操作的流程以及失败原因,统计失败错误可以定位并分析到对应的错误原因。
但是支付失败可能产生于不同的地方,以及我们应该在何处进行边界判断:

productId是否合法

//校验productID是否合法 guard (self.currentProductId != nil && self.currentProductId!.count > 0) else { #if DEBUG SVProgressHUD.showInfo(withStatus: "1.1 商品id不合法") SVProgressHUD.dismiss(withDelay: 3.0) #endif return } //开始支付流程 startCharge()
绝大多数不会因为这个原因导致支付失败,因为我们一般按照正常逻辑接入支付,支付数据一般也是正常的,而且假如发起了SKProductsRequest后,没有找到该productId,也会提示未找到商品:
... func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { productList = response.products var curProduct :SKProduct? if let tem_productList = productList { if tem_productList.count==0 { //如果商品列表为空,那么提示用户没有找到商品 if let reusltBlock = self.result{ let parameter: [String:String] = [ "state":"failed", "msg":"The product was not found","isShowAlert":"1","isShowDialog":"0"] reusltBlock(parameter) return } } ... }
但是假如你没有头绪时,不妨检查下productId是否合法。

检查设备是否允许支付

if SKPaymentQueue.canMakePayments() { //user允许支付 if let proId = currentProductId { let requestSet = Set.init([proId]) payRequest = SKProductsRequest.init(productIdentifiers: requestSet) //设置支付请求代理 payRequest?.delegate=self payRequest?.start() } }else{ //user不允许支付 if let reusltBlock = self.result{ let parameter: [String:String] = [ "state":"failed", "msg":"The user is not allowed to make payments.", "isShowAlert":"1","isShowDialog":"0"] reusltBlock(parameter) } }
这种情况的原因是手机关闭了应用内支付,或者没有绑定支付手段,还有其他…
分析了线上用户的埋点数据,这种报错不多,但是也有一些,如果是这种错误的话,请展示给用户无法进行支付的提示。

发起内购请求失败

//发起内购请求失败 func request(_ request: SKRequest, didFailWithError error: Error) { print("发起内购失败,失败原因:\(error.localizedDescription)") let tem_error = error as NSError #if DEBUG SVProgressHUD.showInfo(withStatus: "2.发起内购失败,失败Domain:\(tem_error.domain),失败code: \(tem_error.code),失败描述:\(tem_error.localizedDescription),失败信息: \(tem_error.userInfo)") SVProgressHUD.dismiss(withDelay: 3.0) #endif if let reusltBlock = self.result{ let parameter: [String:String] = [ "state":"failed", "msg":"2.发起内购失败,失败Domain:\(tem_error.domain),失败code: \(tem_error.code),失败描述:\(tem_error.localizedDescription),失败信息: \(tem_error.userInfo)","isShowAlert":"1","isShowDialog":"1"] reusltBlock(parameter) } }
这种是属于NSURLErrorDomain的错误,这种情况属于payRequest?.start()失败,大部分可以归结为网络原因,网络环境不正常,这种情况下也只可以让用户稍后重试。

判断func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse)中返回的的商品id是否和发起内购商品id一致

这一步其实不是很必要,但是喜马拉雅App的代码里面有这一步判断,有可能也是有意外情况。

处理transactionState为.failed的交易:

//交易失败 func failedTransaction(transaction: SKPaymentTransaction) { let error = transaction.error as? SKError var message :String? var isShowAlert = "0"//是否显示toast var isShowDialog = "1"//是否显示Dialog if let code = error?.code { switch code { case .unknown: message = "未知错误" case .clientInvalid: message = "Client is not allowed to issue the request, etc" case .paymentCancelled: //取消 message = "User cancelled the request, etc" isShowAlert = "1" isShowDialog = "0" case .paymentInvalid: message = "Purchase identifier was invalid, etc" case .paymentNotAllowed: //设备不支持支付 message = "This device is not allowed to make the payment" isShowAlert = "1" isShowDialog = "0" case .storeProductNotAvailable: message = "Product is not available in the current storefront" case .cloudServicePermissionDenied: message = "User has not allowed access to cloud service information" case .cloudServiceNetworkConnectionFailed: message = "The device could not connect to the nework" case .cloudServiceRevoked: message = "User has revoked permission to use this cloud service" case .privacyAcknowledgementRequired: message = "The user needs to acknowledge Apple's privacy policy" case .unauthorizedRequestData: message = "The app is attempting to use SKPayment's requestData property, but does not have the appropriate entitlement" case .invalidOfferIdentifier: message = "The specified subscription offer identifier is not valid" case .invalidSignature: message = "The cryptographic signature provided is not valid" case .missingOfferParams: message = "One or more parameters from SKPaymentDiscount is missing" case .invalidOfferPrice: message = "The price of the selected offer is not valid (e.g. lower than the current base subscription price)" case .overlayCancelled: message = "OverlayCancelled" case .overlayInvalidConfiguration: message = "OverlayInvalidConfiguration" case .overlayTimeout: message = "OverlayTimeout" case .ineligibleForOffer: message = "User is not eligible for the subscription offer" case .unsupportedPlatform: //平台不支持支付 message = "UnsupportedPlatform" isShowAlert = "1" isShowDialog = "0" default: message = "Default" } } #if DEBUG SVProgressHUD.showInfo(withStatus: "4.交易失败,失败原因:*****\(String(describing: message))******") SVProgressHUD.dismiss(withDelay: 3.0) #endif if let reusltBlock = self.result{ if message != nil { let parameter: [String:String] = [ "state":"failed", "msg": message ?? "","isShowAlert":isShowAlert,"isShowDialog":isShowDialog] reusltBlock(parameter) }else{ let description = transaction.error?.localizedDescription let parameter: [String:String] = [ "state":"failed", "msg": description ?? "","isShowAlert":"0","isShowDialog":"1"] reusltBlock(parameter) } } SKPaymentQueue.default().finishTransaction(transaction) }
这里列出了所有的支付失败的状态,大部分状态不会遇到,遇到最多的是.unknown,这个错误真的是很折磨人,如果你接入支付不是参照官网文档,而是参看网上人们的博客,极有可能犯下一个错误,这会导致经常报这个错误,这个错误的详细信息是:
▿ Optional<related decl 'e' for SKErrorCode> ▿ some : related decl 'e' for SKErrorCode - _nsError : Error Domain=SKErrorDomain Code=0 "发生未知错误" UserInfo={NSLocalizedDescription=发生未知错误, NSUnderlyingError=0x281348d20 {Error Domain=ASDErrorDomain Code=500 "Unhandled exception" UserInfo={NSUnderlyingError=0x281349e60 {Error Domain=AMSErrorDomain Code=301 "Invalid Status Code" UserInfo={NSLocalizedDescription=Invalid Status Code, NSLocalizedFailureReason=The response has an invalid status code}}, NSLocalizedFailureReason=An unknown error occurred, NSLocalizedDescription=Unhandled exception}}}
为了调查这个错误,我基本上看过了国内外所有的相关博客,绝大多数人都表示无能为力,并表示这可能不是我们代码导致的原因,是苹果的原因。
 
notion image
 
这里苹果爸爸也只是轻描淡写了一句:
notion image
 
但是看惯了苹果爸爸的demo,苹果爸爸的逻辑缜密到让人害怕,隐隐感觉还是自己代码的问题,随后向苹果发了邮件,询问了此事,苹果爸爸的回复让我豁然开朗。
不卖关子了,这里主要错误原因是,在你支付实现过程中,不可以把想支付队列添加观察者这一步:
SKPaymentQueue.default().add(self)
放在你实现了StoreKit的ViewController中,原因是:这一步是个高流量占用的过程,如果在交易观察器正在查询应用商店服务器排队的交易时,用户发起了购买,这就导致了时间问题,支付就被干扰了,有可能会出现unknown的问题,线上用户埋点分析的话,大概也是有3%的概率出现这个问题。
以下是苹果邮件原件:
I’ve reviewed the problem description described below. There is one possibility - which is a guess on my part as you’ve not presented any programming details. The following is something to consider. Otherwise, I need specific details on how I can replicate the issue. One other thought is that the issue might involve a bad network connection.
If the app makes the addPayment call and the result is a .failed transactionState with the localized error string as Error Domain=SKErrorDomain Code=0 “Cannot connect to iTunes Store” - the error is a generic error - something failed during the in-app purchase process. This is probably not a network connection failure.
A possible reason for this error occurring in the sandbox environment is that the Test User account has become “corrupted”. The App Store does not clear yet user accounts - instead, they will advise that the user create a new test user account to test StoreKit apps with. Try testing in the sandbox with a new test user account.
If this issue happens either in the sandbox (including App Review) or in the production environment, check to see whether the addTransactionObserver is being called from the StoreKit View Controller. The StoreKit documentation states that the addTransactionObserver call should be made at application launch time - preferably from the App Delegate - didFinishLaunchingWithOptions delegate method. If the addTransactionObserver call is made from the StoreKit ViewController, the call generates quite a bit of network traffic which can interfere with the addPayment processing which could be trigger by the user pressing the “Buy” button at the same time the transactionObserver is querying the App Store Server for queued transactions. This leads to a timing issue. The StoreKit app may work fine for several releases, then start returning this error for no apparent reason. If the issue happens in the production environment, use the following technique to get an idea as to whether this might be the cause.
A quick way to check that the transactionObserver is being called properly - install the StoreKit profile. Then capture the Console log and begin the app. (Specific instructions for installing the profile and capturing the concole log are below) When the app settles down - that is the launch screen has disappeared, and the app is at the point where in-app purchase content should be available, search the console contents for the string “inAppCheckDownloadQueue”. The string “inAppCheckDownloadQueue” should appear early in the log. To determine when the app launches, search for the string “”launch request”
The following is an example -
default 12:54:35.080163-0800 runningboardd Executing launch request for application<com.developer.app> (FBApplicationProcess)
Next search for the string “inAppCheckDownloadQueue”. The timestamp for the log statement with the string “inAppCheckDownloadQueue” - should appear shortly after the launch of the app (1 - 2 seconds). Your app may defer calling addTransactionObserver until later. What you shoud not find is the case in making a purchase, that the log statement with the string “inAppCheckDownloadQueue” appears close in time to the log statement which includes the string “inAppBuy”. Such a finding indicates that the addTransactionObserver method was called close to the user pressing the “Buy” button.
Here’s the specific instructions to install the StoreKit profile and to caprture the console log.
Install StoreKit profile to an iOS 11+ devicePlease login to the Apple Developer Bug Report - Profiles and Logs websitehttps://developer.apple.com/bug-reporting/profiles-and-logs/; using Safari on the device you will use to replicate the problem with.Click the “Profile” URL associated with the “App Store/iTunes Store for iOS” item. You will download the “itmsdebugging.mobileconfig” file.For iOS 12.2 or later, you will open the Settings app -> General -> Profiles & Device Management -> iTunes and App Stores Diagnostic logging -> press InstallFor devices with less than iOS 12.2, - PLEASE RESTART THE DEVICE
CAPTURE THE DEVICE CONSOLE LOG - connect the device to a macOS X Sierra system (macOS X 10.12.x or newer)
  1. Launch the Console app on the macOS system, and select the device in the left side of the Console window
  1. Before starting the iOS app, click “Clear”.
  1. Start the application and let the app settle down. Make sure to use the appropriate version of the app - sandbox or production.
Check the contents of the console log for the string “inAppCheckDownloadQueue”. This string should be present shortly after the app is launched. If the call is not present, a suspicion is that the addTransactionObserver call is being made when the StoreKit ViewController is being presented - which could lead to the occasion occurrence of the issue where a purchase attempt results in “Error Domain=SKErrorDomain Code=0 “Cannot connect to iTunes Store”.
It might be that this is an actual bug report issue to be investigated by the App Store Server Engineering group. The following are the instructions for submitting the bug report.
To submit a bug report, please use the Apple Developer Feedback web page -
Enter the “Feedback Assistant” page and loginClick on the Compose icon to start a new bug report
Start by clicking on the appropriate OS button - “iOS and iPadOS”, “tvOS”, or “macOS”
  1. In the “Descriptive Title” field, enter an appropriate description.
  1. In the “Problem Area” field select “StoreKit”
  1. In the “Type of Feedback” select “Incorrect / Unexpected Behavior”
  1. In the “Describe the Issue” section enter the following
      • application ID (and In-App Purchase identifiers if appropriate)
      • sandbox or production environment
      • the instructions for finding the “Buy” button to purchase the In-App Purchase item.(if the app is not in English, please provide screenshots)
      • the problem description in detail.
  1. If you have captured a console log - drop it onto the “Drop Files to upload”.
Save the Feedback number and send it to me - FBxxxxxxxxxx.
Sincerely Yours,
苹果虽然未看代码,但是估计这是个通用问题,指出了问题所在,并且国内大多数的论坛博客,操作方式还是将设置监听器放在ViewController中,所以假如不是按照苹果官网文档接入的话,漏掉这个细节的话,那么只能认命了,绝大多数朋友表示不知道怎么处理这个问题,以至于直接忽略问题。
所以正确的做法是把设置监听器放置在didFinishLaunching中。如果你有兴趣可以尝试苹果邮件推荐的capture the Console log and begin the app步骤,这是一个更精确的可以复现问题和解决问题的方法。
以上是所有遇到的支付问题总结,希望可以帮到你,谢谢。