// // AuthModels.swift // suixinkan // // Created by Codex on 2026/6/18. // import Foundation /// 登录请求实体,表示手机号密码登录接口需要的参数。 struct LoginRequest: Encodable { let username: String let type: Int let password: String let mobile: String let code: String /// 创建手机号密码登录请求,并填充后端要求的默认字段。 init(username: String, password: String) { self.username = username self.type = 1 self.password = password self.mobile = "" self.code = "" } } /// 账号选择请求实体,表示 set-user 接口需要的景区账号或门店账号 ID。 struct SetUserRequest: Encodable, Equatable { let storeUserId: Int? let ssUserId: Int? /// set-user 请求的 JSON 字段映射。 enum CodingKeys: String, CodingKey { case storeUserId = "store_user_id" case ssUserId = "ss_user_id" } /// 创建账号选择请求,门店账号传 storeUserId,景区账号传 ssUserId。 init(storeUserId: Int? = nil, ssUserId: Int? = nil) { self.storeUserId = storeUserId self.ssUserId = ssUserId } } /// 可切换账号实体,统一表示登录返回的景区账号和门店账号。 struct AccountSwitchAccount: Identifiable, Hashable { let accountType: String let businessUserId: Int let title: String let subtitle: String let phone: String let avatar: String let scenicName: String let storeId: Int? let storeName: String let scenicId: Int? let isCurrent: Bool var id: String { "\(accountType)_\(businessUserId)" } var isStoreUser: Bool { accountType == V9StoreUser.accountTypeValue } var accountTypeLabel: String { isStoreUser ? "门店账号" : "景区账号" } /// 转换为 set-user 接口请求参数。 func toSetUserRequest() -> SetUserRequest { if isStoreUser { return SetUserRequest(storeUserId: businessUserId) } return SetUserRequest(ssUserId: businessUserId) } /// 当前账号对应的景区和门店 ID,用于切换后校准业务作用域。 var preferredScopeSelection: (scenicId: Int?, storeId: Int?) { if isStoreUser { return (scenicId, storeId) } return (scenicId, nil) } } /// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。 struct AccountSelectionPayload: Equatable, Identifiable { let id = UUID() let tempToken: String let accounts: [AccountSwitchAccount] var hasTempToken: Bool { !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } /// v9 登录响应实体,包含 token、景区账号列表和门店账号列表。 struct V9AuthResponse: Decodable, Equatable { let token: String let scenicUsers: [V9ScenicUser] let storeUsers: [V9StoreUser] /// 合并景区账号和门店账号,供账号选择页展示。 var accounts: [AccountSwitchAccount] { scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() } } /// 提取当前或首个账号作为全局账号资料,优先使用 isCurrent 标记的账号。 var primaryProfile: AccountProfile? { if let storeUser = resolvedCurrentStoreUser { return AccountProfile( userId: String(storeUser.userId), displayName: storeUser.displayName, phone: storeUser.phone.nonEmpty, avatarURL: storeUser.avatar.nonEmpty ) } if let scenicUser = resolvedCurrentScenicUser { return AccountProfile( userId: String(scenicUser.userId), displayName: scenicUser.displayName, phone: scenicUser.phone.nonEmpty, avatarURL: nil ) } return nil } /// 当前生效账号的类型和业务 ID,用于登录/切换后写入账号上下文。 var currentAccountIdentity: (accountType: String, businessUserId: Int)? { if let storeUser = resolvedCurrentStoreUser { return (storeUser.accountType, storeUser.businessUserId) } if let scenicUser = resolvedCurrentScenicUser { return (scenicUser.accountType, scenicUser.businessUserId) } return nil } /// 当前生效账号对应的景区和门店 ID,用于登录/切换后校准业务作用域。 var preferredScopeSelection: (scenicId: Int?, storeId: Int?) { if let storeUser = resolvedCurrentStoreUser { return ( storeUser.scenicId > 0 ? storeUser.scenicId : nil, storeUser.storeId > 0 ? storeUser.storeId : nil ) } if let scenicUser = resolvedCurrentScenicUser { return (scenicUser.scenicId > 0 ? scenicUser.scenicId : nil, nil) } return (nil, nil) } /// 当前生效账号在「我的」页面展示的标题。 var currentAccountDisplayTitle: String? { if let storeUser = resolvedCurrentStoreUser { return storeUser.displayName.nonEmpty } if let scenicUser = resolvedCurrentScenicUser { return scenicUser.displayName.nonEmpty } return nil } /// 解析当前门店账号:isCurrent 优先,且在有景区 isCurrent 时不回退到其他门店。 private var resolvedCurrentStoreUser: V9StoreUser? { if let current = storeUsers.first(where: \.isCurrent) { return current } guard !scenicUsers.contains(where: \.isCurrent) else { return nil } return storeUsers.first } /// 解析当前景区账号:isCurrent 优先,且在有门店 isCurrent 时不回退到其他景区。 private var resolvedCurrentScenicUser: V9ScenicUser? { if let current = scenicUsers.first(where: \.isCurrent) { return current } guard !storeUsers.contains(where: \.isCurrent) else { return nil } return scenicUsers.first } /// 提取登录响应中的景区作用域,并按当前账号优先排序。 var scenicScopes: [BusinessScope] { var seen = Set() let scenicFromScenicUsers = scenicUsers .sorted { $0.isCurrent && !$1.isCurrent } .compactMap { user -> BusinessScope? in guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil } return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic) } let scenicFromStoreUsers = storeUsers .sorted { $0.isCurrent && !$1.isCurrent } .compactMap { user -> BusinessScope? in guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil } return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic) } return scenicFromScenicUsers + scenicFromStoreUsers } /// 提取登录响应中的门店作用域,并按当前账号优先排序。 var storeScopes: [BusinessScope] { var seen = Set() return storeUsers .sorted { $0.isCurrent && !$1.isCurrent } .compactMap { user -> BusinessScope? in guard user.storeId > 0, seen.insert(user.storeId).inserted else { return nil } return BusinessScope( id: user.storeId, name: user.storeName, kind: .store, parentScenicId: user.scenicId > 0 ? user.scenicId : nil ) } } /// 当前账号的 app_role_code,用于登录后初始角色匹配。 var currentAccountRoleCode: String? { if let storeUser = resolvedCurrentStoreUser { return nonEmpty(storeUser.resolvedAppRoleCode) } if let scenicUser = resolvedCurrentScenicUser { return nonEmpty(scenicUser.resolvedAppRoleCode) } return nil } /// v9 登录响应的 JSON 字段映射。 enum CodingKeys: String, CodingKey { case token case scenicUsers = "scenic_users" case storeUsers = "store_users" } /// 创建空响应或测试响应。 init(token: String = "", scenicUsers: [V9ScenicUser] = [], storeUsers: [V9StoreUser] = []) { self.token = token self.scenicUsers = scenicUsers self.storeUsers = storeUsers } /// 自定义解码,兼容后端账号数组缺失的情况。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) token = try container.decodeLossyString(forKey: .token) scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? [] storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? [] } private func nonEmpty(_ value: String) -> String? { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text } } /// v9 景区账号实体,表示用户可进入的某个景区身份。 struct V9ScenicUser: Decodable, Equatable { static let accountTypeValue = "scenic_user" let accountType: String let id: Int let userId: Int let scenicUserId: Int let ssUserId: Int let username: String let realName: String let nickname: String let phone: String let scenicId: Int let scenicName: String let isCurrent: Bool let appRoleCode: String let appRoleName: String /// 解析后的 app_role_code,优先顶层字段。 var resolvedAppRoleCode: String { appRoleCode.trimmingCharacters(in: .whitespacesAndNewlines) } /// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。 var businessUserId: Int { [ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0 } /// 用于界面展示的景区账号名称。 var displayName: String { scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username } /// 转换为账号选择页使用的统一账号实体。 func toAccountSwitchAccount() -> AccountSwitchAccount { AccountSwitchAccount( accountType: accountType.nonEmpty ?? Self.accountTypeValue, businessUserId: businessUserId, title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号", subtitle: [realName.nonEmpty, nickname.nonEmpty] .compactMap(\.self) .joined(separator: " · "), phone: phone, avatar: "", scenicName: scenicName, storeId: nil, storeName: "", scenicId: scenicId > 0 ? scenicId : nil, isCurrent: isCurrent ) } /// 景区账号响应的 JSON 字段映射。 enum CodingKeys: String, CodingKey { case accountType = "account_type" case id case userId = "user_id" case scenicUserId = "scenic_user_id" case ssUserId = "ss_user_id" case username case realName = "real_name" case nickname case phone case scenicId = "scenic_id" case scenicName = "scenic_name" case isCurrent = "is_current" case appRoleCode = "app_role_code" case appRoleName = "app_role_name" } /// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue id = try container.decodeLossyInt(forKey: .id) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0 scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 username = try container.decodeLossyString(forKey: .username) realName = try container.decodeLossyString(forKey: .realName) nickname = try container.decodeLossyString(forKey: .nickname) phone = try container.decodeLossyString(forKey: .phone) scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 scenicName = try container.decodeLossyString(forKey: .scenicName) isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false appRoleCode = try container.decodeLossyString(forKey: .appRoleCode) appRoleName = try container.decodeLossyString(forKey: .appRoleName) } } /// v9 门店账号实体,表示用户可进入的某个门店身份。 struct V9StoreUser: Decodable, Equatable { static let accountTypeValue = "store_user" let accountType: String let id: Int let userId: Int let storeUserId: Int let username: String let userName: String let realName: String let phone: String let avatar: String let scenicId: Int let scenicName: String let storeId: Int let storeName: String let isCurrent: Bool let appRoleCode: String let appRoleName: String /// 解析后的 app_role_code,优先顶层字段。 var resolvedAppRoleCode: String { appRoleCode.trimmingCharacters(in: .whitespacesAndNewlines) } /// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。 var businessUserId: Int { [storeUserId, userId, id].first { $0 > 0 } ?? 0 } /// 用于界面展示的门店账号名称。 var displayName: String { storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username } /// 转换为账号选择页使用的统一账号实体。 func toAccountSwitchAccount() -> AccountSwitchAccount { AccountSwitchAccount( accountType: accountType.nonEmpty ?? Self.accountTypeValue, businessUserId: businessUserId, title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", subtitle: [scenicName.nonEmpty, realName.nonEmpty ?? userName.nonEmpty] .compactMap(\.self) .joined(separator: " · "), phone: phone, avatar: avatar, scenicName: scenicName, storeId: storeId > 0 ? storeId : nil, storeName: storeName, scenicId: scenicId > 0 ? scenicId : nil, isCurrent: isCurrent ) } /// 门店账号响应的 JSON 字段映射。 enum CodingKeys: String, CodingKey { case accountType = "account_type" case id case userId = "user_id" case storeUserId = "store_user_id" case username case userName = "user_name" case realName = "real_name" case phone case avatar case scenicId = "scenic_id" case scenicName = "scenic_name" case storeId = "store_id" case storeName = "store_name" case isCurrent = "is_current" case appRoleCode = "app_role_code" case appRoleName = "app_role_name" } /// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue id = try container.decodeLossyInt(forKey: .id) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0 storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 username = try container.decodeLossyString(forKey: .username) userName = try container.decodeLossyString(forKey: .userName) realName = try container.decodeLossyString(forKey: .realName) phone = try container.decodeLossyString(forKey: .phone) avatar = try container.decodeLossyString(forKey: .avatar) scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 scenicName = try container.decodeLossyString(forKey: .scenicName) storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0 storeName = try container.decodeLossyString(forKey: .storeName) isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false appRoleCode = try container.decodeLossyString(forKey: .appRoleCode) appRoleName = try container.decodeLossyString(forKey: .appRoleName) } } private extension KeyedDecodingContainer { /// 将 String、数字和 Bool 宽松解码为字符串。 func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "true" : "false" } return "" } /// 将 Int、Double 或数字字符串宽松解码为整数。 func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(String.self, forKey: key) { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) if let intValue = Int(text) { return intValue } if let doubleValue = Double(text) { return Int(doubleValue) } } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } return nil } /// 将 Bool、数字或布尔字符串宽松解码为布尔值。 func decodeLossyBool(forKey key: Key) throws -> Bool? { if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 } if let value = try? decodeIfPresent(String.self, forKey: key) { let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["1", "true", "yes"].contains(normalized) { return true } if ["0", "false", "no"].contains(normalized) { return false } } return nil } } private extension String { var nonEmpty: String? { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? nil : text } }