对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
449 lines
15 KiB
Swift
449 lines
15 KiB
Swift
//
|
||
// 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<Void, Never>?
|
||
|
||
/// 页面状态变化后通知 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)
|
||
}
|
||
}
|