You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 00:49:50