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