// // PaymentViewModel.swift // suixinkan // // Created by Codex on 2026/6/22. // import CoreImage import CoreImage.CIFilterBuiltins import Combine import Foundation import UIKit @MainActor /// 收款页 ViewModel,管理收款码加载、动态金额二维码、推送状态和到账轮询。 final class PaymentCollectionViewModel: ObservableObject { @Published var staticPayUrl = "" @Published var dynamicPayUrl = "" @Published var amountText = "" @Published var remarkText = "" @Published var currentPayUrl = "" @Published var qrImage: UIImage? @Published var errorMessage: String? @Published var status: PaymentCollectionStatus = .idle @Published var isVoiceBroadcast = false @Published var scanSuccessTime: String? @Published var paymentSuccessData: PaymentSuccessDisplayData? @Published private var knownRecordIDs = Set() private var isLoading = false private var isPolling = false private let preferencesStore: AppPreferencesStore private let pushEventCenter: PaymentPushEventCenter private var pushEventsCancellable: AnyCancellable? private let context = CIContext() private let qrFilter = CIFilter.qrCodeGenerator() /// 创建收款页 ViewModel,并注入偏好存储与推送事件中心。 init( preferencesStore: AppPreferencesStore = AppPreferencesStore(), pushEventCenter: PaymentPushEventCenter = .shared ) { self.preferencesStore = preferencesStore self.pushEventCenter = pushEventCenter isVoiceBroadcast = preferencesStore.loadPaymentReceiveVoiceEnabled() startListeningPushEvents() } /// 当前静态收款码是否可展示。 var hasStaticPayCode: Bool { !staticPayUrl.paymentTrimmed.isEmpty } /// 金额是否满足设置动态二维码条件。 var canConfirmAmount: Bool { guard let amount = Self.normalizedMoney(amountText) else { return false } return (Decimal(string: amount) ?? 0) > 0 } /// 加载当前景区收款码,无景区时清空收款状态。 func loadPayCode(api: PaymentServing, scenicId: Int?) async { guard let scenicId else { resetPayCode() errorMessage = "请先选择景区" return } isLoading = true errorMessage = nil defer { isLoading = false } do { let response = try await api.payCode(scenicId: scenicId) staticPayUrl = response.staticPayUrl dynamicPayUrl = response.dynamicPayUrl currentPayUrl = response.staticPayUrl qrImage = makeQRCode(from: currentPayUrl) status = .idle scanSuccessTime = nil paymentSuccessData = nil } catch { errorMessage = error.localizedDescription } } /// 打开设置金额弹窗前清空输入并校验静态码是否可用。 @discardableResult func prepareAmountDialog() -> Bool { guard hasStaticPayCode else { errorMessage = "请先获取收款码" return false } amountText = "" remarkText = "" return true } /// 确认设置金额并更新主二维码,对齐 Android confirmAmount。 @discardableResult func confirmAmount() -> Bool { guard hasStaticPayCode else { errorMessage = "请先获取收款码" return false } guard let amount = Self.normalizedMoney(amountText), (Decimal(string: amount) ?? 0) > 0 else { errorMessage = "请输入有效收款金额" return false } guard amountText.matches(regex: #"^\d+(\.\d{1,2})?$"#) else { errorMessage = "金额最多支持两位小数" return false } guard applyDynamicAmount(normalizedAmount: amount) else { return false } scanSuccessTime = nil paymentSuccessData = nil return true } /// 切换收款到账语音提醒开关并持久化。 func toggleVoiceBroadcast() { isVoiceBroadcast.toggle() preferencesStore.savePaymentReceiveVoiceEnabled(isVoiceBroadcast) } /// 处理收款相关推送事件,对齐 Android dealWithPushEvent。 func handlePaymentPush(type: Int, dataJSON: String?) { if type == 6 { scanSuccessTime = Self.currentTimestamp() paymentSuccessData = nil return } guard type == 1, let dataJSON, let data = dataJSON.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } let orderAmount = Self.stringValue(json["order_amount"]) guard !orderAmount.isEmpty else { return } paymentSuccessData = PaymentSuccessDisplayData( orderAmount: orderAmount, orderNumber: Self.stringValue(json["order_number"]), payTime: Self.formattedPayTime(from: json["pay_time"]), remark: Self.stringValue(json["remark"]) ) scanSuccessTime = nil status = .idle } /// 根据输入金额和备注生成动态二维码 URL。 @discardableResult func applyDynamicAmount() -> Bool { guard let amount = Self.normalizedMoney(amountText) else { errorMessage = "请输入有效收款金额" return false } return applyDynamicAmount(normalizedAmount: amount) } /// 记录当前已知收款记录,轮询时只把新增记录判定为本次到账。 func primePaymentRecords(api: PaymentServing, scenicId: Int?) async { guard let scenicId else { return } if let response = try? await api.paymentCollectionRecords(scenicId: scenicId) { knownRecordIDs = Set(response.list.map(\.id)) } } /// 轮询收款记录,命中新记录后进入成功态。 func pollUntilPaymentDetected( api: PaymentServing, scenicId: Int?, maxAttempts: Int = 60, intervalNanoseconds: UInt64 = 2_000_000_000 ) async { guard let scenicId else { status = .failed("请先选择景区") return } guard maxAttempts > 0 else { return } isPolling = true defer { isPolling = false } for attempt in 0 ..< maxAttempts { if Task.isCancelled { return } do { let response = try await api.paymentCollectionRecords(scenicId: scenicId) if let item = matchedNewRecord(in: response.list) { knownRecordIDs.insert(item.id) applySuccessState(from: item) return } } catch { status = .failed(error.localizedDescription) return } if attempt < maxAttempts - 1 { try? await Task.sleep(nanoseconds: intervalNanoseconds) } } status = .failed("暂未查询到到账记录,请稍后刷新收款记录") } /// 清空收款码和轮询状态。 func resetPayCode() { staticPayUrl = "" dynamicPayUrl = "" currentPayUrl = "" qrImage = nil knownRecordIDs = [] status = .idle scanSuccessTime = nil paymentSuccessData = nil } /// 将输入金额规范成最多两位小数。 static func normalizedMoney(_ rawValue: String) -> String? { let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return nil } let sanitized = text.replacingOccurrences(of: ",", with: "") guard let decimal = Decimal(string: sanitized), decimal > 0 else { return nil } let number = NSDecimalNumber(decimal: decimal) let handler = NSDecimalNumberHandler( roundingMode: .plain, scale: 2, raiseOnExactness: false, raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: false ) return number.rounding(accordingToBehavior: handler).stringValue } /// 生成当前时间字符串,格式对齐 Android 扫码成功展示。 static func currentTimestamp() -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy-MM-dd HH:mm" return formatter.string(from: Date()) } /// 从推送或接口字段中解析付款时间。 static func formattedPayTime(from value: Any?) -> String { if let text = value as? String, !text.isEmpty { return text } if let number = value as? NSNumber { let timestamp = number.int64Value let epochSecond = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp let date = Date(timeIntervalSince1970: TimeInterval(epochSecond)) let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy-MM-dd HH:mm" return formatter.string(from: date) } return "" } /// 从 JSON 字段中提取字符串值。 static func stringValue(_ value: Any?) -> String { switch value { case let string as String: return string case let number as NSNumber: return number.stringValue case .some(let value): return "\(value)" case nil: return "" } } /// 订阅收款推送事件并转发到 ViewModel 状态。 private func startListeningPushEvents() { pushEventsCancellable = pushEventCenter.events .sink { [weak self] event in self?.handlePaymentPush(type: event.type, dataJSON: event.dataJSON) } } /// 根据输入金额和备注生成动态二维码 URL。 @discardableResult private func applyDynamicAmount(normalizedAmount amount: String) -> Bool { guard !dynamicPayUrl.paymentTrimmed.isEmpty else { errorMessage = "动态收款码暂不可用" return false } var components = URLComponents(string: dynamicPayUrl) var queryItems = components?.queryItems ?? [] queryItems.removeAll { $0.name == "amount" || $0.name == "remark" } queryItems.append(URLQueryItem(name: "amount", value: amount)) if !remarkText.paymentTrimmed.isEmpty { queryItems.append(URLQueryItem(name: "remark", value: remarkText.paymentTrimmed)) } components?.queryItems = queryItems currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)" qrImage = makeQRCode(from: currentPayUrl) amountText = amount status = .waiting return true } /// 轮询命中成功后同步 Android 付款详情卡所需字段。 private func applySuccessState(from item: PaymentCollectionRecordItem) { status = .success(item) paymentSuccessData = PaymentSuccessDisplayData( orderAmount: item.orderAmount, orderNumber: item.orderNumber, payTime: [item.createDate, item.createTime] .filter { !$0.isEmpty } .joined(separator: " "), remark: remarkText.paymentTrimmed ) scanSuccessTime = nil } /// 从收款记录中找到本次动态收款对应的新记录。 private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? { let newRecords = records.filter { !knownRecordIDs.contains($0.id) } guard !newRecords.isEmpty else { return nil } guard let amount = Self.normalizedMoney(amountText) else { return newRecords.first } return newRecords.first { Self.normalizedMoney($0.orderAmount) == amount } ?? newRecords.first } /// 生成二维码图片,保存相册和页面预览都使用同一张图。 private func makeQRCode(from text: String) -> UIImage? { guard !text.paymentTrimmed.isEmpty, let data = text.data(using: .utf8) else { return nil } qrFilter.setValue(data, forKey: "inputMessage") qrFilter.setValue("M", forKey: "inputCorrectionLevel") guard let output = qrFilter.outputImage else { return nil } let transformed = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil } return UIImage(cgImage: cgImage) } } private extension String { /// 收款模块内部使用的去空白文本,避免依赖其他文件的 fileprivate 扩展。 var paymentTrimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } /// 判断字符串是否匹配正则表达式。 func matches(regex pattern: String) -> Bool { range(of: pattern, options: .regularExpression) != nil } } @MainActor /// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。 final class PaymentCollectionRecordViewModel: ObservableObject { @Published var groups: [PaymentCollectionRecordGroup] = [] private var isLoading = false @Published var errorMessage: String? /// 加载当前景区收款记录,无景区时不请求接口。 func load(api: PaymentServing, scenicId: Int?) async { guard let scenicId else { groups = [] errorMessage = "请先选择景区" return } isLoading = true errorMessage = nil defer { isLoading = false } do { let response = try await api.paymentCollectionRecords(scenicId: scenicId) groups = Self.makeGroups(from: response) } catch { errorMessage = error.localizedDescription } } /// 将后端响应转换为可展示日期分组,缺少 analyse 时从 list 兜底生成汇总。 static func makeGroups(from response: PaymentCollectionRecordResponse) -> [PaymentCollectionRecordGroup] { if !response.analyse.isEmpty { return response.analyse.map { analyse in PaymentCollectionRecordGroup( analyse: analyse, items: response.list.filter { $0.createDate == analyse.date } ) } } let grouped = Dictionary(grouping: response.list, by: \.createDate) return grouped.keys.sorted(by: >).map { date in let items = grouped[date] ?? [] let total = items.reduce(Decimal(0)) { partial, item in partial + (Decimal(string: item.orderAmount) ?? 0) } let analyse = PaymentCollectionRecordAnalyseItem( date: date, orderCount: items.count, orderAmountSum: NSDecimalNumber(decimal: total).stringValue ) return PaymentCollectionRecordGroup(analyse: analyse, items: items) } } }