Swift应用内消耗型积分商店访问异常排查求助
内购在TestFlight/审核阶段无法加载商品,Xcode调试正常
问题详情
已完成应用开发,按照App Store Connect文档完成内购配置,状态显示为可提交。但遇到以下问题:
- Xcode直接调试时,可正常获取商店商品
- 发布至TestFlight后测试,无法连接商店获取商品;提交审核后,审核人员同样无法看到商品
- 多次更新
PurchaseManager并添加调试代码,Xcode调试无异常,但TestFlight环境无法查看调试输出
当前PurchaseManager代码
import StoreKit import FirebaseAuth import FirebaseFirestore import Combine class PurchaseManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver { static let shared = PurchaseManager() // MARK: - Product IDs private let productIDs = Set([ "muratcancicekk.FaceAiEdit.credits.small", "muratcancicekk.FaceAiEdit.credits.medium", "muratcancicekk.FaceAiEdit.credits.big" // 15 credits ]) // MARK: - Properties private var productsRequest: SKProductsRequest? private var loadProductsCompletion: CheckedContinuation<[SKProduct], Error>? private var purchaseCompletion: CheckedContinuation<Void, Error>? private var restoreCompletion: CheckedContinuation<Void, Error>? private(set) var availableProducts: [SKProduct]? private var isFetchingProducts = false private let creditBalanceSubject = CurrentValueSubject<Int, Never>(0) var creditBalancePublisher: AnyPublisher<Int, Never> { creditBalanceSubject.eraseToAnyPublisher() } // MARK: - Initialization private override init() { super.init() loadStoredCredits() SKPaymentQueue.default().add(self) printDebugInfo() // Load products immediately on init Task { do { availableProducts = try await fetchProducts() // debugPrintProducts() } catch { print("Failed to load products: \(error)") } } } deinit { SKPaymentQueue.default().remove(self) } // MARK: - Debug Methods private func printDebugInfo() { print("=== PurchaseManager Debug Info ===") print("Bundle ID: \(Bundle.main.bundleIdentifier ?? "Not found")") print("Environment: \(isTestFlight ? "TestFlight" : "Production")") print("Receipt URL: \(Bundle.main.appStoreReceiptURL?.absoluteString ?? "Not found")") print("Configured Product IDs: \(productIDs)") print("Can make payments: \(SKPaymentQueue.canMakePayments())") print("================================") } // func debugPrintProducts() { // print("\n=== Available Products ===") // if availableProducts.isEmpty { // print("No products available") // } else { // for product in availableProducts { // print(""" // Product ID: \(product.productIdentifier) // Title: \(product.localizedTitle) // Price: \(formattedPrice(for: product)) // Locale: \(product.priceLocale.identifier) // Description: \(product.localizedDescription) // ---------------------- // """) // } // } // print("========================\n") // } // // MARK: - Product Fetching func fetchProducts() async throws -> [SKProduct] { // Return cached products if available if let cachedProducts = availableProducts, !cachedProducts.isEmpty { return cachedProducts } // Prevent multiple simultaneous requests guard !isFetchingProducts else { throw PurchaseError.fetchInProgress } print("Fetching products...") guard SKPaymentQueue.canMakePayments() else { print("Device cannot make payments!") throw PurchaseError.paymentsNotAllowed } // Cancel any existing request productsRequest?.cancel() productsRequest = nil isFetchingProducts = true do { let products = try await withCheckedThrowingContinuation { continuation in loadProductsCompletion = continuation let request = SKProductsRequest(productIdentifiers: productIDs) request.delegate = self productsRequest = request print("Starting product request...") request.start() } // Cache the products self.availableProducts = products return products } catch { isFetchingProducts = false throw error } } // MARK: - SKProductsRequestDelegate func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { print("\n=== Products Response ===") print("Valid Products Found: \(response.products.count)") // Store completion locally and clear shared reference guard let completion = loadProductsCompletion else { return } loadProductsCompletion = nil // Log invalid product IDs if !response.invalidProductIdentifiers.isEmpty { print("Invalid Product IDs (\(response.invalidProductIdentifiers.count)):") response.invalidProductIdentifiers.forEach { print("- \($0)") } } // Handle the response if response.products.isEmpty { print("No valid products found!") completion.resume(throwing: PurchaseError.noProductsFound) } else { print("\nValid Products:") response.products.forEach { product in print(""" - ID: \(product.productIdentifier) - Title: \(product.localizedTitle) - Price: \(formattedPrice(for: product)) """) } availableProducts = response.products completion.resume(returning: response.products) } // Clean up productsRequest = nil isFetchingProducts = false } func request(_ request: SKRequest, didFailWithError error: Error) { print("Product request failed: \(error.localizedDescription)") // Store completion locally and clear shared reference guard let completion = loadProductsCompletion else { return } loadProductsCompletion = nil // Clean up and complete with error completion.resume(throwing: error) productsRequest = nil isFetchingProducts = false } // MARK: - Purchase Handling func purchase(_ product: SKProduct) async throws { print("Initiating purchase for product: \(product.productIdentifier)") return try await withCheckedThrowingContinuation { continuation in purchaseCompletion = continuation let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) } } // MARK: - SKPaymentTransactionObserver func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchased: print("Transaction purchased: \(transaction.payment.productIdentifier)") handlePurchased(transaction) case .failed: print("Transaction failed: \(transaction.payment.productIdentifier)") handleFailed(transaction) case .restored: print("Transaction restored: \(transaction.payment.productIdentifier)") handleRestored(transaction) case .purchasing: print("Transaction purchasing: \(transaction.payment.productIdentifier)") case .deferred: print("Transaction deferred: \(transaction.payment.productIdentifier)") @unknown default: print("Unknown transaction state: \(transaction.payment.productIdentifier)") } } } func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { print("Restore completed") restoreCompletion?.resume() restoreCompletion = nil } func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { print("Restore failed: \(error.localizedDescription)") restoreCompletion?.resume(throwing: error) restoreCompletion = nil } private func handlePurchased(_ storeTransaction: SKPaymentTransaction) { let credits = creditsFor(productIdentifier: storeTransaction.payment.productIdentifier) Task { do { guard let userId = Auth.auth().currentUser?.uid else { return } let transactionRef = Firestore.firestore() .collection("creditTransactions") .document() let newBalance = CreditManager.shared.currentBalance + credits let creditTransaction = CreditTransaction( id: transactionRef.documentID, userId: userId, amount: credits, type: .system, featureId: nil, timestamp: Date(), balanceAfter: newBalance, metadata: [ "source": "in_app_purchase", "productId": storeTransaction.payment.productIdentifier, "environment": isTestFlight ? "testflight" : "production", "transactionId": storeTransaction.transactionIdentifier ?? "unknown" ] ) try await transactionRef.setData(from: creditTransaction) let userRef = Firestore.firestore() .collection("users") .document(userId) try await userRef.updateData([ "creditBalance": newBalance, "lastUpdated": Date() ]) await MainActor.run { CreditManager.shared.currentBalance = newBalance creditBalanceSubject.send(newBalance) UserDefaults.standard.set(newBalance, forKey: "creditBalance") } print("Purchase successfully processed: \(storeTransaction.payment.productIdentifier)") SKPaymentQueue.default().finishTransaction(storeTransaction) purchaseCompletion?.resume() purchaseCompletion = nil } catch { print("Error processing purchase: \(error)") SKPaymentQueue.default().finishTransaction(storeTransaction) purchaseCompletion?.resume(throwing: error) purchaseCompletion = nil } } } private func handleFailed(_ transaction: SKPaymentTransaction) { print("Purchase failed: \(String(describing: transaction.error))") if let error = transaction.error as? SKError { print("SKError code: \(error.code.rawValue)") print("SKError description: \(error.localizedDescription)") } SKPaymentQueue.default().finishTransaction(transaction) if let error = transaction.error as? SKError { purchaseCompletion?.resume(throwing: error) } else { purchaseCompletion?.resume(throwing: PurchaseError.purchaseFailed) } purchaseCompletion = nil } private func handleRestored(_ transaction: SKPaymentTransaction) { print("Purchase restored: \(transaction.payment.productIdentifier)") let credits = creditsFor(productIdentifier: transaction.original?.payment.productIdentifier ?? "") updateCredits(credits) SKPaymentQueue.default().finishTransaction(transaction) if purchaseCompletion != nil { purchaseCompletion?.resume() purchaseCompletion = nil } } // MARK: - Helper Methods private func creditsFor(productIdentifier: String) -> Int { switch productIdentifier { case "muratcancicekk.FaceAiEdit.credits.small": return 15 case "muratcancicekk.FaceAiEdit.credits.medium": return 40 case "muratcancicekk.FaceAiEdit.credits.big": return 100 default: return 0 } } private func loadStoredCredits() { let credits = UserDefaults.standard.integer(forKey: "creditBalance") creditBalanceSubject.send(credits) } private func updateCredits(_ amount: Int) { let newBalance = creditBalanceSubject.value + amount creditBalanceSubject.send(newBalance) UserDefaults.standard.set(newBalance, forKey: "creditBalance") } private func formattedPrice(for product: SKProduct) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.locale = product.priceLocale return formatter.string(from: product.price) ?? "\(product.price)" } var isTestFlight: Bool { Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandbox" } } enum PurchaseError: LocalizedError { case paymentsNotAllowed case noProductsFound case purchaseFailed case restoreFailed case fetchInProgress case unknown var errorDescription: String? { switch self { case .paymentsNotAllowed: return "Payments are not allowed on this device" case .noProductsFound: return "No products are available for purchase. Please check your App Store Connect configuration." case .purchaseFailed: return "Failed to complete the purchase" case .restoreFailed: return "Failed to restore purchases" case .fetchInProgress: return "Product fetch already in progress" case .unknown: return "An unknown error occurred" } } }
排查与修复方案
1. 验证App Store Connect内购配置
- 确认内购项目状态:必须为「可提交」或「已批准」,且已关联到当前提交的App版本
- 核对产品ID:代码中
productIDs的每个ID必须与App Store Connect中的内购ID完全一致(注意大小写、Bundle前缀) - 检查价格设置:内购项目必须添加价格层级,且所有地区的价格配置完成
- 确认账号状态:App的「协议、税务和银行业务」必须完成,未完成会导致内购无法生效
2. TestFlight环境适配
- 强制使用Sandbox账号:TestFlight测试必须登录App Store Connect中创建的沙盒测试员账号,且需退出正式App Store账号
- 修正TestFlight环境判断:当前代码的
isTestFlight判断可能存在误差,替换为更可靠的实现:var isTestFlight: Bool { guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false } return receiptURL.path.contains("sandboxReceipt") }
3. 代码逻辑优化
- 调整商品加载时机:移除
init中的自动加载,改为在用户进入内购页面时再调用fetchProducts,避免App启动时网络未就绪导致失败 - 添加重试机制:针对网络波动场景,给
fetchProducts添加重试逻辑:func fetchProducts(withRetryCount count: Int = 3) async throws -> [SKProduct] { do { return try await fetchProducts() } catch { if count > 0 { try await Task.sleep(nanoseconds: 2_000_000_000) return try await fetchProducts(withRetryCount: count - 1) } else { throw error } } } - 清空失败缓存:加载失败时清空
availableProducts,避免一直返回空数据:func request(_ request: SKRequest, didFailWithError error: Error) { // ... 原有代码 availableProducts = nil isFetchingProducts = false } - 暴露错误到UI:将内购错误通过
Publisher传递到UI层,给用户明确提示(如"无法加载商品,请检查网络")
4. 获取TestFlight调试日志
- 通过Xcode查看设备日志:打开「Window」-「Devices and Simulators」,选择测试设备,点击「View Device Logs」可查看TestFlight版本的控制台输出
- 集成日志工具:使用Firebase Crashlytics或类似工具,将错误日志上传到后台,便于远程查看TestFlight中的异常
5. 审核阶段注意事项
- 在审核备注中明确告知审核人员需使用Sandbox账号测试内购,并提供测试账号信息
- 确保App内有清晰的内购入口,方便审核人员找到测试路径
内容的提问来源于stack exchange,提问作者murat
相关产品推荐
相关产品推荐

