135 lines
5.0 KiB
Swift
135 lines
5.0 KiB
Swift
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 ?? [:]
|
|
}
|
|
}
|