diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift index 8341e16..e40c7df 100644 --- a/suixinkan/Features/Auth/Models/AuthModels.swift +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -57,6 +57,7 @@ enum LoginValidationError: Equatable { enum LoginResolution { case completed(V9AuthResponse, AccountSwitchAccount) case needsAccountSelection(AccountSelectionPayload) + case needsDeregistrationConfirmation(DeregistrationLoginConfirmation) } /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 @@ -124,8 +125,40 @@ struct AccountSwitchAccount: Identifiable, Hashable { let storeId: Int? let storeName: String let scenicId: Int? + let status: Int let isCurrent: Bool + /// 创建统一账号展示模型;门店状态默认为正常,兼容尚未返回 `status` 的旧响应。 + init( + accountType: String, + businessUserId: Int, + title: String, + subtitle: String, + phone: String, + realName: String, + avatar: String, + scenicName: String, + storeId: Int?, + storeName: String, + scenicId: Int?, + status: Int = 1, + isCurrent: Bool + ) { + self.accountType = accountType + self.businessUserId = businessUserId + self.title = title + self.subtitle = subtitle + self.phone = phone + self.realName = realName + self.avatar = avatar + self.scenicName = scenicName + self.storeId = storeId + self.storeName = storeName + self.scenicId = scenicId + self.status = status + self.isCurrent = isCurrent + } + var id: String { "\(accountType)_\(businessUserId)" } @@ -134,6 +167,11 @@ struct AccountSwitchAccount: Identifiable, Hashable { accountType == V9StoreUser.accountTypeValue } + /// `status == 2` 表示门店身份已提交注销申请,登录前需要用户明确确认撤销。 + var requiresDeregistrationConfirmation: Bool { + isStoreUser && status == 2 + } + var accountTypeLabel: String { isStoreUser ? "门店账号" : "景区账号" } @@ -146,6 +184,12 @@ struct AccountSwitchAccount: Identifiable, Hashable { } } +/// 冷静期门店身份的待确认登录信息;确认后继续调用 `set-user`,由后端自动撤销注销申请。 +struct DeregistrationLoginConfirmation: Equatable { + let tempToken: String + let account: AccountSwitchAccount +} + /// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。 struct AccountSelectionPayload: Equatable, Identifiable { let id = UUID() @@ -285,6 +329,7 @@ struct V9StoreUser: Decodable, Equatable { let roleName: String let appRoleCode: String let appRoleName: String + let status: Int let isCurrent: Bool var businessUserId: Int { @@ -308,6 +353,7 @@ struct V9StoreUser: Decodable, Equatable { storeId: storeId > 0 ? storeId : nil, storeName: storeName, scenicId: scenicId > 0 ? scenicId : nil, + status: status, isCurrent: isCurrent ) } @@ -327,6 +373,7 @@ struct V9StoreUser: Decodable, Equatable { case roleName = "role_name" case appRoleCode = "app_role_code" case appRoleName = "app_role_name" + case status case isCurrent = "is_current" } @@ -346,6 +393,7 @@ struct V9StoreUser: Decodable, Equatable { roleName = try container.decodeLossyString(forKey: .roleName) appRoleCode = try container.decodeLossyString(forKey: .appRoleCode) appRoleName = try container.decodeLossyString(forKey: .appRoleName) + status = try container.decodeLossyInt(forKey: .status) ?? 1 isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false } } diff --git a/suixinkan/SceneDelegate.swift b/suixinkan/SceneDelegate.swift index c4e629f..929eca2 100644 --- a/suixinkan/SceneDelegate.swift +++ b/suixinkan/SceneDelegate.swift @@ -181,11 +181,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { @objc private func handleUserDidLogin() { sessionExpiredDialog?.dismiss(animated: false) sessionExpiredDialog = nil - if AppStore.shared.session.isLoggedIn, AppStore.shared.session.accountType == .storeUser { - deregistrationCoordinator?.check() - } else { - refreshRootForCurrentSession() - } + // v9 登录响应中的 store_users[].status 是登录是否需要注销确认的唯一依据。 + // 旧 account-deregister/status 仅供用户主动进入注销设置流程时查询,不能再拦截正常登录。 + refreshRootForCurrentSession() } @objc private func handleAccountDidSwitch() { diff --git a/suixinkan/UI/Login/AccountSelectionViewController.swift b/suixinkan/UI/Login/AccountSelectionViewController.swift index 8443055..c255ee8 100644 --- a/suixinkan/UI/Login/AccountSelectionViewController.swift +++ b/suixinkan/UI/Login/AccountSelectionViewController.swift @@ -164,6 +164,16 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat @objc private func confirmTapped() { guard canConfirm, let selectedAccount else { return } + if selectedAccount.requiresDeregistrationConfirmation { + present( + makeDeregistrationLoginAlert( + account: selectedAccount, + onConfirm: { [weak self] in self?.onConfirm(selectedAccount) } + ), + animated: true + ) + return + } onConfirm(selectedAccount) } @@ -177,6 +187,23 @@ final class AccountSelectionViewController: UIViewController, UITableViewDelegat } } +/// 创建冷静期账号登录确认弹窗,确认后 `set-user` 会由后端自动撤销原注销申请。 +func makeDeregistrationLoginAlert( + account: AccountSwitchAccount, + onConfirm: @escaping () -> Void +) -> UIAlertController { + let accountName = account.title.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = accountName.isEmpty ? "该门店账号" : "“\(accountName)”" + let alert = UIAlertController( + title: "该账号正在注销", + message: "\(displayName)已提交注销申请,目前处于冷静期。确认登录后,将自动撤销之前的注销申请。", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认登录", style: .default) { _ in onConfirm() }) + return alert +} + /// 账号选择列表 Cell。 private final class AccountSelectionCell: UITableViewCell { static let reuseIdentifier = "AccountSelectionCell" diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift index 9d97172..8cddc96 100644 --- a/suixinkan/UI/Login/LoginViewController.swift +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -290,6 +290,8 @@ final class LoginViewController: BaseViewController { completeLogin(with: response, account: account) case .needsAccountSelection: break + case let .needsDeregistrationConfirmation(confirmation): + presentDeregistrationLoginConfirmation(confirmation) } } catch is CancellationError { return @@ -335,6 +337,31 @@ final class LoginViewController: BaseViewController { } } + private func presentDeregistrationLoginConfirmation(_ confirmation: DeregistrationLoginConfirmation) { + let alert = makeDeregistrationLoginAlert( + account: confirmation.account, + onConfirm: { [weak self] in + self?.confirmDeregistrationLogin(confirmation) + } + ) + present(alert, animated: true) + } + + private func confirmDeregistrationLogin(_ confirmation: DeregistrationLoginConfirmation) { + Task { + showLoading() + defer { hideLoading() } + do { + let response = try await viewModel.confirmDeregistrationLogin(confirmation, authAPI: authAPI) + completeLogin(with: response, account: confirmation.account) + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } + } + } + private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) { AuthSessionHelper.completeLogin( with: response, diff --git a/suixinkan/UI/Login/LoginViewModel.swift b/suixinkan/UI/Login/LoginViewModel.swift index 305ea95..2bf64aa 100644 --- a/suixinkan/UI/Login/LoginViewModel.swift +++ b/suixinkan/UI/Login/LoginViewModel.swift @@ -130,6 +130,28 @@ final class LoginViewModel { guard let payload = pendingAccountSelection, payload.hasTempToken else { throw LoginFlowError.missingToken } + let response = try await setUser(account, tempToken: payload.tempToken, authAPI: authAPI) + pendingAccountSelection = nil + notifyStateChange() + return response + } + + /// 用户确认登录冷静期账号后继续换取正式 token;`set-user` 成功即由后端撤销原注销申请。 + func confirmDeregistrationLogin( + _ confirmation: DeregistrationLoginConfirmation, + authAPI: AuthAPI + ) async throws -> V9AuthResponse { + try await setUser(confirmation.account, tempToken: confirmation.tempToken, authAPI: authAPI) + } + + private func setUser( + _ account: AccountSwitchAccount, + tempToken: String, + authAPI: AuthAPI + ) async throws -> V9AuthResponse { + guard !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } guard account.businessUserId > 0 else { throw LoginFlowError.invalidAccount } @@ -144,12 +166,10 @@ final class LoginViewModel { notifyStateChange() } - let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken) + let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: tempToken) guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw LoginFlowError.missingToken } - pendingAccountSelection = nil - notifyStateChange() return response } @@ -178,6 +198,11 @@ final class LoginViewModel { } if accounts.count == 1, let account = accounts.first { + if account.requiresDeregistrationConfirmation { + return .needsDeregistrationConfirmation( + DeregistrationLoginConfirmation(tempToken: token, account: account) + ) + } let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token) guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw LoginFlowError.missingToken diff --git a/suixinkanTests/AuthModelsTests.swift b/suixinkanTests/AuthModelsTests.swift index 8d0f840..8b5dd98 100644 --- a/suixinkanTests/AuthModelsTests.swift +++ b/suixinkanTests/AuthModelsTests.swift @@ -48,6 +48,7 @@ final class AuthModelsTests: XCTestCase { "scenic_name": "示例景区", "store_id": 20, "store_name": "示例门店", + "status": 2, "role_name": "店长", "is_current": false } @@ -63,6 +64,8 @@ final class AuthModelsTests: XCTestCase { XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"]) XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"]) + XCTAssertEqual(response.storeUsers.first?.status, 2) + XCTAssertTrue(response.storeUsers.first?.toAccountSwitchAccount().requiresDeregistrationConfirmation == true) } func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { @@ -137,6 +140,8 @@ final class AuthModelsTests: XCTestCase { XCTAssertEqual(user.toAccountSwitchAccount().subtitle, "示例景区 · 店长") XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三") XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) + XCTAssertEqual(user.status, 1) + XCTAssertFalse(user.toAccountSwitchAccount().requiresDeregistrationConfirmation) } func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws { diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift index abc221e..37eef13 100644 --- a/suixinkanTests/LoginViewModelTests.swift +++ b/suixinkanTests/LoginViewModelTests.swift @@ -48,6 +48,42 @@ final class LoginViewModelTests: XCTestCase { XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"]) } + /// 单一门店身份处于注销冷静期时,必须先返回确认状态,不能自动调用 set-user 撤销申请。 + func testSingleCoolingOffStoreAccountRequiresConfirmationBeforeSetUser() async throws { + let login = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[{"store_user_id":101,"store_name":"待注销门店","status":2}]}}"#.utf8) + let selected = Data(#"{"code":100000,"data":{"token":"business","scenic_users":[],"store_users":[]}}"#.utf8) + let session = MockURLSession(responses: [login, selected]) + let viewModel = LoginViewModel() + viewModel.updateAccount("13900000001") + viewModel.updatePassword("test-password") + let api = AuthAPI(client: APIClient(environment: .testing, session: session)) + + let resolution = try await viewModel.login(authAPI: api) + + guard case let .needsDeregistrationConfirmation(confirmation) = resolution else { + return XCTFail("冷静期门店身份应等待用户确认") + } + XCTAssertEqual(confirmation.account.status, 2) + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"]) + + let response = try await viewModel.confirmDeregistrationLogin(confirmation, authAPI: api) + + XCTAssertEqual(response.token, "business") + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"]) + } + + /// 冷静期确认弹窗需明确告知登录将撤销注销申请,并提供保留申请的取消入口。 + func testDeregistrationLoginAlertExplainsAutomaticCancellation() { + let alert = makeDeregistrationLoginAlert( + account: reentryAccount(isStore: true, status: 2), + onConfirm: {} + ) + + XCTAssertEqual(alert.title, "该账号正在注销") + XCTAssertTrue(alert.message?.contains("自动撤销之前的注销申请") == true) + XCTAssertEqual(alert.actions.map(\.title), ["暂不登录", "确认登录"]) + } + /// 普通门店身份选择直接回调,不再无条件弹出注销提示。 func testSelectingStoreIdentityContinuesWithoutDeregistrationPrompt() throws { let account = reentryAccount(isStore: true) @@ -157,7 +193,7 @@ final class LoginViewModelTests: XCTestCase { XCTAssertNil(controller.presentedViewController) } - private func reentryAccount(isStore: Bool) -> AccountSwitchAccount { + private func reentryAccount(isStore: Bool, status: Int = 1) -> AccountSwitchAccount { AccountSwitchAccount( accountType: isStore ? "store_user" : "scenic_user", businessUserId: 101, @@ -170,6 +206,7 @@ final class LoginViewModelTests: XCTestCase { storeId: isStore ? 25 : nil, storeName: isStore ? "测试门店" : "", scenicId: 10, + status: status, isCurrent: false ) } diff --git a/suixinkanTests/StoreAccountDeregistrationRootTests.swift b/suixinkanTests/StoreAccountDeregistrationRootTests.swift index bcc45c0..e8982fa 100644 --- a/suixinkanTests/StoreAccountDeregistrationRootTests.swift +++ b/suixinkanTests/StoreAccountDeregistrationRootTests.swift @@ -83,8 +83,24 @@ final class StoreAccountDeregistrationRootTests: XCTestCase { XCTAssertEqual(context.resumeCount, 1) } - /// 新登录得到身份凭证后仍先核验,普通页面前台恢复不追加查询。 - func testLoginChecksStatusBeforeEnteringBusiness() async { + /// 新登录只以 v9 的门店 status 判定;旧注销状态接口和本地未决记录都不能再拦截 status 为正常的登录。 + func testNormalLoginRestoresBusinessWithoutLegacyDeregistrationCheck() async { + let context = DeregistrationRootContext() + defer { context.cleanUp() } + context.service.readError = APIError.networkFailed("旧注销查询不应影响 v9 正常登录") + context.startRestoringSession() + + XCTAssertNil(context.coordinator.accessController) + XCTAssertFalse(context.coordinator.isChecking) + XCTAssertFalse(GlobalLoadingManager.shared.isShowing) + XCTAssertFalse(context.bindingSuspended) + XCTAssertEqual(context.createdRoots, 1) + XCTAssertEqual(context.resumeCount, 1) + XCTAssertEqual(context.service.statusCount, 0) + } + + /// 旧核验入口仅在业务限制等已知受限事件发生时使用,不是正常登录路径的一部分。 + func testExplicitRestrictionCheckStillKeepsOriginalPageUntilResult() async { let context = DeregistrationRootContext() defer { context.cleanUp() } context.service.holdNextRead = true