feat: 增加门店身份注销流程
This commit is contained in:
@@ -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
|
||||
}
|
||||
+60
@@ -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? { "无法保存提交核验记录,请稍后重试" }
|
||||
}
|
||||
Reference in New Issue
Block a user