完善实名认证、钱包与打卡点模块 UI 与业务逻辑。

对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 16:28:10 +08:00
parent 07b2b9d459
commit 5138c1c11a
63 changed files with 3101 additions and 745 deletions

View File

@ -5,59 +5,147 @@
import Foundation
/// ViewModel
///
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 = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
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 pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data?
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 pendingFrontFileName: String?
private var pendingBackFileName: 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) {
idCardNo = value
guard mode.isEditable else { return }
idCardNo = String(value.filter { $0.isNumber || $0 == "X" || $0 == "x" }.prefix(18)).uppercased()
notifyStateChange()
}
/// 11
func updateSmsCode(_ value: String) {
smsCode = value
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) {
startDate = value
guard mode.isEditable else { return }
startDate = Calendar.current.startOfDay(for: value)
notifyStateChange()
}
///
func updateEndDate(_ value: Date) {
endDate = value
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
@ -67,11 +155,37 @@ final class RealNameAuthViewModel {
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 !sendingCode else { return }
guard canRequestCode else { return }
sendingCode = true
notifyStateChange()
defer {
@ -79,31 +193,56 @@ final class RealNameAuthViewModel {
notifyStateChange()
}
try await api.realNameSmsVerifyCode()
statusMessage = "验证码发送"
statusMessage = "验证码发送成功"
startCountdown()
notifyStateChange()
}
func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws {
///
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)
switch side {
case .front:
pendingFrontImageData = processed.data
pendingFrontFileName = processed.fileName
frontUrl = ""
case .back:
pendingBackImageData = processed.data
pendingBackFileName = processed.fileName
backUrl = ""
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, uploader: any OSSUploadServing, scenicId: Int) async throws {
if info?.verified == true {
statusMessage = "已完成实名认证"
notifyStateChange()
return
}
///
func submit(api: ProfileAPI) async throws {
guard mode.isEditable, !submitting else { return }
if let validationMessage {
throw RealNameValidationError.message(validationMessage)
}
@ -114,30 +253,45 @@ final class RealNameAuthViewModel {
submitting = false
notifyStateChange()
}
try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId)
try await api.realNameSubmit(makeRequest())
statusMessage = "已提交审核"
try await api.realNameSubmit(try 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 "证件结束日期不能早于起始日期" }
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 }
@ -152,38 +306,112 @@ final class RealNameAuthViewModel {
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?) {
self.info = info
guard let info else {
notifyStateChange()
isAgreed = false
return
}
realName = info.realName
idCardNo = info.idCardNo
frontUrl = info.frontUrl ?? ""
backUrl = info.backUrl ?? ""
pendingFrontImageData = nil
pendingBackImageData = nil
frontPreviewImageData = nil
backPreviewImageData = nil
isLongValid = info.isLongValid
startDate = Self.date(from: info.startDate) ?? startDate
endDate = Self.date(from: info.endDate) ?? endDate
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 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 func setURL(_ url: String, side: RealNameImageSide) {
if side == .front {
frontUrl = url
} else {
backUrl = url
}
notifyStateChange()
}
private static let dateFormatter: DateFormatter = {
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")
@ -196,33 +424,16 @@ final class RealNameAuthViewModel {
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