From fd0ddfd45a3195401baffb52bc84833b0d3bf9d9 Mon Sep 17 00:00:00 2001 From: CodeST <694468528@qq.com> Date: Tue, 16 Dec 2025 13:10:50 +0800 Subject: [PATCH] 1 --- Podfile | 2 +- keyBoard.xcodeproj/project.pbxproj | 132 +- .../xcshareddata/swiftpm/Package.resolved | 15 + .../Converts/ProductConverter.swift | 116 ++ .../Converts/StoreKitConverter.swift | 98 ++ .../Converts/StoreKitStateConverter.swift | 131 ++ .../Converts/SubscriptionConverter.swift | 237 +++ .../Converts/TransactionConverter.swift | 475 ++++++ .../Internal/StoreKitService.swift | 1300 ++++++++++++++++ .../Internal/StoreKitServiceDelegate.swift | 24 + .../Locals/SubscriptionLocale.swift | 1332 +++++++++++++++++ .../Models/StoreKitConfig.swift | 48 + .../Models/StoreKitError.swift | 85 ++ .../Models/StoreKitState.swift | 95 ++ .../Models/TransactionHistory.swift | 80 + .../Protocols/StoreKitDelegate.swift | 48 + .../StoreKit2Manager/StoreKitManager.swift | 536 +++++++ 17 files changed, 4751 insertions(+), 3 deletions(-) create mode 100644 keyBoard.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Converts/ProductConverter.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitConverter.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitStateConverter.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Converts/SubscriptionConverter.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Converts/TransactionConverter.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitService.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitServiceDelegate.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Locals/SubscriptionLocale.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitConfig.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitError.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitState.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Models/TransactionHistory.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/Protocols/StoreKitDelegate.swift create mode 100644 keyBoard/Class/Pay/StoreKit2Manager/StoreKitManager.swift diff --git a/Podfile b/Podfile index bb5f9fd..d41dce1 100644 --- a/Podfile +++ b/Podfile @@ -1,6 +1,6 @@ # Uncomment the next line to define a global platform for your project source 'https://github.com/CocoaPods/Specs.git' -platform :ios, '13.0' +platform :ios, '15.0' target 'keyBoard' do # Comment the next line if you don't want to use dynamic frameworks diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 7962b58..3bb09eb 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -31,6 +31,21 @@ 043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 04C6EAE12EAF940F0089C901 /* KBPermissionViewController.m */; }; 0450AA742EF013D000B6AF06 /* KBEmojiCollectionCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 0450AA732EF013D000B6AF06 /* KBEmojiCollectionCell.m */; }; 0450AAE22EF03D5100B6AF06 /* KBPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AAE12EF03D5100B6AF06 /* KBPerson.swift */; }; + 0450AB682EF0443E00B6AF06 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 0450AB672EF0443E00B6AF06 /* OrderedCollections */; }; + 0450AC0A2EF11E4400B6AF06 /* StoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AC082EF11E4400B6AF06 /* StoreKitManager.swift */; }; + 0450AC0B2EF11E4400B6AF06 /* StoreKitStateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABF82EF11E4400B6AF06 /* StoreKitStateConverter.swift */; }; + 0450AC0C2EF11E4400B6AF06 /* SubscriptionLocale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABFF2EF11E4400B6AF06 /* SubscriptionLocale.swift */; }; + 0450AC0D2EF11E4400B6AF06 /* StoreKitConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABF72EF11E4400B6AF06 /* StoreKitConverter.swift */; }; + 0450AC0E2EF11E4400B6AF06 /* StoreKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AC022EF11E4400B6AF06 /* StoreKitError.swift */; }; + 0450AC0F2EF11E4400B6AF06 /* StoreKitConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AC012EF11E4400B6AF06 /* StoreKitConfig.swift */; }; + 0450AC102EF11E4400B6AF06 /* StoreKitDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AC062EF11E4400B6AF06 /* StoreKitDelegate.swift */; }; + 0450AC112EF11E4400B6AF06 /* StoreKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABFC2EF11E4400B6AF06 /* StoreKitService.swift */; }; + 0450AC122EF11E4400B6AF06 /* TransactionConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABFA2EF11E4400B6AF06 /* TransactionConverter.swift */; }; + 0450AC132EF11E4400B6AF06 /* SubscriptionConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABF92EF11E4400B6AF06 /* SubscriptionConverter.swift */; }; + 0450AC142EF11E4400B6AF06 /* ProductConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABF62EF11E4400B6AF06 /* ProductConverter.swift */; }; + 0450AC152EF11E4400B6AF06 /* TransactionHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AC042EF11E4400B6AF06 /* TransactionHistory.swift */; }; + 0450AC162EF11E4400B6AF06 /* StoreKitServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450ABFD2EF11E4400B6AF06 /* StoreKitServiceDelegate.swift */; }; + 0450AC172EF11E4400B6AF06 /* StoreKitState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450AC032EF11E4400B6AF06 /* StoreKitState.swift */; }; 0459D1B42EBA284C00F2D189 /* KBSkinCenterVC.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */; }; 0459D1B72EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; }; 0459D1B82EBA287900F2D189 /* KBSkinManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0459D1B62EBA287900F2D189 /* KBSkinManager.m */; }; @@ -268,6 +283,20 @@ 0450AA732EF013D000B6AF06 /* KBEmojiCollectionCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBEmojiCollectionCell.m; sourceTree = ""; }; 0450AAE02EF03D5100B6AF06 /* keyBoard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "keyBoard-Bridging-Header.h"; sourceTree = ""; }; 0450AAE12EF03D5100B6AF06 /* KBPerson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KBPerson.swift; sourceTree = ""; }; + 0450ABF62EF11E4400B6AF06 /* ProductConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductConverter.swift; sourceTree = ""; }; + 0450ABF72EF11E4400B6AF06 /* StoreKitConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitConverter.swift; sourceTree = ""; }; + 0450ABF82EF11E4400B6AF06 /* StoreKitStateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitStateConverter.swift; sourceTree = ""; }; + 0450ABF92EF11E4400B6AF06 /* SubscriptionConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionConverter.swift; sourceTree = ""; }; + 0450ABFA2EF11E4400B6AF06 /* TransactionConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionConverter.swift; sourceTree = ""; }; + 0450ABFC2EF11E4400B6AF06 /* StoreKitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitService.swift; sourceTree = ""; }; + 0450ABFD2EF11E4400B6AF06 /* StoreKitServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitServiceDelegate.swift; sourceTree = ""; }; + 0450ABFF2EF11E4400B6AF06 /* SubscriptionLocale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionLocale.swift; sourceTree = ""; }; + 0450AC012EF11E4400B6AF06 /* StoreKitConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitConfig.swift; sourceTree = ""; }; + 0450AC022EF11E4400B6AF06 /* StoreKitError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitError.swift; sourceTree = ""; }; + 0450AC032EF11E4400B6AF06 /* StoreKitState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitState.swift; sourceTree = ""; }; + 0450AC042EF11E4400B6AF06 /* TransactionHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionHistory.swift; sourceTree = ""; }; + 0450AC062EF11E4400B6AF06 /* StoreKitDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitDelegate.swift; sourceTree = ""; }; + 0450AC082EF11E4400B6AF06 /* StoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitManager.swift; sourceTree = ""; }; 0459D1B22EBA284C00F2D189 /* KBSkinCenterVC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinCenterVC.h; sourceTree = ""; }; 0459D1B32EBA284C00F2D189 /* KBSkinCenterVC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinCenterVC.m; sourceTree = ""; }; 0459D1B52EBA287900F2D189 /* KBSkinManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinManager.h; sourceTree = ""; }; @@ -599,6 +628,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0450AB682EF0443E00B6AF06 /* OrderedCollections in Frameworks */, ECC9EE02174D86E8D792472F /* Pods_keyBoard.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -630,6 +660,7 @@ 04122F612EC5F3DF00EF7AB3 /* Pay */ = { isa = PBXGroup; children = ( + 0450AC092EF11E4400B6AF06 /* StoreKit2Manager */, 04122F812EC5FC6F00EF7AB3 /* VC */, 04122F7D2EC5FC5500EF7AB3 /* V */, 04122F642EC5F40600EF7AB3 /* M */, @@ -714,6 +745,67 @@ path = WMDragView; sourceTree = ""; }; + 0450ABFB2EF11E4400B6AF06 /* Converts */ = { + isa = PBXGroup; + children = ( + 0450ABF62EF11E4400B6AF06 /* ProductConverter.swift */, + 0450ABF72EF11E4400B6AF06 /* StoreKitConverter.swift */, + 0450ABF82EF11E4400B6AF06 /* StoreKitStateConverter.swift */, + 0450ABF92EF11E4400B6AF06 /* SubscriptionConverter.swift */, + 0450ABFA2EF11E4400B6AF06 /* TransactionConverter.swift */, + ); + path = Converts; + sourceTree = ""; + }; + 0450ABFE2EF11E4400B6AF06 /* Internal */ = { + isa = PBXGroup; + children = ( + 0450ABFC2EF11E4400B6AF06 /* StoreKitService.swift */, + 0450ABFD2EF11E4400B6AF06 /* StoreKitServiceDelegate.swift */, + ); + path = Internal; + sourceTree = ""; + }; + 0450AC002EF11E4400B6AF06 /* Locals */ = { + isa = PBXGroup; + children = ( + 0450ABFF2EF11E4400B6AF06 /* SubscriptionLocale.swift */, + ); + path = Locals; + sourceTree = ""; + }; + 0450AC052EF11E4400B6AF06 /* Models */ = { + isa = PBXGroup; + children = ( + 0450AC012EF11E4400B6AF06 /* StoreKitConfig.swift */, + 0450AC022EF11E4400B6AF06 /* StoreKitError.swift */, + 0450AC032EF11E4400B6AF06 /* StoreKitState.swift */, + 0450AC042EF11E4400B6AF06 /* TransactionHistory.swift */, + ); + path = Models; + sourceTree = ""; + }; + 0450AC072EF11E4400B6AF06 /* Protocols */ = { + isa = PBXGroup; + children = ( + 0450AC062EF11E4400B6AF06 /* StoreKitDelegate.swift */, + ); + path = Protocols; + sourceTree = ""; + }; + 0450AC092EF11E4400B6AF06 /* StoreKit2Manager */ = { + isa = PBXGroup; + children = ( + 0450ABFB2EF11E4400B6AF06 /* Converts */, + 0450ABFE2EF11E4400B6AF06 /* Internal */, + 0450AC002EF11E4400B6AF06 /* Locals */, + 0450AC052EF11E4400B6AF06 /* Models */, + 0450AC072EF11E4400B6AF06 /* Protocols */, + 0450AC082EF11E4400B6AF06 /* StoreKitManager.swift */, + ); + path = StoreKit2Manager; + sourceTree = ""; + }; 0477BD942EBAFF4E0055D639 /* Utils */ = { isa = PBXGroup; children = ( @@ -1622,6 +1714,9 @@ ); mainGroup = 727EC74A2EAF848B00B36487; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 0450AB662EF043C000B6AF06 /* XCRemoteSwiftPackageReference "swift-collections" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 727EC7542EAF848B00B36487 /* Products */; projectDirPath = ""; @@ -1808,6 +1903,20 @@ 04122F912EC73AF700EF7AB3 /* KBVipPay.m in Sources */, 0477BE002EBC6A330055D639 /* HomeRankVC.m in Sources */, 047C650D2EBC8A840035E841 /* KBPanModalView.m in Sources */, + 0450AC0A2EF11E4400B6AF06 /* StoreKitManager.swift in Sources */, + 0450AC0B2EF11E4400B6AF06 /* StoreKitStateConverter.swift in Sources */, + 0450AC0C2EF11E4400B6AF06 /* SubscriptionLocale.swift in Sources */, + 0450AC0D2EF11E4400B6AF06 /* StoreKitConverter.swift in Sources */, + 0450AC0E2EF11E4400B6AF06 /* StoreKitError.swift in Sources */, + 0450AC0F2EF11E4400B6AF06 /* StoreKitConfig.swift in Sources */, + 0450AC102EF11E4400B6AF06 /* StoreKitDelegate.swift in Sources */, + 0450AC112EF11E4400B6AF06 /* StoreKitService.swift in Sources */, + 0450AC122EF11E4400B6AF06 /* TransactionConverter.swift in Sources */, + 0450AC132EF11E4400B6AF06 /* SubscriptionConverter.swift in Sources */, + 0450AC142EF11E4400B6AF06 /* ProductConverter.swift in Sources */, + 0450AC152EF11E4400B6AF06 /* TransactionHistory.swift in Sources */, + 0450AC162EF11E4400B6AF06 /* StoreKitServiceDelegate.swift in Sources */, + 0450AC172EF11E4400B6AF06 /* StoreKitState.swift in Sources */, 043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */, 049FB20E2EC1CD2800FAB05D /* KBAlert.m in Sources */, 04A9FE162EB873C80020DB6D /* UIViewController+Extension.m in Sources */, @@ -2074,7 +2183,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "keyBoard/Class/Pay/M/keyBoard-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -2121,7 +2230,7 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "keyBoard/Class/Pay/M/keyBoard-Bridging-Header.h"; - SWIFT_VERSION = 6.0; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -2275,6 +2384,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 0450AB662EF043C000B6AF06 /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0450AB672EF0443E00B6AF06 /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 0450AB662EF043C000B6AF06 /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 727EC74B2EAF848B00B36487 /* Project object */; } diff --git a/keyBoard.xcworkspace/xcshareddata/swiftpm/Package.resolved b/keyBoard.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..387671d --- /dev/null +++ b/keyBoard.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "51f90653b2c9f9f7064c0d52159b40bf7d222e5f314be23e62fe28520fec03db", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Converts/ProductConverter.swift b/keyBoard/Class/Pay/StoreKit2Manager/Converts/ProductConverter.swift new file mode 100644 index 0000000..436009f --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Converts/ProductConverter.swift @@ -0,0 +1,116 @@ +// +// ProductConverter.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// Product 转换器 +/// 将 Product 对象转换为可序列化的基础数据类型(Dictionary/JSON) +public struct ProductConverter { + + /// 将 Product 转换为 Dictionary(可序列化为 JSON) + /// - Parameter product: Product 对象 + /// - Returns: Dictionary 对象,包含所有产品信息 + public static func toDictionary(_ product: Product) -> [String: Any] { + var dict: [String: Any] = [:] + + // 基本信息(确保都是字符串类型) + dict["id"] = product.id + dict["displayName"] = product.displayName + dict["description"] = product.description + + // 价格信息 + let priceDecimal = product.price + let priceDouble = NSDecimalNumber(decimal: priceDecimal).doubleValue + dict["price"] = Double(String(format: "%.2f", priceDouble)) ?? priceDouble + dict["displayPrice"] = product.displayPrice + + // 产品类型 + dict["type"] = productTypeToString(product.type) + + // 家庭共享 + dict["isFamilyShareable"] = product.isFamilyShareable + + // JSON 表示(可选,可能很大,通常用于服务器验证) + // 注意:jsonRepresentation 是 Data 类型,转换为 Base64 字符串以便 JSON 序列化 + let jsonData = product.jsonRepresentation + if let jsonString = String(data: jsonData, encoding: .utf8) { + dict["jsonRepresentation"] = jsonString + } else { + dict["jsonRepresentation"] = "" + } + + // 订阅信息(如果有) + if let subscription = product.subscription { + dict["subscription"] = SubscriptionConverter.subscriptionInfoToDictionary(subscription, product: product) + } else { + dict["subscription"] = NSNull() + } + + return dict + } + + /// 将 Product 数组转换为 Dictionary 数组 + /// - Parameter products: Product 数组 + /// - Returns: Dictionary 数组 + public static func toDictionaryArray(_ products: [Product]) -> [[String: Any]] { + return products.map { toDictionary($0) } + } + + /// 将 Product 转换为 JSON 字符串 + /// - Parameter product: Product 对象 + /// - Returns: JSON 字符串 + public static func toJSONString(_ product: Product) -> String? { + let dict = toDictionary(product) + return dictionaryToJSONString(dict) + } + + /// 将 Product 数组转换为 JSON 字符串 + /// - Parameter products: Product 数组 + /// - Returns: JSON 字符串 + public static func toJSONString(_ products: [Product]) -> String? { + let array = toDictionaryArray(products) + return arrayToJSONString(array) + } + + // MARK: - 私有方法 + + /// 产品类型转字符串 + private static func productTypeToString(_ type: Product.ProductType) -> String { + switch type { + case .consumable: + return "consumable" + case .nonConsumable: + return "nonConsumable" + case .autoRenewable: + return "autoRenewable" + case .nonRenewable: + return "nonRenewable" + default: + return "unknown" + } + } + + /// Dictionary 转 JSON 字符串 + internal static func dictionaryToJSONString(_ dict: [String: Any]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + return jsonString + } + + /// Array 转 JSON 字符串 + internal static func arrayToJSONString(_ array: [[String: Any]]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: array, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + return jsonString + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitConverter.swift b/keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitConverter.swift new file mode 100644 index 0000000..9b9c2b1 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitConverter.swift @@ -0,0 +1,98 @@ +// +// StoreKitConverter.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// StoreKit 统一转换器 +/// 提供统一的转换接口,方便外部调用 +public struct StoreKitConverter { + + // MARK: - Product 转换 + + /// 将 Product 转换为 Dictionary + public static func productToDictionary(_ product: Product) -> [String: Any] { + return ProductConverter.toDictionary(product) + } + + /// 将 Product 数组转换为 Dictionary 数组 + public static func productsToDictionaryArray(_ products: [Product]) -> [[String: Any]] { + return ProductConverter.toDictionaryArray(products) + } + + /// 将 Product 转换为 JSON 字符串 + public static func productToJSONString(_ product: Product) -> String? { + return ProductConverter.toJSONString(product) + } + + /// 将 Product 数组转换为 JSON 字符串 + public static func productsToJSONString(_ products: [Product]) -> String? { + return ProductConverter.toJSONString(products) + } + + // MARK: - Transaction 转换 + + /// 将 Transaction 转换为 Dictionary + public static func transactionToDictionary(_ transaction: Transaction) -> [String: Any] { + return TransactionConverter.toDictionary(transaction) + } + + /// 将 Transaction 数组转换为 Dictionary 数组 + public static func transactionsToDictionaryArray(_ transactions: [Transaction]) -> [[String: Any]] { + return TransactionConverter.toDictionaryArray(transactions) + } + + /// 将 Transaction 转换为 JSON 字符串 + public static func transactionToJSONString(_ transaction: Transaction) -> String? { + return TransactionConverter.toJSONString(transaction) + } + + /// 将 Transaction 数组转换为 JSON 字符串 + public static func transactionsToJSONString(_ transactions: [Transaction]) -> String? { + return TransactionConverter.toJSONString(transactions) + } + + // MARK: - StoreKitState 转换 + + /// 将 StoreKitState 转换为 Dictionary + public static func stateToDictionary(_ state: StoreKitState) -> [String: Any] { + return StoreKitStateConverter.toDictionary(state) + } + + /// 将 StoreKitState 转换为 JSON 字符串 + public static func stateToJSONString(_ state: StoreKitState) -> String? { + return StoreKitStateConverter.toJSONString(state) + } + + // MARK: - RenewalInfo 转换 + + /// 将 RenewalInfo 转换为 Dictionary + public static func renewalInfoToDictionary(_ renewalInfo: Product.SubscriptionInfo.RenewalInfo) -> [String: Any] { + return SubscriptionConverter.renewalInfoToDictionary(renewalInfo) + } + + /// 将 RenewalInfo 转换为 JSON 字符串 + public static func renewalInfoToJSONString(_ renewalInfo: Product.SubscriptionInfo.RenewalInfo) -> String? { + return SubscriptionConverter.renewalInfoToJSONString(renewalInfo) + } + + // MARK: - RenewalState 转换 + + /// 将 RenewalState 转换为字符串 + public static func renewalStateToString(_ state: Product.SubscriptionInfo.RenewalState) -> String { + return SubscriptionConverter.renewalStateToString(state) + } + + // MARK: - SubscriptionInfo 转换 + + /// 将 SubscriptionInfo 转换为 Dictionary + public static func subscriptionInfoToDictionary(_ subscription: Product.SubscriptionInfo, product: Product? = nil) -> [String: Any] { + return SubscriptionConverter.subscriptionInfoToDictionary(subscription, product: product) + } + +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitStateConverter.swift b/keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitStateConverter.swift new file mode 100644 index 0000000..7934b90 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Converts/StoreKitStateConverter.swift @@ -0,0 +1,131 @@ +// +// StoreKitStateConverter.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// StoreKitState 转换器 +/// 将 StoreKitState 对象转换为可序列化的基础数据类型(Dictionary/JSON) +public struct StoreKitStateConverter { + + /// 将 StoreKitState 转换为 Dictionary(可序列化为 JSON) + /// - Parameter state: StoreKitState 对象 + /// - Returns: Dictionary 对象,包含状态信息 + public static func toDictionary(_ state: StoreKitState) -> [String: Any] { + var dict: [String: Any] = [:] + + switch state { + case .idle: + dict["type"] = "idle" + + case .loadingProducts: + dict["type"] = "loadingProducts" + +// case .productsLoaded(let products): +// dict["type"] = "productsLoaded" +// dict["products"] = ProductConverter.toDictionaryArray(products) + + case .loadingPurchases: + dict["type"] = "loadingPurchases" + + case .purchasesLoaded: + dict["type"] = "purchasesLoaded" + + case .purchasing(let productId): + dict["type"] = "purchasing" + dict["productId"] = productId + + case .purchaseSuccess(let productId): + dict["type"] = "purchaseSuccess" + dict["productId"] = productId + + case .purchasePending(let productId): + dict["type"] = "purchasePending" + dict["productId"] = productId + + case .purchaseCancelled(let productId): + dict["type"] = "purchaseCancelled" + dict["productId"] = productId + + case .purchaseFailed(let productId, let error): + dict["type"] = "purchaseFailed" + dict["productId"] = productId + dict["error"] = String(describing: error) + +// case .subscriptionStatusChanged(let renewalState): +// dict["type"] = "subscriptionStatusChanged" +// dict["renewalState"] = renewalStateToString(renewalState) + + case .restoringPurchases: + dict["type"] = "restoringPurchases" + + case .restorePurchasesSuccess: + dict["type"] = "restorePurchasesSuccess" + + case .restorePurchasesFailed(let error): + dict["type"] = "restorePurchasesFailed" + dict["error"] = String(describing: error) + + case .purchaseRefunded(let productId): + dict["type"] = "purchaseRefunded" + dict["productId"] = productId + + case .purchaseRevoked(let productId): + dict["type"] = "purchaseRevoked" + dict["productId"] = productId + + case .subscriptionCancelled(let productId, let isFreeTrialCancelled): + dict["type"] = "subscriptionCancelled" + dict["productId"] = productId + dict["isFreeTrialCancelled"] = isFreeTrialCancelled + + case .error(let error): + dict["type"] = "error" + dict["error"] = String(describing: error) + } + + return dict + } + + /// 将 StoreKitState 转换为 JSON 字符串 + /// - Parameter state: StoreKitState 对象 + /// - Returns: JSON 字符串 + public static func toJSONString(_ state: StoreKitState) -> String? { + let dict = toDictionary(state) + return dictionaryToJSONString(dict) + } + + // MARK: - 私有方法 + + /// 续订状态转字符串 + private static func renewalStateToString(_ state: Product.SubscriptionInfo.RenewalState) -> String { + switch state { + case .subscribed: + return "subscribed" + case .expired: + return "expired" + case .inBillingRetryPeriod: + return "inBillingRetryPeriod" + case .inGracePeriod: + return "inGracePeriod" + case .revoked: + return "revoked" + default: + return "unknown" + } + } + + /// Dictionary 转 JSON 字符串 + private static func dictionaryToJSONString(_ dict: [String: Any]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + return jsonString + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Converts/SubscriptionConverter.swift b/keyBoard/Class/Pay/StoreKit2Manager/Converts/SubscriptionConverter.swift new file mode 100644 index 0000000..8f673a2 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Converts/SubscriptionConverter.swift @@ -0,0 +1,237 @@ +// +// SubscriptionConverter.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// 订阅相关转换器 +/// 将订阅相关的对象转换为可序列化的基础数据类型(Dictionary/JSON) +public struct SubscriptionConverter { + + // MARK: - SubscriptionInfo + + /// 将 SubscriptionInfo 转换为 Dictionary(同步版本,不包含异步属性) + /// - Parameters: + /// - subscription: SubscriptionInfo 对象 + /// - product: 关联的 Product 对象(可选) + /// - Returns: Dictionary 对象 + public static func subscriptionInfoToDictionary(_ subscription: Product.SubscriptionInfo, product: Product? = nil) -> [String: Any] { + var dict: [String: Any] = [:] + + // 订阅组ID + dict["subscriptionGroupID"] = subscription.subscriptionGroupID + + // 订阅周期 + dict["subscriptionPeriodCount"] = subscription.subscriptionPeriod.value + dict["subscriptionPeriodUnit"] = subscriptionPeriodUnitToString(subscription.subscriptionPeriod.unit) + + // 介绍性优惠(如果有) + if let introOffer = subscription.introductoryOffer { + dict["introductoryOffer"] = subscriptionOfferToDictionary(introOffer) + } else { + dict["introductoryOffer"] = NSNull() + } + + // 促销优惠列表 + dict["promotionalOffers"] = subscription.promotionalOffers.map { subscriptionOfferToDictionary($0) } + + // 赢回优惠列表(iOS 18.0+) + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + dict["winBackOffers"] = subscription.winBackOffers.map { subscriptionOfferToDictionary($0) } + } else { + dict["winBackOffers"] = [] + } + + return dict + } + + // MARK: - RenewalInfo + + /// 将 RenewalInfo 转换为 Dictionary + /// - Parameter renewalInfo: RenewalInfo 对象 + /// - Returns: Dictionary 对象 + public static func renewalInfoToDictionary(_ renewalInfo: Product.SubscriptionInfo.RenewalInfo) -> [String: Any] { + var dict: [String: Any] = [:] + + // 是否自动续订 + dict["willAutoRenew"] = renewalInfo.willAutoRenew + + // 续订日期(如果有) + if let renewalDate = renewalInfo.renewalDate { + dict["renewalDate"] = dateToTimestamp(renewalDate) + } else { + dict["renewalDate"] = NSNull() + } + + // 过期原因(如果有) + if let expirationReason = renewalInfo.expirationReason { + dict["expirationReason"] = expirationReasonToString(expirationReason) + } else { + dict["expirationReason"] = NSNull() + } + + // 注意:过期日期(expirationDate)不在 RenewalInfo 中,需要从 Transaction 中获取 + + return dict + } + + /// 将 RenewalInfo 转换为 JSON 字符串 + /// - Parameter renewalInfo: RenewalInfo 对象 + /// - Returns: JSON 字符串 + public static func renewalInfoToJSONString(_ renewalInfo: Product.SubscriptionInfo.RenewalInfo) -> String? { + let dict = renewalInfoToDictionary(renewalInfo) + return dictionaryToJSONString(dict) + } + + // MARK: - RenewalState + + /// 将 RenewalState 转换为字符串 + /// - Parameter state: RenewalState 对象 + /// - Returns: 字符串 + public static func renewalStateToString(_ state: Product.SubscriptionInfo.RenewalState) -> String { + switch state { + case .subscribed: + return "subscribed" + case .expired: + return "expired" + case .inBillingRetryPeriod: + return "inBillingRetryPeriod" + case .inGracePeriod: + return "inGracePeriod" + case .revoked: + return "revoked" + default: + return "unknown" + } + } + + // MARK: - SubscriptionPeriod + + /// 将 SubscriptionPeriod 转换为 Dictionary + /// - Parameter period: SubscriptionPeriod 对象 + /// - Returns: Dictionary 对象 + public static func subscriptionPeriodToDictionary(_ period: Product.SubscriptionPeriod) -> [String: Any] { + var dict: [String: Any] = [:] + + dict["value"] = period.value + dict["unit"] = subscriptionPeriodUnitToString(period.unit) + + return dict + } + + // MARK: - SubscriptionOffer + + /// 将 SubscriptionOffer 转换为 Dictionary + /// - Parameter offer: SubscriptionOffer 对象 + /// - Returns: Dictionary 对象 + private static func subscriptionOfferToDictionary(_ offer: Product.SubscriptionOffer) -> [String: Any] { + var dict: [String: Any] = [:] + + // 优惠ID(介绍性优惠为 nil,确保是字符串类型) + if let offerID = offer.id { + dict["id"] = offerID + } else { + dict["id"] = NSNull() + } + + // 优惠类型 + dict["type"] = subscriptionOfferTypeToString(offer.type) + + // 价格信息(确保是字符串类型) + dict["displayPrice"] = String(describing: offer.displayPrice) + dict["price"] = Double(String(format: "%.2f", NSDecimalNumber(decimal: offer.price).doubleValue)) ?? NSDecimalNumber(decimal: offer.price).doubleValue + + // 支付模式 + dict["paymentMode"] = paymentModeToString(offer.paymentMode) + + // 优惠周期 + dict["periodCount"] = offer.period.value + dict["periodUnit"] = subscriptionPeriodUnitToString(offer.period.unit) + + // 周期数量 + dict["offerPeriodCount"] = offer.periodCount + + return dict + } + + // MARK: - 私有方法 + + /// 日期转时间戳(毫秒) + private static func dateToTimestamp(_ date: Date) -> Int64 { + return Int64(date.timeIntervalSince1970 * 1000) + } + + /// 订阅周期单位转字符串 + private static func subscriptionPeriodUnitToString(_ unit: Product.SubscriptionPeriod.Unit) -> String { + switch unit { + case .day: + return "day" + case .week: + return "week" + case .month: + return "month" + case .year: + return "year" + @unknown default: + return "unknown" + } + } + + /// 优惠类型转字符串 + private static func subscriptionOfferTypeToString(_ type: Product.SubscriptionOffer.OfferType) -> String { + switch type { + case .introductory: + return "introductory" + case .promotional: + return "promotional" + default: + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + if type == .winBack { + return "winBack" + } + } + return "unknown" + } + } + + /// 支付模式转字符串 + private static func paymentModeToString(_ mode: Product.SubscriptionOffer.PaymentMode) -> String { + switch mode { + case .freeTrial: + return "freeTrial" + case .payAsYouGo: + return "payAsYouGo" + case .payUpFront: + return "payUpFront" + default: + return "unknown" + } + } + + /// 过期原因转字符串 + private static func expirationReasonToString(_ reason: Product.SubscriptionInfo.RenewalInfo.ExpirationReason) -> String { + // ExpirationReason 的具体枚举值可能因 iOS 版本而异 + // 使用 String(describing:) 作为后备方案 + let reasonString = String(describing: reason) + // 移除命名空间前缀,只保留枚举值名称 + if let lastDot = reasonString.lastIndex(of: ".") { + let value = String(reasonString[reasonString.index(after: lastDot)...]) + return value + } + return reasonString + } + + /// Dictionary 转 JSON 字符串 + private static func dictionaryToJSONString(_ dict: [String: Any]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + return jsonString + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Converts/TransactionConverter.swift b/keyBoard/Class/Pay/StoreKit2Manager/Converts/TransactionConverter.swift new file mode 100644 index 0000000..1652431 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Converts/TransactionConverter.swift @@ -0,0 +1,475 @@ +// +// TransactionConverter.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// Transaction 转换器 +/// 将 Transaction 对象转换为可序列化的基础数据类型(Dictionary/JSON) +public struct TransactionConverter { + + /// 将 Transaction 转换为 Dictionary(可序列化为 JSON) + /// - Parameter transaction: Transaction 对象 + /// - Returns: Dictionary 对象,包含所有交易信息 + public static func toDictionary(_ transaction: Transaction) -> [String: Any] { + var dict: [String: Any] = [:] + + // 基本信息 + dict["id"] = String(transaction.id) + dict["productID"] = transaction.productID + // 产品类型 + dict["productType"] = productTypeToString(transaction.productType) + // 交易价格(如果有) + if let price = transaction.price { + dict["price"] = Double(String(format: "%.2f", NSDecimalNumber(decimal: price).doubleValue)) ?? NSDecimalNumber(decimal: price).doubleValue + } else { + dict["price"] = 0.00 + } + // 货币代码(iOS 16.0+) + if #available(iOS 16.0, *) { + if let currency = transaction.currency { + // 确保是字符串类型 + dict["currency"] = String(describing: currency) + } else { + dict["currency"] = NSNull() + } + } else { + dict["currency"] = NSNull() + } + // 所有权类型 + dict["ownershipType"] = ownershipTypeToString(transaction.ownershipType) + + // 原始交易ID + dict["originalID"] = String(transaction.originalID) + + // 原始购买日期 + dict["originalPurchaseDate"] = dateToTimestamp(transaction.originalPurchaseDate) + + dict["purchaseDate"] = dateToTimestamp(transaction.purchaseDate) + // 购买数量 + dict["purchasedQuantity"] = transaction.purchasedQuantity + + // 交易原因(iOS 17.0+,表示购买还是续订) + if #available(iOS 17.0, *) { + dict["purchaseReason"] = transactionReasonToString(transaction.reason) + } else { + dict["purchaseReason"] = "" + } + + // 订阅组ID(如果有,仅订阅产品,确保是字符串类型) + if let subscriptionGroupID = transaction.subscriptionGroupID { + dict["subscriptionGroupID"] = String(describing: subscriptionGroupID) + } else { + dict["subscriptionGroupID"] = NSNull() + } + + // 过期日期(如果有) + if let expirationDate = transaction.expirationDate { + dict["expirationDate"] = dateToTimestamp(expirationDate) + } else { + dict["expirationDate"] = NSNull() + } + + // 是否升级 + dict["isUpgraded"] = transaction.isUpgraded + + // 撤销日期(如果有) + if let revocationDate = transaction.revocationDate { + dict["hasRevocation"] = true + dict["revocationDate"] = dateToTimestamp(revocationDate) + } else { + dict["hasRevocation"] = false + dict["revocationDate"] = NSNull() + } + + // 撤销原因 + if let revocationReason = transaction.revocationReason { + dict["revocationReason"] = revocationReasonToString(revocationReason) + } else { + dict["revocationReason"] = NSNull() + } + + // 环境信息(iOS 16.0+) + if #available(iOS 16.0, *) { + dict["environment"] = environmentToString(transaction.environment) + } else { + dict["environment"] = "unknown" + } + + // 应用账户令牌(如果有) + if let appAccountToken = transaction.appAccountToken { + dict["appAccountToken"] = appAccountToken.uuidString + } else { + dict["appAccountToken"] = "" + } + + // 应用交易ID(iOS 18.4+,确保是字符串类型) + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + dict["appBundleID"] = String(describing: transaction.appBundleID) + dict["appTransactionID"] = transaction.appTransactionID + } else { + dict["appTransactionID"] = NSNull() + dict["appBundleID"] = NSNull() + } + + // 签名日期 + dict["signedDate"] = dateToTimestamp(transaction.signedDate) + + // 商店区域(iOS 17.0+) + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) { + let storefront = transaction.storefront + dict["storefrontId"] = storefront.id + dict["storefrontCountryCode"] = storefront.countryCode + if let currency = storefront.currency { + dict["storefrontCurrency"] = String(describing: currency) + } else { + dict["storefrontCurrency"] = "" + } + } else { + dict["storefrontId"] = "" + dict["storefrontCountryCode"] = "" + dict["storefrontCurrency"] = "" + } + + // Web订单行项目ID(如果有) + if let webOrderLineItemID = transaction.webOrderLineItemID { + dict["webOrderLineItemID"] = webOrderLineItemID + } else { + dict["webOrderLineItemID"] = "" + } + + // 设备验证 + dict["deviceVerification"] = transaction.deviceVerification.base64EncodedString() + + // 设备验证Nonce + dict["deviceVerificationNonce"] = transaction.deviceVerificationNonce.uuidString + + // 优惠信息 + dict["offer"] = offerToDictionary(from: transaction) + + // 高级商务信息(iOS 18.4+) + // 注意:Transaction.AdvancedCommerceInfo 的具体结构需要根据实际 API 调整 + // 暂不处理 + + return dict + } + + /// 将 Transaction 数组转换为 Dictionary 数组 + /// - Parameter transactions: Transaction 数组 + /// - Returns: Dictionary 数组 + public static func toDictionaryArray(_ transactions: [Transaction]) -> [[String: Any]] { + return transactions.map { toDictionary($0) } + } + + /// 将 Transaction 转换为 JSON 字符串 + /// - Parameter transaction: Transaction 对象 + /// - Returns: JSON 字符串 + public static func toJSONString(_ transaction: Transaction) -> String? { + let dict = toDictionary(transaction) + return dictionaryToJSONString(dict) + } + + /// 将 Transaction 数组转换为 JSON 字符串 + /// - Parameter transactions: Transaction 数组 + /// - Returns: JSON 字符串 + public static func toJSONString(_ transactions: [Transaction]) -> String? { + let array = toDictionaryArray(transactions) + return arrayToJSONString(array) + } + + // MARK: - 私有方法 + + /// 日期转时间戳(毫秒) + private static func dateToTimestamp(_ date: Date) -> Int64 { + return Int64(date.timeIntervalSince1970 * 1000) + } + + /// 产品类型转字符串 + private static func productTypeToString(_ type: Product.ProductType) -> String { + switch type { + case .consumable: + return "consumable" + case .nonConsumable: + return "nonConsumable" + case .autoRenewable: + return "autoRenewable" + case .nonRenewable: + return "nonRenewable" + default: + return "unknown" + } + } + + /// 所有权类型转字符串 + private static func ownershipTypeToString(_ type: Transaction.OwnershipType) -> String { + switch type { + case .purchased: + return "purchased" + case .familyShared: + return "familyShared" + default: + return "unknown" + } + } + + /// 环境转字符串 + @available(iOS 16.0, *) + private static func environmentToString(_ environment: AppStore.Environment) -> String { + switch environment { + case .production: + return "production" + case .sandbox: + return "sandbox" + case .xcode: + return "xcode" + default: + return "unknown" + } + } + + /// 交易原因转字符串 + @available(iOS 17.0, *) + private static func transactionReasonToString(_ reason: Transaction.Reason) -> String { + switch reason { + case .purchase: + return "purchase" + case .renewal: + return "renewal" + default: + return "unknown" + } + } + + /// 撤销原因转字符串 + private static func revocationReasonToString(_ reason: Transaction.RevocationReason) -> String { + return extractEnumValueName(from: reason) + } + + /// 从枚举值中提取名称(移除命名空间前缀) + /// - Parameter value: 任意类型 + /// - Returns: 枚举值名称字符串 + private static func extractEnumValueName(from value: T) -> String { + let valueString = String(describing: value) + // 移除命名空间前缀(如 "Transaction.OfferType.introductory" -> "introductory") + if let lastDot = valueString.lastIndex(of: ".") { + return String(valueString[valueString.index(after: lastDot)...]) + } + return valueString + } + + /// 交易优惠类型转字符串(已废弃,iOS 15.0-17.1) + @available(iOS 15.0, *) + private static func transactionOfferTypeDeprecatedToString(_ type: Transaction.OfferType) -> String { + switch type { + case .introductory: + return "introductory" + case .promotional: + return "promotional" + case .code: + return "code" + default: + return "unknown" + } + } + + /// 支付模式转字符串(用于 Product.SubscriptionOffer.PaymentMode) + private static func paymentModeToString(_ mode: Product.SubscriptionOffer.PaymentMode) -> String { + switch mode { + case .freeTrial: + return "freeTrial" + case .payAsYouGo: + return "payAsYouGo" + case .payUpFront: + return "payUpFront" + default: + return "unknown" + } + } + + // MARK: - Offer 转换方法 + + /// 交易优惠类型转字符串(用于 Transaction.Offer,iOS 17.2+) + @available(iOS 17.2, *) + private static func transactionOfferTypeToString(_ type: Transaction.OfferType) -> String { + // 使用 if-else 判断,因为 switch 可能无法处理所有情况 + if type == .introductory { + return "introductory" + } else if type == .promotional { + return "promotional" + } else if type == .code { + return "code" + } else { + // iOS 18.0+ 支持 winBack + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + if type == .winBack { + return "winBack" + } + } + return "unknown" + } + } + + /// 交易优惠支付模式转字符串(用于 Transaction.Offer.PaymentMode) + @available(iOS 17.2, *) + private static func transactionOfferPaymentModeToString(_ mode: Transaction.Offer.PaymentMode) -> String { + switch mode { + case .freeTrial: + return "freeTrial" + case .payAsYouGo: + return "payAsYouGo" + case .payUpFront: + return "payUpFront" + default: + return "unknown" + } + } + + /// 将 Transaction 的优惠信息转换为 Dictionary + /// - Parameter transaction: Transaction 对象 + /// - Returns: 优惠信息字典,如果没有优惠则返回 NSNull + private static func offerToDictionary(from transaction: Transaction) -> Any { + // iOS 17.2+ 使用新的 offer 属性 + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, *) { + return modernOfferToDictionary(from: transaction) + } + // iOS 15.0 - iOS 17.1 使用已废弃的属性 + else if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + return deprecatedOfferToDictionary(from: transaction) + } + // iOS 15.0 以下版本不支持优惠信息 + else { + return NSNull() + } + } + + /// 使用新的 offer 属性转换优惠信息(iOS 17.2+) + @available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, *) + private static func modernOfferToDictionary(from transaction: Transaction) -> Any { + guard let offer = transaction.offer else { + return NSNull() + } + + var offerDict: [String: Any] = [:] + + // 优惠类型 + offerDict["type"] = transactionOfferTypeToString(offer.type) + + // 优惠ID + if let offerID = offer.id { + offerDict["id"] = offerID + } else { + offerDict["id"] = NSNull() + } + + // 支付模式(使用自定义方法) + if let paymentMode = offer.paymentMode { + offerDict["paymentMode"] = transactionOfferPaymentModeToString(paymentMode) + } else { + offerDict["paymentMode"] = NSNull() + } + + // 优惠周期(iOS 18.4+) + offerDict["periodCount"] = 0 + offerDict["periodUnit"] = "" + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + if let period = offer.period { + offerDict["periodCount"] = period.value + offerDict["periodUnit"] = subscriptionPeriodUnitToString(period.unit) + } + } + + return offerDict + } + + /// 使用已废弃的属性转换优惠信息(iOS 15.0 - iOS 17.1) + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) + private static func deprecatedOfferToDictionary(from transaction: Transaction) -> Any { + guard let offerType = transaction.offerType else { + return NSNull() + } + + var offerDict: [String: Any] = [:] + + // 优惠类型 + offerDict["type"] = transactionOfferTypeDeprecatedToString(offerType) + + // 优惠ID + if let offerID = transaction.offerID { + offerDict["id"] = String(describing: offerID) + } else { + offerDict["id"] = NSNull() + } + + // 支付模式(字符串表示) + if let paymentMode = transaction.offerPaymentModeStringRepresentation { + offerDict["paymentMode"] = paymentMode + } else { + offerDict["paymentMode"] = NSNull() + } + + // 优惠周期(iOS 15.0 - iOS 18.3 使用字符串,iOS 18.4+ 已废弃) + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + // iOS 18.4+ 已废弃 offerPeriodStringRepresentation,返回 NSNull + offerDict["period"] = NSNull() + } else { + // iOS 15.0 - iOS 18.3 使用字符串表示 + if let period = transaction.offerPeriodStringRepresentation { + offerDict["period"] = period + } else { + offerDict["period"] = NSNull() + } + } + + return offerDict + } + + /// 订阅周期转 Dictionary + private static func subscriptionPeriodToDictionary(_ period: Product.SubscriptionPeriod) -> [String: Any] { + var dict: [String: Any] = [:] + dict["value"] = period.value + dict["unit"] = subscriptionPeriodUnitToString(period.unit) + return dict + } + + /// 订阅周期单位转字符串 + private static func subscriptionPeriodUnitToString(_ unit: Product.SubscriptionPeriod.Unit) -> String { + switch unit { + case .day: + return "day" + case .week: + return "week" + case .month: + return "month" + case .year: + return "year" + default: + return "unknown" + } + } + + // 注意:Transaction.AdvancedCommerceProduct 类型可能不存在,已移除此方法 + // 如果需要,可以直接使用 jsonRepresentation + + /// Dictionary 转 JSON 字符串 + private static func dictionaryToJSONString(_ dict: [String: Any]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + return jsonString + } + + /// Array 转 JSON 字符串 + private static func arrayToJSONString(_ array: [[String: Any]]) -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: array, options: .prettyPrinted), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + return jsonString + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitService.swift b/keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitService.swift new file mode 100644 index 0000000..2be3ff0 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitService.swift @@ -0,0 +1,1300 @@ +// +// StoreKitService.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit +import Combine +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// StoreKit 内部服务类 +/// 负责与 StoreKit API 交互,处理产品加载、购买、交易监听等核心功能 +internal class StoreKitService: ObservableObject { + private let config: StoreKitConfig + weak var delegate: StoreKitServiceDelegate? + + /// 所有产品 + @Published private(set) var allProducts: [Product] = [] + /// 所有有效的非消耗和订阅交易记录集合 + @Published private(set) var purchasedTransactions: [Transaction] = [] + /// 每个产品的最新交易记录集合 + @Published private(set) var latestTransactions: [Transaction] = [] + + // 后台任务 + private var transactionListener: Task? + private var subscriberTasks: [Task] = [] + private var cancellables = Set() + + // 并发购买保护 + private var isPurchasing = false + private let purchasingQueue = DispatchQueue(label: "com.storekit.purchasing") + + // MARK: - 订阅状态监听相关属性 + + /// 订阅状态缓存(产品ID -> 上次的订阅状态) + /// + /// 用途: + /// - 存储每个订阅产品上次检查时的 RenewalState(已订阅/已过期/宽限期等) + /// - 用于比较状态变化,只有变化时才触发通知 + /// - 避免重复通知相同的状态 + private var lastSubscriptionStatus: [String: Product.SubscriptionInfo.RenewalState] = [:] + + /// 续订信息缓存(产品ID -> 上次的续订信息) + /// + /// 用途: + /// - 存储每个订阅产品上次检查时的 RenewalInfo(包含 willAutoRenew、expirationDate 等) + /// - 用于检测订阅取消:比较 willAutoRenew 从 true 变为 false + /// - 避免重复通知相同的续订信息 + private var lastRenewalInfo: [String: Product.SubscriptionInfo.RenewalInfo] = [:] + + /// 订阅状态检查间隔(秒),默认30秒 + /// + /// 说明: + /// - 自动监听任务会每隔此时间间隔检查一次订阅状态 + /// - 可以根据应用需求调整(例如:更频繁的检查需要更小的值) + /// - 注意:过于频繁的检查可能会影响性能和电池寿命 + private let subscriptionCheckInterval: TimeInterval = 30 + + // 当前状态 + private var currentState: StoreKitState = .idle { + didSet { + // 确保在主线程调用 delegate + let state = currentState + Task { @MainActor [weak self] in + guard let self = self else { return } + self.notifyStateChanged(state) + } + } + } + + init(config: StoreKitConfig, delegate: StoreKitServiceDelegate) { + self.config = config + self.delegate = delegate + setupSubscribers() + } + + deinit { + stop() + } + + // MARK: - 公共方法 + + /// 启动服务 + func start() { + guard transactionListener == nil else { return } + + transactionListener = transactionStatusStream() + + // 启动订阅状态监听 + startSubscriptionStatusListener() + + Task { + await clearUnfinishedTransactions() + await loadProducts() + await loadPurchasedTransactions() + + // 初始检查订阅状态 + await checkSubscriptionStatus() + } + } + + /// 停止服务 + func stop() { + transactionListener?.cancel() + transactionListener = nil + + subscriberTasks.forEach { $0.cancel() } + subscriberTasks.removeAll() + + cancellables.removeAll() + } + + /// 从商店获取所有有效产品 + /// - Returns: 加载的产品列表,如果加载失败返回 nil + @MainActor + func loadProducts() async -> [Product]? { + currentState = .loadingProducts + + do { + let storeProducts = try await Product.products(for: config.productIds) + + var products: [Product] = [] + for product in storeProducts { + products.append(product) + } + + // 如果需要,按价格排序 + if config.autoSortProducts { + products = sortByPrice(products) + } + + self.allProducts = products + return products + } catch { + currentState = .error(error) + return nil + } + } + + /// 获取所有有效的非消耗品和订阅交易信息集合 + @MainActor + func loadPurchasedTransactions() async { + currentState = .loadingPurchases + + // 使用 TaskGroup 并行获取所有产品的最新交易记录 + var latestTransactions: [Transaction] = [] + await withTaskGroup(of: Transaction?.self) { group in + // 为每个产品ID创建任务 + for productId in config.productIds { + group.addTask { + if let latestTransaction = await Transaction.latest(for: productId) { + switch latestTransaction { + case .verified(let transaction): + return transaction + case .unverified: + return nil + } + } + return nil + } + } + + // 收集所有结果 + for await transaction in group { + if let transaction = transaction { + latestTransactions.append(transaction) + } + } + } + self.latestTransactions = latestTransactions + + // 将当前有效记录并转换成 purchasedTransactions + var purchasedTransactions: [Transaction] = [] + for await result in Transaction.currentEntitlements { + if case .verified(let transaction) = result { + purchasedTransactions.append(transaction) + } + } + self.purchasedTransactions = purchasedTransactions + + currentState = .purchasesLoaded + } + + /// 完成所有未完成的交易记录 + @MainActor + func clearUnfinishedTransactions() async { + for await result in Transaction.unfinished { + do { + // 使用统一的验证方法 + let transaction = try verifyPurchase(result) + + // 验证成功,完成交易 + await transaction.finish() + print("未完成交易,完成交易处理: \(transaction.productID)") + + } catch { + // 验证失败,记录错误但不完成交易 + if case .unverified(let transaction, _) = result { + print("未完成交易,交易验证失败,产品ID: \(transaction.productID) 错误\(error.localizedDescription)") + + // 更新状态 + currentState = .error(StoreKitError.verificationFailed) + } + + // 注意:验证失败时不要调用 finish() + } + } + } + + /// 购买产品(带并发保护) + func purchase(_ product: Product) async throws { + // 并发购买保护 + return try await withCheckedThrowingContinuation { continuation in + purchasingQueue.async { [weak self] in + guard let self = self else { + continuation.resume(throwing: StoreKitError.unknownError) + return + } + + guard !self.isPurchasing else { + continuation.resume(throwing: StoreKitError.purchaseInProgress) + return + } + + self.isPurchasing = true + + Task { + defer { + self.purchasingQueue.async { + self.isPurchasing = false + } + } + + await self.performPurchase(product, continuation: continuation) + } + } + } + } + + /// 执行购买 + private func performPurchase(_ product: Product, continuation: CheckedContinuation) async { + await MainActor.run { + currentState = .purchasing(product.id) + } + + do { + let result = try await product.purchase() + + switch result { + case .success(let verification): + do { + let transaction = try verifyPurchase(verification) + + await printProductDetails(product) + // 打印详细的交易信息 + await printTransactionDetails(transaction) + + // 先完成交易 + await transaction.finish() + + // 然后刷新购买列表(消耗品不需要) + if product.type != .consumable { + await loadPurchasedTransactions() + } + + await MainActor.run { + currentState = .purchaseSuccess(transaction.productID) + } + continuation.resume() + } catch { + await MainActor.run { + currentState = .purchaseFailed(product.id, error) + } + continuation.resume(throwing: error) + } + + case .pending: + await MainActor.run { + currentState = .purchasePending(product.id) + } + continuation.resume() + + case .userCancelled: + await MainActor.run { + currentState = .purchaseCancelled(product.id) + } + continuation.resume() + + @unknown default: + let error = StoreKitError.unknownError + await MainActor.run { + currentState = .purchaseFailed(product.id, error) + } + continuation.resume(throwing: error) + } + } catch { + await MainActor.run { + currentState = .purchaseFailed(product.id, error) + } + continuation.resume(throwing: error) + } + } + + /// 恢复购买 + @MainActor + func restorePurchases() async throws { + currentState = .restoringPurchases + + do { + /// 将已签名的交易信息和续订详情与应用商店进行同步。 + /// StoreKit 会自动更新已签订单交易及续费信息,因此只有在用户表示已购买的产品无法正常使用时才应使用此功能。 + /// - 重要提示:此操作会提示用户进行身份验证,仅在用户交互时调用此函数。 + /// - 异常情况:如果用户身份验证不成功,或者 StoreKit 无法连接到 App Store。 + try await AppStore.sync() + await loadPurchasedTransactions() + currentState = .restorePurchasesSuccess + } catch { + currentState = .restorePurchasesFailed(error) + throw StoreKitError.restorePurchasesFailed(error) + } + } + + /// 刷新同步最新的订阅信息 + @MainActor + func refreshPurchasesSync() async { + // 同步 App Store 的购买状态 + do { + try await AppStore.sync() + } catch { + print("同步 App Store 状态失败: \(error)") + } + + // 重新获取已购买产品(会更新订阅状态) + await loadPurchasedTransactions() + } + + + /// 获取所有或指定产品ID的交易历史记录 + func getTransactionHistory(for productId: String? = nil) async -> [TransactionHistory] { + var histories: [TransactionHistory] = [] + + // 查询所有历史交易 + for await verificationResult in Transaction.all { + do { + let transaction = try verifyPurchase(verificationResult) + + // 如果指定了产品ID,则过滤 + if let productId = productId, transaction.productID != productId { + continue + } + + // 查找对应的产品对象 + let product = allProducts.first(where: { $0.id == transaction.productID }) + + let history = TransactionHistory.from(transaction, product: product) + histories.append(history) + + // 检查是否退款或撤销 + // 注意:在查询交易历史时,如果发现撤销的交易,也会触发状态通知 + // 这样可以确保应用能够及时响应历史交易中的撤销事件 + if transaction.revocationDate != nil { + await MainActor.run { + if transaction.productType == .autoRenewable { + // 订阅产品被撤销/退款 + // 检查是否在免费试用期(通过交易中的 offer 信息判断) + // 如果用户在免费试用期内退款,isFreeTrialCancelled 应该为 true + let isFreeTrialCancelled = self.isFreeTrialTransaction(transaction) + + // 触发订阅取消通知(虽然实际上是撤销,但使用相同的状态) + // 外部可以通过 isFreeTrialCancelled 来区分是否在免费试用期 + currentState = .subscriptionCancelled(transaction.productID, isFreeTrialCancelled: isFreeTrialCancelled) + } else { + // 非订阅产品被退款 + currentState = .purchaseRefunded(transaction.productID) + } + } + } + } catch { + continue + } + } + + // 按购买日期倒序排列 + return histories.sorted(by: { $0.purchaseDate > $1.purchaseDate }) + } + + /// 获取消耗品的购买历史记录 + func getConsumablePurchaseHistory(for productId: String) async -> [TransactionHistory] { + let allHistory = await getTransactionHistory(for: productId) + return allHistory.filter { history in + history.product?.type == .consumable + } + } + + + // MARK: - 私有方法 + + /// 设置订阅者 + private func setupSubscribers() { + // 监听产品变化 + $allProducts + .receive(on: DispatchQueue.main) + .sink { [weak self] products in + guard let self = self else { return } + Task { @MainActor in + self.notifyProductsLoaded(products) + } + } + .store(in: &cancellables) + + // 监听已购买产品变化 + $purchasedTransactions + .receive(on: DispatchQueue.main) + .sink { [weak self] transactions in + guard let self = self else { return } + Task { @MainActor in + self.notifyPurchasedTransactionsUpdated(transactions, self.latestTransactions) + } + } + .store(in: &cancellables) + } + + /// 验证购买 + private func verifyPurchase(_ verificationResult: VerificationResult) throws -> T { + switch verificationResult { + case .unverified(_, let error): + throw StoreKitError.verificationFailed + case .verified(let result): + return result + } + } + + /// 检查交易是否在免费试用期 + /// + /// 功能说明: + /// - 通过检查交易中的优惠信息(offer)来判断该交易是否使用了免费试用优惠 + /// - 判断标准:优惠类型是介绍性优惠(introductory)且支付模式是免费试用(freeTrial) + /// + /// 使用场景: + /// 1. 订阅取消检测:判断用户是否在免费试用期内取消订阅 + /// 2. 订阅撤销检测:判断用户是否在免费试用期内撤销/退款订阅 + /// 3. 交易历史分析:统计免费试用期的交易数量 + /// + /// - Parameter transaction: 交易对象 + /// - Returns: 如果交易使用的是免费试用优惠返回 true,否则返回 false + /// + /// - Note: + /// - 此方法检查的是交易创建时使用的优惠,而不是当前时间点 + /// - 如果交易使用了免费试用优惠,即使试用期已过,此方法仍返回 true + /// - 要判断"当前是否还在试用期内",需要结合购买日期和试用期长度来计算 + private func isFreeTrialTransaction(_ transaction: Transaction) -> Bool { + // iOS 17.2+ 使用新的 offer 属性 + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, *) { + if let offer = transaction.offer { + // 检查优惠类型和支付模式 + // 判断标准: + // 1. 优惠类型必须是介绍性优惠(introductory) + // 2. 支付模式必须是免费试用(freeTrial) + // 同时满足这两个条件,说明交易使用了免费试用优惠 + if offer.type == .introductory, + offer.paymentMode == .freeTrial { + return true + } + } + } else { + // iOS 15.0 - iOS 17.1 使用已废弃的属性 + if let offerType = transaction.offerType, + let paymentMode = transaction.offerPaymentModeStringRepresentation { + // 检查是否是介绍性优惠且支付模式是免费试用 + // 注意:paymentMode 是字符串类型,需要与 "freeTrial" 比较 + if offerType == .introductory, + paymentMode == "freeTrial" { + return true + } + } + } + + // 没有优惠信息或不是免费试用,返回 false + // 可能的情况: + // 1. 交易没有使用任何优惠(正常付费订阅) + // 2. 交易使用了其他类型的优惠(促销优惠、预付优惠等) + // 3. 交易使用了介绍性优惠但支付模式不是免费试用(如预付优惠) + return false + } + + /// 监听交易状态流 + private func transactionStatusStream() -> Task { + return Task.detached { [weak self] in + guard let self = self else { return } + + for await result in Transaction.updates { + do { + let transaction = try self.verifyPurchase(result) + + await printTransactionDetails(transaction) + + // 检查是否退款或撤销 + // 注意:revocationDate 表示撤销/退款,与订阅取消(cancellation)不同 + // - 撤销(revocation):通常是退款或违规导致的,会立即失效,通过 Transaction.updates 触发 + // - 取消(cancellation):用户主动取消,订阅仍然有效直到过期,通过定期检查 subscription.status 检测 + if transaction.revocationDate != nil { + await MainActor.run { + if transaction.productType == .autoRenewable { + // 订阅产品被撤销/退款 + // 检查是否在免费试用期(通过交易中的 offer 信息判断) + // 如果用户在免费试用期内退款,isFreeTrialCancelled 应该为 true + let isFreeTrialCancelled = self.isFreeTrialTransaction(transaction) + + // 触发订阅取消通知(虽然实际上是撤销,但使用相同的状态) + // 外部可以通过 isFreeTrialCancelled 来区分是否在免费试用期 + print("🔔 检测到订阅取消: \(transaction.productID), isFreeTrialCancelled: \(isFreeTrialCancelled)") + self.currentState = .subscriptionCancelled(transaction.productID, isFreeTrialCancelled: isFreeTrialCancelled) + } else { + // 非订阅产品被退款 + // 有撤销日期通常表示退款 + print("🔔 检测到订阅退款: \(transaction.productID)") + self.currentState = .purchaseRefunded(transaction.productID) + } + } + } + + await self.loadPurchasedTransactions() + + await transaction.finish() + } catch { + print("交易处理失败: \(error)") + } + } + } + } + + /// 按价格排序产品 + private func sortByPrice(_ products: [Product]) -> [Product] { + products.sorted(by: { $0.price < $1.price }) + } + + // MARK: - 订阅状态监听 + + /// 启动订阅状态监听(定期检查) + /// + /// 功能说明: + /// - 创建一个后台任务,定期检查所有已购买订阅的状态 + /// - 检查间隔:默认30秒(可通过 subscriptionCheckInterval 调整) + /// - 检测内容: + /// 1. 订阅取消:通过比较 willAutoRenew 从 true 变为 false + /// 2. 订阅状态变化:通过比较 RenewalState 的变化(已订阅/已过期/宽限期等) + /// 3. 订阅撤销:检测到 revoked 状态时触发通知 + /// - 通知机制:只有检测到变化时才触发状态通知,避免重复通知 + /// - 生命周期:任务会在服务停止时自动取消 + private func startSubscriptionStatusListener() { + // 创建新的监听任务(使用 weak self 避免循环引用) + let task = Task { [weak self] in + guard let self = self else { return } + + // 持续监听,直到任务被取消 + while !Task.isCancelled { + // 检查所有订阅的状态(并行检查,提高效率) + let now = Date() + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.timeZone = TimeZone.current + print("当前订阅检测时间: \(formatter.string(from: now))") + await self.checkSubscriptionStatus() + + // 等待指定间隔(默认30秒)后再次检查 + // 使用 try? 忽略取消错误,因为任务取消是正常情况 + try? await Task.sleep(nanoseconds: UInt64(self.subscriptionCheckInterval * 1_000_000_000)) + } + } + + // 将任务添加到任务列表,以便在停止服务时统一取消 + subscriberTasks.append(task) + } + + /// 检查所有订阅的状态 + /// 功能说明: + /// 1. 并行检查所有已购买的自动续订订阅的状态 + /// 2. 比较续订信息(willAutoRenew)的变化,检测订阅取消 + /// 3. 比较订阅状态(RenewalState)的变化,检测状态变更 + /// 4. 更新缓存,只有变化时才通知,避免重复通知 + /// 5. 触发相应的状态通知(subscriptionCancelled、subscriptionStatusChanged 等) + @MainActor + private func checkSubscriptionStatus() async { + // 获取所有已购买的自动续订订阅 + let purchasedSubscriptions = allProducts.filter { product in + product.type == .autoRenewable && + purchasedTransactions.contains(where: { $0.productID == product.id }) + } + + // 如果没有订阅,直接返回 + guard !purchasedSubscriptions.isEmpty else { return } + + // 使用 TaskGroup 并行检查所有订阅,提高效率 + // 返回类型:(产品ID, 订阅状态, 续订信息, 过期日期, 是否在免费试用期) + await withTaskGroup(of: (String, Product.SubscriptionInfo.RenewalState?, Product.SubscriptionInfo.RenewalInfo?, Date?, Bool?).self) { group in + // 为每个订阅产品创建检查任务 + for product in purchasedSubscriptions { + group.addTask { [weak self] in + guard let self = self else { return (product.id, nil, nil, nil, nil) } + guard let subscription = product.subscription else { return (product.id, nil, nil, nil, nil) } + + do { + // 获取订阅状态数组(通常只有一个当前状态) + let statuses = try await subscription.status + guard let currentStatus = statuses.first else { return (product.id, nil, nil, nil, nil) } + + let currentState = currentStatus.state + var renewalInfo: Product.SubscriptionInfo.RenewalInfo? + var expirationDate: Date? + var isFreeTrial: Bool? = nil + + // 获取续订信息(包含 willAutoRenew、expirationDate 等) + if case .verified(let info) = currentStatus.renewalInfo { + renewalInfo = info + } + + // 从 Transaction 中获取过期日期和优惠信息 + // 注意:subscription.status 中的 transaction 是当前有效的交易 + // 如果用户取消了订阅,这个交易仍然是当前有效的,直到过期 + if case .verified(let transaction) = currentStatus.transaction { + expirationDate = transaction.expirationDate + + // ========== 判断是否在免费试用期 ========== + // 判断逻辑: + // 1. 检查交易中的优惠信息(offer) + // 2. 如果优惠类型是介绍性优惠(introductory)且支付模式是免费试用(freeTrial) + // 3. 则说明当前订阅使用的是免费试用优惠,即用户在免费试用期内 + // + // 注意: + // - 如果用户取消了订阅,但还在免费试用期内,isFreeTrial 应该为 true + // - 如果用户取消了订阅,但已经过了免费试用期,isFreeTrial 应该为 false + // - 这个判断基于当前有效交易的优惠信息,是准确的 + + // iOS 17.2+ 使用新的 offer 属性 + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, *) { + if let offer = transaction.offer { + // 检查优惠类型和支付模式 + // 如果是介绍性优惠且支付模式是免费试用,则是在免费试用期 + if offer.type == .introductory, + offer.paymentMode == .freeTrial { + isFreeTrial = true + } else { + // 其他情况:没有优惠、促销优惠、或其他支付模式,都不算免费试用期 + isFreeTrial = false + } + } else { + // 没有优惠信息,说明不在免费试用期(可能是正常付费订阅) + isFreeTrial = false + } + } else { + // iOS 15.0 - iOS 17.1 使用已废弃的属性 + if let offerType = transaction.offerType, + let paymentMode = transaction.offerPaymentModeStringRepresentation { + // 检查是否是介绍性优惠且支付模式是免费试用 + if offerType == .introductory, + paymentMode == "freeTrial" { + isFreeTrial = true + } else { + // 其他情况:没有优惠、促销优惠、或其他支付模式,都不算免费试用期 + isFreeTrial = false + } + } else { + // 没有优惠信息,说明不在免费试用期(可能是正常付费订阅) + isFreeTrial = false + } + } + } else { + // 如果无法获取交易信息,默认不在免费试用期 + isFreeTrial = false + } + + return (product.id, currentState, renewalInfo, expirationDate, isFreeTrial) + } catch { + print("获取订阅状态失败: \(product.id), 错误: \(error)") + return (product.id, nil, nil, nil, nil) + } + } + } + + // 收集所有任务的结果并处理状态变化 + for await (productId, currentRenewalState, renewalInfo, expirationDate, isFreeTrial) in group { + // 跳过无效结果 + guard let currentRenewalState = currentRenewalState else { continue } + + // 获取上次缓存的续订信息和状态 + let lastInfo = self.lastRenewalInfo[productId] + let lastState = self.lastSubscriptionStatus[productId] + + // ========== 检测订阅取消 ========== + // 订阅取消的判断标准:willAutoRenew 从 true 变为 false + // 这表示用户主动取消了订阅,但订阅在过期日期前仍然有效 + // 注意:订阅取消后,订阅仍然可以使用直到过期日期 + if let lastInfo = lastInfo, + let currentInfo = renewalInfo { + // 检查 willAutoRenew 是否从 true 变为 false + if lastInfo.willAutoRenew == true && currentInfo.willAutoRenew == false { + // ========== 判断是否在免费试用期取消 ========== + // 判断逻辑: + // 1. isFreeTrial 为 true 表示当前有效交易使用的是免费试用优惠 + // 2. 如果用户在免费试用期内取消订阅,isFreeTrial 应该为 true + // 3. 如果用户在付费订阅期内取消订阅,isFreeTrial 应该为 false + // + // 使用场景: + // - isFreeTrialCancelled = true:用户在免费试用期内取消,可以: + // * 显示"免费试用已取消"的提示 + // * 提供重新订阅的引导 + // * 统计免费试用取消率 + // - isFreeTrialCancelled = false:用户在付费订阅期内取消,可以: + // * 显示"订阅已取消,将在XX日期过期"的提示 + // * 提供续订或重新订阅的引导 + // * 统计付费订阅取消率 + let isFreeTrialCancelled = isFreeTrial ?? false + + // 订阅已取消,触发通知(包含是否在免费试用期取消的信息) + if isFreeTrialCancelled { + print("🔔 检测到订阅取消(免费试用期): \(productId)") + print(" 说明:用户在免费试用期内取消了订阅,订阅将在试用期结束时失效") + } else { + print("🔔 检测到订阅取消(付费订阅期): \(productId)") + print(" 说明:用户在付费订阅期内取消了订阅,订阅将在当前周期结束时失效") + } + + // 触发状态通知,包含是否在免费试用期取消的信息 + // 外部可以通过这个信息来区分不同的取消场景,提供不同的处理逻辑 + self.currentState = .subscriptionCancelled(productId, isFreeTrialCancelled: isFreeTrialCancelled) + + // 打印过期日期信息,告知用户订阅何时失效 + if let expirationDate = expirationDate { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + if isFreeTrialCancelled { + print(" 免费试用将在 \(formatter.string(from: expirationDate)) 过期") + } else { + print(" 订阅将在 \(formatter.string(from: expirationDate)) 过期") + } + } + } + } + + // ========== 检测订阅状态变化 ========== + // 比较当前状态和上次状态,如果不同则触发通知 + // 这样可以检测到订阅从已订阅 -> 已过期、已订阅 -> 宽限期等状态变化 + if let lastState = lastState, lastState != currentRenewalState { + // 状态发生变化,根据不同的状态类型进行处理 + switch currentRenewalState { + case .subscribed: + // 订阅已激活(可能是新订阅或从其他状态恢复) + print("📱 订阅状态变化: \(productId) -> 已订阅") + // 注意:这里不触发状态通知,因为 subscribed 是正常状态 + + case .expired: + // 订阅已过期(用户无法再使用订阅功能) + print("⏰ 订阅状态变化: \(productId) -> 已过期") + // 注意:过期状态通常不需要额外通知,因为用户已经知道 + + case .inGracePeriod: + // 订阅在宽限期内(支付失败但仍在宽限期内,功能仍可用) + print("⚠️ 订阅状态变化: \(productId) -> 宽限期") + // 可以在这里触发通知,提醒用户更新支付方式 + + case .inBillingRetryPeriod: + // 订阅在计费重试期(支付失败,正在重试,功能仍可用) + print("🔄 订阅状态变化: \(productId) -> 计费重试期") + // 可以在这里触发通知,提醒用户更新支付方式 + + case .revoked: + // 订阅已撤销(可能是退款或违规,功能立即失效) + print("❌ 订阅状态变化: \(productId) -> 已撤销") + self.currentState = .purchaseRevoked(productId) + + default: + print("❓ 订阅状态变化: \(productId) -> 未知状态: \(currentRenewalState)") + } + } + + // ========== 更新缓存 ========== + // 更新续订信息缓存(用于下次比较 willAutoRenew 的变化) + if let renewalInfo = renewalInfo { + self.lastRenewalInfo[productId] = renewalInfo + } + + // 更新订阅状态缓存(用于下次比较 RenewalState 的变化) + self.lastSubscriptionStatus[productId] = currentRenewalState + } + } + } + + /// 手动检查订阅状态(供外部调用,在关键时机使用) + /// + /// 使用场景: + /// - 应用启动时:确保订阅状态是最新的 + /// - 应用进入前台时:检查是否有状态变化 + /// - 用户打开订阅页面时:显示最新的订阅信息 + /// - 购买/恢复购买后:立即检查订阅状态 + /// - 用户从订阅管理页面返回时:检查是否有变化 + /// + /// 注意: + /// - 此方法会立即执行一次完整的订阅状态检查 + /// - 与自动监听不同,此方法不会定期重复执行 + /// - 建议在关键时机调用,避免频繁调用影响性能 + @MainActor + func checkSubscriptionStatusManually() async { + await checkSubscriptionStatus() + } + + +} + +//MARK: 订阅管理 +extension StoreKitService{ + /// 打开订阅管理页面(使用 URL) + @MainActor + func openSubscriptionManagement() { + guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return } + + #if os(iOS) + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + #elseif os(macOS) + NSWorkspace.shared.open(url) + #endif + } + + /// 显示应用内订阅管理界面(iOS 15.0+ / macOS 12.0+) + /// - Returns: 是否成功显示(如果系统不支持则返回 false) + @MainActor + func showManageSubscriptionsSheet() async -> Bool { + #if os(iOS) + if #available(iOS 15.0, *) { + do { + // 获取当前的 windowScene + let windowScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first + + if let windowScene = windowScene { + try await AppStore.showManageSubscriptions(in: windowScene) + + await loadPurchasedTransactions() + + return true + } else { + // 如果无法获取 windowScene,回退到打开 URL + openSubscriptionManagement() + return false + } + } catch { + print("显示订阅管理界面失败: \(error)") + // 如果失败,回退到打开 URL + openSubscriptionManagement() + return false + } + } else { + // iOS 15.0 以下使用 URL + openSubscriptionManagement() + return false + } + #elseif os(macOS) + if #available(macOS 12.0, *) { + do { + try await AppStore.showManageSubscriptions() + + // 订阅管理界面关闭后,刷新订阅状态 + await loadPurchasedTransactions() + + return true + } catch { + print("显示订阅管理界面失败: \(error)") + openSubscriptionManagement() + return false + } + } else { + openSubscriptionManagement() + return false + } + #else + openSubscriptionManagement() + return false + #endif + } + + /// 显示优惠代码兑换界面(iOS 16.0+) + /// - Throws: StoreKitError 如果显示失败 + /// - Note: 兑换后的交易会通过 Transaction.updates 发出 + @MainActor + @available(iOS 16.0, visionOS 1.0, *) + @available(macOS, unavailable) + @available(watchOS, unavailable) + @available(tvOS, unavailable) + func presentOfferCodeRedeemSheet() async throws { + #if os(iOS) + // 获取当前的 windowScene + let windowScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first + + guard let windowScene = windowScene else { + throw StoreKitError.unknownError + } + + do { + try await AppStore.presentOfferCodeRedeemSheet(in: windowScene) + // 兑换后的交易会通过 Transaction.updates 自动处理 + // 这里可以刷新购买列表以确保数据同步 + await loadPurchasedTransactions() + } catch { + throw StoreKitError.purchaseFailed(error) + } + #else + throw StoreKitError.unknownError + #endif + } + + /// 请求应用评价 + /// - Note: 兼容 iOS 15.0+ 和 iOS 16.0+ + /// - iOS 15.0: 使用 SKStoreReviewController.requestReview() (StoreKit 1) + /// - iOS 16.0+: 使用 AppStore.requestReview(in:) (StoreKit 2) + @MainActor + func requestReview() { + #if os(iOS) + if #available(iOS 16.0, *) { + // iOS 16.0+ 使用 StoreKit 2 的新 API + if let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first { + AppStore.requestReview(in: windowScene) + } + } else { + // iOS 15.0 (以及 iOS 10.3-15.x) 使用 StoreKit 1 的 API + // 在 iOS 15 中,StoreKit 2 存在,但 AppStore.requestReview 需要 iOS 16+ + // 所以回退到 StoreKit 1 的 SKStoreReviewController + SKStoreReviewController.requestReview() + } + #elseif os(macOS) + if #available(macOS 13.0, *) { + // macOS 13.0+ 使用 StoreKit 2 的新 API + if let windowScene = NSApplication.shared.windows.first?.windowScene { + AppStore.requestReview(in: windowScene) + } + } else if #available(macOS 10.14, *) { + // macOS 12.0+ (以及 macOS 10.14-12.x) 使用 StoreKit 1 的 API + SKStoreReviewController.requestReview() + } + #endif + } +} + +//MARK: 代理通知 +extension StoreKitService{ + /// 通知产品加载(在主线程执行) + @MainActor + private func notifyProductsLoaded(_ products: [Product]) { + delegate?.service(self, didLoadProducts: products) + } + + /// 通知已购买交易订单更新(在主线程执行) + @MainActor + private func notifyPurchasedTransactionsUpdated(_ efficient: [Transaction], _ latests: [Transaction]) { + delegate?.service(self, didUpdatePurchasedTransactions: efficient, latests: latests) + } + + /// 通知状态变化(在主线程执行) + @MainActor + private func notifyStateChanged(_ state: StoreKitState) { + delegate?.service(self, didUpdateState: state) + } + +} + +//MARK: 打印调试方法 +extension StoreKitService{ + private func printProductDetails(_ product:Product) async{ + // 时间格式化为东八区(北京时间) + let beijingTimeZone = TimeZone(secondsFromGMT: 8 * 3600) ?? .current + let formatter = DateFormatter() + formatter.timeZone = beijingTimeZone + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + print("════════════════════════════════════════") + print("✅ 购买成功 - 交易详细信息") + print("════════════════════════════════════════") + print("📦 产品信息:") + print(" - 产品ID: \(product.id)") + print(" - 产品类型: \(product.type)") + print(" - 产品名称: \(product.displayName)") + print(" - 产品描述: \(product.description)") + print(" - 产品价格: \(product.displayPrice)") + print(" - 价格数值: \(product.price)") + print(" - 家庭共享: \(product.isFamilyShareable)") + //print(" - 产品JSON: \(String.init(data: product.jsonRepresentation, encoding: .utf8))") + // 如果是订阅产品,打印订阅相关信息 + if let subscription = product.subscription { + print("📱 订阅信息:") + print(" - 订阅组ID: \(subscription.subscriptionGroupID)") + + // 打印订阅周期 + let period = subscription.subscriptionPeriod + let periodName: String + switch period.unit { + case .day: + periodName = "\(period.value) 天" + case .week: + periodName = "\(period.value) 周" + case .month: + periodName = "\(period.value) 月" + case .year: + periodName = "\(period.value) 年" + @unknown default: + periodName = "未知" + } + print(" - 订阅周期: \(periodName)") + + // 检查是否有资格使用介绍性优惠(异步) + let isEligibleForIntroOffer = await subscription.isEligibleForIntroOffer + print(" - 是否有资格使用介绍性优惠: \(isEligibleForIntroOffer ? "是" : "否")") + + // 介绍性优惠详细信息 + if let introductoryOffer = subscription.introductoryOffer { + print(" - 介绍性优惠: 有") + printOfferDetails(introductoryOffer, indent: " ") + } else { + print(" - 介绍性优惠: 无") + } + + // 促销优惠列表 + if !subscription.promotionalOffers.isEmpty { + print(" - 促销优惠: 有 (\(subscription.promotionalOffers.count) 个)") + for (index, promotionalOffer) in subscription.promotionalOffers.enumerated() { + print(" [促销优惠 \(index + 1)]") + printOfferDetails(promotionalOffer, indent: " ") + } + } else { + print(" - 促销优惠: 无") + } + + // 赢回优惠列表(iOS 18.0+) + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + if !subscription.winBackOffers.isEmpty { + print(" - 赢回优惠: 有 (\(subscription.winBackOffers.count) 个)") + for (index, winBackOffer) in subscription.winBackOffers.enumerated() { + print(" [赢回优惠 \(index + 1)]") + printOfferDetails(winBackOffer, indent: " ") + } + } else { + print(" - 赢回优惠: 无") + } + } + } + + let productJSON = ProductConverter.toDictionary(product) + print(" - JSON表示: \(productJSON)") + } + + /// 打印优惠详细信息 + /// - Parameters: + /// - offer: 优惠对象 + /// - indent: 缩进字符串 + private func printOfferDetails(_ offer: Product.SubscriptionOffer, indent: String) { + // 优惠ID(介绍性优惠为 nil,其他类型不为 nil) + if let offerID = offer.id { + print("\(indent)* 优惠ID: \(offerID)") + } else { + print("\(indent)* 优惠ID: 无(介绍性优惠)") + } + + // 优惠类型(不是可选的) + let typeName: String + if offer.type == .introductory { + typeName = "介绍性优惠" + } else if offer.type == .promotional { + typeName = "促销优惠" + } else { + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + if offer.type == .winBack { + typeName = "赢回优惠" + } else { + typeName = "未知类型(\(offer.type.rawValue))" + } + } else { + typeName = "未知类型(\(offer.type.rawValue))" + } + } + print("\(indent)* 优惠类型: \(typeName)") + + // 价格信息 + print("\(indent)* 显示价格: \(offer.displayPrice)") + print("\(indent)* 价格数值: \(offer.price)") + + // 支付模式(显示中文名称) + let paymentModeName: String + switch offer.paymentMode { + case .freeTrial: + paymentModeName = "免费试用" + case .payAsYouGo: + paymentModeName = "按需付费" + case .payUpFront: + paymentModeName = "预付" + default: + paymentModeName = "未知模式(\(offer.paymentMode.rawValue))" + } + print("\(indent)* 支付模式: \(paymentModeName)") + + // 优惠周期(不是可选的) + let offerPeriod = offer.period + let offerPeriodName: String + switch offerPeriod.unit { + case .day: + offerPeriodName = "\(offerPeriod.value) 天" + case .week: + offerPeriodName = "\(offerPeriod.value) 周" + case .month: + offerPeriodName = "\(offerPeriod.value) 月" + case .year: + offerPeriodName = "\(offerPeriod.value) 年" + @unknown default: + offerPeriodName = "未知" + } + print("\(indent)* 优惠周期: \(offerPeriodName)") + + // 周期数量(总是 1,除了 .payAsYouGo) + print("\(indent)* 周期数量: \(offer.periodCount)") + } + + /// 打印详细的产品和交易信息 + private func printTransactionDetails(_ transaction: Transaction) async { + // 时间格式化为东八区(北京时间) + let beijingTimeZone = TimeZone(secondsFromGMT: 8 * 3600) ?? .current + let formatter = DateFormatter() + formatter.timeZone = beijingTimeZone + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + print("") + print("💳 交易信息:") + print(" - 交易ID: \(transaction.id)") // 当前交易的唯一标识符 + print(" - 产品ID: \(transaction.productID)") // 购买的产品ID + print(" - 产品类型: \(transaction.productType)") // 产品类型(消耗品/非消耗品/非续订订阅/自动续订订阅) + print(" - 购买日期: \(formatter.string(from: transaction.purchaseDate))") // 购买时间(UTC时间) + print(" - 所有权类型: \(transaction.ownershipType)") // 所有权类型(purchased/familyShared) + print(" - 原始交易ID: \(transaction.originalID)") // 首次购买的交易ID(用于订阅续订) + print(" - 原始购买日期: \(formatter.string(from: transaction.originalPurchaseDate))") // 首次购买时间 + + // 过期日期(仅订阅产品有) + if let expirationDate = transaction.expirationDate { + let dateStr = formatter.string(from: expirationDate) + print(" - 过期日期: \(dateStr)") // 订阅过期时间 + } else { + print(" - 过期日期: 无") + } + + // 撤销日期(如果已退款/撤销) + if let revocationDate = transaction.revocationDate { + let dateStr = formatter.string(from: revocationDate) + print(" - 撤销日期: \(dateStr)") // 退款或撤销的时间 + } else { + print(" - 撤销日期: 无") + } + + // 撤销原因 + if let revocationReason = transaction.revocationReason { + print(" - 撤销原因: \(revocationReason)") // 退款/撤销的原因代码 + } + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *){ + // 购买原因(purchased/upgraded/renewed等) + print(" - 购买理由: \(transaction.reason)") + }else{ + print(" - 购买理由: 无") + } + print(" - 是否升级: \(transaction.isUpgraded)") // 是否为升级购买 + + // 购买数量 + print(" - 购买数量: \(transaction.purchasedQuantity)") // 购买的数量 + + // 价格 + if let price = transaction.price { + print(" - 交易价格: \(price)") // 实际支付的价格 + } + + // 货币代码 + if #available(iOS 16.0, *) { + if let currency = transaction.currency { + print(" - 货币代码: \(currency)") // 货币代码(如CNY、USD) + } + } else { + // Fallback on earlier versions + } + if #available(iOS 16.0, *) { + print(" - 环境: \(transaction.environment.rawValue)") + } else { + // Fallback on earlier versions + } // 交易环境(sandbox/production) + print(" - 应用交易ID: \(transaction.appTransactionID)") // 应用级别的交易ID + print(" - 应用Bundle ID: \(transaction.appBundleID )") // 应用的Bundle标识符 + // 应用账户Token(用于关联用户账户) + if let appAccountToken = transaction.appAccountToken { + print(" - 应用账户Token: \(appAccountToken)") // 用于关联用户账户的Token + } + // 订阅组ID(仅订阅产品) + if let subscriptionGroupID = transaction.subscriptionGroupID { + print(" - 订阅组ID: \(subscriptionGroupID)") // 订阅所属的组ID + } + + // 订阅状态(仅订阅产品) + //if let subscriptionStatus = await transaction.subscriptionStatus { + // print(" - 订阅状态: \(subscriptionStatus)") // 订阅的当前状态 + //} + + print(" - 签名日期: \(formatter.string(from: transaction.signedDate))") // 交易签名的日期 + if #available(iOS 17.0, *) { + print(" - 商店区域: \(transaction.storefront)") + } else { + // Fallback on earlier versions + } // 商店区域代码 + + // Web订单行项目ID + if let webOrderLineItemID = transaction.webOrderLineItemID { + print(" - Web订单行项目ID: \(webOrderLineItemID)") // Web订单的行项目ID + } + print(" - 设备验证: \(transaction.deviceVerification)") // 设备验证数据 + print(" - 设备验证Nonce: \(transaction.deviceVerificationNonce)") // 设备验证的Nonce值 + + // 优惠信息 + if #available(iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2, *) { + // iOS 17.2+ 使用新的 offer 属性 + if let offer = transaction.offer { + print(" - 优惠信息:") + print(" * 优惠类型: \(offer.type)") + if let offerID = offer.id { + print(" * 优惠ID: \(offerID)") + } + print(" * 支付模式: \(String(describing: offer.paymentMode?.rawValue))") + if #available(iOS 18.4, *) { + if let period = offer.period { + print(" * 优惠周期: \(period)") + } + } else { + // Fallback on earlier versions + } + } + } else if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + // iOS 15.0 - iOS 17.1 使用已废弃的属性 + if let offerType = transaction.offerType { + print(" - 优惠信息:") + print(" * 优惠类型: \(offerType)") + + if let offerID = transaction.offerID { + print(" * 优惠ID: \(offerID)") + } + + if let paymentMode = transaction.offerPaymentModeStringRepresentation { + print(" * 支付模式: \(paymentMode)") + } + + if #available(iOS 18.4, macOS 15.4, tvOS 18.4, watchOS 11.4, visionOS 2.4, *) { + // iOS 18.4+ 已废弃 offerPeriodStringRepresentation,但为了兼容性仍可检查 + // 实际上在 iOS 18.4+ 应该使用上面的 offer.period + } else { + // iOS 15.0 - iOS 18.3 使用 offerPeriodStringRepresentation + if let period = transaction.offerPeriodStringRepresentation { + print(" * 优惠周期: \(period)") + } + } + } + } else { + // iOS 15.0 以下版本不支持优惠信息 + // 不输出任何内容 + } + + // 高级商务信息 + if #available(iOS 18.4, *) { + if let advancedCommerceInfo = transaction.advancedCommerceInfo { + print(" - 高级商务信息: \(advancedCommerceInfo)") // 高级商务相关信息 + } + } else { + // Fallback on earlier versions + } + + // JSON表示(用于服务器验证) + //if let jsonRepresentation = transaction.jsonRepresentation { + // print(" - JSON表示 (前200字符): \(String(jsonRepresentation.prefix(200)))...") // JSON格式的交易数据,可用于服务器验证 + //} + + // Debug描述 + print(" - Debug描述: \(transaction.debugDescription)") // 调试用的描述信息 + print("") + + + print("════════════════════════════════════════") + print("") + + let transactionJSON = TransactionConverter.toDictionary(transaction) + print(" - JSON表示: \(transactionJSON)") + } +} diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitServiceDelegate.swift b/keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitServiceDelegate.swift new file mode 100644 index 0000000..b859ff6 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Internal/StoreKitServiceDelegate.swift @@ -0,0 +1,24 @@ +// +// StoreKitServiceDelegate.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// StoreKitService 内部代理协议 +/// 所有回调都在主线程执行 +@MainActor +internal protocol StoreKitServiceDelegate: AnyObject { + /// 状态更新 + func service(_ service: StoreKitService, didUpdateState state: StoreKitState) + + /// 产品加载成功 + func service(_ service: StoreKitService, didLoadProducts products: [Product]) + + /// 已购买交易订单更新 + func service(_ service: StoreKitService, didUpdatePurchasedTransactions efficient: [Transaction], latests: [Transaction]) +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Locals/SubscriptionLocale.swift b/keyBoard/Class/Pay/StoreKit2Manager/Locals/SubscriptionLocale.swift new file mode 100644 index 0000000..0de596a --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Locals/SubscriptionLocale.swift @@ -0,0 +1,1332 @@ +// +// SubscriptionLocale.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// 订阅产品国际化工具类 +public struct SubscriptionLocale { + + /// 将 StoreKit 的周期单位转换为标准单位 + /// - Parameter period: 订阅周期 + /// - Returns: 标准单位字符串:day, week, month, year + public static func getUnit(from period: Product.SubscriptionPeriod?) -> String { + guard let period = period else { return "day" } + + let unit = period.unit + let numberOfUnits = period.value + + switch unit { + case .day: + if numberOfUnits >= 365 { + return "year" + } else if numberOfUnits >= 30 { + return "month" + } else if numberOfUnits >= 7 { + return "week" + } + return "day" + + case .week: + if numberOfUnits >= 52 { + return "year" + } else if numberOfUnits >= 4 { + return "month" + } + return "week" + + case .month: + if numberOfUnits >= 12 { + return "year" + } + return "month" + + case .year: + return "year" + + @unknown default: + if numberOfUnits >= 365 { + return "year" + } else if numberOfUnits >= 30 { + return "month" + } else if numberOfUnits >= 7 { + return "week" + } + return "day" + } + } + + /// 获取本地化的单位文本 + /// - Parameters: + /// - languageCode: 语言代码 + /// - numberOfPeriods: 周期数量 + /// - unit: 单位(day, week, month, year) + /// - Returns: 本地化的单位文本 + private static func getLocalizedUnit(languageCode: String, numberOfPeriods: Int, unit: String) -> String { + switch languageCode { + case "ar": + switch unit { + case "day": return numberOfPeriods == 1 ? "يوم" : "أيام" + case "week": return numberOfPeriods == 1 ? "أسبوع" : "أسابيع" + case "month": return numberOfPeriods == 1 ? "شهر" : "أشهر" + case "year": return numberOfPeriods == 1 ? "سنة" : "سنوات" + default: return numberOfPeriods == 1 ? "يوم" : "أيام" + } + case "de": + switch unit { + case "day": return "Täg" + case "week": return "Wochen" + case "month": return "Monat" + case "year": return "Jahr" + default: return "Täg" + } + case "en": + switch unit { + case "day": return numberOfPeriods == 1 ? "day" : "days" + case "week": return numberOfPeriods == 1 ? "week" : "weeks" + case "month": return numberOfPeriods == 1 ? "month" : "months" + case "year": return numberOfPeriods == 1 ? "year" : "years" + default: return numberOfPeriods == 1 ? "day" : "days" + } + case "es": + switch unit { + case "day": return numberOfPeriods == 1 ? "día" : "días" + case "week": return numberOfPeriods == 1 ? "semana" : "semanas" + case "month": return numberOfPeriods == 1 ? "mes" : "meses" + case "year": return numberOfPeriods == 1 ? "año" : "años" + default: return numberOfPeriods == 1 ? "día" : "días" + } + case "fil": + switch unit { + case "day": return "araw" + case "week": return "linggo" + case "month": return "buwan" + case "year": return "taon" + default: return "araw" + } + case "fr": + switch unit { + case "day": return numberOfPeriods == 1 ? "jour" : "jours" + case "week": return numberOfPeriods == 1 ? "semaine" : "semaines" + case "month": return numberOfPeriods == 1 ? "mois" : "mois" + case "year": return numberOfPeriods == 1 ? "an" : "ans" + default: return numberOfPeriods == 1 ? "jour" : "jours" + } + case "id": + switch unit { + case "day": return "hari" + case "week": return "minggu" + case "month": return "bulan" + case "year": return "tahun" + default: return "hari" + } + case "it": + switch unit { + case "day": return numberOfPeriods == 1 ? "giorno" : "giorni" + case "week": return numberOfPeriods == 1 ? "settimana" : "settimane" + case "month": return numberOfPeriods == 1 ? "mese" : "mesi" + case "year": return numberOfPeriods == 1 ? "anno" : "anni" + default: return numberOfPeriods == 1 ? "giorno" : "giorni" + } + case "ja": + switch unit { + case "day": return "日間" + case "week": return "週間" + case "month": return "ヶ月" + case "year": return "年間" + default: return "日間" + } + case "ko": + switch unit { + case "day": return "일" + case "week": return "주" + case "month": return "개월" + case "year": return "년" + default: return "일" + } + case "pl": + switch unit { + case "day": return numberOfPeriods == 1 ? "dzień" : "dni" + case "week": return numberOfPeriods == 1 ? "tydzień" : "tygodni" + case "month": return numberOfPeriods == 1 ? "miesiąc" : "miesięcy" + case "year": return numberOfPeriods == 1 ? "rok" : "lat" + default: return numberOfPeriods == 1 ? "dzień" : "dni" + } + case "pt": + switch unit { + case "day": return numberOfPeriods == 1 ? "dia" : "dias" + case "week": return numberOfPeriods == 1 ? "semana" : "semanas" + case "month": return numberOfPeriods == 1 ? "mês" : "meses" + case "year": return numberOfPeriods == 1 ? "ano" : "anos" + default: return numberOfPeriods == 1 ? "dia" : "dias" + } + case "ru": + switch unit { + case "day": return numberOfPeriods == 1 ? "день" : "дней" + case "week": return numberOfPeriods == 1 ? "неделя" : "недель" + case "month": return numberOfPeriods == 1 ? "месяц" : "месяцев" + case "year": return numberOfPeriods == 1 ? "год" : "лет" + default: return numberOfPeriods == 1 ? "день" : "дней" + } + case "th": + switch unit { + case "day": return "วัน" + case "week": return "สัปดาห์" + case "month": return "เดือน" + case "year": return "ปี" + default: return "วัน" + } + case "tr": + switch unit { + case "day": return "gün" + case "week": return "hafta" + case "month": return "ay" + case "year": return "yıl" + default: return "gün" + } + case "uk": + switch unit { + case "day": return numberOfPeriods == 1 ? "день" : "днів" + case "week": return numberOfPeriods == 1 ? "тиждень" : "тижнів" + case "month": return numberOfPeriods == 1 ? "місяць" : "місяців" + case "year": return numberOfPeriods == 1 ? "рік" : "років" + default: return numberOfPeriods == 1 ? "день" : "днів" + } + case "vi": + switch unit { + case "day": return "ngày" + case "week": return "tuần" + case "month": return "tháng" + case "year": return "năm" + default: return "ngày" + } + case "zh_Hans": + switch unit { + case "day": return "天" + case "week": return "周" + case "month": return "月" + case "year": return "年" + default: return "天" + } + case "zh_Hant": + switch unit { + case "day": return "天" + case "week": return "周" + case "month": return "月" + case "year": return "年" + default: return "天" + } + default: + switch unit { + case "day": return numberOfPeriods == 1 ? "day" : "days" + case "week": return numberOfPeriods == 1 ? "week" : "weeks" + case "month": return numberOfPeriods == 1 ? "month" : "months" + case "year": return numberOfPeriods == 1 ? "year" : "years" + default: return numberOfPeriods == 1 ? "day" : "days" + } + } + } + + /// 从 Product 的 displayPrice 中提取货币符号 + /// 通过从 displayPrice 中移除价格数字部分来获取货币符号 + /// - Parameter product: Product 对象 + /// - Returns: 货币符号字符串 + public static func getCurrencySymbol(from product: Product) -> String { + let displayPrice = product.displayPrice + let priceDecimal = product.price + let priceDouble = NSDecimalNumber(decimal: priceDecimal).doubleValue + + // 生成多种可能的价格格式字符串 + var pricePatterns: [String] = [] + + // 1. 标准格式 "9.99" + pricePatterns.append(String(format: "%.2f", priceDouble)) + + // 2. 整数格式(如果价格是整数) + if priceDouble.truncatingRemainder(dividingBy: 1) == 0 { + pricePatterns.append(String(format: "%.0f", priceDouble)) + } + + // 3. 一位小数格式 "9.9"(如果最后一位是0) + let priceString = String(format: "%.2f", priceDouble) + if priceString.hasSuffix("0") { + pricePatterns.append(String(format: "%.1f", priceDouble)) + } + + // 4. 本地化格式(可能包含千位分隔符,如 "1,234.56" 或 "1.234,56") + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + if let localizedPrice = formatter.string(from: NSNumber(value: priceDouble)) { + pricePatterns.append(localizedPrice) + } + + // 5. 本地化整数格式 + if priceDouble.truncatingRemainder(dividingBy: 1) == 0 { + formatter.maximumFractionDigits = 0 + if let localizedPrice = formatter.string(from: NSNumber(value: priceDouble)) { + pricePatterns.append(localizedPrice) + } + } + + // 从 displayPrice 中移除所有可能的价格格式 + var cleanedPrice = displayPrice + for pattern in pricePatterns { + // 移除价格(处理前后可能有空格的情况) + cleanedPrice = cleanedPrice.replacingOccurrences(of: " \(pattern) ", with: " ") + cleanedPrice = cleanedPrice.replacingOccurrences(of: "\(pattern) ", with: " ") + cleanedPrice = cleanedPrice.replacingOccurrences(of: " \(pattern)", with: " ") + cleanedPrice = cleanedPrice.replacingOccurrences(of: pattern, with: "") + } + + // 进一步清理:移除所有数字、小数点、逗号 + cleanedPrice = cleanedPrice.replacingOccurrences(of: #"\d"#, with: "", options: .regularExpression) + cleanedPrice = cleanedPrice.replacingOccurrences(of: #"[.,]"#, with: "", options: .regularExpression) + + // 清理多余的空格 + cleanedPrice = cleanedPrice.trimmingCharacters(in: .whitespaces) + cleanedPrice = cleanedPrice.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + cleanedPrice = cleanedPrice.trimmingCharacters(in: .whitespaces) + + // 如果提取成功且不为空,返回货币符号 + if !cleanedPrice.isEmpty { + return cleanedPrice + } + + // 后备方案:使用 Locale.current.currencySymbol + return Locale.current.currencySymbol ?? "$" + } + // MARK: - 按钮文案 + + /// 获取订阅按钮文案 + /// - Parameters: + /// - type: 按钮类型 + /// - languageCode: 语言代码 + /// - Returns: 本地化的按钮文案 + public static func subscriptionButtonText(type: SubscriptionButtonType, languageCode: String) -> String { + switch languageCode { + case "ar": + switch type { + case .standard: return "اشترك" + case .freeTrial: return "جرّب مجانًا" + case .payUpFront: return "ادفع الآن" + case .payAsYouGo: return "ادفع حسب الاستخدام" + case .lifetime: return "اشتر مدى الحياة" + } + case "de": + switch type { + case .standard: return "Abonnieren" + case .freeTrial: return "Kostenlos testen" + case .payUpFront: return "Jetzt bezahlen" + case .payAsYouGo: return "Bezahlen nach Nutzung" + case .lifetime: return "Lebenslang kaufen" + } + case "en": + switch type { + case .standard: return "Subscribe" + case .freeTrial: return "Start Free Trial" + case .payUpFront: return "Prepay Now" + case .payAsYouGo: return "Pay As You Go" + case .lifetime: return "Buy Lifetime" + } + case "es": + switch type { + case .standard: return "Suscribirse" + case .freeTrial: return "Prueba gratis" + case .payUpFront: return "Pagar ahora" + case .payAsYouGo: return "Pagar por uso" + case .lifetime: return "Comprar de por vida" + } + case "fil": + switch type { + case .standard: return "Mag-subscribe" + case .freeTrial: return "Subukan nang libre" + case .payUpFront: return "Magbayad ngayon" + case .payAsYouGo: return "Magbayad ayon sa paggamit" + case .lifetime: return "Bilhin ang panghabang-buhay" + } + case "fr": + switch type { + case .standard: return "S'abonner" + case .freeTrial: return "Essai gratuit" + case .payUpFront: return "Payer maintenant" + case .payAsYouGo: return "Payer à l'usage" + case .lifetime: return "Acheter à vie" + } + case "id": + switch type { + case .standard: return "Berlangganan" + case .freeTrial: return "Coba gratis" + case .payUpFront: return "Bayar sekarang" + case .payAsYouGo: return "Bayar sesuai pemakaian" + case .lifetime: return "Beli seumur hidup" + } + case "it": + switch type { + case .standard: return "Abbonati" + case .freeTrial: return "Prova gratuita" + case .payUpFront: return "Paga ora" + case .payAsYouGo: return "Paga a consumo" + case .lifetime: return "Acquista a vita" + } + case "ja": + switch type { + case .standard: return "購読する" + case .freeTrial: return "無料トライアル開始" + case .payUpFront: return "今すぐ支払う" + case .payAsYouGo: return "使った分だけ支払う" + case .lifetime: return "生涯購入" + } + case "ko": + switch type { + case .standard: return "구독하기" + case .freeTrial: return "무료 체험 시작" + case .payUpFront: return "지금 결제" + case .payAsYouGo: return "사용한 만큼 결제" + case .lifetime: return "평생 구매" + } + case "pl": + switch type { + case .standard: return "Subskrybuj" + case .freeTrial: return "Wypróbuj za darmo" + case .payUpFront: return "Zapłać teraz" + case .payAsYouGo: return "Płać zgodnie z użyciem" + case .lifetime: return "Kup na całe życie" + } + case "pt": + switch type { + case .standard: return "Assinar" + case .freeTrial: return "Teste grátis" + case .payUpFront: return "Pagar agora" + case .payAsYouGo: return "Pagar conforme o uso" + case .lifetime: return "Comprar vitalício" + } + case "ru": + switch type { + case .standard: return "Подписаться" + case .freeTrial: return "Попробовать бесплатно" + case .payUpFront: return "Оплатить сейчас" + case .payAsYouGo: return "Оплата по мере использования" + case .lifetime: return "Купить навсегда" + } + case "th": + switch type { + case .standard: return "สมัครสมาชิก" + case .freeTrial: return "ทดลองใช้ฟรี" + case .payUpFront: return "ชำระตอนนี้" + case .payAsYouGo: return "จ่ายตามการใช้งาน" + case .lifetime: return "ซื้อตลอดชีพ" + } + case "tr": + switch type { + case .standard: return "Abone ol" + case .freeTrial: return "Ücretsiz dene" + case .payUpFront: return "Şimdi öde" + case .payAsYouGo: return "Kullandıkça öde" + case .lifetime: return "Yaşam boyu satın al" + } + case "uk": + switch type { + case .standard: return "Підписатися" + case .freeTrial: return "Спробувати безкоштовно" + case .payUpFront: return "Оплатити зараз" + case .payAsYouGo: return "Оплата за мірою використання" + case .lifetime: return "Купити назавжди" + } + case "vi": + switch type { + case .standard: return "Đăng ký" + case .freeTrial: return "Dùng thử miễn phí" + case .payUpFront: return "Thanh toán ngay" + case .payAsYouGo: return "Trả theo nhu cầu" + case .lifetime: return "Mua trọn đời" + } + case "zh_Hans": + switch type { + case .standard: return "订阅" + case .freeTrial: return "开始免费试用" + case .payUpFront: return "立即支付" + case .payAsYouGo: return "按需付费" + case .lifetime: return "购买终身" + } + case "zh_Hant": + switch type { + case .standard: return "訂閱" + case .freeTrial: return "開始免費試用" + case .payUpFront: return "立即支付" + case .payAsYouGo: return "按需付費" + case .lifetime: return "購買終身" + } + default: + // 默认返回英语 + switch type { + case .standard: return "Subscribe" + case .freeTrial: return "Start Free Trial" + case .payUpFront: return "Prepay Now" + case .payAsYouGo: return "Pay As You Go" + case .lifetime: return "Buy Lifetime" + } + } + } + + // MARK: - 订阅标题 + + /// 获取订阅标题 + /// - Parameters: + /// - periodType: 持续时间类型(week, month, year, lifetime) + /// - languageCode: 语言代码 + /// - isShort: 是否使用简短版本 + /// - Returns: 本地化的标题 + public static func subscriptionTitle(periodType: SubscriptionPeriodType, languageCode: String, isShort: Bool = false) -> String { + switch languageCode { + case "ar": + switch periodType { + case .week: return isShort ? "أسبوعي" : "اشتراك أسبوعي" + case .month: return isShort ? "شهري" : "اشتراك شهري" + case .year: return isShort ? "سنوي" : "اشتراك سنوي" + case .lifetime: return isShort ? "مدى الحياة" : "اشتراك مدى الحياة" + } + case "de": + switch periodType { + case .week: return isShort ? "Woche" : "Wöchentliches Abo" + case .month: return isShort ? "Monat" : "Monatliches Abo" + case .year: return isShort ? "Jahr" : "Jährliches Abo" + case .lifetime: return isShort ? "Lebenslang" : "Lebenslanges Abo" + } + case "en": + switch periodType { + case .week: return isShort ? "Weekly" : "Weekly Subscription" + case .month: return isShort ? "Monthly" : "Monthly Subscription" + case .year: return isShort ? "Yearly" : "Annual Subscription" + case .lifetime: return isShort ? "Lifetime" : "Lifetime Membership" + } + case "es": + switch periodType { + case .week: return isShort ? "Semanal" : "Suscripción semanal" + case .month: return isShort ? "Mensual" : "Suscripción mensual" + case .year: return isShort ? "Anual" : "Suscripción anual" + case .lifetime: return isShort ? "De por vida" : "Suscripción de por vida" + } + case "fil": + switch periodType { + case .week: return isShort ? "Lingguhan" : "Lingguhang Subscription" + case .month: return isShort ? "Buwanang" : "Buwanang Subscription" + case .year: return isShort ? "Taunan" : "Taunang Subscription" + case .lifetime: return isShort ? "Panghabang-buhay" : "Panghabang-buhay na Subscription" + } + case "fr": + switch periodType { + case .week: return isShort ? "Hebdo" : "Abonnement hebdomadaire" + case .month: return isShort ? "Mensuel" : "Abonnement mensuel" + case .year: return isShort ? "Annuel" : "Abonnement annuel" + case .lifetime: return isShort ? "À vie" : "Abonnement à vie" + } + case "id": + switch periodType { + case .week: return isShort ? "Mingguan" : "Langganan mingguan" + case .month: return isShort ? "Bulanan" : "Langganan bulanan" + case .year: return isShort ? "Tahunan" : "Langganan tahunan" + case .lifetime: return isShort ? "Seumur hidup" : "Langganan seumur hidup" + } + case "it": + switch periodType { + case .week: return isShort ? "Sett." : "Abbonamento settimanale" + case .month: return isShort ? "Mese" : "Abbonamento mensile" + case .year: return isShort ? "Anno" : "Abbonamento annuale" + case .lifetime: return isShort ? "A vita" : "Abbonamento a vita" + } + case "ja": + switch periodType { + case .week: return isShort ? "週額" : "週額プラン" + case .month: return isShort ? "月額" : "月額プラン" + case .year: return isShort ? "年額" : "年額プラン" + case .lifetime: return isShort ? "生涯" : "生涯プラン" + } + case "ko": + switch periodType { + case .week: return isShort ? "주간" : "주간 구독" + case .month: return isShort ? "월간" : "월간 구독" + case .year: return isShort ? "연간" : "연간 구독" + case .lifetime: return isShort ? "평생" : "평생 회원권" + } + case "pl": + switch periodType { + case .week: return isShort ? "Tyg." : "Subskrypcja tygodniowa" + case .month: return isShort ? "Mies." : "Subskrypcja miesięczna" + case .year: return isShort ? "Rocznie" : "Subskrypcja roczna" + case .lifetime: return isShort ? "Dożywotnia" : "Subskrypcja dożywotnia" + } + case "pt": + switch periodType { + case .week: return isShort ? "Semanal" : "Assinatura semanal" + case .month: return isShort ? "Mensal" : "Assinatura mensal" + case .year: return isShort ? "Anual" : "Assinatura anual" + case .lifetime: return isShort ? "Vitalício" : "Assinatura vitalícia" + } + case "ru": + switch periodType { + case .week: return isShort ? "Неделя" : "Еженедельная подписка" + case .month: return isShort ? "Месяц" : "Ежемесячная подписка" + case .year: return isShort ? "Год" : "Годовая подписка" + case .lifetime: return isShort ? "Навсегда" : "Пожизненная подписка" + } + case "th": + switch periodType { + case .week: return isShort ? "รายสัปดาห์" : "สมัครสมาชิกแบบรายสัปดาห์" + case .month: return isShort ? "รายเดือน" : "สมัครสมาชิกแบบรายเดือน" + case .year: return isShort ? "รายปี" : "สมัครสมาชิกแบบรายปี" + case .lifetime: return isShort ? "ตลอดชีพ" : "สมาชิกตลอดชีพ" + } + case "tr": + switch periodType { + case .week: return isShort ? "Haftalık" : "Haftalık abonelik" + case .month: return isShort ? "Aylık" : "Aylık abonelik" + case .year: return isShort ? "Yıllık" : "Yıllık abonelik" + case .lifetime: return isShort ? "Ömür boyu" : "Ömür boyu abonelik" + } + case "uk": + switch periodType { + case .week: return isShort ? "Тиж." : "Тижнева підписка" + case .month: return isShort ? "Міс." : "Місячна підписка" + case .year: return isShort ? "Рік" : "Річна підписка" + case .lifetime: return isShort ? "Довічна" : "Довічна підписка" + } + case "vi": + switch periodType { + case .week: return isShort ? "Tuần" : "Gói thuê bao hàng tuần" + case .month: return isShort ? "Tháng" : "Gói thuê bao hàng tháng" + case .year: return isShort ? "Năm" : "Gói thuê bao hàng năm" + case .lifetime: return isShort ? "Trọn đời" : "Gói trọn đời" + } + case "zh_Hans": + switch periodType { + case .week: return isShort ? "周会员" : "每周会员" + case .month: return isShort ? "月会员" : "每月会员" + case .year: return isShort ? "年会员" : "年度会员" + case .lifetime: return isShort ? "终身会员" : "终身会员" + } + case "zh_Hant": + switch periodType { + case .week: return isShort ? "週會員" : "每週會員" + case .month: return isShort ? "月會員" : "每月會員" + case .year: return isShort ? "年會員" : "年度會員" + case .lifetime: return isShort ? "終身會員" : "終身會員" + } + default: + // 默认返回英语 + switch periodType { + case .week: return isShort ? "Weekly" : "Weekly Subscription" + case .month: return isShort ? "Monthly" : "Monthly Subscription" + case .year: return isShort ? "Yearly" : "Annual Subscription" + case .lifetime: return isShort ? "Lifetime" : "Lifetime Membership" + } + } + } + + // MARK: - 订阅副标题 + + /// 获取订阅类型描述词 + /// - Parameters: + /// - periodType: 持续时间类型 + /// - languageCode: 语言代码 + /// - Returns: 描述词(如:灵活选择、性价比之选、最优惠) + private static func defaultSubDescWord(periodType: SubscriptionPeriodType, languageCode: String) -> String { + switch languageCode { + case "ar": + switch periodType { + case .week: return "مرونة" + case .month: return "قيمة ممتازة" + case .year: return "الأكثر توفيراً" + case .lifetime: return "اشتراك دائم بدون تجديد" + } + case "de": + switch periodType { + case .week: return "Flexibilität" + case .month: return "Bester Wert" + case .year: return "Meist gespart" + case .lifetime: return "Einmalig zahlen, dauerhaft nutzen" + } + case "en": + switch periodType { + case .week: return "Flexible" + case .month: return "Best Value" + case .year: return "Most Popular" + case .lifetime: return "Pay once, own forever" + } + case "es": + switch periodType { + case .week: return "Flexible" + case .month: return "Mejor Valor" + case .year: return "Más Popular" + case .lifetime: return "Paga una vez, disfruta siempre" + } + case "fil": + switch periodType { + case .week: return "Nakakalag" + case .month: return "Pinakamahusay na Halaga" + case .year: return "Pinakasikat" + case .lifetime: return "Isang beses lang, habambuhay na" + } + case "fr": + switch periodType { + case .week: return "Flexible" + case .month: return "Meilleur Rapport" + case .year: return "Plus Populaire" + case .lifetime: return "Achetez une fois, profitez à vie" + } + case "id": + switch periodType { + case .week: return "Fleksibel" + case .month: return "Nilai Terbaik" + case .year: return "Paling Populer" + case .lifetime: return "Bayar sekali, pakai selamanya" + } + case "it": + switch periodType { + case .week: return "Flessibile" + case .month: return "Miglior Valore" + case .year: return "Più Popolare" + case .lifetime: return "Paga una volta, usa per sempre" + } + case "ja": + switch periodType { + case .week: return "柔軟性" + case .month: return "お得" + case .year: return "人気" + case .lifetime: return "一度の支払いで永久利用" + } + case "ko": + switch periodType { + case .week: return "유연함" + case .month: return "최고 가치" + case .year: return "인기" + case .lifetime: return "한 번 결제로 평생 이용" + } + case "pl": + switch periodType { + case .week: return "Elastyczność" + case .month: return "Najlepsza Wartość" + case .year: return "Najpopularniejsze" + case .lifetime: return "Zapłać raz, korzystaj zawsze" + } + case "pt": + switch periodType { + case .week: return "Flexível" + case .month: return "Melhor Valor" + case .year: return "Mais Popular" + case .lifetime: return "Pague uma vez, use para sempre" + } + case "ru": + switch periodType { + case .week: return "Гибкость" + case .month: return "Лучшая Цена" + case .year: return "Популярный" + case .lifetime: return "Оплати один раз, используй всегда" + } + case "th": + switch periodType { + case .week: return "ยืดหยุ่น" + case .month: return "คุ้มค่าที่สุด" + case .year: return "ยอดนิยม" + case .lifetime: return "จ่ายครั้งเดียว ใช้ได้ตลอดชีพ" + } + case "tr": + switch periodType { + case .week: return "Esnek" + case .month: return "En İyi Değer" + case .year: return "En Popüler" + case .lifetime: return "Bir kez öde, sürekli kullan" + } + case "uk": + switch periodType { + case .week: return "Гнучкість" + case .month: return "Найкраща Ціна" + case .year: return "Популярний" + case .lifetime: return "Сплати один раз, використовуй завжди" + } + case "vi": + switch periodType { + case .week: return "Linh hoạt" + case .month: return "Giá trị tốt nhất" + case .year: return "Phổ biến nhất" + case .lifetime: return "Thanh toán một lần, sử dụng mãi mãi" + } + case "zh_Hans": + switch periodType { + case .week: return "灵活选择" + case .month: return "性价比之选" + case .year: return "最优惠" + case .lifetime: return "一次购买,终身访问" + } + case "zh_Hant": + switch periodType { + case .week: return "靈活選擇" + case .month: return "性價比之選" + case .year: return "最優惠" + case .lifetime: return "一次購買,終生訪問" + } + default: + switch periodType { + case .week: return "Flexible" + case .month: return "Best Value" + case .year: return "Most Popular" + case .lifetime: return "Pay once, own forever" + } + } + } + + /// 获取订阅副标题 + /// - Parameters: + /// - product: 产品对象 + /// - periodType: 持续时间类型 + /// - languageCode: 语言代码 + /// - Returns: 本地化的副标题 + public static func defaultSubtitle(product: Product, periodType: SubscriptionPeriodType, languageCode: String) -> String { + let priceDouble = NSDecimalNumber(decimal: product.price).doubleValue + let priceString = String(format: "%.2f", priceDouble) + let currencySymbol = getCurrencySymbol(from: product) + var productUnit = periodType.rawValue + if let subscription = product.subscription { + productUnit = getLocalizedUnit(languageCode: languageCode, numberOfPeriods: 1, unit: getUnit(from: subscription.subscriptionPeriod)) + } + + // 原价订阅描述:灵活选择、性价比之选、最优惠 + let description = defaultSubDescWord(periodType: periodType, languageCode: languageCode) + + if periodType == .lifetime { + // 终身会员只返回描述 + return description + } else if periodType == .week { + return description + "," + buildDefaultSubtitle(languageCode: languageCode, price: priceString, currencySymbol: currencySymbol, productUnit: productUnit) + } else if periodType == .month { + return description + "," + buildMonthlySubtitle(languageCode: languageCode, price: priceString, currencySymbol: currencySymbol) + } else if periodType == .year { + return description + "," + buildYearlySubtitle(languageCode: languageCode, price: priceString, currencySymbol: currencySymbol) + } + return buildDefaultSubtitle(languageCode: languageCode, price: priceString, currencySymbol: currencySymbol, productUnit: productUnit) + } + + /// 获取介绍性优惠副标题 + /// - Parameters: + /// - product: 产品对象 + /// - languageCode: 语言代码 + /// - Returns: 本地化的介绍性优惠副标题 + public static func introductoryOfferSubtitle(product: Product, languageCode: String) async -> String { + guard let subscription = product.subscription, + let introductoryOffer = subscription.introductoryOffer else { + return "" + } + + let priceDouble = NSDecimalNumber(decimal: product.price).doubleValue + let priceString = String(format: "%.2f", priceDouble) + let currencySymbol = getCurrencySymbol(from: product) + + //print("introductoryOfferSubtitle:priceString \(priceString) currencySymbol: \(currencySymbol)") + + // 获取产品单位 + let productUnit = getUnit(from: subscription.subscriptionPeriod) + let localizedUnit = getLocalizedUnit(languageCode: languageCode, numberOfPeriods: 1, unit: productUnit) + + // 获取介绍性优惠价格 + let introPrice = introductoryOffer.price + let introPriceDouble = NSDecimalNumber(decimal: introPrice).doubleValue + let introPriceString = String(format: "%.2f", introPriceDouble) + + switch introductoryOffer.paymentMode { + case .payAsYouGo: + // 按需付费:显示折扣价格 + let numberOfPeriods = introductoryOffer.periodCount + return buildPayAsYouGoText( + languageCode: languageCode, + introPrice: introPriceString, + currencySymbol: currencySymbol, + productUnit: localizedUnit, + numberOfPeriods: numberOfPeriods + ) + + case .payUpFront: + // 预付:显示节省金额和折扣价格 + return buildPayUpFrontText( + languageCode: languageCode, + introPrice: introPriceString, + originalPrice: priceString, + currencySymbol: currencySymbol, + productUnit: localizedUnit + ) + + case .freeTrial: + // 免费试用:显示试用期和后续价格 + let trialPeriod = introductoryOffer.period + let numberOfPeriods = trialPeriod.value + let trialUnit = getUnit(from: trialPeriod) + let trialLocalizedUnit = getLocalizedUnit(languageCode: languageCode, numberOfPeriods: numberOfPeriods, unit: trialUnit) + + return buildFreeTrialText( + languageCode: languageCode, + numberOfPeriods: numberOfPeriods, + trialPeriodUnit: trialLocalizedUnit, + productUnit: localizedUnit, + price: priceString, + currencySymbol: currencySymbol + ) + + default: + return "" + } + } + + /// 获取促销优惠副标题 + /// - Parameters: + /// - product: 产品对象 + /// - languageCode: 语言代码 + /// - Returns: 本地化的促销优惠副标题 + public static func promotionalOfferSubtitle(product: Product, languageCode: String) async -> String { + guard let subscription = product.subscription, + let promotionalOffer = subscription.promotionalOffers.first else { + return "" + } + + let priceDouble = NSDecimalNumber(decimal: product.price).doubleValue + let priceString = String(format: "%.2f", priceDouble) + let currencySymbol = getCurrencySymbol(from: product) + + // 获取产品单位 + let productUnit = getUnit(from: subscription.subscriptionPeriod) + let localizedUnit = getLocalizedUnit(languageCode: languageCode, numberOfPeriods: 1, unit: productUnit) + + // 获取促销优惠价格 + let discountPrice = promotionalOffer.price + let discountPriceDouble = NSDecimalNumber(decimal: discountPrice).doubleValue + let discountPriceString = String(format: "%.2f", discountPriceDouble) + + switch promotionalOffer.paymentMode { + case .payAsYouGo: + // 按需付费:显示折扣价格 + let numberOfPeriods = promotionalOffer.periodCount + return buildPayAsYouGoText( + languageCode: languageCode, + introPrice: discountPriceString, + currencySymbol: currencySymbol, + productUnit: localizedUnit, + numberOfPeriods: numberOfPeriods + ) + + case .payUpFront: + // 预付:显示节省金额和折扣价格 + return buildPayUpFrontText( + languageCode: languageCode, + introPrice: discountPriceString, + originalPrice: priceString, + currencySymbol: currencySymbol, + productUnit: localizedUnit + ) + + case .freeTrial: + // 免费试用:显示试用期和后续价格 + let trialPeriod = promotionalOffer.period + let numberOfPeriods = trialPeriod.value + let trialUnit = getUnit(from: trialPeriod) + let trialLocalizedUnit = getLocalizedUnit(languageCode: languageCode, numberOfPeriods: numberOfPeriods, unit: trialUnit) + + return buildFreeTrialText( + languageCode: languageCode, + numberOfPeriods: numberOfPeriods, + trialPeriodUnit: trialLocalizedUnit, + productUnit: localizedUnit, + price: priceString, + currencySymbol: currencySymbol + ) + + default: + return "" + } + } + + + + // MARK: - 价格格式化辅助方法 + + /// 构建默认价格显示 + /// - Parameters: + /// - languageCode: 语言代码 + /// - price: 价格字符串 + /// - currencySymbol: 货币符号 + /// - productUnit: 产品单位(本地化) + /// - Returns: 格式化的价格字符串 + private static func buildDefaultSubtitle(languageCode: String, price: String, currencySymbol: String, productUnit: String) -> String { + switch languageCode { + case "ar", "de", "en", "es", "fil", "fr", "id", "it", "ja", "ko", "pl", "pt", "ru", "th", "tr", "uk", "vi": + return "\(currencySymbol)\(price)/\(productUnit)" + case "zh_Hans": + return "每\(productUnit)\(currencySymbol)\(price)元" + case "zh_Hant": + return "每\(productUnit)\(currencySymbol)\(price)" + default: + return "\(currencySymbol)\(price)/\(productUnit)" + } + } + + /// 构建月订阅价格显示 - 突出性价比 + /// - Parameters: + /// - languageCode: 语言代码 + /// - price: 价格字符串 + /// - currencySymbol: 货币符号 + /// - Returns: 格式化的价格字符串 + private static func buildMonthlySubtitle(languageCode: String, price: String, currencySymbol: String) -> String { + let monthlyPrice = Double(price) ?? 0 + let weeklyPrice = monthlyPrice / 4.33 + let weeklyPriceString = String(format: "%.2f", weeklyPrice) + + switch languageCode { + case "ar": + return "\(currencySymbol)\(price)/شهر · ~\(currencySymbol)\(weeklyPriceString)/أسبوع" + case "de": + return "\(currencySymbol)\(price)/Monat · ~\(currencySymbol)\(weeklyPriceString)/Woche" + case "en": + return "\(currencySymbol)\(price)/month · ~\(currencySymbol)\(weeklyPriceString)/week" + case "es": + return "\(currencySymbol)\(price)/mes · ~\(currencySymbol)\(weeklyPriceString)/semana" + case "fil": + return "\(currencySymbol)\(price)/buwan · ~\(currencySymbol)\(weeklyPriceString)/linggo" + case "fr": + return "\(currencySymbol)\(price)/mois · ~\(currencySymbol)\(weeklyPriceString)/semaine" + case "id": + return "\(currencySymbol)\(price)/bulan · ~\(currencySymbol)\(weeklyPriceString)/minggu" + case "it": + return "\(currencySymbol)\(price)/mese · ~\(currencySymbol)\(weeklyPriceString)/settimana" + case "ja": + return "\(currencySymbol)\(price)/月 · 約\(currencySymbol)\(weeklyPriceString)/週" + case "ko": + return "\(currencySymbol)\(price)/월 · 약\(currencySymbol)\(weeklyPriceString)/주" + case "pl": + return "\(currencySymbol)\(price)/miesiąc · ~\(currencySymbol)\(weeklyPriceString)/tydzień" + case "pt": + return "\(currencySymbol)\(price)/mês · ~\(currencySymbol)\(weeklyPriceString)/semana" + case "ru": + return "\(currencySymbol)\(price)/мес · ~\(currencySymbol)\(weeklyPriceString)/нед" + case "th": + return "\(currencySymbol)\(price)/เดือน · ~\(currencySymbol)\(weeklyPriceString)/สัปดาห์" + case "tr": + return "\(currencySymbol)\(price)/ay · ~\(currencySymbol)\(weeklyPriceString)/hafta" + case "uk": + return "\(currencySymbol)\(price)/міс · ~\(currencySymbol)\(weeklyPriceString)/тиж" + case "vi": + return "\(currencySymbol)\(price)/tháng · ~\(currencySymbol)\(weeklyPriceString)/tuần" + case "zh_Hans": + return "每月\(currencySymbol)\(price)元 · 约\(currencySymbol)\(weeklyPriceString)元/周" + case "zh_Hant": + return "每月\(currencySymbol)\(price) · 約\(currencySymbol)\(weeklyPriceString)/周" + default: + return "\(currencySymbol)\(price)/month · ~\(currencySymbol)\(weeklyPriceString)/week" + } + } + + /// 构建年订阅价格显示 - 突出最大优惠 + /// - Parameters: + /// - languageCode: 语言代码 + /// - price: 价格字符串 + /// - currencySymbol: 货币符号 + /// - Returns: 格式化的价格字符串 + private static func buildYearlySubtitle(languageCode: String, price: String, currencySymbol: String) -> String { + let yearlyPrice = Double(price) ?? 0 + let weeklyPrice = yearlyPrice / 52 + let weeklyPriceString = String(format: "%.2f", weeklyPrice) + + switch languageCode { + case "ar": + return "\(currencySymbol)\(price)/سنة · فقط \(currencySymbol)\(weeklyPriceString)/أسبوع" + case "de": + return "\(currencySymbol)\(price)/Jahr · nur \(currencySymbol)\(weeklyPriceString)/Woche" + case "en": + return "\(currencySymbol)\(price)/year · only \(currencySymbol)\(weeklyPriceString)/week" + case "es": + return "\(currencySymbol)\(price)/año · solo \(currencySymbol)\(weeklyPriceString)/semana" + case "fil": + return "\(currencySymbol)\(price)/taon · \(currencySymbol)\(weeklyPriceString)/linggo lang" + case "fr": + return "\(currencySymbol)\(price)/an · seulement \(currencySymbol)\(weeklyPriceString)/semaine" + case "id": + return "\(currencySymbol)\(price)/tahun · hanya \(currencySymbol)\(weeklyPriceString)/minggu" + case "it": + return "\(currencySymbol)\(price)/anno · solo \(currencySymbol)\(weeklyPriceString)/settimana" + case "ja": + return "\(currencySymbol)\(price)/年 · わずか\(currencySymbol)\(weeklyPriceString)/週" + case "ko": + return "\(currencySymbol)\(price)/년 · 단\(currencySymbol)\(weeklyPriceString)/주" + case "pl": + return "\(currencySymbol)\(price)/rok · tylko \(currencySymbol)\(weeklyPriceString)/tydzień" + case "pt": + return "\(currencySymbol)\(price)/ano · apenas \(currencySymbol)\(weeklyPriceString)/semana" + case "ru": + return "\(currencySymbol)\(price)/год · всего \(currencySymbol)\(weeklyPriceString)/нед" + case "th": + return "\(currencySymbol)\(price)/ปี · เพียง \(currencySymbol)\(weeklyPriceString)/สัปดาห์" + case "tr": + return "\(currencySymbol)\(price)/yıl · sadece \(currencySymbol)\(weeklyPriceString)/hafta" + case "uk": + return "\(currencySymbol)\(price)/рік · ~\(currencySymbol)\(weeklyPriceString)/тиж" + case "vi": + return "\(currencySymbol)\(price)/năm · chỉ \(currencySymbol)\(weeklyPriceString)/tuần" + case "zh_Hans": + return "每年\(currencySymbol)\(price)元 · 仅\(currencySymbol)\(weeklyPriceString)元/周" + case "zh_Hant": + return "每年\(currencySymbol)\(price) · 僅\(currencySymbol)\(weeklyPriceString)/周" + default: + return "\(currencySymbol)\(price)/year · only \(currencySymbol)\(weeklyPriceString)/week" + } + } + + // MARK: - 优惠相关副标题 + + + + /// 构建按需付费文本 + /// - Parameters: + /// - languageCode: 语言代码 + /// - introPrice: 优惠价格字符串 + /// - currencySymbol: 货币符号 + /// - productUnit: 产品单位(本地化) + /// - numberOfPeriods: 周期数量 + /// - Returns: 格式化的按需付费文本 + private static func buildPayAsYouGoText(languageCode: String, introPrice: String, currencySymbol: String, productUnit: String, numberOfPeriods: Int) -> String { + switch languageCode { + case "ar": + if numberOfPeriods == 1 { + return "عرض خاص: \(currencySymbol)\(introPrice)/\(productUnit) (الأسبوع الأول)" + } else { + return "عرض خاص: \(currencySymbol)\(introPrice)/\(productUnit) (الأسبوعين الأولين)" + } + case "de": + if numberOfPeriods == 1 { + return "Sonderangebot: \(currencySymbol)\(introPrice)/\(productUnit) (erste Woche)" + } else { + return "Sonderangebot: \(currencySymbol)\(introPrice)/\(productUnit) (erste \(numberOfPeriods) Wochen)" + } + case "en": + if numberOfPeriods == 1 { + return "Special offer: \(currencySymbol)\(introPrice)/\(productUnit) (first \(productUnit))" + } else { + return "Special offer: \(currencySymbol)\(introPrice)/\(productUnit) (first \(numberOfPeriods) \(productUnit)s)" + } + case "es": + if numberOfPeriods == 1 { + return "Oferta especial: \(currencySymbol)\(introPrice)/\(productUnit) (primer \(productUnit))" + } else { + return "Oferta especial: \(currencySymbol)\(introPrice)/\(productUnit) (primeros \(numberOfPeriods) \(productUnit)s)" + } + case "fil": + if numberOfPeriods == 1 { + return "Espesyal na alok: \(currencySymbol)\(introPrice)/\(productUnit) (unang \(productUnit))" + } else { + return "Espesyal na alok: \(currencySymbol)\(introPrice)/\(productUnit) (unang \(numberOfPeriods) \(productUnit)s)" + } + case "fr": + if numberOfPeriods == 1 { + return "Offre spéciale: \(currencySymbol)\(introPrice)/\(productUnit) (premier \(productUnit))" + } else { + return "Offre spéciale: \(currencySymbol)\(introPrice)/\(productUnit) (premiers \(numberOfPeriods) \(productUnit)s)" + } + case "id": + if numberOfPeriods == 1 { + return "Penawaran khusus: \(currencySymbol)\(introPrice)/\(productUnit) (\(productUnit) pertama)" + } else { + return "Penawaran khusus: \(currencySymbol)\(introPrice)/\(productUnit) (\(numberOfPeriods) \(productUnit) pertama)" + } + case "it": + if numberOfPeriods == 1 { + return "Offerta speciale: \(currencySymbol)\(introPrice)/\(productUnit) (primo \(productUnit))" + } else { + return "Offerta speciale: \(currencySymbol)\(introPrice)/\(productUnit) (primi \(numberOfPeriods) \(productUnit)s)" + } + case "ja": + if numberOfPeriods == 1 { + return "特別価格: \(currencySymbol)\(introPrice)/\(productUnit) (初回\(productUnit))" + } else { + return "特別価格: \(currencySymbol)\(introPrice)/\(productUnit) (初回\(numberOfPeriods)\(productUnit))" + } + case "ko": + if numberOfPeriods == 1 { + return "특별 할인: \(currencySymbol)\(introPrice)/\(productUnit) (첫 \(productUnit))" + } else { + return "특별 할인: \(currencySymbol)\(introPrice)/\(productUnit) (첫 \(numberOfPeriods)\(productUnit))" + } + case "pl": + if numberOfPeriods == 1 { + return "Oferta specjalna: \(currencySymbol)\(introPrice)/\(productUnit) (pierwszy \(productUnit))" + } else { + return "Oferta specjalna: \(currencySymbol)\(introPrice)/\(productUnit) (pierwsze \(numberOfPeriods) \(productUnit)s)" + } + case "pt": + if numberOfPeriods == 1 { + return "Oferta especial: \(currencySymbol)\(introPrice)/\(productUnit) (primeiro \(productUnit))" + } else { + return "Oferta especial: \(currencySymbol)\(introPrice)/\(productUnit) (primeiros \(numberOfPeriods) \(productUnit)s)" + } + case "ru": + if numberOfPeriods == 1 { + return "Специальное предложение: \(currencySymbol)\(introPrice)/\(productUnit) (первый \(productUnit))" + } else { + return "Специальное предложение: \(currencySymbol)\(introPrice)/\(productUnit) (первые \(numberOfPeriods) \(productUnit)s)" + } + case "th": + if numberOfPeriods == 1 { + return "ข้อเสนอพิเศษ: \(currencySymbol)\(introPrice)/\(productUnit) (\(productUnit)แรก)" + } else { + return "ข้อเสนอพิเศษ: \(currencySymbol)\(introPrice)/\(productUnit) (\(numberOfPeriods) \(productUnit)แรก)" + } + case "tr": + if numberOfPeriods == 1 { + return "Özel teklif: \(currencySymbol)\(introPrice)/\(productUnit) (ilk \(productUnit))" + } else { + return "Özel teklif: \(currencySymbol)\(introPrice)/\(productUnit) (ilk \(numberOfPeriods) \(productUnit))" + } + case "uk": + if numberOfPeriods == 1 { + return "Спеціальна пропозиція: \(currencySymbol)\(introPrice)/\(productUnit) (перший \(productUnit))" + } else { + return "Спеціальна пропозиція: \(currencySymbol)\(introPrice)/\(productUnit) (перші \(numberOfPeriods) \(productUnit)s)" + } + case "vi": + if numberOfPeriods == 1 { + return "Ưu đãi đặc biệt: \(currencySymbol)\(introPrice)/\(productUnit) (\(productUnit) đầu tiên)" + } else { + return "Ưu đãi đặc biệt: \(currencySymbol)\(introPrice)/\(productUnit) (\(numberOfPeriods) \(productUnit) đầu tiên)" + } + case "zh_Hans": + if numberOfPeriods == 1 { + return "限时优惠: 每\(productUnit)\(currencySymbol)\(introPrice)元(首\(productUnit))" + } else { + return "限时优惠: 每\(productUnit)\(currencySymbol)\(introPrice)元(前\(numberOfPeriods)\(productUnit))" + } + case "zh_Hant": + if numberOfPeriods == 1 { + return "限時優惠: 每\(productUnit)\(currencySymbol)\(introPrice)(首\(productUnit))" + } else { + return "限時優惠: 每\(productUnit)\(currencySymbol)\(introPrice)(前\(numberOfPeriods)\(productUnit))" + } + default: + if numberOfPeriods == 1 { + return "Special offer: \(currencySymbol)\(introPrice)/\(productUnit) (first \(productUnit))" + } else { + return "Special offer: \(currencySymbol)\(introPrice)/\(productUnit) (first \(numberOfPeriods) \(productUnit)s)" + } + } + } + + /// 构建预付文本 + /// - Parameters: + /// - languageCode: 语言代码 + /// - introPrice: 优惠价格字符串 + /// - originalPrice: 原价字符串 + /// - currencySymbol: 货币符号 + /// - productUnit: 产品单位(本地化) + /// - Returns: 格式化的预付文本 + private static func buildPayUpFrontText(languageCode: String, introPrice: String, originalPrice: String, currencySymbol: String, productUnit: String) -> String { + // 计算折扣百分比 + let original = Double(originalPrice) ?? 0 + let discounted = Double(introPrice) ?? 0 + let discountPercent = original > 0 ? Int(((original - discounted) / original * 100).rounded()) : 0 + + switch languageCode { + case "ar": + return "وفر \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "de": + return "Spare \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "en": + return "Save \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "es": + return "Ahorra \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "fil": + return "Makatipid ng \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "fr": + return "Économisez \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "id": + return "Hemat \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "it": + return "Risparmia \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "ja": + return "\(discountPercent)%OFF: \(currencySymbol)\(introPrice)/\(productUnit)" + case "ko": + return "\(discountPercent)% 할인: \(currencySymbol)\(introPrice)/\(productUnit)" + case "pl": + return "Zaoszczędź \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "pt": + return "Economize \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "ru": + return "Экономия \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "th": + return "ประหยัด \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "tr": + return "%\(discountPercent) tasarruf: \(currencySymbol)\(introPrice)/\(productUnit)" + case "uk": + return "Економія \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "vi": + return "Tiết kiệm \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + case "zh_Hans": + return "节省\(discountPercent)%: 每\(productUnit)\(currencySymbol)\(introPrice)元" + case "zh_Hant": + return "節省\(discountPercent)%: 每\(productUnit)\(currencySymbol)\(introPrice)" + default: + return "Save \(discountPercent)%: \(currencySymbol)\(introPrice)/\(productUnit)" + } + } + + /// 构建免费试用文本 + /// - Parameters: + /// - languageCode: 语言代码 + /// - numberOfPeriods: 试用周期数量 + /// - trialPeriodUnit: 试用周期单位(本地化) + /// - productUnit: 产品单位(本地化) + /// - price: 后续价格字符串 + /// - currencySymbol: 货币符号 + /// - Returns: 格式化的免费试用文本 + private static func buildFreeTrialText(languageCode: String, numberOfPeriods: Int, trialPeriodUnit: String, productUnit: String, price: String, currencySymbol: String) -> String { + switch languageCode { + case "ar": + return "تجربة مجانية \(numberOfPeriods) \(trialPeriodUnit)، ثم \(currencySymbol)\(price)/\(productUnit)" + case "de": + return "\(numberOfPeriods)-\(trialPeriodUnit)ige kostenlose Testphase, dann \(currencySymbol)\(price)/\(productUnit)" + case "en": + return "Free trial \(numberOfPeriods) \(trialPeriodUnit), then \(currencySymbol)\(price)/\(productUnit)" + case "es": + return "Prueba gratuita \(numberOfPeriods) \(trialPeriodUnit), luego \(currencySymbol)\(price)/\(productUnit)" + case "fil": + return "Libreng pagsubok \(numberOfPeriods) \(trialPeriodUnit), pagkatapos \(currencySymbol)\(price)/\(productUnit)" + case "fr": + return "Essai gratuit \(numberOfPeriods) \(trialPeriodUnit), puis \(currencySymbol)\(price)/\(productUnit)" + case "id": + return "Coba gratis \(numberOfPeriods) \(trialPeriodUnit), lalu \(currencySymbol)\(price)/\(productUnit)" + case "it": + return "Prova gratuita \(numberOfPeriods) \(trialPeriodUnit), poi \(currencySymbol)\(price)/\(productUnit)" + case "ja": + return "\(numberOfPeriods)\(trialPeriodUnit)無料トライアル、その後\(currencySymbol)\(price)/\(productUnit)" + case "ko": + return "\(numberOfPeriods)\(trialPeriodUnit) 무료 체험, 이후 \(currencySymbol)\(price)/\(productUnit)" + case "pl": + return "Bezpłatny okres próbny \(numberOfPeriods) \(trialPeriodUnit), następnie \(currencySymbol)\(price)/\(productUnit)" + case "pt": + return "Teste gratuito \(numberOfPeriods) \(trialPeriodUnit), depois \(currencySymbol)\(price)/\(productUnit)" + case "ru": + return "Бесплатная пробная версия \(numberOfPeriods) \(trialPeriodUnit), затем \(currencySymbol)\(price)/\(productUnit)" + case "th": + return "ทดลองใช้ฟรี \(numberOfPeriods) \(trialPeriodUnit) จากนั้น \(currencySymbol)\(price)/\(productUnit)" + case "tr": + return "\(numberOfPeriods) \(trialPeriodUnit) ücretsiz deneme, sonrasında \(currencySymbol)\(price)/\(productUnit)" + case "uk": + return "Безкоштовна пробна версія \(numberOfPeriods) \(trialPeriodUnit), потім \(currencySymbol)\(price)/\(productUnit)" + case "vi": + return "Dùng thử miễn phí \(numberOfPeriods) \(trialPeriodUnit), sau đó \(currencySymbol)\(price)/\(productUnit)" + case "zh_Hans": + return "免费试用 \(numberOfPeriods) \(trialPeriodUnit),然后每\(productUnit)\(currencySymbol)\(price)元" + case "zh_Hant": + return "免費試用 \(numberOfPeriods) \(trialPeriodUnit),然後每\(productUnit)\(currencySymbol)\(price)" + default: + return "Free trial \(numberOfPeriods) \(trialPeriodUnit), then \(currencySymbol)\(price)/\(productUnit)" + } + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitConfig.swift b/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitConfig.swift new file mode 100644 index 0000000..b4ed77a --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitConfig.swift @@ -0,0 +1,48 @@ +// +// StoreKitConfig.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation + +/// StoreKit 配置模型 +public struct StoreKitConfig { + /// 产品ID数组 + public let productIds: [String] + + /// 终生会员ID + public let lifetimeIds: [String] + + /// 非续订订阅的过期天数(从购买日期开始计算) + /// 如果为 nil,则表示非续订订阅永不过期 + public let nonRenewableExpirationDays: Int? + + /// 是否自动排序产品(按价格从低到高) + public let autoSortProducts: Bool + + /// 是否显示日志 + public let showLog: Bool + + /// 初始化配置 + /// - Parameters: + /// - productIds: 产品ID数组 + /// - lifetimeIds: 终生会员ID + /// - nonRenewableExpirationDays: 非续订订阅过期天数,默认为 365 天 + /// - autoSortProducts: 是否自动排序产品,默认为 true + /// - showLog: 是否显示日志,默认为 false + public init( + productIds: [String], + lifetimeIds: [String], + nonRenewableExpirationDays: Int? = 365, + autoSortProducts: Bool = true, + showLog: Bool = false + ) { + self.productIds = productIds + self.lifetimeIds = lifetimeIds + self.nonRenewableExpirationDays = nonRenewableExpirationDays + self.autoSortProducts = autoSortProducts + self.showLog = showLog + } +} diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitError.swift b/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitError.swift new file mode 100644 index 0000000..d2d242b --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitError.swift @@ -0,0 +1,85 @@ +// +// StoreKitError.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation + +/// StoreKit 错误类型 +public enum StoreKitError: Error, LocalizedError { + /// 产品未找到 + case productNotFound(String) + + /// 购买失败 + case purchaseFailed(Error) + + /// 交易验证失败 + case verificationFailed + + /// 配置缺失 + case configurationMissing + + /// 服务未启动 + case serviceNotStarted + + /// 购买正在进行中 + case purchaseInProgress + + /// 取消订阅失败 + case cancelSubscriptionFailed(Error) + + /// 恢复购买失败 + case restorePurchasesFailed(Error) + + /// 未知错误 + case unknownError + + public var errorDescription: String? { + switch self { + case .productNotFound(let id): + return "产品未找到: \(id)" + case .purchaseFailed(let error): + return "购买失败: \(error.localizedDescription)" + case .verificationFailed: + return "交易验证失败,可能是设备已越狱或交易数据被篡改" + case .configurationMissing: + return "配置缺失,请先调用 configure 方法进行配置" + case .serviceNotStarted: + return "服务未启动,请先调用 configure 方法" + case .purchaseInProgress: + return "购买正在进行中,请等待当前购买完成" + case .cancelSubscriptionFailed(let error): + return "取消订阅失败: \(error.localizedDescription)" + case .restorePurchasesFailed(let error): + return "恢复购买失败: \(error.localizedDescription)" + case .unknownError: + return "未知错误" + } + } + + public var failureReason: String? { + switch self { + case .productNotFound(let id): + return "请检查产品ID是否正确,并确保在 App Store Connect 中已配置该产品" + case .purchaseFailed(let error): + return error.localizedDescription + case .verificationFailed: + return "交易数据无法通过 Apple 的验证,这可能是由于设备已越狱或交易数据被篡改" + case .configurationMissing: + return "在调用其他方法之前,必须先调用 configure(with:delegate:) 或 configure(with:onStateChanged:) 方法" + case .serviceNotStarted: + return "StoreKit2Manager 尚未初始化,请先调用 configure 方法" + case .purchaseInProgress: + return "当前有购买正在进行,请等待完成后再试" + case .cancelSubscriptionFailed(let error): + return (error as? LocalizedError)?.failureReason ?? error.localizedDescription + case .restorePurchasesFailed(let error): + return (error as? LocalizedError)?.failureReason ?? error.localizedDescription + case .unknownError: + return "发生了未预期的错误" + } + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitState.swift b/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitState.swift new file mode 100644 index 0000000..90e1b92 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Models/StoreKitState.swift @@ -0,0 +1,95 @@ +// +// StoreKitState.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// StoreKit 状态枚举 +public enum StoreKitState: Equatable { + /// 空闲状态 + case idle + + /// 正在加载产品 + case loadingProducts + + /// 正在加载已购买产品 + case loadingPurchases + + /// 已购买产品加载完成 + case purchasesLoaded + + /// 正在购买指定产品 + case purchasing(String) // 产品ID + + /// 购买成功 + case purchaseSuccess(String) // 产品ID + + /// 购买待处理(需要用户操作) + case purchasePending(String) // 产品ID + + /// 用户取消购买 + case purchaseCancelled(String) // 产品ID + + /// 购买失败 + case purchaseFailed(String, Error) // 产品ID, 错误 + + /// 正在恢复购买 + case restoringPurchases + + /// 恢复购买成功 + case restorePurchasesSuccess + + /// 恢复购买失败 + case restorePurchasesFailed(Error) + + /// 购买已退款 + case purchaseRefunded(String) // 产品ID + + /// 购买已撤销 + case purchaseRevoked(String) // 产品ID + + /// 订阅已取消 + /// - Parameters: + /// - productId: 产品ID + /// - isFreeTrialCancelled: 是否在免费试用期取消(true 表示在免费试用期内取消,false 表示在付费订阅期内取消) + case subscriptionCancelled(String, isFreeTrialCancelled: Bool) + + /// 发生错误 + case error(Error) + + public static func == (lhs: StoreKitState, rhs: StoreKitState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), + (.loadingProducts, .loadingProducts), + (.loadingPurchases, .loadingPurchases), + (.purchasesLoaded, .purchasesLoaded): + return true + case (.purchasing(let lhsId), .purchasing(let rhsId)), + (.purchaseSuccess(let lhsId), .purchaseSuccess(let rhsId)), + (.purchasePending(let lhsId), .purchasePending(let rhsId)), + (.purchaseCancelled(let lhsId), .purchaseCancelled(let rhsId)): + return lhsId == rhsId + case (.purchaseFailed(let lhsId, _), .purchaseFailed(let rhsId, _)): + return lhsId == rhsId + case (.restoringPurchases, .restoringPurchases), + (.restorePurchasesSuccess, .restorePurchasesSuccess): + return true + case (.restorePurchasesFailed(let lhsError), .restorePurchasesFailed(let rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + case (.purchaseRefunded(let lhsId), .purchaseRefunded(let rhsId)), + (.purchaseRevoked(let lhsId), .purchaseRevoked(let rhsId)): + return lhsId == rhsId + case (.subscriptionCancelled(let lhsId, let lhsIsFreeTrial), .subscriptionCancelled(let rhsId, let rhsIsFreeTrial)): + return lhsId == rhsId && lhsIsFreeTrial == rhsIsFreeTrial + case (.error(let lhsError), .error(let rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return false + } + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Models/TransactionHistory.swift b/keyBoard/Class/Pay/StoreKit2Manager/Models/TransactionHistory.swift new file mode 100644 index 0000000..3be67ef --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Models/TransactionHistory.swift @@ -0,0 +1,80 @@ +// +// TransactionHistory.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// 交易历史记录 +public struct TransactionHistory { + /// 产品ID + public let productId: String + + /// 产品对象 + public let product: Product? + + /// 交易对象 + public let transaction: StoreKit.Transaction + + /// 购买日期 + public let purchaseDate: Date + + /// 过期日期(如果是订阅) + public let expirationDate: Date? + + /// 是否已退款 + public let isRefunded: Bool + + /// 是否已撤销 + public let isRevoked: Bool + + /// 所有权类型 + public let ownershipType: StoreKit.Transaction.OwnershipType + + /// 交易ID + public let transactionId: UInt64 + + public init( + productId: String, + product: Product?, + transaction: StoreKit.Transaction, + purchaseDate: Date, + expirationDate: Date? = nil, + isRefunded: Bool = false, + isRevoked: Bool = false, + ownershipType: StoreKit.Transaction.OwnershipType, + transactionId: UInt64 + ) { + self.productId = productId + self.product = product + self.transaction = transaction + self.purchaseDate = purchaseDate + self.expirationDate = expirationDate + self.isRefunded = isRefunded + self.isRevoked = isRevoked + self.ownershipType = ownershipType + self.transactionId = transactionId + } +} + +// MARK: - 从 Transaction 创建 +extension TransactionHistory { + /// 从 Transaction 创建交易历史 + public static func from(_ transaction: StoreKit.Transaction, product: Product? = nil) -> TransactionHistory { + return TransactionHistory( + productId: transaction.productID, + product: product, + transaction: transaction, + purchaseDate: transaction.purchaseDate, + expirationDate: transaction.expirationDate, + isRefunded: transaction.revocationDate != nil, + isRevoked: transaction.revocationDate != nil, + ownershipType: transaction.ownershipType, + transactionId: transaction.id + ) + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/Protocols/StoreKitDelegate.swift b/keyBoard/Class/Pay/StoreKit2Manager/Protocols/StoreKitDelegate.swift new file mode 100644 index 0000000..ff324d2 --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/Protocols/StoreKitDelegate.swift @@ -0,0 +1,48 @@ +// +// StoreKitDelegate.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// StoreKit 代理协议 +/// 所有方法都在主线程调用 +public protocol StoreKitDelegate: AnyObject { + /// 状态更新回调 + /// - Parameters: + /// - manager: StoreKit2Manager 实例 + /// - state: 新的状态 + func storeKit(_ manager: StoreKit2Manager, didUpdateState state: StoreKitState) + + /// 产品加载成功回调 + /// - Parameters: + /// - manager: StoreKit2Manager 实例 + /// - products: 加载的产品列表 + func storeKit(_ manager: StoreKit2Manager, didLoadProducts products: [Product]) + + /// 已购买交易订单更新回调 + /// - Parameters: + /// - manager: StoreKit2Manager 实例 + /// - efficient: 已购买的交易订单(有效的交易) + /// - latests: 每个产品的最新交易记录 + func storeKit(_ manager: StoreKit2Manager, didUpdatePurchasedTransactions efficient: [Transaction], latests: [Transaction]) +} + +// MARK: - 可选方法默认实现 +extension StoreKitDelegate { + public func storeKit(_ manager: StoreKit2Manager, didUpdateState state: StoreKitState) { + // 默认实现为空,子类可以选择性实现 + } + + public func storeKit(_ manager: StoreKit2Manager, didLoadProducts products: [Product]) { + // 默认实现为空,子类可以选择性实现 + } + + public func storeKit(_ manager: StoreKit2Manager, didUpdatePurchasedTransactions efficient: [Transaction], latests: [Transaction]) { + // 默认实现为空,子类可以选择性实现 + } +} + diff --git a/keyBoard/Class/Pay/StoreKit2Manager/StoreKitManager.swift b/keyBoard/Class/Pay/StoreKit2Manager/StoreKitManager.swift new file mode 100644 index 0000000..d2aef5a --- /dev/null +++ b/keyBoard/Class/Pay/StoreKit2Manager/StoreKitManager.swift @@ -0,0 +1,536 @@ +// +// StoreKit2Manager.swift +// StoreKit2Manager +// +// Created by xiaopin on 2025/12/6. +// + +import Foundation +import StoreKit + +/// 订阅周期类型 +public enum SubscriptionPeriodType: String { + case week = "week" + case month = "month" + case year = "year" + case lifetime = "lifetime" +} + +/// 订阅按钮文案类型 +public enum SubscriptionButtonType: String { + case standard = "standard" // 标准订阅 + case freeTrial = "freeTrial" // 免费试用 + case payUpFront = "payUpFront" // 预付 + case payAsYouGo = "payAsYouGo" // 按需付费 + case lifetime = "lifetime" // 终身会员 +} + +/// 订阅信息类型别名 +public typealias SubscriptionInfo = StoreKit.Product.SubscriptionInfo + +/// 订阅状态类型别名 +public typealias SubscriptionStatus = StoreKit.Product.SubscriptionInfo.Status + +/// 订阅周期类型别名 +public typealias SubscriptionPeriod = StoreKit.Product.SubscriptionPeriod + +/// 续订信息类型别名 +public typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo + +/// 续订状态类型别名 +public typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState + +/// 交易类型别名 +public typealias Transaction = StoreKit.Transaction + +/// StoreKit 管理器 +/// 提供统一的接口来管理应用内购买 +public class StoreKit2Manager { + /// 单例实例 + public static let shared = StoreKit2Manager() + + // MARK: - 配置和代理 + + private var config: StoreKitConfig? + private weak var delegate: StoreKitDelegate? + private var service: StoreKitService? + + // MARK: - 闭包回调(可选,与代理二选一) + + /// 状态变化回调 + public var onStateChanged: ((StoreKitState) -> Void)? + + /// 产品加载成功回调 + public var onProductsLoaded: (([Product]) -> Void)? + + /// 已购买产品更新回调: 有效的交易,每个产品最新的交易 + public var onPurchasedTransactionsUpdated: (([Transaction],[Transaction]) -> Void)? + + // MARK: - 当前状态和数据 + + /// 当前执行的状态 + public private(set) var currentState: StoreKitState = .idle + + /// 所有产品 + public private(set) var allProducts: [Product] = [] + + /// 已购买有效的交易信息 + public private(set) var purchasedTransactions: [Transaction] = [] + + /// 每个产品的最新交易记录集合 + public private(set) var latestTransactions: [Transaction] = [] + + // MARK: - 按类型分类的产品(计算属性) + + /// 非消耗品 + public var nonConsumables: [Product] { + allProducts.filter { $0.type == .nonConsumable } + } + + /// 消耗品 + public var consumables: [Product] { + allProducts.filter { $0.type == .consumable } + } + + /// 非续订订阅 + public var nonRenewables: [Product] { + allProducts.filter { $0.type == .nonRenewable } + } + + /// 自动续订订阅 + public var autoRenewables: [Product] { + allProducts.filter { $0.type == .autoRenewable } + } + + // MARK: - 初始化 + + private init() {} + + // MARK: - 配置和启动 + + /// 使用代理配置管理器 + /// - Parameters: + /// - config: 配置对象 + /// - delegate: 代理对象 + public func configure(with config: StoreKitConfig, delegate: StoreKitDelegate) { + self.config = config + self.delegate = delegate + self.service = StoreKitService(config: config, delegate: self) + service?.start() + } + + /// 使用闭包配置管理器 + /// - Parameter config: 配置对象 + public func configure(with config: StoreKitConfig) { + self.config = config + self.service = StoreKitService(config: config, delegate: self) + service?.start() + } + + // MARK: - 获取产品信息 + + /// 手动刷新产品列表 + /// - Note: 会异步从 App Store 拉取最新的产品信息,更新本地产品列表 + /// - Returns: 刷新后的产品列表,如果刷新失败返回 nil + public func refreshProducts() async { + await service?.loadProducts() + } + + /// 获取所有产品 + /// - Returns: 当前已知的全部产品列表 + public func getAllProducts() async -> [Product] { + if(allProducts.isEmpty){ + if let products = await service?.loadProducts() { + allProducts = products + } + } + return allProducts + } + + /// 获取所有非消耗型产品 + /// - Returns: 非消耗品数组(如:永久解锁类产品) + public func getNonConsumablesProducts() async -> [Product] { + return nonConsumables + } + + /// 获取所有消耗型产品 + /// - Returns: 消耗品数组(如:虚拟币、道具等) + public func getConsumablesProducts() async -> [Product] { + return consumables + } + + /// 获取所有非续订型订阅产品 + /// - Returns: 非续订订阅产品数组(如:半年的订阅) + public func getNonRenewablesProducts() async -> [Product] { + return nonRenewables + } + + /// 获取所有自动续订型订阅产品 + /// - Returns: 自动续订订阅产品数组(如:包月/包年订阅) + public func getAutoRenewablesProducts() async -> [Product] { + return autoRenewables + } + + /// 获取产品对象 + /// - Parameter productId: 产品ID + /// - Returns: 产品对象,如果未找到返回 nil + public func product(for productId: String) -> Product? { + return allProducts.first(where: { $0.id == productId }) + } + + /// 获取VIP订阅产品的标题 + /// - Parameter productId: 产品ID + /// - Parameter periodType: 周期类型:周,月,年,终身 + /// - Parameter languageCode: 语言代码 + /// - Parameter isShort: 是否短标题 + /// - Returns: 本地化的产品标题 + public func productForVipTitle(for productId: String, periodType: SubscriptionPeriodType , languageCode: String, isShort: Bool = false) -> String { + guard let product = product(for: productId) else { + return "" + } + return SubscriptionLocale.subscriptionTitle( + periodType: periodType, + languageCode: languageCode, + isShort: isShort + ) + } + + /// 获取VIP订阅产品的副标题 + /// - Parameter productId: 产品ID + /// - Returns: 本地化的产品副标题 + public func productForVipSubtitle(for productId: String, periodType: SubscriptionPeriodType , languageCode: String) async -> String { + guard let product = product(for: productId) else { + return "" + } + + // 检查是否有自动续订 + if let subscription = product.subscription { + // 是自动续订, 且有资格享受介绍性优惠 + let isEligible = await subscription.isEligibleForIntroOffer + if isEligible { + // 介绍性优惠:免费试用,随用随付,提前支付 + if subscription.introductoryOffer != nil { + return await SubscriptionLocale.introductoryOfferSubtitle( + product: product, + languageCode: languageCode + ) + } + } else { + // 促销优惠:免费试用,随用随付,提前支付(似乎有可以有多个促销优惠,后续完善,目前暂时只考虑只有1个的情况) + if !subscription.promotionalOffers.isEmpty { + return await SubscriptionLocale.promotionalOfferSubtitle( + product: product, + languageCode: languageCode + ) + } + } + } + + // 如果是终生购买 + if config?.lifetimeIds.contains(productId) == true { + return SubscriptionLocale.defaultSubtitle( + product: product, + periodType: SubscriptionPeriodType.lifetime, + languageCode: languageCode + ) + } + //常规订阅 + return SubscriptionLocale.defaultSubtitle( + product: product, + periodType: periodType, + languageCode: languageCode + ) + } + + /// 获取产品的按钮文案 + /// - Parameter productId: 产品ID + /// - Returns: 本地化的按钮文案 + public func productForVipButtonText(for productId: String, languageCode: String) async -> String { + guard let product = product(for: productId) else { + return "" + } + + // 判断按钮类型 + var buttonType: SubscriptionButtonType = .standard + + // 检查是否有自动续订 + if let subscription = product.subscription { + //是自动续订, 且有资格享受介绍性优惠 + let isEligible = await subscription.isEligibleForIntroOffer + if isEligible { + //介绍性优惠:免费试用,随用随付,提前支付 + if let introOffer = subscription.introductoryOffer { + switch introOffer.paymentMode { + case .freeTrial: + buttonType = .freeTrial + case .payUpFront: + buttonType = .payUpFront + case .payAsYouGo: + buttonType = .payAsYouGo + default: + buttonType = .standard + } + } + }else{ + // 促销优惠:免费试用,随用随付,提前支付(似乎有可以有多个促销优惠,后续完善,目前暂时只考虑只有1个的情况) + if let promotionalOffer = subscription.promotionalOffers.first { + switch promotionalOffer.paymentMode { + case .freeTrial: + buttonType = .freeTrial + case .payUpFront: + buttonType = .payUpFront + case .payAsYouGo: + buttonType = .payAsYouGo + default: + buttonType = .standard + } + } + } + } + //如果是终生购买 + if config?.lifetimeIds.contains(productId) == true { + buttonType = .lifetime + } + + return SubscriptionLocale.subscriptionButtonText( + type: buttonType, + languageCode: languageCode + ) + } + + + // MARK: - 购买相关 + + /// 通过产品ID购买产品 + /// - Parameter productId: 产品ID + /// - Throws: StoreKitError.productNotFound 如果产品未找到 + public func purchase(productId: String) async throws { + guard let product = allProducts.first(where: { $0.id == productId }) else { + throw StoreKitError.productNotFound(productId) + } + try await service?.purchase(product) + } + + /// 通过产品对象购买 + /// - Parameter product: 产品对象 + /// - Throws: StoreKitError.purchaseInProgress 如果已有购买正在进行 + public func purchase(_ product: Product) async throws { + guard let service = service else { + throw StoreKitError.serviceNotStarted + } + try await service.purchase(product) + } + + /// 恢复购买 + /// - Throws: StoreKitError.restorePurchasesFailed 如果恢复失败 + public func restorePurchases() async throws { + try await service?.restorePurchases() + } + + /// 手动刷新已购买产品交易信息,包括:有效的订阅交易信息,每个产品的最新交易信息 + public func refreshPurchases() async { + await service?.loadPurchasedTransactions() + } + + // MARK: - 查询方法 + + /// 检查产品是否已购买 + /// - Parameter productId: 产品ID + /// - Returns: 如果已购买返回 true + public func isPurchased(productId: String) -> Bool { + return latestTransactions.contains(where: { $0.productID == productId }) + } + + /// 检查产品是否通过家庭共享获得 + /// - Parameter productId: 产品ID + /// - Returns: 如果是通过家庭共享获得返回 true,否则返回 false + /// - Note: 只有支持家庭共享的产品才能通过家庭共享获得 + public func isFamilyShared(productId: String) -> Bool { + guard let transaction = latestTransactions.first(where: { $0.productID == productId }) else { + return false + } + return transaction.ownershipType == .familyShared + } + + /// 检查是否符合享受介绍性优惠资格 + /// - Parameter productId: 产品ID + /// - Returns: 如果有资格享受介绍性优惠返回 true,否则返回 false + /// - Note: 仅对支持订阅的产品有效 + public func isEligibleForIntroOffer(productId: String) async -> Bool { + guard let product = allProducts.first(where: { $0.id == productId }) else { + return false + } + guard let subscription = product.subscription else { + return false + } + return await subscription.isEligibleForIntroOffer + } + + // MARK: - 交易相关 + /// 获取有效的已购买交易 + /// - Returns: 有效(未过期、未撤销、未退款)的已购买交易数组 + public func getValidPurchasedTransactions() async -> [Transaction] { + return purchasedTransactions + } + + /// 获取每个产品的最新交易 + /// - Returns: 最新交易数组,每个产品只保留最新一笔交易 + public func getLatestTransactions() async -> [Transaction] { + return latestTransactions + } + + /// 获取交易历史 + /// - Parameter productId: 可选的产品ID,如果提供则只返回该产品的交易历史 + /// - Returns: 交易历史记录数组,按购买日期倒序排列 + public func getTransactionHistory(for productId: String? = nil) async -> [TransactionHistory] { + await service?.getTransactionHistory(for: productId) ?? [] + } + + /// 获取消耗品的购买历史 + /// - Parameter productId: 产品ID + /// - Returns: 该消耗品的所有购买历史 + public func getConsumablePurchaseHistory(for productId: String) async -> [TransactionHistory] { + await service?.getConsumablePurchaseHistory(for: productId) ?? [] + } + + // MARK: - 订阅相关 + + /// 手动检查订阅状态 + /// - Note: 建议在以下时机调用: + /// - 应用启动时 + /// - 应用进入前台时 + /// - 用户打开订阅页面时 + /// - 购买/恢复购买后 + @MainActor + public func checkSubscriptionStatus() async { + await service?.checkSubscriptionStatusManually() + } + + + /// 获取订阅详细信息 + /// - Parameter productId: 产品ID + /// - Returns: 订阅信息,如果不是订阅产品则返回 nil + public func getSubscriptionInfo(for productId: String) async -> SubscriptionInfo? { + guard let product = allProducts.first(where: { $0.id == productId }) else { + return nil + } + return product.subscription + } + + /// 获取订阅续订信息 + /// - Parameter productId: 产品ID + /// - Returns: 续订信息,如果不是订阅产品或获取失败则返回 nil + /// - Note: RenewalInfo 包含 willAutoRenew(是否自动续订)、expirationDate(过期日期)、renewalDate(续订日期)等信息 + public func getRenewalInfo(for productId: String) async -> RenewalInfo? { + guard let product = allProducts.first(where: { $0.id == productId }), + let subscription = product.subscription else { + return nil + } + + do { + let statuses = try await subscription.status + if let status = statuses.first, + case .verified(let renewalInfo) = status.renewalInfo { + return renewalInfo + } + } catch { + print("获取续订信息失败: \(error)") + return nil + } + return nil + } + + /// 打开订阅管理页面(使用 URL) + @MainActor + public func openSubscriptionManagement() { + service?.openSubscriptionManagement() + } + + /// 显示应用内订阅管理界面(iOS 15.0+ / macOS 12.0+) + /// - Returns: 是否成功显示管理界面 + @MainActor + public func showManageSubscriptionsSheet() async -> Bool { + await service?.showManageSubscriptionsSheet() ?? false + } + + /// 显示优惠代码兑换界面(iOS 16.0+) + /// - Throws: StoreKitError 如果显示失败 + /// - Note: 兑换后的交易会通过 Transaction.updates 发出 + @MainActor + public func presentOfferCodeRedeemSheet() async -> Bool { + guard let service = service else { + return false + } + if #available(iOS 16.0, visionOS 1.0, *){ + do { + try await service.presentOfferCodeRedeemSheet() + return true + } catch { + return false + } + } else { + return false + } + } + + + // MARK: - 其他方法 + /// 请求应用评价 + /// - Note: 兼容 iOS 15.0+ 和 iOS 16.0+ + /// - iOS 15.0: 使用 SKStoreReviewController.requestReview() (StoreKit 1) + /// - iOS 16.0+: 使用 AppStore.requestReview(in:) (StoreKit 2) + /// - Important: 系统会根据用户的使用情况决定是否显示评价弹窗 + /// 每个应用在每个版本中最多显示 3 次评价请求 + @MainActor + public func requestReview() { + service?.requestReview() + } + + /// 停止服务(释放资源) + public func stop() { + service?.stop() + service = nil + config = nil + delegate = nil + currentState = .idle + allProducts = [] + } +} + +// MARK: - StoreKitServiceDelegate + +extension StoreKit2Manager: StoreKitServiceDelegate { + @MainActor + func service(_ service: StoreKitService, didUpdateState state: StoreKitState) { + currentState = state + + // 通知代理 + delegate?.storeKit(self, didUpdateState: state) + + // 通知闭包回调 + onStateChanged?(state) + } + + @MainActor + func service(_ service: StoreKitService, didLoadProducts products: [Product]) { + allProducts = products + + // 通知代理 + delegate?.storeKit(self, didLoadProducts: products) + + // 通知闭包回调 + onProductsLoaded?(products) + } + + @MainActor + func service(_ service: StoreKitService, didUpdatePurchasedTransactions efficient: [Transaction], latests: [Transaction]) { + purchasedTransactions = efficient + + // 通知代理 + delegate?.storeKit(self, didUpdatePurchasedTransactions: efficient, latests: latests) + + // 通知闭包回调 + onPurchasedTransactionsUpdated?(efficient, latests) + } +} +