iOS通过StoreKit2的Transaction校验内购退款信息
iOS通过StoreKit2的Transaction校验内购退款信息

iOS通过StoreKit2的Transaction校验内购退款信息

最近在开发应用时,突然想到之前有好几个用户虽然完成了内购,但是申请了退款,因为我的应用没有校验退款的逻辑,因此如果用户申请了退款,那么应用仍然是完成内购的状态。

为了解决这个问题,需要通过StoreKit2的Transaction校验内购标识。

校验逻辑

具体的逻辑为:在应用的入口文件中添加一个检查历史交易的checkAllTransactions异步方法。这个方法执行一个异步序列,也可以理解为是一个异步的遍历。

for await transaction in Transaction.all { }

通过遍历Transaction.all可以获取所有的交易信息。因为可能存在用户内购-退款-再内购的实际场景,因此还需要使用一个字典来存储每一个内购商品最新(对比purchaseDate字段)的Transaction交易信息。

接着,我们创建一个交易标识,然后通过for-in轮训存储Transaction交易信息的字典。如果最新的Transaction信息含有revocationDate撤销日期,那么我们移除这个Transation对应的内购商品标识。如果没有revocationDate撤销日期,则设置内购标识。

这样,我们可以在用户内购完成后,退出Apple ID时,应用会检测到没有Transaction信息,这样就会移除内购标识。如果用户重新登陆完成内购的Apple ID,应用也会通过Transaction校验到内购信息,从而新增内购标识。

因为还有一个场景,那就是用户没有内购或者交易全部退款,也就不存在Transaction交易信息,最后清理所有的内购标识。

实际代码

因为StoreKit2的代码比较多,这里的代码是在《iOS通过StoreKit 2实现应用内购》基础上添加的代码。如果对交易代码不了解,可以查看前文。

在IAPManager内购代码中新增checkAllTransactions和removePurchasedState方法:

// 检查所有交易,如果用户退款,则取消内购标识。
func checkAllTransactions() async {
    print("开始检查所有交易记录...")
    let allTransactions = Transaction.all
    var latestTransactions: [String: Transaction] = [:]

    for await transaction in allTransactions {
        do {
            let verifiedTransaction = try checkVerified(transaction)
            print("verifiedTransaction:\(verifiedTransaction)")
            // 只保留最新的交易
            if let existingTransaction = latestTransactions[verifiedTransaction.productID] {
                if verifiedTransaction.purchaseDate > existingTransaction.purchaseDate {
                    latestTransactions[verifiedTransaction.productID] = verifiedTransaction
                }
            } else {
                latestTransactions[verifiedTransaction.productID] = verifiedTransaction
            }
        } catch {
            print("交易验证失败:\(error)")
        }
    }

    var validPurchasedProducts: Set<String> = []
    
    // 处理最新的交易
    for (productID, transaction) in latestTransactions {
        if let revocationDate = transaction.revocationDate {
            print("交易 \(productID) 已退款,撤销日期: \(revocationDate)")
            removePurchasedState(for: productID)
        } else {
            validPurchasedProducts.insert(productID)
            savePurchasedState(for: productID)
        }
    }

    // **移除所有未在最新交易中的商品**
    let allPossibleProductIDs: Set<String> = Set(productID) // 所有可能的商品 ID
    let productsToRemove = allPossibleProductIDs.subtracting(validPurchasedProducts)

    for id in productsToRemove {
        removePurchasedState(for: id)
    }
}

这段 checkAllTransactions() 方法的作用是 检查所有的交易记录,并基于最新的交易状态更新内购标识,以确保:

1、正确识别最新的购买交易(防止旧交易干扰)。

2、如果商品被退款,则撤销购买状态。

3、如果没有任何有效购买,则清除所有内购标识。

代码解析

1、获取所有交易记录
let allTransactions = Transaction.all
var latestTransactions: [String: Transaction] = [:]

Transaction.all 获取设备上的所有交易记录(包括购买和退款)。

这包括

1)已完成的购买

1)被退款的购买

3)可能存在的重复购买

问题:Transaction.all 可能包含多个相同 productID 的交易,所以需要找到最新的有效交易。

latestTransactions 是一个字典,用于存储每个商品的最新交易(productID 作为 key)。

2、遍历所有交易
for await transaction in allTransactions {
    do {
        let verifiedTransaction = try checkVerified(transaction)
        print("verifiedTransaction:\(verifiedTransaction)")
        ...
    }
}

遍历 allTransactions 里的每个交易。

checkVerified(transaction) 验证交易是否有效:

verifiedTransaction 是 StoreKit 认证的交易数据。

如果交易未被 Apple 服务器验证,则会抛出 StoreError.failedVerification 异常。

这里 verifiedTransaction 可能输出为:

verifiedTransaction:{
  "bundleId" : "com.fangjunyu.piglet",
  "currency" : "CNY",
  "deviceVerification" : "X5mzyofB5Luoez3vNhSLoZkPuEYJKAoxx8lP8PvIwamCunGotq1ogFTivYORcofn",
  "deviceVerificationNonce" : "698a1d1d-3c83-4737-bc02-3c3a8b335fc4",
  "environment" : "Sandbox",
  "inAppOwnershipType" : "PURCHASED",
  "originalPurchaseDate" : 1738414374000,
  "originalTransactionId" : "2000000845425339",
  "price" : 8000,
  "productId" : "20240523",
  "purchaseDate" : 1738414374000,
  "quantity" : 1,
  "signedDate" : 1738414653927,
  "storefront" : "CHN",
  "storefrontId" : "143465",
  "transactionId" : "2000000845425339",
  "transactionReason" : "PURCHASE",
  "type" : "Non-Consumable"
}
3、只保留最新的交易
if let existingTransaction = latestTransactions[verifiedTransaction.productID] {
    if verifiedTransaction.purchaseDate > existingTransaction.purchaseDate {
        latestTransactions[verifiedTransaction.productID] = verifiedTransaction
    }
} else {
    latestTransactions[verifiedTransaction.productID] = verifiedTransaction
}

latestTransactions 是一个字典,用于存储每个 productID 的最新交易。

var latestTransactions: [String: Transaction] = [:]

逻辑

1、如果 latestTransactions 里已有这个 productID 的交易:

比较 purchaseDate,只保留最新的交易(purchaseDate 最大的)。

2、如果 productID 还没有存入字典:

直接存入 latestTransactions。

目的:确保每个 productID 只存储最新的交易记录,防止旧交易影响判断。

4、处理最新交易
var validPurchasedProducts: Set<String> = []

for (productID, transaction) in latestTransactions {
    if let revocationDate = transaction.revocationDate {
        print("交易 \(productID) 已退款,撤销日期: \(revocationDate)")
        removePurchasedState(for: productID)
    } else {
        validPurchasedProducts.insert(productID)
        savePurchasedState(for: productID)
    }
}

遍历 latestTransactions,检查最新交易是否已退款。

如果 revocationDate 存在,说明该商品已退款,调用 removePurchasedState(for: productID) 删除本地内购标识。

否则,该商品仍然有效,添加到 validPurchasedProducts 集合,并 savePurchasedState(for: productID) 保留内购状态。

这样,我们确保只处理最新交易,避免错误覆盖状态。

5、确保移除未购买或已退款的商品
let allPossibleProductIDs: Set<String> = Set(productID) // 你的所有商品 ID
let productsToRemove = allPossibleProductIDs.subtracting(validPurchasedProducts)

for id in productsToRemove {
    removePurchasedState(for: id)
}

在这里单独补充一句,productID是《iOS通过StoreKit 2实现应用内购》中用于存储商品ID的数组:

@Published var productID = ["A","B","C"]  //  需要内购的产品ID数组

allPossibleProductIDs 是应用内所有可能的商品 ID(如 [“A”, “B”, “C”])。

validPurchasedProducts 是用户仍然有效的内购商品(如 [“A”])。

计算 productsToRemove:

productsToRemove = allPossibleProductIDs.subtracting(validPurchasedProducts)

差集运算,找出 allPossibleProductIDs 中不在 validPurchasedProducts 里的商品。

也就是说,这些商品要么没购买过,要么已经被退款,需要删除本地标识。

遍历 productsToRemove,移除这些商品的本地内购标识:

for id in productsToRemove {
    removePurchasedState(for: id)
}

最终确保本地的内购状态与最新交易记录保持一致,避免误保留的内购权益。

6、入口文件配置

在入口文件中,异步调用checkAllTransactions方法:

import SwiftUI
@main
struct pigletApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    await iapManager.loadProduct()   // 加载产品信息
                    await iapManager.checkAllTransactions()  // 先检查历史交易
                    await iapManager.handleTransactions()   // 加载内购交易更新
                }
        }
        .environmentObject(iapManager)
    }
}

总结

这篇文章中的代码适用于非消耗型商品,如果要支持订阅(Auto-renewable),例如内购商品的product.type == .autoRenewable,那么就需要额外逻辑检查订阅是否已过期。

通过Transaction可以避免应用在内购退款的情况下,仍然带有内购标识,防止老交易干扰:确保只处理每个商品最新的交易,正确删除被退款或未购买的商品,避免误保留状态。

这个场景之前也有一个朋友跟我讨论过,今天刚好把这个新学习的知识分享给他。但他告诉我,如果用户在退款后,离线使用应用的话,那么Transaction交易内购退款也就无效了。并提出设置有效期的情况。

因此通过Transaction校验内购退款信息,只有在网络环境下才能实现,当然设置内购标识有效期也是一种方法,但对于内购用户无网络的情况下,突然要求其进行验证内购信息,总感觉违背了用户的权益。

最后,希望这一方法可以帮助更多的开发者解决退款后,应用仍然含有内购标识的问题。

相关文章

iOS通过StoreKit 2实现应用内购:https://fangjunyu.com/2024/05/30/ios%e5%ae%9e%e7%8e%b0%e5%ba%94%e7%94%a8%e5%86%85%e8%b4%ad/

完整代码

入口文件

import SwiftUI
import SwiftData
@main
struct pigletApp: App {
    @StateObject var iapManager = IAPManager.shared
    @State private var modelConfigManager = ModelConfigManager()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    await iapManager.loadProduct()   // 加载产品信息
                    await iapManager.checkAllTransactions()  // 先检查历史交易
                    await iapManager.handleTransactions()   // 加载内购交易更新
                }
        }
        .environment(modelConfigManager)
        .environmentObject(iapManager)
        .modelContainer(try! ModelContainer(for: PiggyBank.self,SavingsRecord.self,configurations: modelConfigManager.currentConfiguration))
    }
}

IAPManager文件

//  IAPManager.swift

import StoreKit

@available(iOS 15.0, *)
class IAPManager:ObservableObject {
    static let shared = IAPManager()
    
    private init() {}
    
    @Published var productID = ["20240523"]  //  需要内购的产品ID数组
    @Published var products: [Product] = []    // 存储从 App Store 获取的内购商品信息
    @Published var loadPurchased = false    // 如果开始内购流程,loadPurchased为true,View视图显示加载画布
    
    // 视图自动加载loadProduct()方法
    func loadProduct() async {
        do {
            // 传入 productID 产品ID数组,调取Product.products接口从App Store返回产品信息
            // App Store会返回对应的产品信息,如果数组中个别产品ID有误,只会返回正确的产品ID的产品信息
            let fetchedProducts = try await Product.products(for: productID)
            if fetchedProducts.isEmpty {    // 判断返回的是否是否为空
                // 抛出内购信息为空的错误,可能是所有的产品ID都不存在,中断执行,不会return返回products产品信息
                throw StoreError.IAPInformationIsEmpty
            }
            DispatchQueue.main.async {
                self.products = fetchedProducts  // 将获取的内购商品保存到products变量
            }
            print("成功加载产品: \(products)")    // 输出内购商品数组信息
        } catch {
            print("加载产品失败:\(error)")    // 输出报错
        }
    }
    
    // 发送到服务器端
    func sendToServer(signedTransactionInfo: String?) {
        guard let info = signedTransactionInfo else { return }
        let url = URL(string: "https://your-backend-server.com/verify")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: ["signedTransactionInfo": info], options: [])
        
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("Error sending transaction info to server: \(error)")
                return
            }
            print("Server response: \(String(data: data!, encoding: .utf8) ?? "")")
        }
        task.resume()
    }
    
    // purchaseProduct:购买商品的方法,返回购买结果
    func purchaseProduct(_ product: Product) {
        // 在这里输出要购买的商品id
        print("Purchasing product: \(product.id)")
        Task {  @MainActor in
            do {
                let result = try await product.purchase()
                switch result {
                case .success(let verification):    // 购买成功的情况,返回verification包含交易的验证信息
                    let transaction = try checkVerified(verification)    // 验证交易
                    savePurchasedState(for: product.id)    // 更新UserDefaults中的购买状态
                    await transaction.finish()    // 告诉系统交易完成
                    print("交易成功:\(result)")
                case .userCancelled:    // 用户取消交易
                    print("用户取消交易:\(result)")
                case .pending:    // 购买交易被挂起
                    print("购买交易被挂起:\(result)")
                default:    // 其他情况
                    throw StoreError.failedVerification    // 购买失败
                }
            } catch {
                print("购买失败:\(error)")
                await resetProduct()    // 购买失败后重置 product 以便允许再次尝试购买
            }
            DispatchQueue.main.async {
                self.loadPurchased = false   // 隐藏内购时的加载画布
            }
            print("loadPurchased:\(loadPurchased)")
        }
    }
    // 验证购买结果
    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:    // unverified校验失败,StoreKit不能确定交易有效
            print("校验购买结果失败")
            throw StoreError.failedVerification
        case .verified(let signedType):    // verfied校验成功
            print("校验购买结果成功")
            return signedType    // StoreKit确认本笔交易信息由苹果服务器合法签署
        }
    }
    // handleTransactions处理所有的交易情况
    func handleTransactions() async {
        print("进入handleTransactions方法")
        for await result in Transaction.updates {
            // 遍历当前所有已完成的交易
            do {
                let transaction = try checkVerified(result) // 验证交易
                print("transaction:\(transaction)")
                
                // 处理交易,例如解锁内容
                savePurchasedState(for: transaction.productID)
                await transaction.finish()
            } catch {
                print("交易处理失败:\(error)")
            }
        }
    }
    
    // 检查所有交易,如果用户退款,则取消内购标识。
    func checkAllTransactions() async {
        print("开始检查所有交易记录...")
        let allTransactions = Transaction.all
        var latestTransactions: [String: Transaction] = [:]

        for await transaction in allTransactions {
            do {
                let verifiedTransaction = try checkVerified(transaction)
                print("verifiedTransaction:\(verifiedTransaction)")
                // 只保留最新的交易
                if let existingTransaction = latestTransactions[verifiedTransaction.productID] {
                    if verifiedTransaction.purchaseDate > existingTransaction.purchaseDate {
                        latestTransactions[verifiedTransaction.productID] = verifiedTransaction
                    }
                } else {
                    latestTransactions[verifiedTransaction.productID] = verifiedTransaction
                }
            } catch {
                print("交易验证失败:\(error)")
            }
        }

        var validPurchasedProducts: Set<String> = []
        
        // 处理最新的交易
        for (productID, transaction) in latestTransactions {
            if let revocationDate = transaction.revocationDate {
                print("交易 \(productID) 已退款,撤销日期: \(revocationDate)")
                removePurchasedState(for: productID)
            } else {
                validPurchasedProducts.insert(productID)
                savePurchasedState(for: productID)
            }
        }

        // **移除所有未在最新交易中的商品**
        let allPossibleProductIDs: Set<String> = Set(productID) // 所有可能的商品 ID
        let productsToRemove = allPossibleProductIDs.subtracting(validPurchasedProducts)

        for id in productsToRemove {
            removePurchasedState(for: id)
        }
    }
    
    // 当购买失败时,会尝试重新加载产品信息。
    func resetProduct() async {
        self.products = []
        await loadProduct()    // 调取loadProduct方法获取产品信息
    }
    // 保存购买状态到用户偏好设置或其他存储位置
    func savePurchasedState(for productID: String) {
        UserDefaults.standard.set(true, forKey: productID)
        print("保存购买状态: \(productID)")
    }
    // 移除内购状态
    func removePurchasedState(for productID: String) {
        UserDefaults.standard.removeObject(forKey: productID)
        print("已移除购买状态: \(productID)")
    }
    
    // 通过productID检查是否已完成购买
    func loadPurchasedState(for productID: String) -> Bool{
        let isPurchased = UserDefaults.standard.bool(forKey: productID)    // UserDefaults读取购买状态
        print("Purchased state loaded for product: \(productID) - \(isPurchased)")
        return isPurchased    // 返回购买状态
    }
}
// 定义 throws 报错
enum StoreError: Error {
    case IAPInformationIsEmpty
    case failedVerification
    case invalidURL
    case serverVerificationFailed
}

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注