import Foundation import Security import Combine struct PayeeProfile: Codable, Equatable { var recipient = "" var bankName = "" var accountNumber = "" var preparer = "" func validated() throws -> PayeeProfile { var result = self result.recipient = recipient.trimmingCharacters(in: .whitespacesAndNewlines) result.bankName = bankName.trimmingCharacters(in: .whitespacesAndNewlines) result.accountNumber = accountNumber.filter { !$0.isWhitespace } result.preparer = preparer.trimmingCharacters(in: .whitespacesAndNewlines) guard !result.recipient.isEmpty, !result.bankName.isEmpty, !result.accountNumber.isEmpty else { throw PayeeFailure(message: "请填写收款人、开户行和账号。") } guard result.recipient.count <= 100, result.bankName.count <= 200, result.accountNumber.count <= 64, result.preparer.count <= 100 else { throw PayeeFailure(message: "收款人和制单人最多 100 字,开户行最多 200 字,账号最多 64 位。") } guard [result.recipient, result.bankName, result.accountNumber, result.preparer].allSatisfy({ $0.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } }) else { throw PayeeFailure(message: "资料中不能包含换行或控制字符。") } return result } var exportFields: [String: String] { ["recipient": recipient, "bankName": bankName, "accountNumber": accountNumber, "preparer": preparer.isEmpty ? recipient : preparer] } } struct PayeeFailure: LocalizedError { let message: String var errorDescription: String? { message } } protocol PayeeStorage { func read() throws -> Data? func write(_ data: Data) throws func delete() throws } struct KeychainPayeeStorage: PayeeStorage { private var query: [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "Scoopex.Reimburse.LocalPayee", kSecAttrAccount as String: "local-mac-user", kSecAttrSynchronizable as String: false] } func read() throws -> Data? { var request = query request[kSecReturnData as String] = true request[kSecMatchLimit as String] = kSecMatchLimitOne var result: CFTypeRef? let status = SecItemCopyMatching(request as CFDictionary, &result) if status == errSecItemNotFound { return nil } guard status == errSecSuccess, let data = result as? Data else { throw PayeeFailure(message: "无法读取收款信息,请解锁钥匙串并重试。") } return data } func write(_ data: Data) throws { let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) if status == errSecItemNotFound { var request = query request[kSecValueData as String] = data request[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly guard SecItemAdd(request as CFDictionary, nil) == errSecSuccess else { throw PayeeFailure(message: "无法保存收款信息,请解锁钥匙串并重试。") } } else if status != errSecSuccess { throw PayeeFailure(message: "无法更新收款信息,原资料未被替换。") } } func delete() throws { let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw PayeeFailure(message: "无法删除收款信息,请解锁钥匙串并重试。") } } } @MainActor final class PayeeProfileStore: ObservableObject { @Published private(set) var profile: PayeeProfile? @Published private(set) var loadError: String? private let storage: any PayeeStorage init(storage: any PayeeStorage = KeychainPayeeStorage()) { self.storage = storage reload() } func reload() { do { if let data = try storage.read() { profile = try JSONDecoder().decode(PayeeProfile.self, from: data).validated() } else { profile = nil } loadError = nil } catch { profile = nil loadError = "收款信息读取失败,请重试;不会使用模板中的示例账号。" } } func save(_ draft: PayeeProfile) throws { guard loadError == nil else { throw PayeeFailure(message: "请先重新读取收款信息,避免覆盖未读取的资料。") } let value = try draft.validated() try storage.write(JSONEncoder().encode(value)) profile = value } func clear() throws { try storage.delete() profile = nil loadError = nil } func exportFields() throws -> [String: String] { guard loadError == nil else { throw PayeeFailure(message: "收款信息读取失败,请打开“收款信息”重试后再导出。") } return profile?.exportFields ?? [:] } }