420 lines
14 KiB
Swift
420 lines
14 KiB
Swift
//
|
|
// AuthModels.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// App 配置响应,对齐 Android `AppConfigResponse`。
|
|
struct AppConfigResponse: Codable, Equatable {
|
|
let enableRegister: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case enableRegister = "enable_register"
|
|
}
|
|
|
|
init(enableRegister: Bool = false) {
|
|
self.enableRegister = enableRegister
|
|
}
|
|
}
|
|
|
|
/// 登录输入焦点实体,标识当前需要聚焦的输入框。
|
|
enum LoginField: Hashable {
|
|
case username
|
|
case password
|
|
}
|
|
|
|
/// 登录表单校验错误实体,负责提供用户提示和焦点位置。
|
|
enum LoginValidationError: Equatable {
|
|
case invalidPhone
|
|
case emptyPassword
|
|
case privacyUnchecked
|
|
|
|
var message: String {
|
|
switch self {
|
|
case .invalidPhone:
|
|
"请输入有效的手机号码"
|
|
case .emptyPassword:
|
|
"请输入密码"
|
|
case .privacyUnchecked:
|
|
"请先阅读并同意用户协议与隐私政策"
|
|
}
|
|
}
|
|
|
|
var focusField: LoginField? {
|
|
switch self {
|
|
case .invalidPhone:
|
|
.username
|
|
case .emptyPassword:
|
|
.password
|
|
case .privacyUnchecked:
|
|
nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
|
|
enum LoginResolution {
|
|
case completed(V9AuthResponse, AccountSwitchAccount)
|
|
case needsAccountSelection(AccountSelectionPayload)
|
|
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
|
|
}
|
|
|
|
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
|
|
enum LoginFlowError: LocalizedError {
|
|
case missingToken
|
|
case noAvailableAccount
|
|
case invalidAccount
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .missingToken:
|
|
"登录响应缺少 token"
|
|
case .noAvailableAccount:
|
|
"当前账号没有可用的景区账号或门店账号,请联系管理员"
|
|
case .invalidAccount:
|
|
"账号信息异常,请重新选择账号"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 登录请求实体,表示手机号密码登录接口需要的参数。
|
|
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?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case storeUserId = "store_user_id"
|
|
case ssUserId = "ss_user_id"
|
|
}
|
|
|
|
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 realName: 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 ? "门店账号" : "景区账号"
|
|
}
|
|
|
|
func toSetUserRequest() -> SetUserRequest {
|
|
if isStoreUser {
|
|
return SetUserRequest(storeUserId: businessUserId)
|
|
}
|
|
return SetUserRequest(ssUserId: businessUserId)
|
|
}
|
|
}
|
|
|
|
/// 登录账号选择载荷,保存临时 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() }
|
|
}
|
|
|
|
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)) ?? []
|
|
}
|
|
}
|
|
|
|
/// 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 realName: String
|
|
let nickname: String
|
|
let phone: String
|
|
let scenicId: Int
|
|
let scenicName: String
|
|
let roleName: String
|
|
let appRoleCode: String
|
|
let appRoleName: String
|
|
let isCurrent: Bool
|
|
|
|
var businessUserId: Int {
|
|
[ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0
|
|
}
|
|
|
|
var displayName: String {
|
|
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
|
|
}
|
|
|
|
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
|
AccountSwitchAccount(
|
|
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
|
businessUserId: businessUserId,
|
|
title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号",
|
|
subtitle: joinAccountSubtitle(scenicName, roleName),
|
|
phone: phone,
|
|
realName: realName,
|
|
avatar: "",
|
|
scenicName: scenicName,
|
|
storeId: nil,
|
|
storeName: "",
|
|
scenicId: scenicId > 0 ? scenicId : nil,
|
|
isCurrent: isCurrent
|
|
)
|
|
}
|
|
|
|
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 realName = "real_name"
|
|
case nickname
|
|
case phone
|
|
case scenicId = "scenic_id"
|
|
case scenicName = "scenic_name"
|
|
case roleName = "role_name"
|
|
case appRoleCode = "app_role_code"
|
|
case appRoleName = "app_role_name"
|
|
case isCurrent = "is_current"
|
|
}
|
|
|
|
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
|
|
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)
|
|
roleName = try container.decodeLossyString(forKey: .roleName)
|
|
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
|
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
|
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
|
}
|
|
}
|
|
|
|
/// v9 门店账号实体,表示用户可进入的某个门店身份。
|
|
struct V9StoreUser: Decodable, Equatable {
|
|
static let accountTypeValue = "store_user"
|
|
|
|
let accountType: String
|
|
let id: Int
|
|
let userId: Int
|
|
let storeUserId: Int
|
|
let realName: String
|
|
let phone: String
|
|
let avatar: String
|
|
let scenicId: Int
|
|
let scenicName: String
|
|
let storeId: Int
|
|
let storeName: String
|
|
let roleName: String
|
|
let appRoleCode: String
|
|
let appRoleName: String
|
|
let isCurrent: Bool
|
|
|
|
var businessUserId: Int {
|
|
[storeUserId, userId, id].first { $0 > 0 } ?? 0
|
|
}
|
|
|
|
var displayName: String {
|
|
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
|
|
}
|
|
|
|
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
|
AccountSwitchAccount(
|
|
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
|
businessUserId: businessUserId,
|
|
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
|
|
subtitle: joinAccountSubtitle(scenicName, roleName),
|
|
phone: phone,
|
|
realName: realName,
|
|
avatar: avatar,
|
|
scenicName: scenicName,
|
|
storeId: storeId > 0 ? storeId : nil,
|
|
storeName: storeName,
|
|
scenicId: scenicId > 0 ? scenicId : nil,
|
|
isCurrent: isCurrent
|
|
)
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case accountType = "account_type"
|
|
case id
|
|
case userId = "user_id"
|
|
case storeUserId = "store_user_id"
|
|
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 roleName = "role_name"
|
|
case appRoleCode = "app_role_code"
|
|
case appRoleName = "app_role_name"
|
|
case isCurrent = "is_current"
|
|
}
|
|
|
|
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
|
|
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)
|
|
roleName = try container.decodeLossyString(forKey: .roleName)
|
|
appRoleCode = try container.decodeLossyString(forKey: .appRoleCode)
|
|
appRoleName = try container.decodeLossyString(forKey: .appRoleName)
|
|
isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false
|
|
}
|
|
}
|
|
|
|
private extension KeyedDecodingContainer {
|
|
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 ""
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 func joinAccountSubtitle(_ values: String...) -> String {
|
|
values
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " · ")
|
|
}
|
|
|
|
private extension String {
|
|
var nonEmpty: String? {
|
|
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return text.isEmpty ? nil : text
|
|
}
|
|
}
|