fix: use v9 store status for deregistration login
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user