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

为了解决这个问题,需要通过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
}