fix: use v9 store status for deregistration login

This commit is contained in:
2026-08-31 16:36:20 +08:00
parent 4444d328df
commit b8343ad9eb
8 changed files with 194 additions and 11 deletions
@@ -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
}
}
+3 -5
View File
@@ -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() {
@@ -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"
@@ -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,
+28 -3
View File
@@ -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
+5
View File
@@ -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 {
+38 -1
View File
@@ -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
)
}
@@ -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