// // 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? /// 页面状态变化后通知 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) } }