feat: 增加门店身份注销流程

This commit is contained in:
2026-08-31 09:43:14 +08:00
parent 396597a160
commit e529bb5942
35 changed files with 6062 additions and 45 deletions
@@ -0,0 +1,105 @@
import Foundation
/// 门店身份注销依赖;网络服务在主线程协调,ViewModel 自身不绑定 MainActor。
@MainActor
protocol StoreAccountDeregistrationStatusServing {
/// 只读查询当前注销记录,供进入普通业务前核验。
func status() async throws -> StoreAccountDeregistrationStatus
}
/// 完整注销服务,在状态查询之外提供条件、确认和申请操作。
@MainActor
protocol StoreAccountDeregistrationServing: StoreAccountDeregistrationStatusServing {
/// 获取资产与全部注销条件。
func eligibility() async throws -> StoreAccountDeregistrationEligibility
/// 分别确认现金余额。
func waiveWallet() async throws
/// 分别确认积分。
func waivePoints() async throws
/// 向当前身份的绑定手机号发送短信。
func sendSMS() async throws
/// 提交用户输入的验证码与注销原因。
func apply(smsCode: String, reason: String) async throws
/// 服务端判定是否允许撤销,不按本机截止时间判断。
func cancel() async throws
}
/// 旧门店身份注销接口请求层;仅使用提供的 account-deregister 路径和请求字段。
@MainActor
final class StoreAccountDeregistrationAPI: StoreAccountDeregistrationServing {
private let client: APIClient
private let identity: StoreAccountDeregistrationIdentity
private let isCurrentIdentity: @MainActor () -> Bool
private let basePath = "/api/yf-handset-app/account-deregister"
/// 注入网络客户端、冻结的门店身份和当前会话校验,便于隔离真实与测试请求。
init(
client: APIClient,
identity: StoreAccountDeregistrationIdentity,
isCurrentIdentity: @escaping @MainActor () -> Bool
) {
self.client = client
self.identity = identity
self.isCurrentIdentity = isCurrentIdentity
}
/// 查询当前门店身份的资产、风险期和全部阻断项。
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
let value: StoreAccountDeregistrationEligibility = try await send(APIRequest(method: .get, path: basePath + "/eligibility"))
guard String(value.storeUserID) == identity.userID else {
throw StoreAccountDeregistrationError.sessionChanged
}
return value
}
/// 调用旧现金确认接口;与积分接口的顺序协调由ViewModel负责。
func waiveWallet() async throws {
let _: EmptyPayload = try await send(APIRequest(
method: .post,
path: basePath + "/waivers/wallet",
body: StoreAccountDeregistrationWaiverRequest()
))
}
/// 调用旧积分确认接口;与现金接口的顺序协调由ViewModel负责。
func waivePoints() async throws {
let _: EmptyPayload = try await send(APIRequest(
method: .post,
path: basePath + "/waivers/points",
body: StoreAccountDeregistrationWaiverRequest()
))
}
/// 向当前门店身份绑定手机号发送短信;收件人由后端从 Token 确定。
func sendSMS() async throws {
let _: EmptyPayload = try await send(APIRequest(method: .post, path: basePath + "/send-sms"))
}
/// 使用用户输入的验证码和原因提交申请;成功后需查询服务端状态。
func apply(smsCode: String, reason: String) async throws {
let _: EmptyPayload = try await send(APIRequest(
method: .post,
path: basePath + "/apply",
body: StoreAccountDeregistrationApplyRequest(smsCode: smsCode, reason: reason)
))
}
/// 查询当前身份的草稿、冷静期、撤销、阻断或完成状态,不触发重新登录。
func status() async throws -> StoreAccountDeregistrationStatus {
try await send(APIRequest(method: .get, path: basePath + "/status"))
}
/// 正式完成前由用户主动撤销;是否可撤销始终由服务端判定。
func cancel() async throws {
let _: EmptyPayload = try await send(APIRequest(method: .post, path: basePath + "/cancel"))
}
private func send<Response: Decodable>(_ request: APIRequest<Response>) async throws -> Response {
guard isCurrentIdentity() else { throw StoreAccountDeregistrationError.sessionChanged }
var sensitiveRequest = request
sensitiveRequest.logsPayload = false
let response = try await client.send(sensitiveRequest, tokenOverride: identity.token)
guard isCurrentIdentity() else { throw StoreAccountDeregistrationError.sessionChanged }
return response
}
}
@@ -0,0 +1,102 @@
import Foundation
/// 旧注销接口的门店身份上下文;冻结身份与凭证,防止切换账号后误操作另一身份。
struct StoreAccountDeregistrationIdentity: Equatable, Sendable {
/// 当前门店用户身份 ID,对应 ss_store_user.id,不是门店实体 ID。
let userID: String
/// 进入流程时的门店身份 Token,仅用于接口鉴权。
let token: String
/// 用于确认页展示当前正在注销的门店身份。
let displayName: String
/// 从当前业务会话构造上下文;景区身份、未知身份和未登录状态均不可发起注销。
init(session: AppSessionStore) throws {
guard session.accountType == .storeUser else {
throw StoreAccountDeregistrationError.unsupportedIdentity
}
let id = session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
let token = session.token.trimmingCharacters(in: .whitespacesAndNewlines)
guard let numericID = Int(id), numericID > 0, !token.isEmpty else {
throw StoreAccountDeregistrationError.invalidSession
}
userID = id
self.token = token
let name = session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
displayName = name.isEmpty ? "当前门店身份" : name
}
/// 校验操作前后仍为同一个身份和同一份登录凭证。
func matches(session: AppSessionStore) -> Bool {
session.accountType == .storeUser
&& session.userId.trimmingCharacters(in: .whitespacesAndNewlines) == userID
&& session.token.trimmingCharacters(in: .whitespacesAndNewlines) == token
}
}
/// 门店身份注销的本地安全校验错误,不替代后端业务错误码。
enum StoreAccountDeregistrationError: LocalizedError, Equatable {
case unsupportedIdentity
case invalidSession
case sessionChanged
/// 可直接展示给用户的错误说明。
var errorDescription: String? {
switch self {
case .unsupportedIdentity:
"当前身份暂不支持注销"
case .invalidSession:
"当前门店身份信息不完整,请重新登录"
case .sessionChanged:
"登录状态或当前身份已变化,请重新进入注销页面"
}
}
}
/// 旧接口的余额或积分放弃确认请求;两种资产必须分别调用对应接口。
struct StoreAccountDeregistrationWaiverRequest: Encodable, Sendable {
/// 用户明确接受后才创建该请求,固定提交 true。
let accepted = true
}
/// 旧接口提交注销申请的请求体,不附加新协议中的主账号 ID 或核验参数。
struct StoreAccountDeregistrationApplyRequest: Encodable, Sendable {
/// 用户实际收到的短信验证码,不使用本地固定验证码。
let smsCode: String
/// 用户确认的注销原因。
let reason: String
/// 对齐旧文档的请求字段。
enum CodingKeys: String, CodingKey {
case smsCode = "sms_code"
case reason
}
}
/// 未定义完整响应字段的旧接口原始 JSON;保留信息,待实际契约确认后映射为业务模型。
/// 此类型不推断状态、余额或确认结果,也不应直接用于决定是否允许注销。
indirect enum StoreAccountDeregistrationJSON: Decodable, Equatable, Sendable {
case object([String: StoreAccountDeregistrationJSON])
case array([StoreAccountDeregistrationJSON])
case string(String)
case number(Decimal)
case bool(Bool)
case null
/// 无损保留 JSON 结构,金额数值使用 Decimal,避免浮点误差。
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode(Decimal.self) {
self = .number(value)
} else if let value = try? container.decode([String: Self].self) {
self = .object(value)
} else {
self = .array(try container.decode([Self].self))
}
}
}
@@ -0,0 +1,193 @@
import Foundation
/// 注销条件中的单个服务端阻断项;保留未知 code/action,不能据此绕过服务端限制。
struct StoreAccountDeregistrationBlocker: Decodable, Equatable, Sendable {
/// 稳定业务错误码。
let code: String
/// 供用户阅读的阻断说明。
let message: String
/// 后端建议动作;客户端仅对已确认的动作提供说明。
let action: String
/// 已知资产确认问题集中展示在资产卡,其余阻断仍逐条展示并阻止继续。
var isAssetConfirmation: Bool {
["WALLET_WAIVER_MISSING", "POINTS_WAIVER_MISSING", "WAIVER_STALE"].contains(code)
}
/// 未实现跳转的动作仍展示处理建议,不悄悄丢弃该阻断项。
var guidance: String {
switch action {
case "complete_orders": "请先处理未履约订单或带单,然后刷新条件。"
case "wait_risk_window": "请等待业务风险期结束,然后刷新条件。"
case "confirm_wallet_waiver": "请单独确认放弃现金余额。"
case "confirm_points_waiver": "请单独确认放弃积分。"
default: "请按上述说明处理;如不清楚如何操作,请联系管理员或客服。"
}
}
}
/// 2026-08-28 真实 eligibility 响应;必需的金额、确认及权限字段缺失时解码失败。
struct StoreAccountDeregistrationEligibility: Decodable, Equatable, Sendable {
/// 服务端是否允许申请,不能单独替代完整阻断检查。
let canApply: Bool
/// 当前门店用户身份 ID。
let storeUserID: Int
/// 对应的财务身份 ID。
let financeIdentityID: Int
/// 现金余额,单位分,避免浮点运算。
let walletBalanceFen: Int64
/// 服务端金额展示字符串。
let walletBalance: String
/// 积分余额。
let pointsBalance: Int64
/// 服务端当前余额快照是否已有现金放弃确认。
let walletWaived: Bool
/// 服务端当前余额快照是否已有积分放弃确认。
let pointsWaived: Bool
/// 未履约主订单及带单数量。
let unfulfilledCount: Int
/// 尚在处理的交付任务数量。
let fulfillmentInProgressCount: Int
/// 最后业务风险结束时间;尚未确认时区,按服务端原文展示。
let riskEndAt: String?
/// 业务风险等待截止时间,不是注销冷静期截止时间。
let eligibleAt: String?
/// 服务端业务风险等待时长。
let riskWindowHours: Int
/// 全部阻断原因,包含客户端尚不认识的新错误码。
let blockers: [StoreAccountDeregistrationBlocker]
/// 当前注销记录;已实测草稿、冷静期和主动撤销,其他状态保留原始结构。
let deregister: StoreAccountDeregistrationJSON
/// 资产之外尚需处理的业务条件,包含客户端不认识的阻断代码。
var businessBlockers: [StoreAccountDeregistrationBlocker] {
blockers.filter { !$0.isAssetConfirmation }
}
/// 两步页面可以开始资产确认的前提,不替代提交前的完整服务端核验。
var permitsAssetConfirmation: Bool {
permitsPreparation && businessBlockers.isEmpty && storeUserID > 0 && financeIdentityID > 0
&& walletBalanceFen >= 0 && pointsBalance >= 0
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
}
/// 字段名对齐真实响应,不接受缺失字段的乐观默认值。
enum CodingKeys: String, CodingKey {
case canApply = "can_apply", storeUserID = "store_user_id", financeIdentityID = "finance_identity_id"
case walletBalanceFen = "wallet_balance_fen", walletBalance = "wallet_balance", pointsBalance = "points_balance"
case walletWaived = "wallet_waived", pointsWaived = "points_waived"
case unfulfilledCount = "unfulfilled_count", fulfillmentInProgressCount = "fulfillment_in_progress_count"
case riskEndAt = "risk_end_at", eligibleAt = "eligible_at", riskWindowHours = "risk_window_hours"
case blockers, deregister
}
/// 没有记录、未提交草稿或已撤销时可准备申请,其他条件仍以服务端核验为准。
var permitsPreparation: Bool {
StoreAccountDeregistrationStatus(deregister: deregister).permitsPreparation
}
/// 完整条件满足才允许提交;有矛盾或未知状态则拒绝,截止时间不由本机推断。
var permitsApplication: Bool {
canApply && blockers.isEmpty && walletWaived && pointsWaived
&& walletBalanceFen >= 0 && pointsBalance >= 0
&& unfulfilledCount == 0 && fulfillmentInProgressCount == 0
&& storeUserID > 0 && permitsPreparation
}
/// 确认弹窗期间两种资产或财务身份发生变化,必须重新展示并确认。
func hasSameAssets(as other: Self) -> Bool {
storeUserID == other.storeUserID && financeIdentityID == other.financeIdentityID
&& walletBalanceFen == other.walletBalanceFen && pointsBalance == other.pointsBalance
}
}
/// status 的实测结构:0 草稿、1 冷静期、9 已撤销;缺失或未知状态不默认放行。
struct StoreAccountDeregistrationStatus: Decodable, Equatable, Sendable {
/// 保留原始记录,尚不推断未观测的阻断和完成状态值。
let deregister: StoreAccountDeregistrationJSON
/// 2026-08-28 两次资产确认后的真实草稿形态;不能仅凭 status=0 忽略矛盾的提交或终态字段。
var isUnsubmittedDraft: Bool {
guard case let .object(record) = deregister,
case let .number(id) = record["id"], id > 0,
id <= Decimal(Int64.max), id == Decimal(NSDecimalNumber(decimal: id).int64Value),
record["status"] == .number(0),
case let .string(label) = record["status_label"], !label.isEmpty,
case .string = record["reason"],
record["apply_time"] == .null, record["cooling_until"] == .null,
record["remaining_seconds"] == .number(0), record["cancel_time"] == .null,
record["blocked_code"] == .string(""), record["blocked_reason"] == .string(""),
record["completed_at"] == .null else { return false }
return true
}
/// 实测冷静期;即使剩余秒数为 0,也须等待服务端复核,不能视为完成。
var isCooling: Bool {
guard let record = validatedRecord else { return false }
return record["status"] == .number(1) && nonemptyString(record["apply_time"]) != nil
&& nonemptyString(record["cooling_until"]) != nil && record["cancel_time"] == .null
&& record["completed_at"] == .null && record["blocked_code"] == .string("")
&& record["blocked_reason"] == .string("")
}
/// 已撤销必须同时有撤销时间,不能只因剩余秒数为零或仍保留冷静期截止时间而推断。
var isCancelled: Bool {
guard let record = validatedRecord else { return false }
return record["status"] == .number(9) && nonemptyString(record["apply_time"]) != nil
&& nonemptyString(record["cancel_time"]) != nil && record["completed_at"] == .null
&& record["remaining_seconds"] == .number(0)
}
/// 用于核对取消前后是否为同一申请,不包含账号凭证。
var recordID: String? {
guard let record = validatedRecord, case let .number(id) = record["id"] else { return nil }
return NSDecimalNumber(decimal: id).stringValue
}
/// 识别同一次已撤销记录;避免重新申请时用旧撤销响应清除新的提交意图。
var cancellationFingerprint: String? {
guard isCancelled, let recordID, let time = nonemptyString(validatedRecord?["cancel_time"]) else { return nil }
return recordID + "|" + time
}
/// 按服务端原文展示,不推断时间字符串的时区。
var coolingUntil: String? { nonemptyString(validatedRecord?["cooling_until"]) }
/// 服务端查询时的剩余秒数;客户端不据此宣布注销完成。
var remainingSeconds: Int64? {
guard let record = validatedRecord, case let .number(seconds) = record["remaining_seconds"] else { return nil }
return NSDecimalNumber(decimal: seconds).int64Value
}
/// 没有申请、未提交草稿或已撤销允许恢复业务;准备新申请仍须重新核验资产及条件。
var permitsPreparation: Bool { deregister == .null || isUnsubmittedDraft || isCancelled }
private var validatedRecord: [String: StoreAccountDeregistrationJSON]? {
guard case let .object(record) = deregister,
case let .number(id) = record["id"], id > 0, isInteger(id),
case let .number(seconds) = record["remaining_seconds"], seconds >= 0, isInteger(seconds),
case let .string(label) = record["status_label"], !label.isEmpty,
case .string = record["reason"], case .string = record["blocked_code"],
case .string = record["blocked_reason"] else { return nil }
for key in ["apply_time", "cooling_until", "cancel_time", "completed_at"] {
guard record[key] == .null || nonemptyString(record[key]) != nil else { return nil }
}
return record
}
private func nonemptyString(_ value: StoreAccountDeregistrationJSON?) -> String? {
guard case let .string(text) = value, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return text
}
private func isInteger(_ value: Decimal) -> Bool {
value <= Decimal(Int64.max) && value >= Decimal(Int64.min)
&& value == Decimal(NSDecimalNumber(decimal: value).int64Value)
}
}
/// 必须分别确认的两种资产。
enum StoreAccountDeregistrationAsset: Sendable {
case wallet
case points
}
@@ -0,0 +1,60 @@
import Foundation
import CoreFoundation
/// 本机提交意图记录,不代表后端已受理或任何注销业务状态。
protocol StoreAccountDeregistrationSubmissionTracking {
/// 是否存在本机尚无法确定结果的提交意图。
var hasUnresolvedSubmission: Bool { get }
/// 在发出 POST 前记录意图;无法记录时不允许发出申请。
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus?) throws
/// 仅在服务端明确拒绝本次申请时清除此意图。
func clearRejectedSubmission()
/// 用新撤销记录核销提交意图;与重新申请前相同的旧撤销响应不能放行。
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool
}
/// 按环境和门店用户 ID 隔离提交意图;不按手机号保存,不存储凭证、验证码或资产。
final class StoreAccountDeregistrationSubmissionStore: StoreAccountDeregistrationSubmissionTracking {
private let defaults: UserDefaults
private let key: String
/// 注入持久化容器便于测试;生产环境使用当前服务域名隔离测试/正式账号。
init(storeUserID: String, environment: APIEnvironment = .current, defaults: UserDefaults = .standard) {
self.defaults = defaults
key = "store_deregister_submission_intent_v1_\(environment.baseURL.host ?? "unknown")_\(storeUserID)"
}
/// 任何残留记录都按待核实处理,不因不认识的本地值而允许重新提交。
var hasUnresolvedSubmission: Bool { defaults.object(forKey: key) != nil }
/// UserDefaults 记录提交意图;不把本地写入成功等同于后端收到申请。
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus? = nil) throws {
defaults.set(["previous_cancellation": previousStatus?.cancellationFingerprint ?? ""], forKey: key)
guard hasUnresolvedSubmission else { throw SubmissionPersistenceError.unavailable }
}
/// 只删除当前环境、当前门店用户对应的意图,不影响其他身份。
func clearRejectedSubmission() { defaults.removeObject(forKey: key) }
/// 旧版本只会从无记录/草稿提交,其布尔意图可由完整撤销记录核销。
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool {
guard let fingerprint = status.cancellationFingerprint else { return false }
if let stored = defaults.object(forKey: key) {
if let record = stored as? [String: String], let previous = record["previous_cancellation"] {
guard previous != fingerprint else { return false }
} else {
// 仅兼容旧版本写入的 true;损坏或未知结构继续等待核验。
guard let value = stored as? NSNumber,
CFGetTypeID(value) == CFBooleanGetTypeID(), value.boolValue else { return false }
}
}
defaults.removeObject(forKey: key)
return true
}
}
/// 无法保存提交意图时阻止申请,避免在重启后失去重复提交保护。
private enum SubmissionPersistenceError: LocalizedError {
case unavailable
var errorDescription: String? { "无法保存提交核验记录,请稍后重试" }
}
@@ -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
}
}
@@ -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: "资产确认尚未完成,请刷新后重试"
}
}
}