171 lines
5.8 KiB
Swift
171 lines
5.8 KiB
Swift
//
|
||
// RealNameAuthViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import Foundation
|
||
import Observation
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||
final class RealNameAuthViewModel {
|
||
private(set) var info: RealNameInfo?
|
||
var realName = ""
|
||
var idCardNo = ""
|
||
var smsCode = ""
|
||
var startDate = Date()
|
||
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||
var isLongValid = false
|
||
var frontUrl = ""
|
||
var backUrl = ""
|
||
private(set) var loading = false
|
||
private(set) var sendingCode = false
|
||
private(set) var submitting = false
|
||
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 submit(api: ProfileAPI) async throws {
|
||
if info?.verified == true {
|
||
statusMessage = "已完成实名认证"
|
||
return
|
||
}
|
||
if let validationMessage {
|
||
throw RealNameValidationError.message(validationMessage)
|
||
}
|
||
|
||
submitting = true
|
||
defer { submitting = false }
|
||
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 { return "请填写身份证人像面图片 URL" }
|
||
if backUrl.trimmed.isEmpty { return "请填写身份证国徽面图片 URL" }
|
||
}
|
||
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 ?? ""
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。
|
||
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)
|
||
}
|
||
}
|