feat: 增加门店身份注销流程
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
|
||||
/// 本机提交意图记录,不代表后端已受理或任何注销业务状态。
|
||||
protocol StoreAccountDeregistrationSubmissionTracking {
|
||||
/// 是否存在本机尚无法确定结果的提交意图。
|
||||
var hasUnresolvedSubmission: Bool { get }
|
||||
/// 在发出 POST 前记录意图;无法记录时不允许发出申请。
|
||||
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus?) throws
|
||||
/// 仅在服务端明确拒绝本次申请时清除此意图。
|
||||
func clearRejectedSubmission()
|
||||
/// 用新撤销记录核销提交意图;与重新申请前相同的旧撤销响应不能放行。
|
||||
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool
|
||||
}
|
||||
|
||||
/// 按环境和门店用户 ID 隔离提交意图;不按手机号保存,不存储凭证、验证码或资产。
|
||||
final class StoreAccountDeregistrationSubmissionStore: StoreAccountDeregistrationSubmissionTracking {
|
||||
private let defaults: UserDefaults
|
||||
private let key: String
|
||||
|
||||
/// 注入持久化容器便于测试;生产环境使用当前服务域名隔离测试/正式账号。
|
||||
init(storeUserID: String, environment: APIEnvironment = .current, defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
key = "store_deregister_submission_intent_v1_\(environment.baseURL.host ?? "unknown")_\(storeUserID)"
|
||||
}
|
||||
|
||||
/// 任何残留记录都按待核实处理,不因不认识的本地值而允许重新提交。
|
||||
var hasUnresolvedSubmission: Bool { defaults.object(forKey: key) != nil }
|
||||
|
||||
/// UserDefaults 记录提交意图;不把本地写入成功等同于后端收到申请。
|
||||
func recordSubmissionIntent(previousStatus: StoreAccountDeregistrationStatus? = nil) throws {
|
||||
defaults.set(["previous_cancellation": previousStatus?.cancellationFingerprint ?? ""], forKey: key)
|
||||
guard hasUnresolvedSubmission else { throw SubmissionPersistenceError.unavailable }
|
||||
}
|
||||
|
||||
/// 只删除当前环境、当前门店用户对应的意图,不影响其他身份。
|
||||
func clearRejectedSubmission() { defaults.removeObject(forKey: key) }
|
||||
|
||||
/// 旧版本只会从无记录/草稿提交,其布尔意图可由完整撤销记录核销。
|
||||
func clearCancelledSubmission(matching status: StoreAccountDeregistrationStatus) -> Bool {
|
||||
guard let fingerprint = status.cancellationFingerprint else { return false }
|
||||
if let stored = defaults.object(forKey: key) {
|
||||
if let record = stored as? [String: String], let previous = record["previous_cancellation"] {
|
||||
guard previous != fingerprint else { return false }
|
||||
} else {
|
||||
// 仅兼容旧版本写入的 true;损坏或未知结构继续等待核验。
|
||||
guard let value = stored as? NSNumber,
|
||||
CFGetTypeID(value) == CFBooleanGetTypeID(), value.boolValue else { return false }
|
||||
}
|
||||
}
|
||||
defaults.removeObject(forKey: key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// 无法保存提交意图时阻止申请,避免在重启后失去重复提交保护。
|
||||
private enum SubmissionPersistenceError: LocalizedError {
|
||||
case unavailable
|
||||
var errorDescription: String? { "无法保存提交核验记录,请稍后重试" }
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import Foundation
|
||||
|
||||
/// 进入业务页面前的核验结果,不将非空未知记录解释为任何已知注销状态。
|
||||
enum StoreAccountDeregistrationAccessDecision: Equatable {
|
||||
case notChecked
|
||||
case checking
|
||||
case allowed
|
||||
case cooling
|
||||
case unresolved
|
||||
case failed(String)
|
||||
case obsolete
|
||||
}
|
||||
|
||||
/// 进入业务前核验当前身份;冷静期只能查询或由用户明确撤销,不自动重新登录。
|
||||
final class StoreAccountDeregistrationAccessViewModel {
|
||||
/// 未核验与查询失败都不能创建普通业务根页面。
|
||||
private(set) var decision: StoreAccountDeregistrationAccessDecision = .notChecked
|
||||
/// 防止同一页面重复发起查询。
|
||||
private(set) var isChecking = false
|
||||
/// 最近一次通过身份核验的服务端状态,用于冷静期展示和撤销前比对。
|
||||
private(set) var status: StoreAccountDeregistrationStatus?
|
||||
private var restrictionRevision = 0
|
||||
private var requiresSubmissionReconciliation: Bool
|
||||
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
||||
private var observedCooling = false
|
||||
|
||||
/// 本机存在待核实提交意图时,即使暂时返回 null 也不直接恢复普通业务。
|
||||
init(requiresSubmissionReconciliation: Bool = false,
|
||||
submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
|
||||
self.submissionStore = submissionStore
|
||||
self.requiresSubmissionReconciliation = requiresSubmissionReconciliation
|
||||
|| submissionStore?.hasUnresolvedSubmission == true
|
||||
}
|
||||
|
||||
/// 新的限制信号优先于此前已发出的状态查询,防止旧正常响应覆盖新限制。
|
||||
func recordRestriction() {
|
||||
restrictionRevision += 1
|
||||
status = nil
|
||||
decision = .unresolved
|
||||
}
|
||||
|
||||
/// 查询前后核对身份;旧身份响应和错误均不能决定新会话是否进入业务页。
|
||||
func verify(api: any StoreAccountDeregistrationStatusServing,
|
||||
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
|
||||
guard !isChecking else { return }
|
||||
isChecking = true
|
||||
let revision = restrictionRevision
|
||||
decision = .checking
|
||||
status = nil
|
||||
defer { isChecking = false }
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
do {
|
||||
let result = try await api.status()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
apply(result)
|
||||
} catch {
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
if case APIError.serverCode(150015, _) = error {
|
||||
decision = .unresolved
|
||||
} else if case StoreAccountDeregistrationError.sessionChanged = error {
|
||||
decision = .obsolete
|
||||
} else {
|
||||
decision = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户确认后先重查同一申请再撤销,结果丢失时保留限制,禁止自动重发 POST。
|
||||
func cancel(api: any StoreAccountDeregistrationServing,
|
||||
isCurrentIdentity: @escaping @MainActor () -> Bool) async {
|
||||
guard !isChecking, decision == .cooling, let recordID = status?.recordID else { return }
|
||||
isChecking = true
|
||||
let revision = restrictionRevision
|
||||
defer { isChecking = false }
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
do {
|
||||
let current = try await api.status()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
guard current.isCooling, current.recordID == recordID else { applyCancellationCheck(current); return }
|
||||
try await api.cancel()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
let confirmed = try await api.status()
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
applyCancellationCheck(confirmed)
|
||||
} catch {
|
||||
guard await isCurrentIdentity() else { decision = .obsolete; return }
|
||||
guard restrictionRevision == revision else { return }
|
||||
status = nil
|
||||
decision = .failed("撤销结果尚未确认,请重新查询状态。\n" + error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCancellationCheck(_ result: StoreAccountDeregistrationStatus) {
|
||||
// 曾查到冷静期后,旧 null/草稿不能证明撤销完成;须重新核实完整状态。
|
||||
guard result.isCooling || result.isCancelled else {
|
||||
status = result
|
||||
decision = .unresolved
|
||||
return
|
||||
}
|
||||
apply(result)
|
||||
}
|
||||
|
||||
private func apply(_ result: StoreAccountDeregistrationStatus) {
|
||||
status = result
|
||||
if submissionStore?.hasUnresolvedSubmission == true { requiresSubmissionReconciliation = true }
|
||||
if result.isCancelled, submissionStore?.clearCancelledSubmission(matching: result) == true {
|
||||
requiresSubmissionReconciliation = false
|
||||
}
|
||||
if result.isCooling {
|
||||
observedCooling = true
|
||||
decision = .cooling
|
||||
} else {
|
||||
decision = result.permitsPreparation && !requiresSubmissionReconciliation
|
||||
&& (!observedCooling || result.isCancelled) ? .allowed : .unresolved
|
||||
}
|
||||
}
|
||||
|
||||
/// 150015 只影响发出请求时的同一门店身份凭证,不能按手机号传播限制。
|
||||
static func restrictionApplies(requestToken: String?, session: AppSessionStore) -> Bool {
|
||||
guard session.accountType == .storeUser,
|
||||
let requestToken, !requestToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
||||
return requestToken == session.token
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
import Foundation
|
||||
|
||||
/// 注销流程本地交互阶段;不使用这些阶段伪造服务端业务状态。
|
||||
enum StoreAccountDeregistrationStep: Equatable {
|
||||
case conditions
|
||||
case verification
|
||||
case unresolvedRequest
|
||||
}
|
||||
|
||||
/// 门店身份注销流程,所有资产确认和提交都重新检查服务端条件,不持久化手机号级注销状态。
|
||||
final class StoreAccountDeregistrationViewModel {
|
||||
/// 当前已核验的条件;刷新失败即清空,禁止继续使用过期权限。
|
||||
private(set) var eligibility: StoreAccountDeregistrationEligibility?
|
||||
/// 当前已查询的原始申请记录。
|
||||
private(set) var status: StoreAccountDeregistrationStatus?
|
||||
/// 页面交互阶段,不代表服务端冷静期状态。
|
||||
private(set) var step: StoreAccountDeregistrationStep = .conditions
|
||||
/// 同一流程只允许一个在途操作。
|
||||
private(set) var isBusy = false
|
||||
/// 显示给用户的错误或需要重新核验的原因。
|
||||
private(set) var errorMessage: String?
|
||||
/// 请求已发出后阻止同一流程重复提交,包括响应丢失的情况。
|
||||
private(set) var submissionAttempted = false
|
||||
/// 成功响应仅代表申请已提交,不代表账号注销完成。
|
||||
private(set) var submissionAccepted = false
|
||||
private let storeUserID: Int
|
||||
private let submissionStore: (any StoreAccountDeregistrationSubmissionTracking)?
|
||||
private var previousCancellationFingerprint: String?
|
||||
private var needsUpdatedAssetConsent = false
|
||||
|
||||
/// 注入被冻结的业务身份 ID;网络服务另行注入以便测试。
|
||||
init(storeUserID: Int, submissionStore: (any StoreAccountDeregistrationSubmissionTracking)? = nil) {
|
||||
self.storeUserID = storeUserID
|
||||
self.submissionStore = submissionStore
|
||||
if submissionStore?.hasUnresolvedSubmission == true {
|
||||
submissionAttempted = true
|
||||
step = .unresolvedRequest
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅当完整条件、两项确认和状态查询一致时,允许进入验证码步骤。
|
||||
var canContinue: Bool {
|
||||
!isBusy && !submissionAttempted && status?.permitsPreparation == true
|
||||
&& eligibility?.permitsApplication == true
|
||||
}
|
||||
|
||||
/// 一个入口处理两项确认;其他业务条件未满足时不能开始。
|
||||
var canConfirmAssetsAndContinue: Bool {
|
||||
!isBusy && !submissionAttempted && status?.permitsPreparation == true
|
||||
&& eligibility?.permitsAssetConfirmation == true
|
||||
&& (requiresAssetConfirmation || eligibility?.permitsApplication == true)
|
||||
}
|
||||
|
||||
/// 未确认或确认期间资产变化时,必须重新向用户列出两项金额。
|
||||
var requiresAssetConfirmation: Bool {
|
||||
needsUpdatedAssetConsent || eligibility?.walletWaived != true || eligibility?.pointsWaived != true
|
||||
}
|
||||
|
||||
/// 用户一次明确确认两项资产后,顺序调用旧接口;只读核实部分结果,不自动重发修改请求。
|
||||
func confirmAssetsAndContinue(snapshot: StoreAccountDeregistrationEligibility,
|
||||
api: any StoreAccountDeregistrationServing) async {
|
||||
guard canConfirmAssetsAndContinue else { return }
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
for asset in [StoreAccountDeregistrationAsset.wallet, .points] {
|
||||
try await reload(api: api)
|
||||
let current = try validateAssetConsent(snapshot)
|
||||
let waived = asset == .wallet ? current.walletWaived : current.pointsWaived
|
||||
if !waived {
|
||||
if asset == .wallet { try await api.waiveWallet() }
|
||||
else { try await api.waivePoints() }
|
||||
try await reload(api: api)
|
||||
let confirmed = try validateAssetConsent(snapshot)
|
||||
guard asset == .wallet ? confirmed.walletWaived : confirmed.pointsWaived else {
|
||||
throw StoreAccountDeregistrationFlowError.confirmationIncomplete
|
||||
}
|
||||
}
|
||||
}
|
||||
guard eligibility?.permitsApplication == true, status?.permitsPreparation == true else {
|
||||
throw StoreAccountDeregistrationFlowError.conditionsChanged
|
||||
}
|
||||
needsUpdatedAssetConsent = false
|
||||
step = .verification
|
||||
} catch {
|
||||
if case StoreAccountDeregistrationFlowError.assetsChanged = error { needsUpdatedAssetConsent = true }
|
||||
if case StoreAccountDeregistrationError.sessionChanged = error { invalidate(error); return }
|
||||
// POST响应丢失也可能已经成功:仅GET恢复真实确认标记,下一次由用户主动重试。
|
||||
do { try await reload(api: api) }
|
||||
catch { invalidate(error); return }
|
||||
if status?.permitsPreparation == true, eligibility?.permitsPreparation == true { step = .conditions }
|
||||
let partial = eligibility?.walletWaived == true && eligibility?.pointsWaived == false
|
||||
? "现金余额已确认,积分尚未确认。\n" : ""
|
||||
errorMessage = partial + error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func validateAssetConsent(_ snapshot: StoreAccountDeregistrationEligibility) throws -> StoreAccountDeregistrationEligibility {
|
||||
guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else {
|
||||
throw StoreAccountDeregistrationFlowError.existingRequest
|
||||
}
|
||||
guard current.hasSameAssets(as: snapshot),
|
||||
!(snapshot.walletWaived && !current.walletWaived),
|
||||
!(snapshot.pointsWaived && !current.pointsWaived) else {
|
||||
throw StoreAccountDeregistrationFlowError.assetsChanged
|
||||
}
|
||||
guard current.permitsAssetConfirmation else { throw StoreAccountDeregistrationFlowError.conditionsChanged }
|
||||
return current
|
||||
}
|
||||
|
||||
/// 无申请或未提交草稿且条件可读取时允许确认资产,零余额也不自动确认。
|
||||
func canConfirm(_ asset: StoreAccountDeregistrationAsset) -> Bool {
|
||||
guard !isBusy, !submissionAttempted, status?.permitsPreparation == true,
|
||||
let eligibility, eligibility.permitsPreparation,
|
||||
eligibility.walletBalanceFen >= 0, eligibility.pointsBalance >= 0 else { return false }
|
||||
return asset == .wallet ? !eligibility.walletWaived : !eligibility.pointsWaived
|
||||
}
|
||||
|
||||
/// 用户主动刷新;后端非空记录无法识别时禁止继续申请,不以七天本地倒计时替代查询。
|
||||
func refresh(api: any StoreAccountDeregistrationServing) async {
|
||||
guard !isBusy else { return }
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do { try await reload(api: api) }
|
||||
catch { invalidate(error) }
|
||||
}
|
||||
|
||||
/// 用户已在单独弹窗中确认某种资产;提交前比对弹窗中的两项余额快照。
|
||||
func confirm(_ asset: StoreAccountDeregistrationAsset, snapshot: StoreAccountDeregistrationEligibility,
|
||||
api: any StoreAccountDeregistrationServing) async {
|
||||
guard canConfirm(asset) else { return }
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
try await reload(api: api)
|
||||
guard status?.permitsPreparation == true, let current = eligibility, current.permitsPreparation else {
|
||||
throw StoreAccountDeregistrationFlowError.existingRequest
|
||||
}
|
||||
guard current.hasSameAssets(as: snapshot) else { throw StoreAccountDeregistrationFlowError.assetsChanged }
|
||||
guard current.walletBalanceFen >= 0, current.pointsBalance >= 0 else {
|
||||
throw StoreAccountDeregistrationFlowError.conditionsChanged
|
||||
}
|
||||
if asset == .wallet {
|
||||
if !current.walletWaived { try await api.waiveWallet() }
|
||||
} else if !current.pointsWaived {
|
||||
try await api.waivePoints()
|
||||
}
|
||||
// 不乐观设置确认标记:只有新查询返回 true 才算已确认。
|
||||
try await reload(api: api)
|
||||
} catch { invalidate(error) }
|
||||
}
|
||||
|
||||
/// 条件全部满足后进入真实短信验证步骤。
|
||||
func beginVerification() {
|
||||
guard canContinue else { return }
|
||||
step = .verification
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 未发出申请前可以返回重新查看条件。
|
||||
func returnToConditions() {
|
||||
guard !isBusy, !submissionAttempted else { return }
|
||||
step = .conditions
|
||||
}
|
||||
|
||||
/// 不接受用户指定收件手机号,使用接口绑定的门店手机号。
|
||||
func sendSMS(api: any StoreAccountDeregistrationServing) async -> Bool {
|
||||
guard canContinue, step == .verification else { return false }
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
try await checkReady(api: api)
|
||||
try await api.sendSMS()
|
||||
return true
|
||||
} catch { invalidate(error); return false }
|
||||
}
|
||||
|
||||
/// 校验输入后申请;服务端明确接受即交由页面退出,不再查询状态或推断正式完成。
|
||||
func submit(smsCode: String, reason: String, api: any StoreAccountDeregistrationServing) async {
|
||||
guard canContinue, step == .verification else { return }
|
||||
let code = smsCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let reason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !code.isEmpty, !reason.isEmpty else {
|
||||
errorMessage = "请输入收到的短信验证码和注销原因"
|
||||
return
|
||||
}
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
try await checkReady(api: api)
|
||||
previousCancellationFingerprint = status?.cancellationFingerprint
|
||||
try submissionStore?.recordSubmissionIntent(previousStatus: status)
|
||||
submissionAttempted = true
|
||||
step = .unresolvedRequest
|
||||
eligibility = nil
|
||||
try await api.apply(smsCode: code, reason: reason)
|
||||
submissionAccepted = true
|
||||
} catch {
|
||||
// 业务码明确拒绝申请时可重新核验;网络/解码错误不能证明服务端没有收到申请。
|
||||
if !submissionAccepted, case APIError.serverCode = error {
|
||||
submissionStore?.clearRejectedSubmission()
|
||||
submissionAttempted = false
|
||||
step = .conditions
|
||||
}
|
||||
invalidate(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkReady(api: any StoreAccountDeregistrationServing) async throws {
|
||||
try await reload(api: api)
|
||||
guard status?.permitsPreparation == true, eligibility?.permitsApplication == true else {
|
||||
throw StoreAccountDeregistrationFlowError.conditionsChanged
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(api: any StoreAccountDeregistrationServing) async throws {
|
||||
eligibility = nil
|
||||
status = nil
|
||||
let currentStatus = try await api.status()
|
||||
status = currentStatus
|
||||
if !currentStatus.permitsPreparation || submissionAttempted { step = .unresolvedRequest }
|
||||
let current = try await api.eligibility()
|
||||
guard current.storeUserID == storeUserID, storeUserID > 0 else {
|
||||
throw StoreAccountDeregistrationError.sessionChanged
|
||||
}
|
||||
eligibility = current
|
||||
let eligibilityStatus = StoreAccountDeregistrationStatus(deregister: current.deregister)
|
||||
if submissionAttempted, let fingerprint = currentStatus.cancellationFingerprint,
|
||||
eligibilityStatus.cancellationFingerprint == fingerprint {
|
||||
let reconciled = submissionStore.map { $0.clearCancelledSubmission(matching: currentStatus) }
|
||||
?? (fingerprint != previousCancellationFingerprint)
|
||||
if reconciled {
|
||||
submissionAttempted = false
|
||||
submissionAccepted = false
|
||||
}
|
||||
}
|
||||
if !current.permitsPreparation { step = .unresolvedRequest }
|
||||
if step == .unresolvedRequest, currentStatus.permitsPreparation, current.permitsPreparation, !submissionAttempted {
|
||||
step = .conditions
|
||||
}
|
||||
if step == .verification, !current.permitsApplication { step = .conditions }
|
||||
}
|
||||
|
||||
private func invalidate(_ error: Error) {
|
||||
eligibility = nil
|
||||
errorMessage = error.localizedDescription
|
||||
if !submissionAttempted, status?.permitsPreparation == true { step = .conditions }
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地交互检查失败,不替代服务端业务错误。
|
||||
private enum StoreAccountDeregistrationFlowError: LocalizedError {
|
||||
case assetsChanged
|
||||
case conditionsChanged
|
||||
case existingRequest
|
||||
case confirmationIncomplete
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .assetsChanged: "资产余额已变化,请刷新后重新确认"
|
||||
case .conditionsChanged: "注销条件已变化,请刷新并处理全部阻断项"
|
||||
case .existingRequest: "已查询到注销记录,请先核实申请状态"
|
||||
case .confirmationIncomplete: "资产确认尚未完成,请刷新后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user