Files
suixinkan_ios_new/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

231 lines
8.6 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
//
// 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)
}
}