统一收款详情布局、设置金额弹窗和收款记录样式,接入 type 6/1 推送驱动与轮询 fallback,使 iOS 收款反馈与 Android 一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
413 lines
15 KiB
Swift
413 lines
15 KiB
Swift
//
|
||
// 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<String>()
|
||
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)
|
||
}
|
||
}
|
||
}
|