切换账号时以用户选中账号为准写入展示标题与业务作用域,避免沿用旧门店名称;同时将首页举报摄影师入口 URI 对齐为 report_photographer。 Co-authored-by: Cursor <cursoragent@cursor.com>
510 lines
19 KiB
Swift
510 lines
19 KiB
Swift
//
|
||
// 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<Int>()
|
||
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<Int>()
|
||
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
|
||
}
|
||
}
|