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,77 @@
//
// AccountSwitchViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class AccountSwitchViewModel {
private(set) var accounts: [AccountSwitchAccount] = []
private(set) var selectedAccountId: String?
private(set) var loading = false
private(set) var switching = false
private var didLoad = false
var onStateChange: (() -> Void)?
var selectedAccount: AccountSwitchAccount? {
accounts.first { $0.id == selectedAccountId }
}
func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws {
guard force || !didLoad else { return }
didLoad = true
loading = true
notifyStateChange()
defer {
loading = false
notifyStateChange()
}
let response = try await api.switchableAccounts()
accounts = response.accounts.filter { $0.businessUserId > 0 }
selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id
?? accounts.first?.id
notifyStateChange()
}
func select(_ account: AccountSwitchAccount) {
selectedAccountId = account.id
notifyStateChange()
}
func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse {
guard !switching else { throw AccountSwitchError.submitting }
guard account.businessUserId > 0 else { throw AccountSwitchError.invalidAccount }
switching = true
notifyStateChange()
defer {
switching = false
notifyStateChange()
}
return try await api.setUser(account.toSetUserRequest())
}
func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool {
guard let currentAccountId else { return false }
return account.id == currentAccountId || String(account.businessUserId) == currentAccountId
}
private func notifyStateChange() {
onStateChange?()
}
}
enum AccountSwitchError: LocalizedError, Equatable {
case submitting
case invalidAccount
var errorDescription: String? {
switch self {
case .submitting: "账号正在切换,请稍候"
case .invalidAccount: "账号信息异常,请重新选择账号"
}
}
}

View File

@ -0,0 +1,249 @@
//
// ProfileViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class ProfileViewModel {
private(set) var userInfo: UserInfoResponse?
private(set) var realNameInfo: RealNameInfo?
private(set) var bankCardInfo: BankCardInfo?
private(set) var isLoading = false
private(set) var isSaving = false
private(set) var isEditingProfile = false
private(set) var editingNickname = ""
private(set) var pendingAvatarData: Data?
private(set) var pendingAvatarFileName: String?
private(set) var avatarUploadProgress: Int?
var onStateChange: (() -> Void)?
var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
}
var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--"
}
var displayPhone: String {
nonEmpty(userInfo?.phone) ?? AppStore.shared.phone.nonEmpty ?? "--"
}
var displayAvatarURL: String {
nonEmpty(userInfo?.avatar) ?? AppStore.shared.avatar.nonEmpty ?? ""
}
var displayUID: String {
let uid = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
return uid.isEmpty ? "--" : uid
}
var accountDisplayName: String {
let name = AppStore.shared.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name
}
var currentScenicName: String {
let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name
}
var accountStatusText: String {
nonEmpty(userInfo?.statusName) ?? "--"
}
var showPhotographerFields: Bool {
if AppStore.shared.isPhotographerRole {
return true
}
if let roleName = nonEmpty(userInfo?.roleName) {
return AppRoleCode.fromRoleName(roleName)?.isPhotographer == true || roleName == "摄影师"
}
return false
}
var realNameStatusText: String {
guard let realNameInfo else { return "点击去实名认证" }
switch realNameInfo.auditStatus {
case 2: return "已实名认证"
case 3: return "审核不通过"
default: return "审核中"
}
}
var bankCardDisplayText: String {
guard let bankCardInfo else { return "点击绑定银行卡" }
if bankCardInfo.auditStatus == 2 {
let masked = Self.maskCardNumber(bankCardInfo.cardNumber)
return "\(bankCardInfo.bankName) \(masked)"
}
let label = bankCardInfo.auditStatusLabel.trimmingCharacters(in: .whitespacesAndNewlines)
return label.isEmpty ? "审核中" : label
}
func updateEditingNickname(_ value: String) {
editingNickname = value
notifyStateChange()
}
///
func reload(api: ProfileAPI) async throws {
guard !isLoading else { return }
isLoading = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
let info = try await api.userInfo()
userInfo = info
AppStore.shared.applyUserInfo(info)
if !info.roleName.isEmpty {
AppStore.shared.roleName = info.roleName
}
if showPhotographerFields {
async let realName = api.realNameInfo()
async let bankCard = api.bankCardInfo()
let extra = try await (realName, bankCard)
realNameInfo = extra.0.realNameInfo
bankCardInfo = extra.1.bankCard
} else {
realNameInfo = nil
bankCardInfo = nil
}
notifyStateChange()
}
func beginEditing() {
editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname
isEditingProfile = true
notifyStateChange()
}
func cancelEditing() {
editingNickname = ""
isEditingProfile = false
clearPendingAvatar()
notifyStateChange()
}
func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp)
pendingAvatarData = processed.data
pendingAvatarFileName = processed.fileName
if !isEditingProfile {
beginEditing()
} else {
notifyStateChange()
}
}
func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines)
guard !nextNickname.isEmpty else {
throw ProfileValidationError.emptyNickname
}
let nicknameChanged = nextNickname != displayNickname
let avatarChanged = pendingAvatarData != nil
guard nicknameChanged || avatarChanged else {
cancelEditing()
return
}
guard !isSaving else { return }
isSaving = true
avatarUploadProgress = avatarChanged ? 1 : nil
notifyStateChange()
defer {
isSaving = false
avatarUploadProgress = nil
notifyStateChange()
}
let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId)
if let uploadedAvatarURL {
try await api.updateUserAvatarURL(uploadedAvatarURL)
}
if nicknameChanged {
try await api.updateUserInfo(nickname: nextNickname)
}
let previous = userInfo ?? UserInfoResponse()
userInfo = UserInfoResponse(
avatar: uploadedAvatarURL ?? previous.avatar,
realName: previous.realName,
phone: previous.phone,
nickname: nextNickname,
roleName: previous.roleName,
status: previous.status,
statusName: previous.statusName
)
AppStore.shared.userName = nextNickname
if let uploadedAvatarURL {
AppStore.shared.avatar = uploadedAvatarURL
}
clearPendingAvatar()
cancelEditing()
}
func updatePassword(_ password: String, api: ProfileAPI) async throws {
let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
guard nextPassword.count >= 6 else {
throw ProfileValidationError.shortPassword
}
guard !isSaving else { return }
isSaving = true
notifyStateChange()
defer {
isSaving = false
notifyStateChange()
}
try await api.updateUserInfo(password: nextPassword)
}
static func maskCardNumber(_ cardNumber: String) -> String {
let digits = cardNumber.filter(\.isNumber)
guard digits.count >= 4 else { return cardNumber }
return "**** **** **** \(digits.suffix(4))"
}
private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? {
guard let pendingAvatarData else { return nil }
let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg"
return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in
self?.avatarUploadProgress = progress
self?.notifyStateChange()
}
}
private func clearPendingAvatar() {
pendingAvatarData = nil
pendingAvatarFileName = nil
avatarUploadProgress = nil
}
private func nonEmpty(_ value: String?) -> String? {
let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? nil : text
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension String {
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

@ -0,0 +1,237 @@
//
// RealNameAuthViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class RealNameAuthViewModel {
private(set) var info: RealNameInfo?
private(set) var realName = ""
private(set) var idCardNo = ""
private(set) var smsCode = ""
private(set) var startDate = Date()
private(set) var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
private(set) var isLongValid = false
private(set) var frontUrl = ""
private(set) var backUrl = ""
private(set) var pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data?
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var statusMessage: String?
private var pendingFrontFileName: String?
private var pendingBackFileName: String?
var onStateChange: (() -> Void)?
func updateRealName(_ value: String) {
realName = value
notifyStateChange()
}
func updateIdCardNo(_ value: String) {
idCardNo = value
notifyStateChange()
}
func updateSmsCode(_ value: String) {
smsCode = value
notifyStateChange()
}
func updateLongValid(_ value: Bool) {
isLongValid = value
notifyStateChange()
}
func updateStartDate(_ value: Date) {
startDate = value
notifyStateChange()
}
func updateEndDate(_ value: Date) {
endDate = value
notifyStateChange()
}
func load(api: ProfileAPI) async throws {
guard !loading else { return }
loading = true
notifyStateChange()
defer {
loading = false
notifyStateChange()
}
let response = try await api.realNameInfo()
apply(response.realNameInfo)
}
func sendCode(api: ProfileAPI) async throws {
guard !sendingCode else { return }
sendingCode = true
notifyStateChange()
defer {
sendingCode = false
notifyStateChange()
}
try await api.realNameSmsVerifyCode()
statusMessage = "验证码已发送"
notifyStateChange()
}
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
switch side {
case .front:
pendingFrontImageData = processed.data
pendingFrontFileName = processed.fileName
frontUrl = ""
case .back:
pendingBackImageData = processed.data
pendingBackFileName = processed.fileName
backUrl = ""
}
notifyStateChange()
}
func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws {
if info?.verified == true {
statusMessage = "已完成实名认证"
notifyStateChange()
return
}
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
submitting = true
notifyStateChange()
defer {
submitting = false
notifyStateChange()
}
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
try await api.realNameSubmit(makeRequest())
statusMessage = "已提交审核"
notifyStateChange()
try await load(api: api)
}
var validationMessage: String? {
if realName.trimmed.isEmpty { return "请输入真实姓名" }
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
if info?.verified != true {
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" }
if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" }
}
if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" }
return nil
}
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool {
let number = normalizedIDCardNumber(value)
guard number.count == 18 else { return false }
let chars = Array(number)
guard chars.prefix(17).allSatisfy(\.isNumber) else { return false }
guard chars[17].isNumber || chars[17] == "X" else { return false }
let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
let checkCodes = Array("10X98765432")
let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in
partial + (pair.0.wholeNumberValue ?? 0) * pair.1
}
return chars[17] == checkCodes[sum % 11]
}
private func apply(_ info: RealNameInfo?) {
self.info = info
guard let info else {
notifyStateChange()
return
}
realName = info.realName
idCardNo = info.idCardNo
frontUrl = info.frontUrl ?? ""
backUrl = info.backUrl ?? ""
pendingFrontImageData = nil
pendingBackImageData = nil
isLongValid = info.isLongValid
startDate = Self.date(from: info.startDate) ?? startDate
endDate = Self.date(from: info.endDate) ?? endDate
notifyStateChange()
}
private func makeRequest() -> RealNameAuthRequest {
RealNameAuthRequest(
realName: realName.trimmed,
idCardNo: Self.normalizedIDCardNumber(idCardNo),
smsVerifyCode: smsCode.trimmed,
startDate: Self.dateFormatter.string(from: startDate),
endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate),
isLongValid: isLongValid ? 1 : 0,
frontUrl: frontUrl.trimmed,
backUrl: backUrl.trimmed
)
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private static func date(from value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
return dateFormatter.date(from: value)
}
private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws {
if let pendingFrontImageData {
frontUrl = try await uploader.uploadRealNameImage(
data: pendingFrontImageData,
fileName: pendingFrontFileName ?? "real_name_front.jpg",
scenicId: scenicId
) { _ in }
self.pendingFrontImageData = nil
}
if let pendingBackImageData {
backUrl = try await uploader.uploadRealNameImage(
data: pendingBackImageData,
fileName: pendingBackFileName ?? "real_name_back.jpg",
scenicId: scenicId
) { _ in }
self.pendingBackImageData = nil
}
}
private func notifyStateChange() {
onStateChange?()
}
}
enum RealNameValidationError: LocalizedError, Equatable {
case message(String)
var errorDescription: String? {
switch self {
case .message(let message): message
}
}
}
private extension String {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
}

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)
}
}