Files
suixinkan_ios_new/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift
汉秋 26f4d0e671 Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.
Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

245 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PaymentViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import CoreImage
import CoreImage.CIFilterBuiltins
import Foundation
import Combine
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 isLoading = false
@Published var isPolling = false
@Published var errorMessage: String?
@Published var status: PaymentCollectionStatus = .idle
@Published private var knownRecordIDs = Set<String>()
private let context = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator()
///
var hasStaticPayCode: Bool {
!staticPayUrl.paymentTrimmed.isEmpty
}
///
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
} catch {
errorMessage = error.localizedDescription
}
}
/// URL
@discardableResult
func applyDynamicAmount() -> Bool {
guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else {
errorMessage = "请输入有效收款金额"
return false
}
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
}
///
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)
status = .success(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
}
///
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
}
///
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)
}
}
@MainActor
/// ViewModel
final class PaymentCollectionRecordViewModel: ObservableObject {
@Published var groups: [PaymentCollectionRecordGroup] = []
@Published 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)
}
}
}