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
+3 -1
View File
@@ -6,6 +6,7 @@
import UIKit
/// 应用根页面路由。
@MainActor
enum AppRouter {
enum Root {
@@ -37,7 +38,8 @@ enum AppRouter {
}
}
private static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool) {
/// 切换到显式构造的根页面,用于业务进入前的注销状态核验。
static func setRoot(_ viewController: UIViewController, on window: UIWindow?, animated: Bool = true) {
guard let window else { return }
guard animated, let snapshot = window.snapshotView(afterScreenUpdates: true) else {
+2 -1
View File
@@ -19,7 +19,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
AMapBootstrap.configureIfNeeded()
}
PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions)
let pushManager = PushNotificationManager.shared
pushManager.initializeIfPrivacyAccepted(launchOptions: launchOptions)
return true
}
+6
View File
@@ -29,6 +29,9 @@ enum NotificationName {
/// Token 失效或鉴权失败,需重新登录
static let sessionDidExpire = name("sessionDidExpire")
/// 当前门店身份受限或申请结果待核实;保留凭证转入只读状态查询。
static let storeAccountDeregistrationRestricted = name("storeAccountDeregistrationRestricted")
// MARK: - Scenic
/// 当前景区切换
@@ -68,6 +71,9 @@ enum NotificationName {
/// `Notification.userInfo` 字典键的统一入口。
enum NotificationUserInfoKey {
/// 产生注销限制错误的原请求凭证,仅在内存中匹配当前会话,禁止记录日志。
static let deregistrationRequestToken = "deregistrationRequestToken"
static let scenicId = "scenicId"
static let scenicName = "scenicName"
static let orderId = "orderId"
+28 -13
View File
@@ -19,6 +19,7 @@ final class APIClient {
private let session: URLSessionProtocol
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let notificationCenter: NotificationCenter
private var authTokenProvider: (() -> String?)?
private let environment: APIEnvironment
@@ -32,12 +33,14 @@ final class APIClient {
encoder: JSONEncoder = JSONEncoder(),
decoder: JSONDecoder = JSONDecoder(),
appVersion: String = AppClientInfo.appVersion(),
osType: String = AppClientInfo.osType
osType: String = AppClientInfo.osType,
notificationCenter: NotificationCenter = .default
) {
self.environment = environment
self.session = session
self.encoder = encoder
self.decoder = decoder
self.notificationCenter = notificationCenter
self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0"
self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType
}
@@ -61,7 +64,7 @@ final class APIClient {
tokenOverride: String? = nil
) async throws -> Response {
let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride)
logRequest(request)
logRequest(request, includeBody: apiRequest.logsPayload)
let data: Data
let response: URLResponse
@@ -80,12 +83,12 @@ final class APIClient {
throw APIError.networkFailed(error.localizedDescription)
}
logResponse(for: request, response: response, data: data)
logResponse(for: request, response: response, data: data, includeBody: apiRequest.logsPayload)
do {
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
notifySessionErrorIfNeeded(for: error, request: request)
throw error
}
}
@@ -141,7 +144,7 @@ final class APIClient {
try validateHTTPResponse(response, data: data)
return try decodeEnvelope(Response.self, from: data)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
notifySessionErrorIfNeeded(for: error, request: request)
throw error
}
}
@@ -212,7 +215,7 @@ final class APIClient {
try validateHTTPResponse(response, data: responseData)
return try decodeEnvelope(Response.self, from: responseData)
} catch let error as APIError {
notifySessionExpiredIfNeeded(for: error)
notifySessionErrorIfNeeded(for: error, request: request)
throw error
}
}
@@ -267,6 +270,9 @@ final class APIClient {
}
guard 200 ..< 300 ~= httpResponse.statusCode else {
if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data), envelope.code == 150015 {
throw APIError.serverCode(150015, parseHTTPErrorMessage(data: data))
}
throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data))
}
}
@@ -295,10 +301,19 @@ final class APIClient {
return payload
}
/// Token 失效时广播 sessionDidExpire,触发全局登出。
private func notifySessionExpiredIfNeeded(for error: APIError) {
/// 区分身份注销受限与凭证失效;150015 附带原请求 Token,便于忽略切换身份后的旧错误。
private func notifySessionErrorIfNeeded(for error: APIError, request: URLRequest) {
if case .serverCode(150015, _) = error {
guard let token = request.value(forHTTPHeaderField: "token"), !token.isEmpty else { return }
notificationCenter.post(
name: NotificationName.storeAccountDeregistrationRestricted,
object: nil,
userInfo: [NotificationUserInfoKey.deregistrationRequestToken: token]
)
return
}
guard APIError.isAuthenticationExpired(error) else { return }
NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil)
notificationCenter.post(name: NotificationName.sessionDidExpire, object: nil)
}
/// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。
@@ -341,7 +356,7 @@ final class APIClient {
}
/// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。
private func logRequest(_ request: URLRequest) {
private func logRequest(_ request: URLRequest, includeBody: Bool = true) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
@@ -359,7 +374,7 @@ final class APIClient {
}
let contentType = request.value(forHTTPHeaderField: "Content-Type")
if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
if includeBody, let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) {
lines.append("body:\n\(body)")
}
@@ -368,12 +383,12 @@ final class APIClient {
}
/// 在 Debug 环境打印响应状态和响应体。
private func logResponse(for request: URLRequest, response: URLResponse, data: Data) {
private func logResponse(for request: URLRequest, response: URLResponse, data: Data, includeBody: Bool = true) {
#if DEBUG
let method = request.httpMethod ?? "REQUEST"
let url = request.url?.absoluteString ?? "<invalid url>"
let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown"
let body = Self.debugResponseBody(from: data)
let body = includeBody ? Self.debugResponseBody(from: data) : "<sensitive payload omitted>"
print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)")
#endif
}
+5 -1
View File
@@ -20,6 +20,8 @@ nonisolated struct APIRequest<Response: Decodable> {
var queryItems: [URLQueryItem]
var headers: [String: String]
var body: AnyEncodable?
/// 敏感请求关闭正文日志,避免验证码和注销资产进入调试日志。
var logsPayload: Bool
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
init<Body: Encodable>(
@@ -27,13 +29,15 @@ nonisolated struct APIRequest<Response: Decodable> {
path: String,
queryItems: [URLQueryItem] = [],
headers: [String: String] = [:],
body: Body? = Optional<EmptyPayload>.none
body: Body? = Optional<EmptyPayload>.none,
logsPayload: Bool = true
) {
self.method = method
self.path = path
self.queryItems = queryItems
self.headers = headers
self.body = body.map(AnyEncodable.init)
self.logsPayload = logsPayload
}
}
@@ -116,6 +116,8 @@ final class PushNotificationManager: NSObject {
private var isInitialized = false
private var didRequestAuthorization = false
private var uploadTask: Task<Void, Never>?
private var uploadAttemptID: UUID?
private var isAccountBindingSuspended = false
private var queuedForcedUpload = false
private var isFetchingRegistrationID = false
private var queuedForcedFetch = false
@@ -161,7 +163,7 @@ final class PushNotificationManager: NSObject {
/// 登录成功后请求通知权限,并强制绑定当前账号。
func handleLoginCompleted() {
initializeIfPrivacyAccepted()
guard isInitialized, appStore.session.isLoggedIn else { return }
guard !isAccountBindingSuspended, isInitialized, appStore.session.isLoggedIn else { return }
if !didRequestAuthorization {
didRequestAuthorization = true
sdk.requestAuthorization(delegate: self)
@@ -171,7 +173,7 @@ final class PushNotificationManager: NSObject {
/// 账号切换后把同一设备重新绑定到新的业务账号。
func handleAccountSwitched() {
guard appStore.session.isLoggedIn else { return }
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
bindCurrentAccount()
}
@@ -179,11 +181,22 @@ final class PushNotificationManager: NSObject {
func handleLogout() {
uploadTask?.cancel()
uploadTask = nil
uploadAttemptID = nil
queuedForcedUpload = false
router.resetPendingRoute()
Task { await updateApplicationIconBadgeCount(0) }
}
/// 注销核验或受限期间暂停业务账号绑定,保留 Token、Registration ID 与待处理通知。
func setAccountBindingSuspended(_ suspended: Bool) {
isAccountBindingSuspended = suspended
guard suspended else { return }
uploadTask?.cancel()
uploadTask = nil
uploadAttemptID = nil
queuedForcedUpload = false
}
/// 将桌面 App Icon 角标更新为最新未读消息数量。
func updateApplicationIconBadgeCount(_ count: Int) async {
await applicationIconBadgeSetter.setBadgeCount(max(count, 0))
@@ -191,13 +204,14 @@ final class PushNotificationManager: NSObject {
/// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。
func retryPendingRegistrationUpload() {
guard appStore.session.isLoggedIn else { return }
guard !isAccountBindingSuspended, appStore.session.isLoggedIn else { return }
uploadCachedRegistrationID(force: false)
refreshRegistrationID(forceUpload: false)
}
/// 登录根页面建立后继续执行通知点击暂存的路由。
func routePendingNotificationIfPossible() {
guard !isAccountBindingSuspended else { return }
router.routePendingIfPossible()
}
@@ -323,7 +337,7 @@ final class PushNotificationManager: NSObject {
}
private func upload(registrationID: String, force: Bool) {
guard appStore.session.isLoggedIn,
guard !isAccountBindingSuspended, appStore.session.isLoggedIn,
let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix)
else { return }
@@ -336,12 +350,15 @@ final class PushNotificationManager: NSObject {
}
let accountScope = appStore.session.accountCachePrefix
let attemptID = UUID()
uploadAttemptID = attemptID
uploadTask = Task { [weak self] in
guard let self else { return }
var succeeded = false
do {
try await self.api.registerJPushID(registrationID)
if self.appStore.session.accountCachePrefix == accountScope {
if self.uploadAttemptID == attemptID, !self.isAccountBindingSuspended,
self.appStore.session.accountCachePrefix == accountScope {
self.defaults.set(registrationID, forKey: uploadedKey)
}
succeeded = true
@@ -353,7 +370,9 @@ final class PushNotificationManager: NSObject {
#endif
}
guard self.uploadAttemptID == attemptID else { return }
self.uploadTask = nil
self.uploadAttemptID = nil
let shouldForceAgain = self.queuedForcedUpload
self.queuedForcedUpload = false
if succeeded, shouldForceAgain {
@@ -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: "资产确认尚未完成,请刷新后重试"
}
}
}
+78 -13
View File
@@ -9,6 +9,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
private var deregistrationCoordinator: StoreAccountDeregistrationRootCoordinator?
private var needsForegroundDeregistrationCheck = false
func scene(
_ scene: UIScene,
@@ -20,26 +22,39 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppNavigationBarAppearance.applyGlobalAppearance()
let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppRouter.makeRootViewController()
window.makeKeyAndVisible()
window.rootViewController = UIViewController()
self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.window = window
let appSession = AppStore.shared.session
deregistrationCoordinator = StoreAccountDeregistrationRootCoordinator(
window: window, session: appSession,
makeAPI: { identity in
StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
identity.matches(session: appSession)
}
},
makeSubmissionStore: { StoreAccountDeregistrationSubmissionStore(storeUserID: $0) },
makeBusinessRoot: { MainTabBarController() },
setBindingSuspended: { PushNotificationManager.shared.setAccountBindingSuspended($0) },
onBusinessResumed: {
PushNotificationManager.shared.handleLoginCompleted()
PushNotificationManager.shared.routePendingNotificationIfPossible()
}
)
PushNotificationManager.shared.attach(window: window)
registerNotifications()
if AppStore.shared.session.isLoggedIn {
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
}
}
refreshRootForCurrentSession()
window.makeKeyAndVisible()
if let response = connectionOptions.notificationResponse {
PushNotificationManager.shared.handleNotificationResponse(response)
}
}
func sceneDidDisconnect(_ scene: UIScene) {
deregistrationCoordinator?.cancelPendingCheck()
deregistrationCoordinator = nil
NotificationCenter.default.removeObserver(self)
if (UIApplication.shared.delegate as? AppDelegate)?.window === window {
(UIApplication.shared.delegate as? AppDelegate)?.window = nil
@@ -56,10 +71,26 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
func sceneDidBecomeActive(_ scene: UIScene) {
guard accessController == nil, deregistrationCoordinator?.isChecking != true else { return }
PushNotificationManager.shared.retryPendingRegistrationUpload()
}
func sceneDidEnterBackground(_ scene: UIScene) {
needsForegroundDeregistrationCheck = true
}
func sceneWillEnterForeground(_ scene: UIScene) {
guard needsForegroundDeregistrationCheck else { return }
needsForegroundDeregistrationCheck = false
guard sessionExpiredDialog == nil else { return }
deregistrationCoordinator?.resumeFromBackground()
}
private func registerNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(handleStoreDeregistrationRestriction(_:)),
name: NotificationName.storeAccountDeregistrationRestricted, object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleSessionDidExpire),
@@ -90,6 +121,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
deregistrationCoordinator?.cancelPendingCheck()
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
@@ -104,8 +136,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
private func transitionToLogin() {
deregistrationCoordinator?.cancelPendingCheck()
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout()
PushNotificationManager.shared.setAccountBindingSuspended(false)
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
}
@@ -117,15 +151,46 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
PushNotificationManager.shared.routePendingNotificationIfPossible()
if AppStore.shared.session.isLoggedIn, AppStore.shared.session.accountType == .storeUser {
deregistrationCoordinator?.check()
} else {
refreshRootForCurrentSession()
}
}
@objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched()
if AppStore.shared.session.accountType == .storeUser {
refreshRootForCurrentSession()
} else {
deregistrationCoordinator?.cancelPendingCheck()
PushNotificationManager.shared.setAccountBindingSuspended(false)
PushNotificationManager.shared.handleAccountSwitched()
}
}
/// 仅检查真实根控制器,避免被旧的异步查询或页面导航状态误判。
private var accessController: StoreAccountDeregistrationAccessViewController? {
deregistrationCoordinator?.accessController
}
/// 冷启动和切换身份使用已有会话,不主动查询注销状态;新登录由独立入口核验。
private func refreshRootForCurrentSession() {
let session = AppStore.shared.session
guard session.isLoggedIn else {
deregistrationCoordinator?.cancelPendingCheck()
PushNotificationManager.shared.setAccountBindingSuspended(false)
AppRouter.setRoot(.login, on: window, animated: false)
return
}
deregistrationCoordinator?.restoreSession()
}
/// 150015 只限制原请求对应的当前门店身份;旧 Token 响应不会影响新身份。
@objc private func handleStoreDeregistrationRestriction(_ notification: Notification) {
let token = notification.userInfo?[NotificationUserInfoKey.deregistrationRequestToken] as? String
let session = AppStore.shared.session
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: token, session: session) else { return }
deregistrationCoordinator?.recordRestriction(requestToken: token)
}
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
@@ -163,7 +163,7 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat
}
@objc private func confirmTapped() {
guard let selectedAccount else { return }
guard canConfirm, let selectedAccount else { return }
onConfirm(selectedAccount)
}
+13 -1
View File
@@ -11,7 +11,7 @@ import UIKit
final class LoginViewController: BaseViewController {
private let viewModel = LoginViewModel()
private let authAPI = NetworkServices.shared.authAPI
private let authAPI: AuthAPI
private let backgroundImageView = UIImageView()
private let welcomeLabel = UILabel()
@@ -29,6 +29,17 @@ final class LoginViewController: BaseViewController {
private weak var accountSelectionController: AccountSelectionViewController?
/// 默认使用共享登录服务,测试可注入Mock网络客户端而不修改真实会话。
init(authAPI: AuthAPI? = nil) {
self.authAPI = authAPI ?? NetworkServices.shared.authAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
@@ -264,6 +275,7 @@ final class LoginViewController: BaseViewController {
}
private func performLogin() {
guard !viewModel.isLoading else { return }
viewModel.normalizeUsernameCountryCodeIfNeeded()
accountField.text = viewModel.normalizedUsername
@@ -96,14 +96,8 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate
}
private var currentAccountId: String? {
let store = AppStore.shared
if store.session.currentStoreId > 0 {
return "\(V9StoreUser.accountTypeValue)_\(store.session.currentStoreId)"
}
if store.session.currentScenicId > 0 {
return "\(V9ScenicUser.accountTypeValue)_\(store.session.userId)"
}
return store.session.userId.isEmpty ? nil : store.session.userId
let scope = AppStore.shared.session.accountCachePrefix
return scope.isEmpty ? nil : scope
}
private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool {
@@ -14,6 +14,7 @@ final class SettingViewController: BaseViewController {
private let contentView = UIView()
private let cardView = UIView()
private let rowsStack = UIStackView()
private let deregistrationRow = SettingMenuRow(title: "注销当前门店身份", titleColor: AppColor.danger, showsDivider: false)
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel()
@@ -68,6 +69,10 @@ final class SettingViewController: BaseViewController {
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
if AppStore.shared.session.accountType == .storeUser {
rowsStack.addArrangedSubview(deregistrationRow)
deregistrationRow.addTarget(self, action: #selector(deregistrationTapped), for: .touchUpInside)
}
}
override func setupConstraints() {
@@ -119,6 +124,29 @@ final class SettingViewController: BaseViewController {
openAgreement(.privacyPolicy)
}
/// 仅从当前门店业务会话创建注销流程,冻结 Token 与门店用户 ID。
@objc private func deregistrationTapped() {
do {
let session = AppStore.shared.session
let identity = try StoreAccountDeregistrationIdentity(session: session)
let api = StoreAccountDeregistrationAPI(client: NetworkServices.shared.apiClient, identity: identity) {
identity.matches(session: session)
}
let controller = StoreAccountDeregistrationViewController(
identityName: identity.displayName,
viewModel: StoreAccountDeregistrationViewModel(
storeUserID: Int(identity.userID) ?? 0,
submissionStore: StoreAccountDeregistrationSubmissionStore(storeUserID: identity.userID)
), api: api, onUnresolvedSubmission: {
NotificationCenter.default.post(name: NotificationName.storeAccountDeregistrationRestricted,
object: nil, userInfo: [NotificationUserInfoKey.deregistrationRequestToken: identity.token])
}
)
controller.hidesBottomBarWhenPushed = true
navigationController?.pushViewController(controller, animated: true)
} catch { showError(error.localizedDescription) }
}
private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController(
@@ -138,8 +166,10 @@ final class SettingMenuRow: UIControl {
private let divider = UIView()
private let showsChevron: Bool
/// 创建菜单行,可为注销等操作单独指定标题颜色,不影响其他行。
init(
title: String,
titleColor: UIColor = UIColor(hex: 0x4B5563),
value: String? = nil,
valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true,
@@ -150,6 +180,7 @@ final class SettingMenuRow: UIControl {
setupUI()
setupConstraints()
titleLabel.text = title
titleLabel.textColor = titleColor
valueLabel.text = value
valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron
@@ -175,7 +206,6 @@ final class SettingMenuRow: UIControl {
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary
@@ -0,0 +1,286 @@
import SnapKit
import UIKit
/// 业务根页面之前核验注销状态;冷静期提供明确撤销,未知状态只允许查询和主动退出。
@MainActor
final class StoreAccountDeregistrationAccessViewController: BaseViewController {
/// 冻结的门店身份,用于 Scene 对旧请求的隔离。
let identity: StoreAccountDeregistrationIdentity?
private let api: (any StoreAccountDeregistrationServing)?
private let session: AppSessionStore
private let onAllowed: (StoreAccountDeregistrationAccessViewController) -> Void
private let onAccessDenied: () -> Void
private let viewModel: StoreAccountDeregistrationAccessViewModel
private let hasInitialResult: Bool
private typealias Style = StoreAccountDeregistrationStyle
private let scrollView = UIScrollView()
private let refreshControl = UIRefreshControl()
private let stack = UIStackView()
private let bottomBar = UIView()
private let titleLabel = Style.label(size: 24, weight: .semibold)
private let stateIcon = Style.icon("clock", size: 36)
private let deadlineLabel = Style.label(size: 19, weight: .semibold)
private let warningLabel = Style.label(size: 13, color: AppColor.textSecondary)
private var deadlineCard = UIView()
private let messageLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let conditionsButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let logoutButton = UIButton(type: .system)
private var queryTask: Task<Void, Never>?
private var needsForegroundRefresh = false
/// 显式注入会话与 API;身份构造失败时仍展示错误,不自动退出或重新登录。
init(identity: StoreAccountDeregistrationIdentity?, api: (any StoreAccountDeregistrationServing)?,
session: AppSessionStore, requiresSubmissionReconciliation: Bool = false,
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil,
initialViewModel: StoreAccountDeregistrationAccessViewModel? = nil,
onAccessDenied: @escaping () -> Void = {},
onAllowed: @escaping (StoreAccountDeregistrationAccessViewController) -> Void) {
self.identity = identity
self.api = api
self.session = session
self.onAllowed = onAllowed
self.onAccessDenied = onAccessDenied
hasInitialResult = initialViewModel != nil
viewModel = initialViewModel ?? StoreAccountDeregistrationAccessViewModel(
requiresSubmissionReconciliation: requiresSubmissionReconciliation, submissionStore: submissionStore)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "注销账号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.addSubview(scrollView)
scrollView.alwaysBounceVertical = true
refreshControl.accessibilityIdentifier = "deregister.access.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
refreshControl.tintColor = .clear
refreshControl.addTarget(self, action: #selector(retryTapped), for: .valueChanged)
scrollView.refreshControl = refreshControl
scrollView.addSubview(stack)
stack.axis = .vertical
stack.spacing = 20
stateIcon.snp.makeConstraints { $0.height.equalTo(64) }
titleLabel.textAlignment = .center
messageLabel.numberOfLines = 0
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textColor = AppColor.textSecondary
messageLabel.textAlignment = .center
let hero = Style.stack([stateIcon, titleLabel, messageLabel], spacing: 12)
stack.addArrangedSubview(hero)
let identity = Style.stack([
Style.label("当前门店身份", size: 12, color: AppColor.textSecondary),
Style.label(self.identity?.displayName ?? "当前身份", size: 17, weight: .semibold)
], spacing: 8)
stack.addArrangedSubview(Style.card(identity))
deadlineLabel.accessibilityIdentifier = "deregister.access.deadline"
deadlineCard = Style.card(Style.stack([
Style.label("冷静期截止时间", size: 13, color: AppColor.textSecondary),
deadlineLabel,
Style.label("以服务端时间为准,到期后仍需复核。", size: 12, color: AppColor.textSecondary)
], spacing: 10))
stack.addArrangedSubview(deadlineCard)
warningLabel.text = "重新登录或选中此身份,会自动撤销尚未完成的注销申请。其他身份不受影响。"
stack.addArrangedSubview(warningLabel)
conditionsButton.setTitle("查看注销条件", for: .normal)
conditionsButton.titleLabel?.font = .systemFont(ofSize: 14)
conditionsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
stack.addArrangedSubview(conditionsButton)
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
Style.configure(retryButton, title: "重新查询状态")
Style.configure(logoutButton, title: "退出登录")
Style.configure(cancelButton, title: "撤销注销申请", primary: false)
let actions = Style.stack([retryButton, logoutButton, cancelButton], spacing: 4)
bottomBar.addSubview(actions)
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
for button in [retryButton, logoutButton, cancelButton] {
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
}
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
conditionsButton.addTarget(self, action: #selector(conditionsTapped), for: .touchUpInside)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
logoutButton.addTarget(self, action: #selector(logoutTapped), for: .touchUpInside)
messageLabel.accessibilityIdentifier = "deregister.access.message"
titleLabel.accessibilityIdentifier = "deregister.access.title"
retryButton.accessibilityIdentifier = "deregister.access.retry"
cancelButton.accessibilityIdentifier = "deregister.access.cancel"
logoutButton.accessibilityIdentifier = "deregister.access.logout"
conditionsButton.accessibilityIdentifier = "deregister.access.conditions"
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.safeAreaLayoutGuide)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
$0.bottom.equalTo(bottomBar.snp.top)
}
stack.snp.makeConstraints {
$0.top.equalTo(scrollView.contentLayoutGuide).offset(24)
$0.leading.trailing.bottom.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewDidLoad() {
super.viewDidLoad()
if hasInitialResult { applyViewModel() } else { retryTapped() }
}
/// 在途旧查询不能覆盖刚收到的受限信号;之后由用户主动重新查询。
func recordRestriction() {
viewModel.recordRestriction()
onAccessDenied()
if isViewLoaded { applyViewModel() }
}
/// 从后台回来只读重查;废弃后台前的在途结果,不重发申请或撤销操作。
func refreshAfterBackground() {
viewModel.recordRestriction()
navigationController?.popToRootViewController(animated: false)
if presentedViewController is UIAlertController { dismiss(animated: false) }
if queryTask != nil {
needsForegroundRefresh = true
applyViewModel()
} else {
retryTapped()
}
}
private func applyViewModel() {
let busy = queryTask != nil
let cooling = viewModel.decision == .cooling
retryButton.isEnabled = !busy && api != nil
retryButton.isHidden = cooling
refreshControl.isEnabled = !busy && api != nil
conditionsButton.isEnabled = !busy && api != nil
conditionsButton.isHidden = ![.unresolved, .cooling].contains(viewModel.decision)
cancelButton.isHidden = !cooling
cancelButton.isEnabled = !busy && api != nil
logoutButton.isEnabled = !busy
Style.configure(logoutButton, title: "退出登录", primary: cooling)
deadlineCard.isHidden = !cooling
warningLabel.isHidden = !cooling
deadlineLabel.text = viewModel.status?.coolingUntil
guard identity != nil, api != nil else {
titleLabel.text = "暂时无法查询"
messageLabel.text = "当前身份信息不完整,请联系管理员。"
stateIcon.image = UIImage(systemName: "exclamationmark.circle")
return
}
var symbol = "clock"
switch viewModel.decision {
case .notChecked, .checking:
titleLabel.text = nil
messageLabel.text = nil
case .allowed:
titleLabel.text = "当前身份可正常使用"
messageLabel.text = "正在返回首页"
symbol = "checkmark.circle"
case .cooling:
titleLabel.text = "注销申请已提交"
messageLabel.text = (viewModel.status?.remainingSeconds ?? 0) > 0
? "当前处于冷静期,期间暂停普通业务。\n你仍可以撤销申请,恢复使用。"
: "冷静期已结束,正在等待服务端复核。\n最终结果请刷新后查看。"
case .unresolved:
titleLabel.text = "注销状态待确认"
messageLabel.text = "暂时未获取到明确结果,请刷新重试。\n请勿重复提交;如持续出现,请联系管理员。"
symbol = "questionmark.circle"
case .failed:
titleLabel.text = "暂时无法查询"
messageLabel.text = "请检查网络后重试。\n查询失败不会撤销你的注销申请。"
symbol = "wifi.exclamationmark"
case .obsolete:
titleLabel.text = "当前身份已变化"
messageLabel.text = "请返回当前账号后重试。"
symbol = "person.crop.circle.badge.exclamationmark"
}
stateIcon.image = UIImage(systemName: symbol,
withConfiguration: UIImage.SymbolConfiguration(pointSize: 36, weight: .medium))
}
@objc private func cancelTapped() {
guard queryTask == nil, let identity, let api, identity.matches(session: session),
viewModel.decision == .cooling else { return }
let alert = UIAlertController(title: "撤销当前身份的注销申请?",
message: "将撤销“\(identity.displayName)”的申请,服务端确认后恢复使用。其他身份不受影响。",
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "保留注销申请", style: .cancel))
alert.addAction(UIAlertAction(title: "确认撤销", style: .destructive) { [weak self] _ in
guard let self, self.queryTask == nil else { return }
self.queryTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await self.viewModel.cancel(api: api) { [session = self.session] in identity.matches(session: session) }
self.hideLoading()
self.finishQuery()
}
self.applyViewModel()
})
present(alert, animated: true)
}
@objc private func retryTapped() {
guard queryTask == nil, let identity, let api else {
refreshControl.endRefreshing()
applyViewModel()
return
}
queryTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await self.viewModel.verify(api: api) { [session = self.session] in identity.matches(session: session) }
self.hideLoading()
self.finishQuery()
}
applyViewModel()
}
private func finishQuery() {
queryTask = nil
refreshControl.endRefreshing()
if needsForegroundRefresh {
needsForegroundRefresh = false
retryTapped()
return
}
applyViewModel()
if viewModel.decision == .allowed, identity?.matches(session: session) == true {
onAllowed(self)
} else {
onAccessDenied()
}
}
@objc private func conditionsTapped() {
guard let identity, let api, identity.matches(session: session),
[.unresolved, .cooling].contains(viewModel.decision) else { return }
let controller = StoreAccountDeregistrationViewController(
identityName: identity.displayName,
viewModel: StoreAccountDeregistrationViewModel(storeUserID: Int(identity.userID) ?? 0), api: api,
readOnly: true
)
navigationController?.pushViewController(controller, animated: true)
}
@objc private func logoutTapped() {
let alert = UIAlertController(title: "退出登录?", message:
"退出本身不会撤销申请,但会清除本机登录凭证。再次登录或选中此身份可能自动撤销尚未完成的申请。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "继续保留查询", style: .cancel))
alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { _ in
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
})
present(alert, animated: true)
}
}
@@ -0,0 +1,148 @@
import UIKit
/// 登录后或收到业务限制时核验门店注销状态;普通启动和前台恢复不主动查询。
@MainActor
final class StoreAccountDeregistrationRootCoordinator {
private weak var window: UIWindow?
private let session: AppSessionStore
private let makeAPI: (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing
private let makeSubmissionStore: (String) -> any StoreAccountDeregistrationSubmissionTracking
private let makeBusinessRoot: () -> UIViewController
private let setBindingSuspended: (Bool) -> Void
private let onBusinessResumed: () -> Void
private var pendingCheck: PendingCheck?
/// 登录核验期间保留原页面与身份;只允许该次请求结束其持有的全局加载。
private final class PendingCheck {
let identity: StoreAccountDeregistrationIdentity
let root: UIViewController?
let viewModel: StoreAccountDeregistrationAccessViewModel
var task: Task<Void, Never>?
var needsForegroundRefresh = false
init(identity: StoreAccountDeregistrationIdentity, root: UIViewController?,
viewModel: StoreAccountDeregistrationAccessViewModel) {
self.identity = identity
self.root = root
self.viewModel = viewModel
}
}
/// 注入窗口、会话、服务和业务恢复动作;测试无需访问共享会话或真实网络。
init(window: UIWindow, session: AppSessionStore,
makeAPI: @escaping (StoreAccountDeregistrationIdentity) -> any StoreAccountDeregistrationServing,
makeSubmissionStore: @escaping (String) -> any StoreAccountDeregistrationSubmissionTracking,
makeBusinessRoot: @escaping () -> UIViewController,
setBindingSuspended: @escaping (Bool) -> Void,
onBusinessResumed: @escaping () -> Void) {
self.window = window
self.session = session
self.makeAPI = makeAPI
self.makeSubmissionStore = makeSubmissionStore
self.makeBusinessRoot = makeBusinessRoot
self.setBindingSuspended = setBindingSuspended
self.onBusinessResumed = onBusinessResumed
}
/// 只认当前窗口实际显示的核验根,不用旧请求保存的控制器判断。
var accessController: StoreAccountDeregistrationAccessViewController? {
(window?.rootViewController as? UINavigationController)?.viewControllers.first
as? StoreAccountDeregistrationAccessViewController
}
/// 正在原登录页面上核验时,也应暂停推送账号绑定。
var isChecking: Bool { pendingCheck != nil }
/// 使用已有登录会话直接恢复首页,不创建注销服务或发起状态查询。
func restoreSession() {
guard let window, session.isLoggedIn else { return }
cancelPendingCheck()
AppRouter.setRoot(makeBusinessRoot(), on: window, animated: false)
setBindingSuspended(false)
onBusinessResumed()
}
/// 保留登录页背景并显示全局加载;核验通过进入首页,有异常结果才展示状态页。
func check() {
guard let window, session.isLoggedIn, session.accountType == .storeUser else { return }
if let pendingCheck, pendingCheck.identity.matches(session: session),
window.rootViewController === pendingCheck.root { return }
cancelPendingCheck()
setBindingSuspended(true)
guard let identity = try? StoreAccountDeregistrationIdentity(session: session) else {
showResult(identity: nil, api: nil, viewModel: StoreAccountDeregistrationAccessViewModel())
return
}
let api = makeAPI(identity)
let model = StoreAccountDeregistrationAccessViewModel(submissionStore: makeSubmissionStore(identity.userID))
let pending = PendingCheck(identity: identity, root: window.rootViewController, viewModel: model)
pendingCheck = pending
GlobalLoadingManager.shared.show()
pending.task = Task { @MainActor [weak self, session] in
await model.verify(api: api) { identity.matches(session: session) }
guard let self, self.pendingCheck === pending else { return }
let isCurrent = self.window?.rootViewController === pending.root && identity.matches(session: session)
self.pendingCheck = nil
pending.task = nil
GlobalLoadingManager.shared.hide()
guard isCurrent else { return }
if pending.needsForegroundRefresh {
self.check()
} else if model.decision == .allowed {
self.restoreSession()
} else {
self.showResult(identity: identity, api: api, viewModel: model)
}
}
}
/// 退出或切换会话时释放本次加载;迟到的旧响应不能关闭新请求的加载或替换新根。
func cancelPendingCheck() {
guard let pending = pendingCheck else { return }
pendingCheck = nil
pending.task?.cancel()
pending.task = nil
GlobalLoadingManager.shared.hide()
}
private func showResult(identity: StoreAccountDeregistrationIdentity?,
api: (any StoreAccountDeregistrationServing)?,
viewModel: StoreAccountDeregistrationAccessViewModel) {
guard let window else { return }
let controller = StoreAccountDeregistrationAccessViewController(
identity: identity, api: api, session: session, initialViewModel: viewModel
) { [weak self] candidate in
guard let self, self.accessController === candidate,
let identity = candidate.identity, identity.matches(session: self.session) else { return }
self.restoreSession()
}
AppRouter.setRoot(UINavigationController(rootViewController: controller), on: window, animated: false)
}
/// 普通页面返回前台不查询;仅已受限的核验页刷新,并废弃后台前的旧查询。
func resumeFromBackground() {
guard session.isLoggedIn, session.accountType == .storeUser else { return }
if let pendingCheck, pendingCheck.identity.matches(session: session),
window?.rootViewController === pendingCheck.root {
pendingCheck.viewModel.recordRestriction()
pendingCheck.needsForegroundRefresh = true
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
setBindingSuspended(true)
controller.refreshAfterBackground()
}
}
/// 仅原请求 Token 与当前门店会话一致时限制业务,不恢复被新页面替换的旧根。
func recordRestriction(requestToken: String?) {
guard StoreAccountDeregistrationAccessViewModel.restrictionApplies(requestToken: requestToken, session: session) else { return }
setBindingSuspended(true)
if let pendingCheck, pendingCheck.identity.matches(session: session),
window?.rootViewController === pendingCheck.root {
pendingCheck.viewModel.recordRestriction()
} else if let controller = accessController, controller.identity?.matches(session: session) == true {
controller.recordRestriction()
} else {
check()
}
}
}
@@ -0,0 +1,84 @@
import SnapKit
import UIKit
/// 注销页面共用的轻量视觉组件,仅负责颜色、字号和布局,不包含业务状态。
@MainActor
enum StoreAccountDeregistrationStyle {
/// 创建支持多行的系统字体标签。
static func label(_ text: String = "", size: CGFloat = 15, weight: UIFont.Weight = .regular,
color: UIColor = AppColor.textPrimary) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: size, weight: weight)
label.textColor = color
label.numberOfLines = 0
return label
}
/// 创建统一间距的纵向内容组。
static func stack(_ views: [UIView], spacing: CGFloat = 12) -> UIStackView {
let stack = UIStackView(arrangedSubviews: views)
stack.axis = .vertical
stack.spacing = spacing
return stack
}
/// 白色圆角卡片,内容由页面提供。
static func card(_ content: UIView) -> UIView {
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 16
card.addSubview(content)
content.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
}
/// 统一主次按钮;禁用态及加载期间不依赖系统默认的灰底样式。
static func button(_ title: String, id: String, primary: Bool = true) -> UIButton {
let button = UIButton(type: .system)
button.accessibilityIdentifier = id
configure(button, title: title, primary: primary)
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(50) }
return button
}
/// 更新按钮角色和文案,保留清晰的主次关系。
static func configure(_ button: UIButton, title: String, primary: Bool = true) {
var config = primary ? UIButton.Configuration.filled() : .plain()
config.title = title
config.baseBackgroundColor = AppColor.primary
config.baseForegroundColor = primary ? .white : AppColor.primary
config.background.cornerRadius = 12
config.contentInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16)
config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
return attributes
}
button.configuration = config
button.configurationUpdateHandler = { button in
let enabled = button.isEnabled
var updated = button.configuration
updated?.background.backgroundColorTransformer = UIConfigurationColorTransformer { _ in
primary ? (enabled ? AppColor.primary : AppColor.primary.withAlphaComponent(0.12)) : .clear
}
updated?.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
attributes.foregroundColor = enabled ? (primary ? .white : AppColor.primary) : AppColor.textSecondary
return attributes
}
button.configuration = updated
}
}
/// 以统一的SF Symbol展示提示图标,不加载额外图片资源。
static func icon(_ name: String, size: CGFloat = 22) -> UIImageView {
let image = UIImageView(image: UIImage(systemName: name,
withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .medium)))
image.tintColor = AppColor.primary
image.contentMode = .scaleAspectFit
image.setContentHuggingPriority(.required, for: .horizontal)
return image
}
}
@@ -0,0 +1,409 @@
import IQKeyboardCore
import IQKeyboardManagerSwift
import SnapKit
import UIKit
/// 简洁两步注销页面;一次确认两项资产,短信和实际申请仍由用户主动操作。
@MainActor
final class StoreAccountDeregistrationViewController: BaseViewController {
private typealias Style = StoreAccountDeregistrationStyle
private let viewModel: StoreAccountDeregistrationViewModel
private let api: any StoreAccountDeregistrationServing
private let identityName: String
private let readOnly: Bool
private let onUnresolvedSubmission: (() -> Void)?
private let onSubmissionAccepted: () -> Void
private var didHandleAcceptedSubmission = false
private let scrollView = UIScrollView()
private let refreshControl = UIRefreshControl()
private let content = UIStackView()
private let bottomBar = UIView()
private let steps = UIStackView()
private let firstStep = Style.label("1 确认资产", size: 13, weight: .semibold)
private let secondStep = Style.label("2 手机验证", size: 13, weight: .semibold)
private let heading = Style.label(size: 24, weight: .semibold)
private let subtitle = Style.label(size: 14, color: AppColor.textSecondary)
private let conditionStack = UIStackView()
private let verificationStack = UIStackView()
private let walletAmount = Style.label("—", size: 28, weight: .semibold)
private let pointsAmount = Style.label("—", size: 28, weight: .semibold)
private let walletState = Style.label(size: 12, color: AppColor.textSecondary)
private let pointsState = Style.label(size: 12, color: AppColor.textSecondary)
private let assetHint = Style.label(size: 13, color: AppColor.textSecondary)
private let blockersLabel = Style.label(size: 14, color: AppColor.textSecondary)
private let riskLabel = Style.label(size: 13, color: AppColor.warning)
private let statusLabel = Style.label(size: 13, color: AppColor.textSecondary)
private let errorLabel = Style.label(size: 14, color: AppColor.danger)
private let codeField = UITextField()
private let reasonField = UITextField()
private let smsButton = UIButton(type: .system)
private let continueButton = Style.button("确认资产并继续", id: "deregister.continue")
private let submitButton = Style.button("提交注销申请", id: "deregister.submit")
private let backButton = UIButton(type: .system)
private let footerHint = Style.label(size: 12, color: AppColor.textSecondary)
private var blockersCard = UIView()
private var errorCard = UIView()
private var actionTask: Task<Void, Never>?
private var previousPopGestureEnabled: Bool?
private var previousViewportHeight: CGFloat = 0
/// 注入当前身份及旧接口,不读取任意手机号,也不自动确认资产。
init(identityName: String, viewModel: StoreAccountDeregistrationViewModel,
api: any StoreAccountDeregistrationServing, readOnly: Bool = false,
onUnresolvedSubmission: (() -> Void)? = nil,
onSubmissionAccepted: @escaping () -> Void = {
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
}) {
self.identityName = identityName
self.viewModel = viewModel
self.api = api
self.readOnly = readOnly
self.onUnresolvedSubmission = onUnresolvedSubmission
self.onSubmissionAccepted = onSubmissionAccepted
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = readOnly ? "注销条件" : "注销账号"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.addSubview(scrollView)
refreshControl.accessibilityIdentifier = "deregister.refresh"
refreshControl.accessibilityLabel = "下拉刷新"
refreshControl.tintColor = .clear
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
scrollView.refreshControl = refreshControl
scrollView.addSubview(content)
content.axis = .vertical
content.spacing = 16
content.addArrangedSubview(steps)
steps.axis = .horizontal
steps.distribution = .fillEqually
steps.addArrangedSubview(firstStep)
steps.addArrangedSubview(secondStep)
steps.isHidden = readOnly
content.addArrangedSubview(Style.stack([heading, subtitle], spacing: 8))
let identityText = Style.stack([
Style.label("当前门店身份", size: 12, color: AppColor.textSecondary),
Style.label(identityName, size: 17, weight: .semibold)
], spacing: 5)
let identityRow = UIStackView(arrangedSubviews: [Style.icon("person.crop.circle"), identityText])
identityRow.axis = .horizontal
identityRow.alignment = .center
identityRow.spacing = 12
content.addArrangedSubview(Style.card(identityRow))
conditionStack.axis = .vertical
conditionStack.spacing = 16
verificationStack.axis = .vertical
verificationStack.spacing = 16
buildConditions()
buildVerification()
content.addArrangedSubview(conditionStack)
content.addArrangedSubview(verificationStack)
errorCard = Style.card(errorLabel)
errorCard.backgroundColor = AppColor.dangerBackground
content.addArrangedSubview(errorCard)
content.addArrangedSubview(statusLabel)
statusLabel.accessibilityIdentifier = "deregister.status"
errorLabel.accessibilityIdentifier = "deregister.error"
view.addSubview(bottomBar)
bottomBar.backgroundColor = .white
footerHint.textAlignment = .center
let actions = Style.stack([continueButton, submitButton, footerHint], spacing: 10)
bottomBar.addSubview(actions)
actions.snp.makeConstraints { $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)) }
bottomBar.isHidden = readOnly
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
scrollView.keyboardDismissMode = .onDrag
scrollView.alwaysBounceVertical = true
}
private func buildConditions() {
let columns = UIStackView()
columns.axis = .horizontal
columns.distribution = .fillEqually
columns.spacing = 16
columns.addArrangedSubview(Style.stack([
Style.label("现金余额", size: 13, color: AppColor.textSecondary), walletAmount, walletState
], spacing: 8))
columns.addArrangedSubview(Style.stack([
Style.label("积分", size: 13, color: AppColor.textSecondary), pointsAmount, pointsState
], spacing: 8))
walletAmount.adjustsFontSizeToFitWidth = true
walletAmount.minimumScaleFactor = 0.6
walletAmount.numberOfLines = 1
pointsAmount.adjustsFontSizeToFitWidth = true
pointsAmount.minimumScaleFactor = 0.6
pointsAmount.numberOfLines = 1
walletState.accessibilityIdentifier = "deregister.wallet.state"
pointsState.accessibilityIdentifier = "deregister.points.state"
let assets = Style.card(Style.stack([
Style.label("账号资产", size: 16, weight: .semibold), columns, assetHint
], spacing: 16))
assets.accessibilityIdentifier = "deregister.assets"
conditionStack.addArrangedSubview(assets)
blockersLabel.accessibilityIdentifier = "deregister.blockers"
blockersCard = Style.card(Style.stack([
Style.label("待处理事项", size: 16, weight: .semibold), blockersLabel, riskLabel
]))
conditionStack.addArrangedSubview(blockersCard)
let notices = Style.stack([
Style.label("注销须知", size: 16, weight: .semibold),
notice("person.crop.circle", title: "仅注销当前身份", detail: "同手机号的其他身份不受影响。"),
notice("clock", title: "7 天冷静期", detail: "提交后可撤销申请,到期由服务端复核。"),
notice("exclamationmark.shield", title: "正式完成后不可恢复", detail: "历史订单、财务及审计记录按规则保留。")
], spacing: 16)
conditionStack.addArrangedSubview(Style.card(notices))
}
private func notice(_ icon: String, title: String, detail: String) -> UIView {
let image = Style.icon(icon, size: 18)
image.snp.makeConstraints { $0.width.equalTo(22) }
let row = UIStackView(arrangedSubviews: [image, Style.stack([
Style.label(title, size: 14, weight: .medium),
Style.label(detail, size: 13, color: AppColor.textSecondary)
], spacing: 4)])
row.axis = .horizontal
row.alignment = .top
row.spacing = 10
return row
}
private func buildVerification() {
codeField.placeholder = "请输入短信验证码"
codeField.keyboardType = .numberPad
codeField.textContentType = .oneTimeCode
codeField.accessibilityIdentifier = "deregister.code"
codeField.accessibilityLabel = "短信验证码"
reasonField.placeholder = "请输入注销原因"
reasonField.accessibilityIdentifier = "deregister.reason"
reasonField.accessibilityLabel = "注销原因"
for field in [codeField, reasonField] {
field.font = .systemFont(ofSize: 16)
field.autocorrectionType = .no
// 本页通过keyboardLayoutGuide和滚动区域避让,避免全局键盘库重复抬升整页。
field.iq.enableMode = .disabled
field.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
field.addTarget(self, action: #selector(revealFocusedInput), for: .editingDidBegin)
field.snp.makeConstraints { $0.height.equalTo(48) }
}
smsButton.setTitle("获取验证码", for: .normal)
smsButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
smsButton.accessibilityIdentifier = "deregister.sms"
smsButton.setContentHuggingPriority(.required, for: .horizontal)
smsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
smsButton.addTarget(self, action: #selector(smsTapped), for: .touchUpInside)
smsButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
let codeRow = UIStackView(arrangedSubviews: [codeField, smsButton])
codeRow.axis = .horizontal
codeRow.spacing = 12
verificationStack.addArrangedSubview(Style.card(Style.stack([
Style.label("短信验证", size: 16, weight: .semibold), codeRow,
Style.label("验证码将发送至当前身份绑定的手机号。", size: 13, color: AppColor.textSecondary)
], spacing: 8)))
verificationStack.addArrangedSubview(Style.card(Style.stack([
Style.label("注销原因", size: 16, weight: .semibold), reasonField
], spacing: 8)))
backButton.setTitle("返回查看资产与须知", for: .normal)
backButton.titleLabel?.font = .systemFont(ofSize: 14)
backButton.accessibilityIdentifier = "deregister.back"
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
verificationStack.addArrangedSubview(backButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints {
$0.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
}
scrollView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
if readOnly { $0.bottom.equalTo(view.safeAreaLayoutGuide) }
else { $0.bottom.equalTo(bottomBar.snp.top) }
}
content.snp.makeConstraints {
$0.edges.equalTo(scrollView.contentLayoutGuide).inset(16)
$0.width.equalTo(scrollView.frameLayoutGuide).offset(-32)
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
refreshTapped()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
previousPopGestureEnabled = navigationController?.interactivePopGestureRecognizer?.isEnabled
applyViewModel()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let previousPopGestureEnabled {
navigationController?.interactivePopGestureRecognizer?.isEnabled = previousPopGestureEnabled
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard scrollView.bounds.height != previousViewportHeight else { return }
previousViewportHeight = scrollView.bounds.height
revealFocusedInput()
}
@objc private func revealFocusedInput() {
guard let field = [codeField, reasonField].first(where: \.isFirstResponder) else { return }
let rect = field.convert(field.bounds, to: scrollView).insetBy(dx: 0, dy: -12)
scrollView.scrollRectToVisible(rect, animated: false)
}
private func applyViewModel() {
let busy = actionTask != nil || viewModel.isBusy
let verifying = viewModel.step == .verification && !readOnly
let unresolved = viewModel.step == .unresolvedRequest && !readOnly
heading.text = readOnly ? "当前注销条件" : (verifying ? "验证绑定手机号" : "注销前,请确认")
subtitle.text = verifying ? "完成验证后,即可提交注销申请。" : "仅注销此身份,其他身份不受影响。"
firstStep.textColor = verifying ? AppColor.textSecondary : AppColor.primary
secondStep.textColor = verifying ? AppColor.primary : AppColor.textSecondary
conditionStack.isHidden = verifying || unresolved
verificationStack.isHidden = !verifying
continueButton.isHidden = verifying || readOnly
submitButton.isHidden = !verifying
continueButton.configuration?.title = viewModel.requiresAssetConfirmation ? "确认资产并继续" : "下一步"
continueButton.isEnabled = !busy && viewModel.canConfirmAssetsAndContinue
submitButton.isEnabled = !busy && viewModel.canContinue && hasVerificationInput
smsButton.isEnabled = !busy && viewModel.canContinue
codeField.isEnabled = !busy
reasonField.isEnabled = !busy
backButton.isEnabled = !busy
refreshControl.isEnabled = !busy
footerHint.text = verifying ? "7 天内可撤销,正式完成后不可恢复" : "请确认资产及注销须知后继续"
errorLabel.text = viewModel.errorMessage
errorCard.isHidden = viewModel.errorMessage == nil
if let value = viewModel.eligibility {
walletAmount.text = "¥\(value.walletBalance)"
pointsAmount.text = "\(value.pointsBalance)"
walletState.text = value.walletWaived ? "已确认放弃" : "待确认放弃"
pointsState.text = value.pointsWaived ? "已确认放弃" : "待确认放弃"
walletState.textColor = value.walletWaived ? AppColor.primary : AppColor.textSecondary
pointsState.textColor = value.pointsWaived ? AppColor.primary : AppColor.textSecondary
let assetIssues = value.blockers.filter(\.isAssetConfirmation)
assetHint.text = readOnly ? "以当前查询结果为准,冷静期内不能再次确认资产。"
: (assetIssues.contains { $0.code == "WAIVER_STALE" }
? "资产已变化,请按最新金额重新确认。"
: "正式注销时,将清空已确认放弃的现金和积分。")
blockersCard.isHidden = value.businessBlockers.isEmpty
blockersLabel.text = value.businessBlockers.map { "• \($0.message)\n \($0.guidance)" }.joined(separator: "\n\n")
riskLabel.text = value.eligibleAt.map { "业务风险期预计结束:\($0)" }
riskLabel.isHidden = value.eligibleAt == nil
} else {
walletAmount.text = "—"
pointsAmount.text = "—"
walletState.text = "待查询"
pointsState.text = "待查询"
assetHint.text = busy ? "正在查询资产与注销条件…" : "暂未获取到资产,请刷新重试。"
blockersCard.isHidden = true
}
statusLabel.text = unresolved ? "申请状态待确认,请刷新后重试,暂勿重复提交。"
: (viewModel.status?.isCancelled == true ? "上次申请已撤销,可重新申请。" : nil)
statusLabel.isHidden = statusLabel.text == nil
let preventsBack = busy || unresolved || (!readOnly && viewModel.submissionAttempted)
navigationItem.hidesBackButton = preventsBack
if view.window != nil {
navigationController?.interactivePopGestureRecognizer?.isEnabled = !preventsBack && (previousPopGestureEnabled ?? true)
}
}
private var hasVerificationInput: Bool {
!(codeField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !(reasonField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func run(_ operation: @escaping @MainActor () async -> Void) {
guard actionTask == nil, !didHandleAcceptedSubmission else {
refreshControl.endRefreshing()
return
}
actionTask = Task { @MainActor [weak self] in
guard let self else { return }
self.showLoading()
await operation()
self.hideLoading()
self.refreshControl.endRefreshing()
self.actionTask = nil
if !self.readOnly, self.viewModel.submissionAccepted {
self.didHandleAcceptedSubmission = true
self.onSubmissionAccepted()
return
}
self.applyViewModel()
if !self.readOnly, self.viewModel.submissionAttempted || self.viewModel.status?.isCooling == true {
self.onUnresolvedSubmission?()
}
}
applyViewModel()
}
@objc private func inputChanged() { applyViewModel() }
@objc private func refreshTapped() { run { [self] in await self.viewModel.refresh(api: self.api) } }
@objc private func continueTapped() {
guard !readOnly, viewModel.canConfirmAssetsAndContinue, let snapshot = viewModel.eligibility else { return }
if !viewModel.requiresAssetConfirmation {
run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
return
}
let alert = UIAlertController(title: "确认放弃账号资产?", message:
"当前身份:\(identityName)\n\n现金余额:¥\(snapshot.walletBalance)\n积分:\(snapshot.pointsBalance)\n\n我确认自愿放弃以上现金余额,并确认自愿放弃以上积分。正式注销时将按规则清零。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不确认", style: .cancel))
alert.addAction(UIAlertAction(title: "确认并继续", style: .destructive) { [weak self] _ in
guard let self else { return }
self.run { [self] in await self.viewModel.confirmAssetsAndContinue(snapshot: snapshot, api: self.api) }
})
present(alert, animated: true)
}
@objc private func backTapped() {
view.endEditing(true)
viewModel.returnToConditions()
applyViewModel()
}
@objc private func smsTapped() {
guard !readOnly else { return }
run { [self] in
if await self.viewModel.sendSMS(api: self.api) { showToast("验证码已发送至当前身份绑定手机号") }
}
}
@objc private func submitTapped() {
guard !readOnly, viewModel.canContinue, hasVerificationInput else { return }
let code = codeField.text ?? ""
let reason = reasonField.text ?? ""
view.endEditing(true)
let alert = UIAlertController(title: "提交注销申请?", message:
"仅注销“\(identityName)”。提交成功后将退出登录,并进入 7 天冷静期,最终由服务端复核;正式完成不可恢复。\n\n重新登录或选中此身份会自动撤销尚未完成的申请。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "暂不提交", style: .cancel))
alert.addAction(UIAlertAction(title: "确认提交申请", style: .destructive) { [weak self] _ in
self?.submitConfirmedApplication(smsCode: code, reason: reason)
})
present(alert, animated: true)
}
/// 用户确认最终弹窗后提交;仅明确成功触发一次退出,未知结果仍进入核验流程。
func submitConfirmedApplication(smsCode: String, reason: String) {
guard !readOnly else { return }
run { [self] in await viewModel.submit(smsCode: smsCode, reason: reason, api: api) }
}
}