import Foundation /// 注销流程本地交互阶段;不使用这些阶段伪造服务端业务状态。 enum StoreAccountDeregistrationStep: Equatable { case conditions case verification case unresolvedRequest } /// 门店身份注销流程,所有资产确认和提交都重新检查服务端条件,不持久化手机号级注销状态。 final class StoreAccountDeregistrationViewModel { /// 当前已核验的条件;刷新失败即清空,禁止继续使用过期权限。 private(set) var eligibility: StoreAccountDeregistrationEligibility? /// 当前已查询的原始申请记录。 private(set) var status: StoreAccountDeregistrationStatus? /// 页面交互阶段,不代表服务端冷静期状态。 private(set) var step: StoreAccountDeregistrationStep = .conditions /// 同一流程只允许一个在途操作。 private(set) var isBusy = false /// 显示给用户的错误或需要重新核验的原因。 private(set) var errorMessage: String? /// 请求已发出后阻止同一流程重复提交,包括响应丢失的情况。 private(set) var submissionAttempted = false /// 成功响应仅代表申请已提交,不代表账号注销完成。 private(set) var submissionAccepted = false /// 申请明确受理后,以当前凭证只读查询到的冷静期截止时间;查询失败不改变受理结果。 private(set) var submittedCoolingUntil: String? private let storeUserID: Int private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? private var previousCancellationFingerprint: String? private var needsUpdatedAssetConsent = false /// 注入被冻结的业务身份 ID;网络服务另行注入以便测试。 init(storeUserID: Int, submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) { self.storeUserID = storeUserID self.submissionStore = submissionStore if submissionStore?.hasUnresolvedSubmission == true { submissionAttempted = true step = .unresolvedRequest } } /// 仅当完整条件、两项确认和状态查询一致时,允许进入验证码步骤。 var canContinue: Bool { !isBusy && !submissionAttempted && status?.permitsPreparation == true && eligibility?.permitsApplication == true } /// 一个入口处理两项确认;其他业务条件未满足时不能开始。 var canConfirmAssetsAndContinue: Bool { !isBusy && !submissionAttempted && status?.permitsPreparation == true && eligibility?.permitsAssetConfirmation == true && (requiresAssetConfirmation || eligibility?.permitsApplication == true) } /// 未确认或确认期间资产变化时,必须重新向用户列出两项金额。 var requiresAssetConfirmation: Bool { needsUpdatedAssetConsent || eligibility?.walletWaived != true || eligibility?.pointsWaived != true } /// 用户一次明确确认两项资产后,顺序调用旧接口;只读核实部分结果,不自动重发修改请求。 func confirmAssetsAndContinue(snapshot: StoreAccountDeregistrationEligibility, api: any StoreAccountDeregistrationServing) async { guard canConfirmAssetsAndContinue else { return } isBusy = true errorMessage = nil defer { isBusy = false } do { for asset in [StoreAccountDeregistrationAsset.wallet, .points] { try await reload(api: api) let current = try validateAssetConsent(snapshot) let waived = asset == .wallet ? current.walletWaived : current.pointsWaived if !waived { if asset == .wallet { try await api.waiveWallet() } else { try await api.waivePoints() } try await reload(api: api) let confirmed = try validateAssetConsent(snapshot) guard asset == .wallet ? confirmed.walletWaived : confirmed.pointsWaived else { throw StoreAccountDeregistrationFlowError.confirmationIncomplete } } } guard eligibility?.permitsApplication == true, status?.permitsPreparation == true else { throw StoreAccountDeregistrationFlowError.conditionsChanged } needsUpdatedAssetConsent = false step = .verification } catch { if case StoreAccountDeregistrationFlowError.assetsChanged = error { needsUpdatedAssetConsent = true } if case StoreAccountDeregistrationError.sessionChanged = error { invalidate(error); return } // POST响应丢失也可能已经成功:仅GET恢复真实确认标记,下一次由用户主动重试。 do { try await reload(api: api) } catch { invalidate(error); return } if status?.permitsPreparation == true, eligibility?.permitsPreparation == true { step = .conditions } let partial = eligibility?.walletWaived == true && eligibility?.pointsWaived == false ? "现金余额已确认,积分尚未确认。\n" : "" errorMessage = partial + error.localizedDescription } } private func validateAssetConsent(_ snapshot: StoreAccountDeregistrationEligibility) throws -> StoreAccountDeregistrationEligibility { guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else { throw StoreAccountDeregistrationFlowError.existingRequest } guard current.hasSameAssets(as: snapshot), !(snapshot.walletWaived && !current.walletWaived), !(snapshot.pointsWaived && !current.pointsWaived) else { throw StoreAccountDeregistrationFlowError.assetsChanged } guard current.permitsAssetConfirmation else { throw StoreAccountDeregistrationFlowError.conditionsChanged } return current } /// 无申请或未提交草稿且条件可读取时允许确认资产,零余额也不自动确认。 func canConfirm(_ asset: StoreAccountDeregistrationAsset) -> Bool { guard !isBusy, !submissionAttempted, status?.permitsPreparation == true, let eligibility, eligibility.permitsPreparation, eligibility.walletBalanceFen >= 0, eligibility.pointsBalance >= 0 else { return false } return asset == .wallet ? !eligibility.walletWaived : !eligibility.pointsWaived } /// 用户主动刷新;后端非空记录无法识别时禁止继续申请,不以七天本地倒计时替代查询。 func refresh(api: any StoreAccountDeregistrationServing) async { guard !isBusy else { return } isBusy = true errorMessage = nil defer { isBusy = false } do { try await reload(api: api) } catch { invalidate(error) } } /// 用户已在单独弹窗中确认某种资产;提交前比对弹窗中的两项余额快照。 func confirm(_ asset: StoreAccountDeregistrationAsset, snapshot: StoreAccountDeregistrationEligibility, api: any StoreAccountDeregistrationServing) async { guard canConfirm(asset) else { return } isBusy = true errorMessage = nil defer { isBusy = false } do { try await reload(api: api) guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else { throw StoreAccountDeregistrationFlowError.existingRequest } guard current.hasSameAssets(as: snapshot) else { throw StoreAccountDeregistrationFlowError.assetsChanged } guard current.walletBalanceFen >= 0, current.pointsBalance >= 0 else { throw StoreAccountDeregistrationFlowError.conditionsChanged } if asset == .wallet { if !current.walletWaived { try await api.waiveWallet() } } else if !current.pointsWaived { try await api.waivePoints() } // 不乐观设置确认标记:只有新查询返回 true 才算已确认。 try await reload(api: api) } catch { invalidate(error) } } /// 条件全部满足后进入真实短信验证步骤。 func beginVerification() { guard canContinue else { return } step = .verification errorMessage = nil } /// 未发出申请前可以返回重新查看条件。 func returnToConditions() { guard !isBusy, !submissionAttempted else { return } step = .conditions } /// 不接受用户指定收件手机号,使用接口绑定的门店手机号。 func sendSMS(api: any StoreAccountDeregistrationServing) async -> Bool { guard canContinue, step == .verification else { return false } isBusy = true errorMessage = nil defer { isBusy = false } do { try await checkReady(api: api) try await api.sendSMS() return true } catch { invalidate(error); return false } } /// 同步校验验证码与必填原因;失败时不启动查询、持久化或申请请求。 @discardableResult func validateSubmissionInput(smsCode: String, reason: String) -> Bool { let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines) let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines) if code.isEmpty { errorMessage = "请输入收到的短信验证码" return false } if reason.isEmpty { errorMessage = "请输入注销原因" return false } if reason.count > 50 { errorMessage = "注销原因不能超过 50 个字" return false } errorMessage = nil return true } /// 校验输入后申请;服务端明确接受后只读查询一次冷静期截止时间,再交由页面退出。 func submit(smsCode: String, reason: String, api: any StoreAccountDeregistrationServing) async { guard canContinue, step == .verification else { return } let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines) let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines) guard validateSubmissionInput(smsCode: code, reason: reason) else { return } var verifiedEligibility: StoreAccountDeregistrationEligibility? var verifiedStatus: StoreAccountDeregistrationStatus? isBusy = true errorMessage = nil defer { isBusy = false } do { try await checkReady(api: api) verifiedEligibility = eligibility verifiedStatus = status previousCancellationFingerprint = status?.cancellationFingerprint try submissionStore?.recordSubmissionIntent(previousStatus: status) submissionAttempted = true step = .unresolvedRequest eligibility = nil try await api.apply(smsCode: code, reason: reason) submissionAccepted = true // 申请成功已经成立;状态查询只用于结果页展示,失败或尚未进入冷静期都不能推翻成功响应。 if let acceptedStatus = try? await api.status(), acceptedStatus.isCooling { status = acceptedStatus submittedCoolingUntil = acceptedStatus.coolingUntil } } catch { if !submissionAccepted, isVerificationCodeRejection(error) { submissionStore?.clearRejectedSubmission() submissionAttempted = false eligibility = verifiedEligibility status = verifiedStatus step = .verification errorMessage = error.localizedDescription return } // 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。 if !submissionAccepted, case APIError.serverCode = error { submissionStore?.clearRejectedSubmission() submissionAttempted = false step = .conditions } invalidate(error) } } /// 旧接口没有独立错误码,验证码拒绝只能按服务端业务提示识别。 private func isVerificationCodeRejection(_ error: Error) -> Bool { guard case let APIError.serverCode(_, message) = error else { return false } return message.localizedCaseInsensitiveContains("验证码") || message.localizedCaseInsensitiveContains("verification code") || message.localizedCaseInsensitiveContains("sms code") } private func checkReady(api: any StoreAccountDeregistrationServing) async throws { try await reload(api: api) guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else { throw StoreAccountDeregistrationFlowError.conditionsChanged } } private func reload(api: any StoreAccountDeregistrationServing) async throws { eligibility = nil status = nil let currentStatus = try await api.status() status = currentStatus if !currentStatus.permitsPreparation || submissionAttempted { step = .unresolvedRequest } let current = try await api.eligibility() guard current.storeUserID == storeUserID, storeUserID > 0 else { throw StoreAccountDeregistrationError.sessionChanged } eligibility = current let eligibilityStatus = StoreAccountDeregistrationStatus(deregister: current.deregister) if submissionAttempted, let fingerprint = currentStatus.cancellationFingerprint, eligibilityStatus.cancellationFingerprint == fingerprint { let reconciled = submissionStore.map { $0.clearCancelledSubmission(matching: currentStatus) } ?? (fingerprint != previousCancellationFingerprint) if reconciled { submissionAttempted = false submissionAccepted = false } } if !current.permitsPreparation { step = .unresolvedRequest } if step == .unresolvedRequest, currentStatus.permitsPreparation, current.permitsPreparation, !submissionAttempted { step = .conditions } if step == .verification, !current.permitsApplication { step = .conditions } } private func invalidate(_ error: Error) { eligibility = nil errorMessage = error.localizedDescription if !submissionAttempted, status?.permitsPreparation == true { step = .conditions } } } /// 本地交互检查失败,不替代服务端业务错误。 private enum StoreAccountDeregistrationFlowError: LocalizedError { case assetsChanged case conditionsChanged case existingRequest case confirmationIncomplete var errorDescription: String? { switch self { case .assetsChanged: "资产余额已变化,请刷新后重新确认" case .conditionsChanged: "注销条件已变化,请刷新并处理全部阻断项" case .existingRequest: "已查询到注销记录,请先核实申请状态" case .confirmationIncomplete: "资产确认尚未完成,请刷新后重试" } } }