// // RealNameAuthViewModel.swift // suixinkan // // Created by Codex on 2026/6/22. // import Foundation import Combine @MainActor /// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。 final class RealNameAuthViewModel: ObservableObject { @Published private(set) var info: RealNameInfo? @Published var realName = "" @Published var idCardNo = "" @Published var smsCode = "" @Published var startDate = Date() @Published var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() @Published var isLongValid = false @Published var frontUrl = "" @Published var backUrl = "" @Published private(set) var pendingFrontImageData: Data? @Published private(set) var pendingBackImageData: Data? @Published private(set) var frontUploadProgress: Int? @Published private(set) var backUploadProgress: Int? @Published private var pendingFrontFileName: String? @Published private var pendingBackFileName: String? @Published private(set) var loading = false @Published private(set) var sendingCode = false @Published private(set) var submitting = false @Published var statusMessage: String? /// 拉取实名认证信息,并回填表单。 func load(api: ProfileAPI) async throws { guard !loading else { return } loading = true defer { loading = false } let response = try await api.realNameInfo() apply(response.realNameInfo) } /// 发送短信验证码。 func sendCode(api: ProfileAPI) async throws { guard !sendingCode else { return } sendingCode = true defer { sendingCode = false } try await api.realNameSmsVerifyCode() statusMessage = "验证码已发送" } /// 准备待上传证件图片,并更新对应面的本地预览。 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 = "" } } /// 提交实名认证资料,成功后刷新审核状态。 func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { if info?.verified == true { statusMessage = "已完成实名认证" return } if let validationMessage { throw RealNameValidationError.message(validationMessage) } submitting = true defer { submitting = false } try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId) try await api.realNameSubmit(makeRequest()) statusMessage = "已提交审核" try await load(api: api) } /// 根据审核状态生成展示文案。 func auditStatusText(_ status: Int) -> String { switch status { case 1: "待审核" case 2: "审核通过" case 3: "审核不通过" default: "未认证" } } /// 当前表单校验错误,nil 表示可以提交。 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 } /// 应用接口返回的实名认证信息。 private func apply(_ info: RealNameInfo?) { self.info = info guard let info else { return } realName = info.realName idCardNo = info.idCardNo frontUrl = info.frontUrl ?? "" backUrl = info.backUrl ?? "" pendingFrontImageData = nil pendingBackImageData = nil pendingFrontFileName = nil pendingBackFileName = nil isLongValid = info.isLongValid startDate = Self.date(from: info.startDate) ?? startDate endDate = Self.date(from: info.endDate) ?? endDate } /// 生成提交请求。 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 ) } /// 规范化身份证号,去除空白并大写末位 X。 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 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 }() /// 将 yyyy-MM-dd 文本解析为日期。 private static func date(from value: String?) -> Date? { guard let value, !value.isEmpty else { return nil } return dateFormatter.date(from: value) } /// 上传待提交证件图片,并把返回 URL 写回表单字段。 private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws { if let pendingFrontImageData { frontUploadProgress = 1 defer { frontUploadProgress = nil } frontUrl = try await uploader.uploadRealNameImage( data: pendingFrontImageData, fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg", scenicId: scenicId ) { [weak self] progress in Task { @MainActor in self?.frontUploadProgress = progress } } self.pendingFrontImageData = nil pendingFrontFileName = nil } if let pendingBackImageData { backUploadProgress = 1 defer { backUploadProgress = nil } backUrl = try await uploader.uploadRealNameImage( data: pendingBackImageData, fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg", scenicId: scenicId ) { [weak self] progress in Task { @MainActor in self?.backUploadProgress = progress } } self.pendingBackImageData = nil pendingBackFileName = nil } } } /// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。 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) } }