Add Payment and Wallet modules with home routing integration.

Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-22 17:46:01 +08:00
parent c2652b83c4
commit e8bc9b7f44
23 changed files with 3117 additions and 9 deletions

View File

@ -25,6 +25,10 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection))
}
///
@ -33,10 +37,6 @@ final class HomeMenuRouterTests: XCTestCase {
HomeMenuRouter.resolve(uri: "task_management", title: ""),
.destination(.modulePlaceholder(uri: "task_management", title: "任务管理"))
)
XCTAssertEqual(
HomeMenuRouter.resolve(uri: "payment_qr", title: ""),
.destination(.modulePlaceholder(uri: "payment_qr", title: "收款码"))
)
}
/// Tab HomeMenuRouter

View File

@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase {
func testPushedRoutesHideTabBarByDefault() {
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
}

View File

@ -0,0 +1,108 @@
//
// PaymentViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/22.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class PaymentViewModelTests: XCTestCase {
///
func testLoadPayCodeWithoutScenicClearsState() async {
let api = PaymentMockAPI()
let viewModel = PaymentCollectionViewModel()
viewModel.staticPayUrl = "old"
await viewModel.loadPayCode(api: api, scenicId: nil)
XCTAssertTrue(viewModel.staticPayUrl.isEmpty)
XCTAssertEqual(api.payCodeScenicIds, [])
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
}
/// URL
func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async {
let api = PaymentMockAPI()
api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic")
let viewModel = PaymentCollectionViewModel()
await viewModel.loadPayCode(api: api, scenicId: 9)
XCTAssertEqual(api.payCodeScenicIds, [9])
XCTAssertEqual(viewModel.currentPayUrl, "https://pay.example.com/static")
XCTAssertTrue(viewModel.hasStaticPayCode)
}
/// URL
func testApplyDynamicAmountBuildsDynamicURL() async {
let viewModel = PaymentCollectionViewModel()
viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9"
viewModel.amountText = "12.345"
viewModel.remarkText = "茶水"
XCTAssertTrue(viewModel.applyDynamicAmount())
XCTAssertTrue(viewModel.currentPayUrl.contains("amount=12.35"))
XCTAssertTrue(viewModel.currentPayUrl.contains("remark="))
XCTAssertEqual(viewModel.status, .waiting)
}
///
func testPollingFindsNewPaymentRecord() async {
let api = PaymentMockAPI()
api.recordResponses = [
PaymentCollectionRecordResponse(list: [
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00")
]),
PaymentCollectionRecordResponse(list: [
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00"),
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")
])
]
let viewModel = PaymentCollectionViewModel()
viewModel.amountText = "12.35"
await viewModel.primePaymentRecords(api: api, scenicId: 9)
await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0)
XCTAssertEqual(viewModel.status, .success(PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")))
}
///
func testRecordGroupsFallbackFromList() {
let response = PaymentCollectionRecordResponse(list: [
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-21", createTime: "10:00"),
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.00", createDate: "2026-06-21", createTime: "10:01")
])
let groups = PaymentCollectionRecordViewModel.makeGroups(from: response)
XCTAssertEqual(groups.count, 1)
XCTAssertEqual(groups[0].analyse.orderCount, 2)
XCTAssertEqual(groups[0].analyse.orderAmountSum, "20")
}
}
@MainActor
/// API
private final class PaymentMockAPI: PaymentServing {
var payCodeResponse = PayCodeResponse()
var recordResponses: [PaymentCollectionRecordResponse] = []
private(set) var payCodeScenicIds: [Int] = []
private(set) var recordScenicIds: [Int] = []
/// ID
func payCode(scenicId: Int) async throws -> PayCodeResponse {
payCodeScenicIds.append(scenicId)
return payCodeResponse
}
/// ID
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
recordScenicIds.append(scenicId)
return recordResponses.isEmpty ? PaymentCollectionRecordResponse() : recordResponses.removeFirst()
}
}

View File

@ -370,6 +370,11 @@ private final class MockOSSUploader: OSSUploadServing {
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
avatarURL
}
///
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
avatarURL
}
}
/// JPEG

View File

@ -43,6 +43,10 @@ final class UploadTests: XCTestCase {
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
)
XCTAssertEqual(
OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone),
"bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg"
)
}
/// MIME URL

View File

@ -0,0 +1,222 @@
//
// WalletViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/22.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class WalletViewModelTests: XCTestCase {
///
func testWalletInitialLoadFetchesSummaryAndEarnings() async {
let api = WalletMockAPI()
api.summary = WalletSummaryResponse(amountTotal: "100", amountCurrentBalance: "80", amountWithdrawable: "60")
api.earningResponses = [WalletEarningDetailResponse(total: 1, list: [WalletEarningDetailGroup(date: "2026-06-22")])]
let viewModel = WalletViewModel()
await viewModel.loadInitial(api: api, staffId: 7)
XCTAssertEqual(viewModel.withdrawableText, "¥ 60")
XCTAssertEqual(api.pointOverviewStaffIds, [7])
XCTAssertEqual(api.earningQueries.count, 1)
XCTAssertEqual(viewModel.earningsGroups.map(\.date), ["2026-06-22"])
}
/// Tab
func testSelectWithdrawTabLoadsWithdrawRecords() async {
let api = WalletMockAPI()
let viewModel = WalletViewModel()
await viewModel.selectTab(.withdraws, api: api)
XCTAssertEqual(api.withdrawListPages, [1])
}
///
func testWithdrawDecisionRoutesByRealNameAndBankCardState() async throws {
let profile = WalletRealNameMockAPI()
let wallet = WalletMockAPI()
let viewModel = WalletViewModel()
profile.response = RealNameInfoResponse(realNameInfo: nil)
var decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
XCTAssertEqual(decision, .route(.realNameAuth))
profile.response = try Self.realNameInfo(status: 2)
wallet.bankCardResponse = BankCardInfoResponse(bankCard: nil)
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
XCTAssertEqual(decision, .route(.bankCardSettings))
wallet.bankCardResponse = try BankCardInfoResponse(bankCard: Self.bankCard(status: 2))
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
XCTAssertEqual(decision, .route(.withdrawApply))
}
///
func testWithdrawApplySubmitsNormalizedAmount() async throws {
let api = WalletMockAPI()
api.withdrawInfoResponse = try Self.withdrawInfo(withdrawable: "100")
let viewModel = WithdrawApplyViewModel()
await viewModel.load(api: api)
viewModel.amountText = "12.345"
viewModel.smsCode = "8899"
let success = await viewModel.submit(api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.withdrawApplyBodies.map(\.amount), ["12.35"])
XCTAssertEqual(api.withdrawApplyBodies.map(\.smsCode), ["8899"])
}
/// URL
func testWithdrawalSettingsUploadsImagesBeforeSubmit() async {
let api = WalletMockAPI()
let uploader = WalletUploadMock()
let viewModel = WithdrawalSettingsViewModel()
viewModel.realName = "张三"
viewModel.cardNumber = "622200"
viewModel.bankName = "中国银行"
viewModel.branchName = "杭州支行"
viewModel.provinceCode = "330000"
viewModel.cityCode = "330100"
viewModel.smsCode = "1234"
viewModel.frontImageData = Data([1])
viewModel.backImageData = Data([2])
let success = await viewModel.submit(api: api, uploader: uploader, scenicId: 9)
XCTAssertTrue(success)
XCTAssertEqual(uploader.bankUploads.count, 2)
XCTAssertEqual(api.updateBankRequests.first?.frontUrl, "https://cdn.example.com/bank_card_front.jpg")
XCTAssertEqual(api.updateBankRequests.first?.backUrl, "https://cdn.example.com/bank_card_back.jpg")
}
///
func testPointsRedemptionValidationAndSubmit() async {
let api = WalletMockAPI()
api.pointOverviewResponse = PointOverviewResponse(withdrawnPoints: 100)
let viewModel = PointsRedemptionViewModel()
await viewModel.load(api: api, staffId: 7)
viewModel.pointsText = "120"
var success = await viewModel.submit(api: api, staffId: 7)
XCTAssertFalse(success)
XCTAssertEqual(viewModel.errorMessage, "兑换积分不能超过可兑换积分")
viewModel.pointsText = "80"
success = await viewModel.submit(api: api, staffId: 7)
XCTAssertTrue(success)
XCTAssertEqual(api.pointApplyBodies.map(\.points), [80])
}
/// JSON
private static func realNameInfo(status: Int) throws -> RealNameInfoResponse {
let data = """
{"real_name_info":{"real_name":"","id_card_no":"11010519491231002X","audit_status":\(status),"is_long_valid":1}}
""".data(using: .utf8)!
return try JSONDecoder().decode(RealNameInfoResponse.self, from: data)
}
/// JSON
private static func bankCard(status: Int) throws -> WalletBankCardInfo {
let data = """
{"real_name":"","bank_name":"","branch_name":"","card_number":"622200","audit_status":\(status),"audit_status_label":""}
""".data(using: .utf8)!
return try JSONDecoder().decode(WalletBankCardInfo.self, from: data)
}
/// JSON
fileprivate static func withdrawInfo(withdrawable: String) throws -> WithdrawInfoResponse {
let data = """
{"amount_withdrawable":"\(withdrawable)","min_withdraw_amount":"1","max_single_withdraw_amount":"1000","max_daily_withdraw_amount":"1000","user_phone":"13800000000","bank_card":{"real_name":"","bank_name":"","card_number":"622200"},"withdraw_info":[]}
""".data(using: .utf8)!
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
}
}
@MainActor
/// API
private final class WalletMockAPI: WalletServing {
var summary = WalletSummaryResponse()
var earningResponses: [WalletEarningDetailResponse] = []
var withdrawResponses: [WalletWithdrawListResponse] = []
var withdrawInfoResponse: WithdrawInfoResponse?
var bankCardResponse = BankCardInfoResponse(bankCard: nil)
var pointOverviewResponse = PointOverviewResponse()
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
private(set) var withdrawListPages: [Int] = []
private(set) var withdrawApplyBodies: [(amount: String, smsCode: String)] = []
private(set) var updateBankRequests: [UpdateBankInfoRequest] = []
private(set) var pointOverviewStaffIds: [Int] = []
private(set) var pointApplyBodies: [(points: Int, remark: String)] = []
func walletSummary(type: Int) async throws -> WalletSummaryResponse { summary }
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
earningQueries.append((startDate, endDate, page))
return earningResponses.isEmpty ? WalletEarningDetailResponse() : earningResponses.removeFirst()
}
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
withdrawListPages.append(page)
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
}
func withdrawInfo() async throws -> WithdrawInfoResponse {
if let withdrawInfoResponse { return withdrawInfoResponse }
return try WalletViewModelTests.withdrawInfo(withdrawable: "0")
}
func withdrawSendSms() async throws {}
func withdrawApply(amount: String, smsCode: String) async throws {
withdrawApplyBodies.append((amount, smsCode))
}
func bankCardInfo() async throws -> BankCardInfoResponse { bankCardResponse }
func bankList() async throws -> BankListResponse { BankListResponse(banks: ["中国银行"]) }
func areas() async throws -> [AreaNode] { [] }
func bankCardVerifyCode() async throws {}
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
updateBankRequests.append(request)
}
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
pointOverviewStaffIds.append(staffId)
return pointOverviewResponse
}
func pointWithdrawApply(points: Int, remark: String) async throws {
pointApplyBodies.append((points, remark))
}
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
PointWithdrawListResponse()
}
}
@MainActor
///
private final class WalletRealNameMockAPI: WalletRealNameServing {
var response = RealNameInfoResponse(realNameInfo: nil)
///
func realNameInfo() async throws -> RealNameInfoResponse {
response
}
}
@MainActor
///
private final class WalletUploadMock: OSSUploadServing {
private(set) var bankUploads: [String] = []
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
bankUploads.append(fileName)
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}