Files
suixinkan_uikit/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift
汉秋 5138c1c11a 完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:28:10 +08:00

449 lines
15 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.

//
// RealNameAuthViewModel.swift
// suixinkan
//
import Foundation
///
enum RealNameAuthMode: Equatable {
case newApplication
case pending
case approved
case rejected
///
var isReadOnly: Bool {
self == .pending || self == .approved
}
///
var isEditable: Bool {
!isReadOnly
}
}
///
enum RealNameAuthStep: Int, Equatable {
case identity = 1
case verification = 2
}
/// ViewModel
final class RealNameAuthViewModel {
private(set) var info: RealNameInfo?
private(set) var mode: RealNameAuthMode
private(set) var currentStep: RealNameAuthStep = .identity
private(set) var realName = ""
private(set) var idCardNo = ""
private(set) var smsCode = ""
private(set) var startDate: Date?
private(set) var endDate: Date?
private(set) var isLongValid = false
private(set) var isAgreed = false
private(set) var frontUrl = ""
private(set) var backUrl = ""
private(set) var frontPreviewImageData: Data?
private(set) var backPreviewImageData: Data?
private(set) var frontUploadProgress = 0
private(set) var backUploadProgress = 0
private(set) var isFrontUploading = false
private(set) var isBackUploading = false
private(set) var loading = false
private(set) var sendingCode = false
private(set) var submitting = false
private(set) var otpCountdown = 0
private(set) var statusMessage: String?
private var countdownTask: Task<Void, Never>?
/// View
var onStateChange: (() -> Void)?
/// 使
init(info: RealNameInfo? = nil) {
self.info = info
mode = Self.mode(for: info)
apply(info)
}
deinit {
countdownTask?.cancel()
}
///
var isUploading: Bool {
isFrontUploading || isBackUploading
}
///
var canGoNextStep: Bool {
guard mode.isEditable, currentStep == .identity, !isUploading else { return false }
return identityValidationMessage == nil && isAgreed
}
///
var canSubmit: Bool {
guard mode.isEditable, currentStep == .verification, !submitting, !isUploading else { return false }
return validationMessage == nil
}
///
var canRequestCode: Bool {
mode.isEditable && currentStep == .verification && otpCountdown == 0 && !sendingCode
}
///
func updateRealName(_ value: String) {
guard mode.isEditable else { return }
realName = value
notifyStateChange()
}
/// X
func updateIdCardNo(_ value: String) {
guard mode.isEditable else { return }
idCardNo = String(value.filter { $0.isNumber || $0 == "X" || $0 == "x" }.prefix(18)).uppercased()
notifyStateChange()
}
/// 11
func updateSmsCode(_ value: String) {
guard mode.isEditable else { return }
smsCode = String(value.filter(\.isNumber).prefix(11))
notifyStateChange()
}
///
func toggleAgreement() {
guard mode.isEditable else { return }
isAgreed.toggle()
notifyStateChange()
}
///
func updateLongValid(_ value: Bool) {
guard mode.isEditable else { return }
isLongValid = value
if value {
endDate = nil
}
notifyStateChange()
}
///
func updateStartDate(_ value: Date) {
guard mode.isEditable else { return }
startDate = Calendar.current.startOfDay(for: value)
notifyStateChange()
}
///
func updateEndDate(_ value: Date) {
guard mode.isEditable, !isLongValid else { return }
endDate = Calendar.current.startOfDay(for: 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()
info = response.realNameInfo
mode = Self.mode(for: response.realNameInfo)
apply(response.realNameInfo)
}
///
@discardableResult
func goToVerificationStep() -> String? {
guard mode.isEditable else {
currentStep = .verification
notifyStateChange()
return nil
}
if let identityValidationMessage {
return identityValidationMessage
}
guard isAgreed else { return "请阅读并同意用户协议与隐私政策" }
currentStep = .verification
notifyStateChange()
return nil
}
///
func backToIdentityStep() {
currentStep = .identity
notifyStateChange()
}
/// 60
func sendCode(api: ProfileAPI) async throws {
guard canRequestCode else { return }
sendingCode = true
notifyStateChange()
defer {
sendingCode = false
notifyStateChange()
}
try await api.realNameSmsVerifyCode()
statusMessage = "验证码发送成功"
startCountdown()
notifyStateChange()
}
///
func uploadIdentityImage(
data: Data,
side: RealNameImageSide,
uploader: any OSSUploadServing,
scenicId: Int,
timestamp: TimeInterval = Date().timeIntervalSince1970
) async throws {
guard mode.isEditable else { return }
guard !uploading(side) else { return }
let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp)
setPreview(processed.data, side: side)
setUploading(true, progress: 0, side: side)
defer { setUploading(false, progress: progress(for: side), side: side) }
do {
let url = try await uploader.uploadRealNameImage(
data: processed.data,
fileName: processed.fileName,
scenicId: scenicId
) { [weak self] progress in
self?.setProgress(progress, side: side)
}
setURL(url, side: side)
setProgress(100, side: side)
} catch {
setPreview(nil, side: side)
setURL("", side: side)
throw error
}
}
///
func deleteIdentityImage(side: RealNameImageSide) {
guard mode.isEditable, !uploading(side) else { return }
setPreview(nil, side: side)
setURL("", side: side)
setProgress(0, side: side)
notifyStateChange()
}
///
func submit(api: ProfileAPI) async throws {
guard mode.isEditable, !submitting else { return }
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
submitting = true
notifyStateChange()
defer {
submitting = false
notifyStateChange()
}
try await api.realNameSubmit(try makeRequest())
statusMessage = "认证成功"
notifyStateChange()
}
///
var validationMessage: String? {
if let identityValidationMessage { return identityValidationMessage }
if !isAgreed { return "请阅读并同意用户协议与隐私政策" }
if smsCode.trimmed.isEmpty { return "请输入短信验证码" }
return nil
}
/// Android DTO
func makeRequest() throws -> RealNameAuthRequest {
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
guard let startDate else {
throw RealNameValidationError.message("请选择证件起始日期")
}
return RealNameAuthRequest(
realName: realName.trimmed,
idCardNo: Self.normalizedIDCardNumber(idCardNo),
smsVerifyCode: smsCode.trimmed,
startDate: Self.dateFormatter.string(from: startDate),
endDate: isLongValid ? "" : endDate.map(Self.dateFormatter.string(from:)) ?? "",
isLongValid: isLongValid ? 1 : 0,
frontUrl: frontUrl.trimmed,
backUrl: backUrl.trimmed
)
}
///
nonisolated static func normalizedIDCardNumber(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
/// 18
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 var identityValidationMessage: String? {
if realName.trimmed.isEmpty { return "请输入真实姓名" }
if idCardNo.trimmed.isEmpty { return "请输入身份证号" }
if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" }
if backUrl.trimmed.isEmpty { return "请选择身份证国徽面图片" }
if frontUrl.trimmed.isEmpty { return "请选择身份证人像面图片" }
guard let startDate else { return "请选择证件起始日期" }
if !isLongValid {
guard let endDate else { return "请选择证件截止日期" }
if endDate < startDate { return "证件结束日期不能早于起始日期" }
}
return nil
}
private func apply(_ info: RealNameInfo?) {
guard let info else {
isAgreed = false
return
}
realName = info.realName
idCardNo = info.idCardNo
frontUrl = info.frontUrl ?? ""
backUrl = info.backUrl ?? ""
frontPreviewImageData = nil
backPreviewImageData = nil
isLongValid = info.isLongValid
startDate = Self.date(from: info.startDate)
endDate = info.isLongValid ? nil : Self.date(from: info.endDate)
isAgreed = true
}
private static func mode(for info: RealNameInfo?) -> RealNameAuthMode {
guard let info else { return .newApplication }
switch info.auditStatus {
case 2: return .approved
case 3: return .rejected
default: return .pending
}
}
private func uploading(_ side: RealNameImageSide) -> Bool {
side == .front ? isFrontUploading : isBackUploading
}
private func progress(for side: RealNameImageSide) -> Int {
side == .front ? frontUploadProgress : backUploadProgress
}
private func setPreview(_ data: Data?, side: RealNameImageSide) {
if side == .front {
frontPreviewImageData = data
} else {
backPreviewImageData = data
}
notifyStateChange()
}
private func setURL(_ url: String, side: RealNameImageSide) {
if side == .front {
frontUrl = url
} else {
backUrl = url
}
notifyStateChange()
}
private func setUploading(_ uploading: Bool, progress: Int, side: RealNameImageSide) {
if side == .front {
isFrontUploading = uploading
frontUploadProgress = progress
} else {
isBackUploading = uploading
backUploadProgress = progress
}
notifyStateChange()
}
private func setProgress(_ progress: Int, side: RealNameImageSide) {
let normalized = max(0, min(100, progress))
if side == .front {
frontUploadProgress = normalized
} else {
backUploadProgress = normalized
}
notifyStateChange()
}
private func startCountdown(seconds: Int = 60) {
countdownTask?.cancel()
otpCountdown = seconds
countdownTask = Task { [weak self] in
guard seconds > 0 else { return }
for remaining in stride(from: seconds - 1, through: 0, by: -1) {
do {
try await Task.sleep(for: .seconds(1))
} catch {
return
}
guard let self else { return }
self.otpCountdown = remaining
self.notifyStateChange()
}
}
}
nonisolated 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 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)
}
}