Implement profile tab with Android-aligned flows and OSS upload.

Add personal info page, account switch, real-name auth, withdrawal settings, session cache extensions, AlibabaCloudOSS SPM, and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:01:05 +08:00
parent 02a80764ea
commit 005cac3f78
36 changed files with 4077 additions and 57 deletions

View File

@ -0,0 +1,219 @@
//
// WithdrawalSettingsViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class WithdrawalSettingsViewModel {
private(set) var currentStep = 1
private(set) var holderName = ""
private(set) var bankNo = ""
private(set) var bankCardFrontURL = ""
private(set) var bankCardBackURL = ""
private(set) var bankName = ""
private(set) var provinceCode = ""
private(set) var provinceName = ""
private(set) var cityCode = ""
private(set) var cityName = ""
private(set) var branchName = ""
private(set) var verificationCode = ""
private(set) var isAgree = false
private(set) var banks: [String] = []
private(set) var areas: [AreasResponse] = []
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data?
private var pendingFrontFileName: String?
private var pendingBackFileName: String?
var onStateChange: (() -> Void)?
var canGoNextStep: Bool {
currentStep == 1
&& !holderName.trimmed.isEmpty
&& (12 ... 25).contains(bankNo.filter(\.isNumber).count)
&& !bankCardFrontURL.isEmpty
&& !bankCardBackURL.isEmpty
&& isAgree
}
var canSubmit: Bool {
currentStep == 2
&& !holderName.trimmed.isEmpty
&& (12 ... 25).contains(bankNo.filter(\.isNumber).count)
&& !bankName.trimmed.isEmpty
&& !provinceCode.isEmpty
&& !cityCode.isEmpty
&& !branchName.trimmed.isEmpty
&& !verificationCode.trimmed.isEmpty
&& isAgree
&& !submitting
}
func updateHolderName(_ value: String) {
holderName = value
notifyStateChange()
}
func updateBankNo(_ value: String) {
bankNo = value
notifyStateChange()
}
func updateBankName(_ value: String) {
bankName = value
notifyStateChange()
}
func updateBranchName(_ value: String) {
branchName = value
notifyStateChange()
}
func updateVerificationCode(_ value: String) {
verificationCode = value
notifyStateChange()
}
func updateAgree(_ value: Bool) {
isAgree = value
notifyStateChange()
}
func selectProvince(_ area: AreasResponse) {
provinceCode = area.code
provinceName = area.name
cityCode = ""
cityName = ""
notifyStateChange()
}
func selectCity(_ area: AreasResponse) {
cityCode = area.code
cityName = area.name
notifyStateChange()
}
func goNextStep() {
guard canGoNextStep else { return }
currentStep = 2
notifyStateChange()
}
func prepareBankCardImage(data: Data, side: BankCardImageSide) throws {
let processed = try BankCardImageProcessor.process(data: data, side: side)
switch side {
case .front:
pendingFrontImageData = processed.data
pendingFrontFileName = processed.fileName
bankCardFrontURL = "pending"
case .back:
pendingBackImageData = processed.data
pendingBackFileName = processed.fileName
bankCardBackURL = "pending"
}
notifyStateChange()
}
func load(api: ProfileAPI) async throws {
guard !loading else { return }
loading = true
notifyStateChange()
defer {
loading = false
notifyStateChange()
}
async let bankList = api.bankList()
async let areaList = api.areas()
let result = try await (bankList, areaList)
banks = result.0.banks
areas = result.1
notifyStateChange()
}
func sendCode(api: ProfileAPI) async throws {
guard !sendingCode else { return }
sendingCode = true
notifyStateChange()
defer {
sendingCode = false
notifyStateChange()
}
try await api.bankCardSmsVerifyCode()
}
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
guard canSubmit else {
throw WithdrawalValidationError.message("请完善银行卡信息")
}
submitting = true
notifyStateChange()
defer {
submitting = false
notifyStateChange()
}
try await uploadPendingImages(uploader: uploader, scenicId: scenicId)
let request = UpdateBankInfoRequest(
realName: holderName.trimmed,
cardNumber: bankNo.filter(\.isNumber),
bankName: bankName.trimmed,
branchName: branchName.trimmed,
frontUrl: bankCardFrontURL,
backUrl: bankCardBackURL,
provinceCode: provinceCode,
cityCode: cityCode,
smsVerifyCode: verificationCode.trimmed
)
try await api.updateBankCard(request)
}
var provinceOptions: [AreasResponse] { areas }
var cityOptions: [AreasResponse] {
areas.first { $0.code == provinceCode || $0.name == provinceName }?.children ?? []
}
private func uploadPendingImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
if let pendingFrontImageData {
bankCardFrontURL = try await uploader.uploadBankCardImage(
data: pendingFrontImageData,
fileName: pendingFrontFileName ?? "bank_card_front.jpg",
scenicId: scenicId
) { _ in }
self.pendingFrontImageData = nil
}
if let pendingBackImageData {
bankCardBackURL = try await uploader.uploadBankCardImage(
data: pendingBackImageData,
fileName: pendingBackFileName ?? "bank_card_back.jpg",
scenicId: scenicId
) { _ in }
self.pendingBackImageData = nil
}
}
private func notifyStateChange() {
onStateChange?()
}
}
enum WithdrawalValidationError: LocalizedError, Equatable {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}