Files
suixinkan_uikit/suixinkan/Features/AccountDeletion/Models/StoreAccountDeregistrationResponses.swift
T

194 lines
9.7 KiB
Swift

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
}