Files
reimburse/reimburse/PayeeProfile.swift
T
2026-09-16 16:06:21 +08:00

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 ?? [:]
}
}