feat: 增加门店身份注销流程
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
/// 进入业务页面前的核验结果,不将非空未知记录解释为任何已知注销状态。
|
||||
enum StoreAccountDeregistrationAccessDecision: Equatable {
|
||||
case notChecked
|
||||
case checking
|
||||
case allowed
|
||||
case cooling
|
||||
case unresolved
|
||||
case failed(String)
|
||||
case obsolete
|
||||
}
|
||||
|
||||
/// 进入业务前核验当前身份;冷静期只能查询或由用户明确撤销,不自动重新登录。
|
||||
final class StoreAccountDeregistrationAccessViewModel {
|
||||
/// 未核验与查询失败都不能创建普通业务根页面。
|
||||
private(set) var decision: StoreAccountDeregistrationAccessDecision = .notChecked
|
||||
/// 防止同一页面重复发起查询。
|
||||
private(set) var isChecking = false
|
||||
/// 最近一次通过身份核验的服务端状态,用于冷静期展示和撤销前比对。
|
||||
private(set) var status: StoreAccountDeregistrationStatus?
|
||||
private var restrictionRevision = 0
|
||||
private var requiresSubmissionReconciliation: Bool
|
||||
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
||||
private var observedCooling = false
|
||||
|
||||
/// 本机存在待核实提交意图时,即使暂时返回 null 也不直接恢复普通业务。
|
||||
init(requiresSubmissionReconciliation: Bool = false,
|
||||
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
|
||||
self.submissionStore = submissionStore
|
||||
self.requiresSubmissionReconciliation = requiresSubmissionReconciliation
|
||||
|| submissionStore?.hasUnresolvedSubmission == true
|
||||
}
|
||||
|
||||
/// 新的限制信号优先于此前已发出的状态查询,防止旧正常响应覆盖新限制。
|
||||
func recordRestriction() {
|
||||
restrictionRevision += 1
|
||||
status = nil
|
||||
decision = .unresolved
|
||||
}
|
||||
|
||||
/// 查询前后核对身份;旧身份响应和错误均不能决定新会话是否进入业务页。
|
||||
func verify(api: any StoreAccountDeregistrationStatusServing,
|
||||
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
|
||||
guard !isChecking else { return }
|
||||
isChecking = true
|
||||
let revision = restrictionRevision
|
||||
decision = .checking
|
||||
status = nil
|
||||
defer { isChecking = false }
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
do {
|
||||
let result = try await api.status()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
apply(result)
|
||||
} catch {
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
if case APIError.serverCode(150015, _) = error {
|
||||
decision = .unresolved
|
||||
} else if case StoreAccountDeregistrationError.sessionChanged = error {
|
||||
decision = .obsolete
|
||||
} else {
|
||||
decision = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户确认后先重查同一申请再撤销,结果丢失时保留限制,禁止自动重发 POST。
|
||||
func cancel(api: any StoreAccountDeregistrationServing,
|
||||
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
|
||||
guard !isChecking, decision == .cooling, let recordID = status?.recordID else { return }
|
||||
isChecking = true
|
||||
let revision = restrictionRevision
|
||||
defer { isChecking = false }
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
do {
|
||||
let current = try await api.status()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
guard current.isCooling, current.recordID == recordID else { applyCancellationCheck(current); return }
|
||||
try await api.cancel()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
let confirmed = try await api.status()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
applyCancellationCheck(confirmed)
|
||||
} catch {
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
status = nil
|
||||
decision = .failed("撤销结果尚未确认,请重新查询状态。\n" + error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCancellationCheck(_ result: StoreAccountDeregistrationStatus) {
|
||||
// 曾查到冷静期后,旧 null/草稿不能证明撤销完成;须重新核实完整状态。
|
||||
guard result.isCooling || result.isCancelled else {
|
||||
status = result
|
||||
decision = .unresolved
|
||||
return
|
||||
}
|
||||
apply(result)
|
||||
}
|
||||
|
||||
private func apply(_ result: StoreAccountDeregistrationStatus) {
|
||||
status = result
|
||||
if submissionStore?.hasUnresolvedSubmission == true { requiresSubmissionReconciliation = true }
|
||||
if result.isCancelled, submissionStore?.clearCancelledSubmission(matching: result) == true {
|
||||
requiresSubmissionReconciliation = false
|
||||
}
|
||||
if result.isCooling {
|
||||
observedCooling = true
|
||||
decision = .cooling
|
||||
} else {
|
||||
decision = result.permitsPreparation && !requiresSubmissionReconciliation
|
||||
&& (!observedCooling || result.isCancelled) ? .allowed : .unresolved
|
||||
}
|
||||
}
|
||||
|
||||
/// 150015 只影响发出请求时的同一门店身份凭证,不能按手机号传播限制。
|
||||
static func restrictionApplies(requestToken: String?, session: AppSessionStore) -> Bool {
|
||||
guard session.accountType == .storeUser,
|
||||
let requestToken, !requestToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
||||
return requestToken == session.token
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
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 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 }
|
||||
}
|
||||
|
||||
/// 校验输入后申请;服务端明确接受即交由页面退出,不再查询状态或推断正式完成。
|
||||
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 !code.isEmpty, !reason.isEmpty else {
|
||||
errorMessage = "请输入收到的短信验证码和注销原因"
|
||||
return
|
||||
}
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
try await checkReady(api: api)
|
||||
previousCancellationFingerprint = status?.cancellationFingerprint
|
||||
try submissionStore?.recordSubmissionIntent(previousStatus: status)
|
||||
submissionAttempted = true
|
||||
step = .unresolvedRequest
|
||||
eligibility = nil
|
||||
try await api.apply(smsCode: code, reason: reason)
|
||||
submissionAccepted = true
|
||||
} catch {
|
||||
// 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。
|
||||
if !submissionAccepted, case APIError.serverCode = error {
|
||||
submissionStore?.clearRejectedSubmission()
|
||||
submissionAttempted = false
|
||||
step = .conditions
|
||||
}
|
||||
invalidate(error)
|
||||
}
|
||||
}
|
||||
|
||||
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: "资产确认尚未完成,请刷新后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user