From 699f1ba7c40d0b57e2ced8b5447beff3caf2bd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 6 Jul 2026 15:33:34 +0800 Subject: [PATCH] Add networking layer and wire up v9 login flow with unit tests. Introduce APIClient/AuthAPI, complete login with multi-account selection, and polish the login card UI with shadow and keyboard dismiss. Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 136 ++++++ .../xcshareddata/xcschemes/suixinkan.xcscheme | 91 ++++ suixinkan/App/NetworkServices.swift | 32 ++ suixinkan/Core/Networking/APIClient.swift | 264 ++++++++++++ suixinkan/Core/Networking/APIEnvelope.swift | 60 +++ .../Core/Networking/APIEnvironment.swift | 60 +++ suixinkan/Core/Networking/APIError.swift | 54 +++ suixinkan/Core/Networking/APIRequest.swift | 53 +++ suixinkan/DataStore/AppStore.swift | 20 + suixinkan/Features/Auth/API/AuthAPI.swift | 47 ++ .../Features/Auth/AuthSessionHelper.swift | 26 ++ .../Features/Auth/Models/AuthModels.swift | 402 ++++++++++++++++++ suixinkan/UI/Common/LoginTextField.swift | 6 + .../AccountSelectionViewController.swift | 318 ++++++++++++++ suixinkan/UI/Login/LoginViewController.swift | 162 ++++++- suixinkan/UI/Login/LoginViewModel.swift | 152 ++++++- suixinkanTests/APIClientTests.swift | 99 +++++ suixinkanTests/APIErrorTests.swift | 29 ++ suixinkanTests/AuthModelsTests.swift | 129 ++++++ suixinkanTests/LoginViewModelTests.swift | 202 +++++++++ suixinkanTests/MockURLSession.swift | 63 +++ 21 files changed, 2387 insertions(+), 18 deletions(-) create mode 100644 suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme create mode 100644 suixinkan/App/NetworkServices.swift create mode 100644 suixinkan/Core/Networking/APIClient.swift create mode 100644 suixinkan/Core/Networking/APIEnvelope.swift create mode 100644 suixinkan/Core/Networking/APIEnvironment.swift create mode 100644 suixinkan/Core/Networking/APIError.swift create mode 100644 suixinkan/Core/Networking/APIRequest.swift create mode 100644 suixinkan/Features/Auth/API/AuthAPI.swift create mode 100644 suixinkan/Features/Auth/AuthSessionHelper.swift create mode 100644 suixinkan/Features/Auth/Models/AuthModels.swift create mode 100644 suixinkan/UI/Login/AccountSelectionViewController.swift create mode 100644 suixinkanTests/APIClientTests.swift create mode 100644 suixinkanTests/APIErrorTests.swift create mode 100644 suixinkanTests/AuthModelsTests.swift create mode 100644 suixinkanTests/LoginViewModelTests.swift create mode 100644 suixinkanTests/MockURLSession.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 25d5189..01c721c 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -9,10 +9,23 @@ /* Begin PBXBuildFile section */ 93FF17E42FFB84950033C304 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; }; 93FF17E52FFB84950033C304 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; }; + A1B2C3D12FFB84950033C305 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E22FFB84950033C304 /* SnapKit */; }; + A1B2C3D22FFB84950033C305 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93FF17E32FFB84950033C304 /* Kingfisher */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A1B2C3D82FFB84950033C305 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 93FF17BD2FFB84950033C304 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 93FF17C42FFB84950033C304; + remoteInfo = suixinkan; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -34,6 +47,11 @@ path = suixinkan; sourceTree = ""; }; + A1B2C3D42FFB84950033C305 /* suixinkanTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = suixinkanTests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -46,6 +64,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A1B2C3D62FFB84950033C305 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1B2C3D12FFB84950033C305 /* SnapKit in Frameworks */, + A1B2C3D22FFB84950033C305 /* Kingfisher in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -53,6 +80,7 @@ isa = PBXGroup; children = ( 93FF17C72FFB84950033C304 /* suixinkan */, + A1B2C3D42FFB84950033C305 /* suixinkanTests */, 93FF17C62FFB84950033C304 /* Products */, ); sourceTree = ""; @@ -61,6 +89,7 @@ isa = PBXGroup; children = ( 93FF17C52FFB84950033C304 /* suixinkan.app */, + A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */, ); name = Products; sourceTree = ""; @@ -92,8 +121,41 @@ productReference = 93FF17C52FFB84950033C304 /* suixinkan.app */; productType = "com.apple.product-type.application"; }; + A1B2C3D32FFB84950033C305 /* suixinkanTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1B2C3DB2FFB84950033C305 /* Build configuration list for PBXNativeTarget "suixinkanTests" */; + buildPhases = ( + A1B2C3D52FFB84950033C305 /* Sources */, + A1B2C3D62FFB84950033C305 /* Frameworks */, + A1B2C3D72FFB84950033C305 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A1B2C3D92FFB84950033C305 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A1B2C3D42FFB84950033C305 /* suixinkanTests */, + ); + name = suixinkanTests; + packageProductDependencies = ( + 93FF17E22FFB84950033C304 /* SnapKit */, + 93FF17E32FFB84950033C304 /* Kingfisher */, + ); + productName = suixinkanTests; + productReference = A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ +/* Begin PBXTargetDependency section */ + A1B2C3D92FFB84950033C305 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 93FF17C42FFB84950033C304 /* suixinkan */; + targetProxy = A1B2C3D82FFB84950033C305 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXProject section */ 93FF17BD2FFB84950033C304 /* Project object */ = { isa = PBXProject; @@ -105,6 +167,10 @@ 93FF17C42FFB84950033C304 = { CreatedOnToolsVersion = 26.6; }; + A1B2C3D32FFB84950033C305 = { + CreatedOnToolsVersion = 26.6; + TestTargetID = 93FF17C42FFB84950033C304; + }; }; }; buildConfigurationList = 93FF17C02FFB84950033C304 /* Build configuration list for PBXProject "suixinkan" */; @@ -126,6 +192,7 @@ projectRoot = ""; targets = ( 93FF17C42FFB84950033C304 /* suixinkan */, + A1B2C3D32FFB84950033C305 /* suixinkanTests */, ); }; /* End PBXProject section */ @@ -138,6 +205,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A1B2C3D72FFB84950033C305 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -148,6 +222,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A1B2C3D52FFB84950033C305 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -344,6 +425,52 @@ }; name = Release; }; + A1B2C3DC2FFB84950033C305 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan"; + }; + name = Debug; + }; + A1B2C3DA2FFB84950033C305 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -365,6 +492,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A1B2C3DB2FFB84950033C305 /* Build configuration list for PBXNativeTarget "suixinkanTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1B2C3DC2FFB84950033C305 /* Debug */, + A1B2C3DA2FFB84950033C305 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme new file mode 100644 index 0000000..087fe46 --- /dev/null +++ b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift new file mode 100644 index 0000000..545d164 --- /dev/null +++ b/suixinkan/App/NetworkServices.swift @@ -0,0 +1,32 @@ +// +// NetworkServices.swift +// suixinkan +// + +import Foundation + +@MainActor +/// 全局网络依赖容器,集中提供 APIClient 与 AuthAPI。 +final class NetworkServices { + static let shared = NetworkServices() + + let apiClient: APIClient + let authAPI: AuthAPI + + /// 生产环境默认初始化。 + private init() { + let client = APIClient(environment: .current) + apiClient = client + authAPI = AuthAPI(client: client) + client.bindAuthTokenProvider { + let token = AppStore.shared.token.trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } + } + + /// 测试专用初始化,允许注入 mock `APIClient`。 + init(apiClient: APIClient) { + self.apiClient = apiClient + authAPI = AuthAPI(client: apiClient) + } +} diff --git a/suixinkan/Core/Networking/APIClient.swift b/suixinkan/Core/Networking/APIClient.swift new file mode 100644 index 0000000..d45088f --- /dev/null +++ b/suixinkan/Core/Networking/APIClient.swift @@ -0,0 +1,264 @@ +// +// APIClient.swift +// suixinkan +// + +import Foundation + +/// URLSession 抽象协议,用于让网络客户端支持测试替身。 +protocol URLSessionProtocol { + /// 发起 URLRequest 并返回原始数据和响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: URLSessionProtocol {} + +@MainActor +/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。 +final class APIClient { + private let session: URLSessionProtocol + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private var authTokenProvider: (() -> String?)? + + private let environment: APIEnvironment + private let appVersion: String + private let osType: String + + /// 初始化网络客户端及其编码、解码和环境配置。 + init( + environment: APIEnvironment = .current, + session: URLSessionProtocol = APIClient.defaultSession, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder(), + appVersion: String = AppClientInfo.appVersion(), + osType: String = AppClientInfo.osType + ) { + self.environment = environment + self.session = session + self.encoder = encoder + self.decoder = decoder + self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0" + self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType + } + + nonisolated private static let defaultSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 12 + configuration.timeoutIntervalForResource = 20 + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + }() + + /// 绑定 token 提供者,让业务 API 不需要直接持有登录状态。 + func bindAuthTokenProvider(_ provider: @escaping () -> String?) { + authTokenProvider = provider + } + + /// 发送强类型 APIRequest,并返回解包后的业务数据。 + func send( + _ apiRequest: APIRequest, + tokenOverride: String? = nil + ) async throws -> Response { + let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride) + logRequest(request) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch is CancellationError { + logCancelled(for: request, reason: "CancellationError") + throw CancellationError() + } catch let error as URLError { + if error.code == .cancelled { + logCancelled(for: request, reason: "URLError.cancelled") + throw CancellationError() + } + throw APIError.networkFailed(networkErrorMessage(for: error)) + } catch { + throw APIError.networkFailed(error.localizedDescription) + } + + logResponse(for: request, response: response, data: data) + do { + try validateHTTPResponse(response, data: data) + return try decodeEnvelope(Response.self, from: data) + } catch let error as APIError { + notifySessionExpiredIfNeeded(for: error) + throw error + } + } + + /// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。 + private func makeURLRequest( + _ apiRequest: APIRequest, + tokenOverride: String? + ) throws -> URLRequest { + let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path + guard var components = URLComponents( + string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path + ) else { + throw APIError.invalidURL + } + + if !apiRequest.queryItems.isEmpty { + components.queryItems = apiRequest.queryItems + } + + guard let url = components.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = apiRequest.method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION") + request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE") + + apiRequest.headers.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + let token = tokenOverride ?? authTokenProvider?() + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue(token, forHTTPHeaderField: "token") + } + + if let body = apiRequest.body { + request.httpBody = try encoder.encode(body) + } + + return request + } + + /// 校验 HTTP 层响应状态码,非 2xx 时提取错误信息。 + private func validateHTTPResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard 200 ..< 300 ~= httpResponse.statusCode else { + throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data)) + } + } + + /// 解码后端统一 Envelope,并返回内部 data 数据。 + private func decodeEnvelope(_ responseType: Response.Type, from data: Data) throws -> Response { + let envelope: APIEnvelope + do { + envelope = try decoder.decode(APIEnvelope.self, from: data) + } catch { + throw APIError.decodeFailed(error.localizedDescription) + } + + guard envelope.isSuccess else { + throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败") + } + + if responseType == EmptyPayload.self { + return EmptyPayload() as! Response + } + + guard let payload = envelope.data else { + throw APIError.emptyData + } + + return payload + } + + /// Token 失效时广播 sessionDidExpire,触发全局登出。 + private func notifySessionExpiredIfNeeded(for error: APIError) { + guard APIError.isAuthenticationExpired(error) else { return } + NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil) + } + + /// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。 + private func parseHTTPErrorMessage(data: Data) -> String { + if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) { + if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { + return msg + } + if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { + return message + } + if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty { + return error + } + } + + if let plainText = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !plainText.isEmpty { + return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText + } + + return "服务端返回错误" + } + + /// 将 URLError 转换成中文网络错误提示。 + private func networkErrorMessage(for error: URLError) -> String { + switch error.code { + case .timedOut: + "请求超时,请稍后重试" + case .notConnectedToInternet: + "网络不可用,请检查网络连接" + case .networkConnectionLost: + "网络连接中断,请重试" + case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed: + "无法连接服务器,请稍后重试" + default: + error.localizedDescription + } + } + + /// 在 Debug 环境打印请求信息。 + private func logRequest(_ request: URLRequest) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + print("[API][Request] \(method) \(url)") + #endif + } + + /// 在 Debug 环境打印响应状态和响应体。 + private func logResponse(for request: URLRequest, response: URLResponse, data: Data) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown" + let body = Self.debugResponseBody(from: data) + print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)") + #endif + } + + /// 在 Debug 环境打印被取消的请求信息。 + private func logCancelled(for request: URLRequest, reason: String) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + print("[API][Cancelled] \(method) \(url) reason=\(reason)") + #endif + } + + #if DEBUG + /// 将响应体格式化为便于调试阅读的字符串。 + private static func debugResponseBody(from data: Data) -> String { + guard !data.isEmpty else { return "" } + if + let object = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), + let prettyJSON = String(data: prettyData, encoding: .utf8) { + return prettyJSON + } + return String(data: data, encoding: .utf8) ?? "" + } + #endif +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Core/Networking/APIEnvelope.swift b/suixinkan/Core/Networking/APIEnvelope.swift new file mode 100644 index 0000000..e940eae --- /dev/null +++ b/suixinkan/Core/Networking/APIEnvelope.swift @@ -0,0 +1,60 @@ +// +// APIEnvelope.swift +// suixinkan +// + +import Foundation + +/// 后端统一响应包裹实体,承载业务 code、msg 和真实 data。 +struct APIEnvelope: Decodable { + let data: T? + let code: Int + let msg: String? + + /// 判断后端业务 code 是否代表成功。 + var isSuccess: Bool { + code == 100000 + } + + /// 后端统一 Envelope 的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case data + case code + case msg + } + + /// 自定义解码逻辑,兼容成功响应、空 data 和 EmptyPayload。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0 + msg = try container.decodeIfPresent(String.self, forKey: .msg) + + guard code == 100000 else { + data = nil + return + } + + guard container.contains(.data), try !container.decodeNil(forKey: .data) else { + data = nil + return + } + + if T.self == EmptyPayload.self { + data = EmptyPayload() as? T + return + } + + data = try container.decode(T.self, forKey: .data) + } +} + +/// 空响应实体,用于表示接口成功但不返回业务 data。 +struct EmptyPayload: Codable {} + +/// 错误响应实体,用于从 HTTP 错误体中提取服务端文案。 +struct ErrorEnvelope: Decodable { + let code: Int? + let msg: String? + let message: String? + let error: String? +} diff --git a/suixinkan/Core/Networking/APIEnvironment.swift b/suixinkan/Core/Networking/APIEnvironment.swift new file mode 100644 index 0000000..38abe27 --- /dev/null +++ b/suixinkan/Core/Networking/APIEnvironment.swift @@ -0,0 +1,60 @@ +// +// APIEnvironment.swift +// suixinkan +// + +import Foundation + +/// 网络环境实体,集中描述 HTTP 和 WebSocket 的服务地址。 +struct APIEnvironment: Equatable { + let baseURL: URL + let webSocketURL: URL + + nonisolated static let production = APIEnvironment( + baseURL: URL(string: "https://api.zhifly.cn")!, + webSocketURL: URL(string: "wss://api.zhifly.cn/wss")! + ) + + nonisolated static let testing = APIEnvironment( + baseURL: URL(string: "https://api-test.zhifly.cn")!, + webSocketURL: URL(string: "wss://api-test.zhifly.cn/wss")! + ) + + nonisolated static var current: APIEnvironment { + #if DEBUG + .testing + #else + .production + #endif + } +} + +/// 客户端信息工具,负责提供网络请求所需的 App 版本和系统类型。 +enum AppClientInfo { + nonisolated static let osType = "iOS" + + /// 生成后端要求的 App 版本号,版本号不足三段时用 build 号补齐。 + nonisolated static func appVersion(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String { + let version = (infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "1.0.0" + let build = (infoDictionary?["CFBundleVersion"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let versionParts = version.split(separator: ".", omittingEmptySubsequences: false) + + if versionParts.count >= 3 { + return version + } + if versionParts.count == 2, let build { + return "\(version).\(build)" + } + return version + } +} + +private extension String { + nonisolated var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Core/Networking/APIError.swift b/suixinkan/Core/Networking/APIError.swift new file mode 100644 index 0000000..8d8c22e --- /dev/null +++ b/suixinkan/Core/Networking/APIError.swift @@ -0,0 +1,54 @@ +// +// APIError.swift +// suixinkan +// + +import Foundation + +/// 网络层错误实体,统一转换 URL、HTTP、业务码、解析和网络异常。 +enum APIError: Error, LocalizedError { + case invalidURL + case invalidResponse + case httpStatus(Int, String) + case serverCode(Int, String) + case emptyData + case decodeFailed(String) + case networkFailed(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + "请求地址无效" + case .invalidResponse: + "服务响应异常" + case let .httpStatus(statusCode, message): + "请求失败(HTTP \(statusCode)):\(message)" + case .serverCode(_, let message): + message + case .emptyData: + "接口返回数据为空" + case .decodeFailed(let message): + "数据解析失败:\(message)" + case .networkFailed(let message): + "网络请求失败:\(message)" + } + } +} + +extension APIError { + /// 与 Android `NetworkResponse.isTokenFailed` 对齐的 Token 失效业务码。 + static let tokenInvalidCodes: Set = [180024, 100091, 100090, 100060] + + /// 判断任意错误是否代表登录凭证失效。 + static func isAuthenticationExpired(_ error: Error) -> Bool { + guard let apiError = error as? APIError else { return false } + switch apiError { + case let .httpStatus(statusCode, _): + return statusCode == 401 || statusCode == 403 + case let .serverCode(code, _): + return tokenInvalidCodes.contains(code) + default: + return false + } + } +} diff --git a/suixinkan/Core/Networking/APIRequest.swift b/suixinkan/Core/Networking/APIRequest.swift new file mode 100644 index 0000000..fd1c633 --- /dev/null +++ b/suixinkan/Core/Networking/APIRequest.swift @@ -0,0 +1,53 @@ +// +// APIRequest.swift +// suixinkan +// + +import Foundation + +/// HTTP 方法实体,限制网络层支持的请求方法。 +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} + +/// 强类型 API 请求实体,描述一次请求的方法、路径、参数、Header 和响应类型。 +nonisolated struct APIRequest { + var method: HTTPMethod + var path: String + var queryItems: [URLQueryItem] + var headers: [String: String] + var body: AnyEncodable? + + /// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。 + init( + method: HTTPMethod, + path: String, + queryItems: [URLQueryItem] = [], + headers: [String: String] = [:], + body: Body? = Optional.none + ) { + self.method = method + self.path = path + self.queryItems = queryItems + self.headers = headers + self.body = body.map(AnyEncodable.init) + } +} + +/// Encodable 类型擦除包装器,用于在 APIRequest 中保存任意请求体。 +nonisolated struct AnyEncodable: Encodable, Sendable { + private let encodeValue: @Sendable (Encoder) throws -> Void + + init(_ value: Value) { + encodeValue = { encoder in + try value.encode(to: encoder) + } + } + + func encode(to encoder: Encoder) throws { + try encodeValue(encoder) + } +} diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index 8cb1b70..35d08d0 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -13,6 +13,8 @@ final class AppStore { private enum Key { static let token = "key_in_token" + static let lastLoginUsername = "key_last_login_username" + static let privacyAgreementAccepted = "key_privacy_agreement_accepted" } private let defaults: UserDefaults @@ -27,6 +29,24 @@ final class AppStore { set { defaults.set(newValue, forKey: Key.token) } } + /// 上次登录手机号,用于登录页恢复。 + var lastLoginUsername: String? { + get { defaults.string(forKey: Key.lastLoginUsername) } + set { + if let newValue { + defaults.set(newValue, forKey: Key.lastLoginUsername) + } else { + defaults.removeObject(forKey: Key.lastLoginUsername) + } + } + } + + /// 是否已同意隐私协议。 + var privacyAgreementAccepted: Bool { + get { defaults.bool(forKey: Key.privacyAgreementAccepted) } + set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted) } + } + /// Token 非空视为已登录。 var isLoggedIn: Bool { !token.isEmpty diff --git a/suixinkan/Features/Auth/API/AuthAPI.swift b/suixinkan/Features/Auth/API/AuthAPI.swift new file mode 100644 index 0000000..d0f1545 --- /dev/null +++ b/suixinkan/Features/Auth/API/AuthAPI.swift @@ -0,0 +1,47 @@ +// +// AuthAPI.swift +// suixinkan +// + +import Foundation + +@MainActor +/// 登录认证 API,封装 config、v9 登录和账号选择相关接口。 +final class AuthAPI { + private let client: APIClient + + /// 初始化登录 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取 App 配置,控制注册/验证码登录入口显隐。 + func getAppConfig() async throws -> AppConfigResponse { + try await client.send( + APIRequest(method: .get, path: "/api/app/config") + ) + } + + /// 使用手机号和密码发起 v9 登录,返回临时 token 及可选账号列表。 + func login(username: String, password: String) async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/login", + body: LoginRequest(username: username, password: password) + ) + ) + } + + /// 选择具体景区或门店账号,换取正式登录 token。 + func setUser(_ request: SetUserRequest, tokenOverride: String? = nil) async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/set-user", + body: request + ), + tokenOverride: tokenOverride + ) + } +} diff --git a/suixinkan/Features/Auth/AuthSessionHelper.swift b/suixinkan/Features/Auth/AuthSessionHelper.swift new file mode 100644 index 0000000..2c3aa08 --- /dev/null +++ b/suixinkan/Features/Auth/AuthSessionHelper.swift @@ -0,0 +1,26 @@ +// +// AuthSessionHelper.swift +// suixinkan +// + +import Foundation + +/// 登录会话辅助工具,负责写入本地 Token 并广播登录成功通知。 +enum AuthSessionHelper { + + /// 完成登录,保存 Token 与用户偏好,并通知 SceneDelegate 切换根页面。 + static func completeLogin( + with response: V9AuthResponse, + username: String, + privacyAgreementAccepted: Bool + ) { + let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return } + + AppStore.shared.saveToken(token) + AppStore.shared.lastLoginUsername = username + AppStore.shared.privacyAgreementAccepted = privacyAgreementAccepted + + NotificationCenter.default.post(name: NotificationName.userDidLogin, object: nil) + } +} diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift new file mode 100644 index 0000000..7d04dc1 --- /dev/null +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -0,0 +1,402 @@ +// +// 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) + case needsAccountSelection(AccountSelectionPayload) +} + +/// 登录流程错误实体,表示 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 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 username: String + let realName: String + let nickname: String + let phone: String + let scenicId: Int + let scenicName: String + let isCurrent: Bool + + 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 + ) + } + + 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" + } + + 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 + } +} + +/// 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 + + 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 + ) + } + + 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" + } + + 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 + } +} + +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 extension String { + var nonEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/UI/Common/LoginTextField.swift b/suixinkan/UI/Common/LoginTextField.swift index a291a22..b36c4a8 100644 --- a/suixinkan/UI/Common/LoginTextField.swift +++ b/suixinkan/UI/Common/LoginTextField.swift @@ -18,6 +18,7 @@ final class LoginTextField: UIView { var onTextChange: ((String) -> Void)? var onReturnKey: (() -> Void)? + var onEditingDidEnd: (() -> Void)? init(placeholder: String) { super.init(frame: .zero) @@ -55,6 +56,7 @@ final class LoginTextField: UIView { textField.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes) textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged) + textField.addTarget(self, action: #selector(editingDidEnd), for: .editingDidEnd) addSubview(textField) } @@ -72,6 +74,10 @@ final class LoginTextField: UIView { @objc private func textDidChange() { onTextChange?(text) } + + @objc private func editingDidEnd() { + onEditingDidEnd?() + } } extension LoginTextField: UITextFieldDelegate { diff --git a/suixinkan/UI/Login/AccountSelectionViewController.swift b/suixinkan/UI/Login/AccountSelectionViewController.swift new file mode 100644 index 0000000..047d995 --- /dev/null +++ b/suixinkan/UI/Login/AccountSelectionViewController.swift @@ -0,0 +1,318 @@ +// +// AccountSelectionViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +private typealias AccountSelectionSection = Int +private typealias AccountSelectionItem = String + +/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。 +final class AccountSelectionViewController: UIViewController, UITableViewDelegate { + + private let payload: AccountSelectionPayload + private var isLoading: Bool + private let onCancel: () -> Void + private let onConfirm: (AccountSwitchAccount) -> Void + + private var selectedAccountId: String? + private let tableView = UITableView(frame: .zero, style: .plain) + private var tableDataSource: UITableViewDiffableDataSource! + private let confirmButton = UIButton(type: .system) + private let bottomBar = UIView() + + init( + payload: AccountSelectionPayload, + isLoading: Bool, + onCancel: @escaping () -> Void, + onConfirm: @escaping (AccountSwitchAccount) -> Void + ) { + self.payload = payload + self.isLoading = isLoading + self.onCancel = onCancel + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "选择账号" + view.backgroundColor = UIColor(hex: 0xF5F7FB) + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "取消", + style: .plain, + target: self, + action: #selector(cancelTapped) + ) + isModalInPresentation = isLoading + + selectedAccountId = payload.accounts.first?.id + configureTableView() + configureTableDataSource() + configureBottomBar() + } + + /// 更新 Loading 状态。 + func updateLoading(_ loading: Bool) { + isLoading = loading + isModalInPresentation = loading + confirmButton.isEnabled = canConfirm + confirmButton.backgroundColor = canConfirm ? AppColor.primary : AppColor.buttonDisabled + } + + private var selectedAccount: AccountSwitchAccount? { + payload.accounts.first { $0.id == selectedAccountId } + } + + private var canConfirm: Bool { + selectedAccount != nil && !isLoading + } + + private func configureTableDataSource() { + tableDataSource = UITableViewDiffableDataSource( + tableView: tableView + ) { [weak self] tableView, indexPath, item in + guard let self, + let cell = tableView.dequeueReusableCell( + withIdentifier: AccountSelectionCell.reuseIdentifier, + for: indexPath + ) as? AccountSelectionCell, + let account = self.payload.accounts.first(where: { $0.id == item }) else { + return UITableViewCell() + } + cell.configure(account: account, selected: account.id == self.selectedAccountId) + return cell + } + applyTableSnapshot(animated: false) + } + + private func buildTableSnapshot() -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(payload.accounts.map(\.id), toSection: 0) + return snapshot + } + + private func applyTableSnapshot(animated: Bool = true, reconfigure: Bool = false) { + var snapshot = buildTableSnapshot() + if reconfigure, !snapshot.itemIdentifiers.isEmpty { + snapshot.reconfigureItems(snapshot.itemIdentifiers) + } + tableDataSource.apply(snapshot, animatingDifferences: animated) + } + + private func configureTableView() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.delegate = self + tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier) + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(90) + } + } + + private func configureBottomBar() { + bottomBar.backgroundColor = .white + + let divider = UIView() + divider.backgroundColor = UIColor.separator + + confirmButton.setTitle("进入系统", for: .normal) + confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold) + confirmButton.setTitleColor(.white, for: .normal) + confirmButton.layer.cornerRadius = 8 + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + updateLoading(isLoading) + + view.addSubview(bottomBar) + bottomBar.addSubview(divider) + bottomBar.addSubview(confirmButton) + + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + + divider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + + confirmButton.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14) + make.height.equalTo(50) + } + } + + @objc private func cancelTapped() { + onCancel() + dismiss(animated: true) + } + + @objc private func confirmTapped() { + guard let selectedAccount else { return } + onConfirm(selectedAccount) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard let item = tableDataSource.itemIdentifier(for: indexPath) else { return } + selectedAccountId = item + applyTableSnapshot(animated: false, reconfigure: true) + confirmButton.isEnabled = canConfirm + confirmButton.backgroundColor = canConfirm ? AppColor.primary : AppColor.buttonDisabled + } +} + +/// 账号选择列表 Cell。 +private final class AccountSelectionCell: UITableViewCell { + static let reuseIdentifier = "AccountSelectionCell" + + private let avatarView = UIView() + private let avatarLabel = UILabel() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let phoneLabel = UILabel() + private let typeTag = UILabel() + private let currentTag = UILabel() + private let checkmark = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 8 + contentView.layer.masksToBounds = true + + avatarView.layer.cornerRadius = 25 + + avatarLabel.font = .systemFont(ofSize: 14, weight: .semibold) + + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + titleLabel.textColor = AppColor.text333 + + subtitleLabel.font = .systemFont(ofSize: 13) + subtitleLabel.textColor = AppColor.text666 + + phoneLabel.font = .systemFont(ofSize: 12) + phoneLabel.textColor = UIColor(hex: 0x9AA1AA) + + typeTag.font = .systemFont(ofSize: 11, weight: .semibold) + typeTag.textAlignment = .center + typeTag.layer.cornerRadius = 4 + typeTag.clipsToBounds = true + + currentTag.font = .systemFont(ofSize: 11, weight: .semibold) + currentTag.textAlignment = .center + currentTag.text = "当前" + currentTag.textColor = AppColor.primary + currentTag.backgroundColor = AppColor.secondaryButtonBackground + currentTag.layer.cornerRadius = 4 + currentTag.clipsToBounds = true + currentTag.isHidden = true + + checkmark.contentMode = .scaleAspectFit + + contentView.addSubview(avatarView) + avatarView.addSubview(avatarLabel) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(phoneLabel) + contentView.addSubview(typeTag) + contentView.addSubview(currentTag) + contentView.addSubview(checkmark) + + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + make.width.height.equalTo(50) + } + + avatarLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(16) + make.leading.equalTo(avatarView.snp.trailing).offset(12) + make.trailing.lessThanOrEqualTo(checkmark.snp.leading).offset(-8) + } + + currentTag.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(6) + make.centerY.equalTo(titleLabel) + make.height.equalTo(18) + } + + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.leading.equalTo(titleLabel) + make.trailing.equalTo(checkmark.snp.leading).offset(-8) + } + + phoneLabel.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(4) + make.leading.equalTo(titleLabel) + make.bottom.equalToSuperview().inset(16) + } + + typeTag.snp.makeConstraints { make in + make.trailing.equalTo(checkmark.snp.leading).offset(-8) + make.top.equalTo(titleLabel) + make.height.equalTo(18) + } + + checkmark.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(16) + make.centerY.equalToSuperview() + make.width.height.equalTo(22) + } + } + + required init?(coder: NSCoder) { + nil + } + + func configure(account: AccountSwitchAccount, selected: Bool) { + titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title + subtitleLabel.text = account.subtitle + subtitleLabel.isHidden = account.subtitle.isEmpty + phoneLabel.text = account.phone + phoneLabel.isHidden = account.phone.isEmpty + currentTag.isHidden = !account.isCurrent + + if account.isStoreUser { + avatarView.backgroundColor = UIColor(hex: 0xE8F8F1) + avatarLabel.text = "店" + avatarLabel.textColor = UIColor(hex: 0x0F9F6E) + typeTag.text = " 门店 " + typeTag.textColor = UIColor(hex: 0x0F9F6E) + typeTag.backgroundColor = UIColor(hex: 0xE8F8F1) + } else { + avatarView.backgroundColor = UIColor(hex: 0xF3ECFF) + avatarLabel.text = "景" + avatarLabel.textColor = UIColor(hex: 0x7C3AED) + typeTag.text = " 景区 " + typeTag.textColor = UIColor(hex: 0x7C3AED) + typeTag.backgroundColor = UIColor(hex: 0xF3ECFF) + } + + let symbolName = selected ? "checkmark.circle.fill" : "circle" + checkmark.image = UIImage(systemName: symbolName) + checkmark.tintColor = selected ? AppColor.primary : UIColor(hex: 0xB6BECA) + + contentView.layer.borderWidth = selected ? 1.4 : 1 + contentView.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xE6ECF4)).cgColor + } +} diff --git a/suixinkan/UI/Login/LoginViewController.swift b/suixinkan/UI/Login/LoginViewController.swift index 0f33604..8ed02c9 100644 --- a/suixinkan/UI/Login/LoginViewController.swift +++ b/suixinkan/UI/Login/LoginViewController.swift @@ -10,9 +10,11 @@ import UIKit final class LoginViewController: BaseViewController { private let viewModel = LoginViewModel() + private let authAPI = NetworkServices.shared.authAPI private let backgroundImageView = UIImageView() private let welcomeLabel = UILabel() + private let formCardShadowView = UIView() private let formCardView = UIView() private let topFormStack = UIStackView() private let accountField = LoginTextField(placeholder: "请输入账号") @@ -24,7 +26,7 @@ final class LoginViewController: BaseViewController { private let registerPrefixLabel = UILabel() private let registerButton = UIButton(type: .system) - private var hasPlayedEntranceAnimation = false + private weak var accountSelectionController: AccountSelectionViewController? override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent @@ -46,6 +48,12 @@ final class LoginViewController: BaseViewController { welcomeLabel.font = .systemFont(ofSize: 28, weight: .semibold) welcomeLabel.textColor = .white + formCardShadowView.backgroundColor = .clear + formCardShadowView.layer.shadowColor = UIColor.black.cgColor + formCardShadowView.layer.shadowOpacity = 0.14 + formCardShadowView.layer.shadowOffset = CGSize(width: 0, height: 8) + formCardShadowView.layer.shadowRadius = 20 + formCardView.backgroundColor = .white formCardView.layer.cornerRadius = 16 formCardView.clipsToBounds = true @@ -75,7 +83,8 @@ final class LoginViewController: BaseViewController { view.addSubview(backgroundImageView) view.addSubview(welcomeLabel) - view.addSubview(formCardView) + view.addSubview(formCardShadowView) + formCardShadowView.addSubview(formCardView) formCardView.addSubview(topFormStack) formCardView.addSubview(registerContainer) @@ -89,6 +98,12 @@ final class LoginViewController: BaseViewController { topFormStack.setCustomSpacing(8, after: loginButton) topFormStack.addArrangedSubview(loginByCodeButton) + viewModel.applyStoredPreferences() + if let username = viewModel.normalizedUsername.nonEmpty ?? viewModel.account.nonEmpty { + accountField.text = username + } + agreementRow.isChecked = viewModel.isPrivacyChecked + bindViewModel() updateUI() } @@ -103,12 +118,16 @@ final class LoginViewController: BaseViewController { make.leading.trailing.equalToSuperview().inset(24) } - formCardView.snp.makeConstraints { make in + formCardShadowView.snp.makeConstraints { make in make.top.equalTo(welcomeLabel.snp.bottom).offset(48) make.leading.trailing.equalToSuperview().inset(24) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24) } + formCardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + topFormStack.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(16) make.bottom.equalToSuperview().inset(16) @@ -120,6 +139,14 @@ final class LoginViewController: BaseViewController { } } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + formCardShadowView.layer.shadowPath = UIBezierPath( + roundedRect: formCardShadowView.bounds, + cornerRadius: 16 + ).cgPath + } + override func bindActions() { accountField.onTextChange = { [weak self] text in self?.viewModel.updateAccount(text) @@ -127,6 +154,12 @@ final class LoginViewController: BaseViewController { accountField.onReturnKey = { [weak self] in self?.passwordField.becomeFirstResponder() } + accountField.onEditingDidEnd = { [weak self] in + self?.viewModel.normalizeUsernameCountryCodeIfNeeded() + if let username = self?.viewModel.normalizedUsername { + self?.accountField.text = username + } + } passwordField.onTextChange = { [weak self] text in self?.viewModel.updatePassword(text) @@ -145,11 +178,24 @@ final class LoginViewController: BaseViewController { agreementRow.onPrivacyPolicyTap = { [weak self] in self?.viewModel.onPrivacyPolicy?() } + + setupDismissKeyboardGesture() + } + + private func setupDismissKeyboardGesture() { + let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tap.cancelsTouchesInView = false + tap.delegate = self + view.addGestureRecognizer(tap) + } + + @objc private func dismissKeyboard() { + view.endEditing(true) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - playEntranceAnimationIfNeeded() + Task { await viewModel.loadAppConfig(authAPI: authAPI) } } private func bindViewModel() { @@ -160,7 +206,7 @@ final class LoginViewController: BaseViewController { self?.showToast(message) } viewModel.onLogin = { [weak self] in - self?.showToast("登录功能待接入") + self?.performLogin() } viewModel.onLoginByCode = { [weak self] in self?.showToast("验证码登录待接入") @@ -178,7 +224,8 @@ final class LoginViewController: BaseViewController { private func updateUI() { agreementRow.isChecked = viewModel.isPrivacyChecked - loginButton.isEnabled = viewModel.isLoginEnabled + loginButton.isEnabled = viewModel.isLoginEnabled && !viewModel.isLoading + loginButton.alpha = viewModel.isLoading ? 0.7 : 1 let showRegisterSection = viewModel.enableRegister loginByCodeButton.isHidden = !showRegisterSection @@ -192,21 +239,87 @@ final class LoginViewController: BaseViewController { make.bottom.equalToSuperview().inset(16) } } + + if let payload = viewModel.pendingAccountSelection { + if let controller = accountSelectionController { + controller.updateLoading(viewModel.isSelectingAccount) + } else if presentedViewController == nil { + presentAccountSelection(payload) + } + } else if accountSelectionController != nil { + dismiss(animated: true) + accountSelectionController = nil + } } - private func playEntranceAnimationIfNeeded() { - guard !hasPlayedEntranceAnimation else { return } - hasPlayedEntranceAnimation = true + private func performLogin() { + viewModel.normalizeUsernameCountryCodeIfNeeded() + accountField.text = viewModel.normalizedUsername - formCardView.transform = CGAffineTransform(translationX: 0, y: view.bounds.height) - welcomeLabel.alpha = 0 + Task { + showLoading() + defer { hideLoading() } - UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) { - self.formCardView.transform = .identity - self.welcomeLabel.alpha = 1 + do { + let resolution = try await viewModel.login(authAPI: authAPI) + switch resolution { + case let .completed(response): + completeLogin(with: response) + case .needsAccountSelection: + break + } + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } } } + private func presentAccountSelection(_ payload: AccountSelectionPayload) { + let controller = AccountSelectionViewController( + payload: payload, + isLoading: viewModel.isSelectingAccount, + onCancel: { [weak self] in + self?.viewModel.clearPendingAccountSelection() + self?.accountSelectionController = nil + }, + onConfirm: { [weak self] account in + self?.selectAccount(account) + } + ) + accountSelectionController = controller + let navigation = UINavigationController(rootViewController: controller) + navigation.modalPresentationStyle = .formSheet + present(navigation, animated: true) + } + + private func selectAccount(_ account: AccountSwitchAccount) { + Task { + accountSelectionController?.updateLoading(true) + defer { accountSelectionController?.updateLoading(false) } + + do { + let response = try await viewModel.selectAccount(account, authAPI: authAPI) + completeLogin(with: response) + accountSelectionController = nil + dismiss(animated: true) + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } + } + } + + private func completeLogin(with response: V9AuthResponse) { + AuthSessionHelper.completeLogin( + with: response, + username: viewModel.normalizedUsername, + privacyAgreementAccepted: viewModel.isPrivacyChecked + ) + } + @objc private func loginButtonTapped() { view.endEditing(true) viewModel.login() @@ -222,3 +335,24 @@ final class LoginViewController: BaseViewController { viewModel.onRegister?() } } + +extension LoginViewController: UIGestureRecognizerDelegate { + /// 点击输入框、按钮时不触发收键盘,避免干扰正常交互。 + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + var candidate: UIView? = touch.view + while let view = candidate { + if view is UITextField || view is UIButton { + return false + } + candidate = view.superview + } + return true + } +} + +private extension String { + var nonEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/UI/Login/LoginViewModel.swift b/suixinkan/UI/Login/LoginViewModel.swift index 520cbed..f912a00 100644 --- a/suixinkan/UI/Login/LoginViewModel.swift +++ b/suixinkan/UI/Login/LoginViewModel.swift @@ -12,13 +12,38 @@ final class LoginViewModel { private(set) var password = "" private(set) var isPrivacyChecked = false private(set) var enableRegister = false + private(set) var isLoading = false + private(set) var isSelectingAccount = false + private(set) var pendingAccountSelection: AccountSelectionPayload? var isLoginEnabled: Bool { - !account.isEmpty && !password.isEmpty + isValidPhone && !trimmedPassword.isEmpty + } + + var trimmedPassword: String { + password.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedUsername: String { + normalizedPhoneNumber(account) + } + + var isValidPhone: Bool { + normalizedUsername.count == 11 && normalizedUsername.first == "1" } var onStateChange: (() -> Void)? + /// 从本地存储恢复上次登录手机号与协议勾选状态。 + func applyStoredPreferences() { + if account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let lastUsername = AppStore.shared.lastLoginUsername { + account = lastUsername + } + isPrivacyChecked = AppStore.shared.privacyAgreementAccepted + notifyStateChange() + } + func updateAccount(_ value: String) { account = value notifyStateChange() @@ -39,15 +64,101 @@ final class LoginViewModel { notifyStateChange() } - func login() { + /// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。 + func normalizeUsernameCountryCodeIfNeeded() { + let digits = account.filter(\.isNumber) + let normalizedPhone = normalizedPhoneNumber(account) + guard normalizedPhone != digits, normalizedPhone.count == 11 else { return } + account = normalizedPhone + notifyStateChange() + } + + /// 校验登录表单,并返回第一个需要处理的错误。 + func validateForLogin() -> LoginValidationError? { + guard isValidPhone else { + return .invalidPhone + } + guard !trimmedPassword.isEmpty else { + return .emptyPassword + } guard isPrivacyChecked else { - onShowMessage?("请先阅读并同意用户协议与隐私政策") + return .privacyUnchecked + } + return nil + } + + func login() { + if let error = validateForLogin() { + onShowMessage?(error.message) return } - guard isLoginEnabled else { return } onLogin?() } + /// 加载 App 配置,控制验证码登录/注册入口显隐。 + func loadAppConfig(authAPI: AuthAPI) async { + do { + let config = try await authAPI.getAppConfig() + updateEnableRegister(config.enableRegister) + } catch { + // 配置加载失败不影响密码登录,静默忽略。 + } + } + + /// 发起登录,并根据账号数量决定直接完成或进入账号选择。 + func login(authAPI: AuthAPI) async throws -> LoginResolution { + guard !isLoading else { + throw CancellationError() + } + + isLoading = true + notifyStateChange() + defer { + isLoading = false + notifyStateChange() + } + + let response = try await authAPI.login( + username: normalizedUsername, + password: trimmedPassword + ) + return try await resolveLoginResponse(response, authAPI: authAPI) + } + + /// 选择一个账号并调用 set-user 换取正式 token。 + func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { + guard let payload = pendingAccountSelection, payload.hasTempToken else { + throw LoginFlowError.missingToken + } + guard account.businessUserId > 0 else { + throw LoginFlowError.invalidAccount + } + guard !isSelectingAccount else { + throw CancellationError() + } + + isSelectingAccount = true + notifyStateChange() + defer { + isSelectingAccount = false + notifyStateChange() + } + + let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken) + guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } + pendingAccountSelection = nil + notifyStateChange() + return response + } + + /// 清空待选账号信息,通常在用户取消账号选择时调用。 + func clearPendingAccountSelection() { + pendingAccountSelection = nil + notifyStateChange() + } + var onShowMessage: ((String) -> Void)? var onLogin: (() -> Void)? var onLoginByCode: (() -> Void)? @@ -55,6 +166,39 @@ final class LoginViewModel { var onUserAgreement: (() -> Void)? var onPrivacyPolicy: (() -> Void)? + private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution { + let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + throw LoginFlowError.missingToken + } + + let accounts = response.accounts.filter { $0.businessUserId > 0 } + guard !accounts.isEmpty else { + throw LoginFlowError.noAvailableAccount + } + + if accounts.count == 1, let account = accounts.first { + let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token) + guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } + return .completed(finalResponse) + } + + let payload = AccountSelectionPayload(tempToken: token, accounts: accounts) + pendingAccountSelection = payload + notifyStateChange() + return .needsAccountSelection(payload) + } + + private func normalizedPhoneNumber(_ value: String) -> String { + var digits = value.filter(\.isNumber) + if digits.hasPrefix("86"), digits.count == 13 { + digits.removeFirst(2) + } + return digits + } + private func notifyStateChange() { onStateChange?() } diff --git a/suixinkanTests/APIClientTests.swift b/suixinkanTests/APIClientTests.swift new file mode 100644 index 0000000..54acb07 --- /dev/null +++ b/suixinkanTests/APIClientTests.swift @@ -0,0 +1,99 @@ +// +// APIClientTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// APIClient 测试,覆盖 Envelope 解包、业务码失败和 token 注入。 +final class APIClientTests: XCTestCase { + func testSendDecodesSuccessEnvelope() async throws { + let responseData = try TestJSON.envelope(data: AppConfigResponse(enableRegister: true)) + let session = MockURLSession(responses: [responseData]) + let client = APIClient( + environment: .testing, + session: session, + appVersion: "1.0.0", + osType: "iOS" + ) + + let config: AppConfigResponse = try await client.send( + APIRequest(method: .get, path: "/api/app/config") + ) + + XCTAssertTrue(config.enableRegister) + XCTAssertEqual(session.requests.count, 1) + XCTAssertEqual(session.requests[0].value(forHTTPHeaderField: "X-APP-VERSION"), "1.0.0") + XCTAssertEqual(session.requests[0].value(forHTTPHeaderField: "X-OS-TYPE"), "iOS") + } + + func testSendInjectsTokenHeaderWhenProviderBound() async throws { + let responseData = try TestJSON.envelope(data: AppConfigResponse(enableRegister: false)) + let session = MockURLSession(responses: [responseData]) + let client = APIClient(environment: .testing, session: session) + client.bindAuthTokenProvider { "test-token-abc" } + + _ = try await client.send(APIRequest(method: .get, path: "/api/app/config")) + + XCTAssertEqual(session.requests[0].value(forHTTPHeaderField: "token"), "test-token-abc") + } + + func testSendThrowsServerCodeErrorForBusinessFailure() async throws { + let responseData = try TestJSON.errorEnvelope(code: 100001, msg: "密码错误") + let session = MockURLSession(responses: [responseData]) + let client = APIClient(environment: .testing, session: session) + + do { + let _: AppConfigResponse = try await client.send( + APIRequest(method: .get, path: "/api/app/config") + ) + XCTFail("业务码失败时应抛出错误") + } catch let error as APIError { + guard case let .serverCode(code, message) = error else { + return XCTFail("期望 serverCode,实际为 \(error)") + } + XCTAssertEqual(code, 100001) + XCTAssertEqual(message, "密码错误") + } + } + + func testSendThrowsHTTPStatusError() async throws { + let session = MockURLSession(results: [ + .success(( + Data(), + HTTPURLResponse( + url: URL(string: "https://api-test.zhifly.cn/mock")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + )), + ]) + let client = APIClient(environment: .testing, session: session) + + do { + let _: AppConfigResponse = try await client.send( + APIRequest(method: .get, path: "/api/app/config") + ) + XCTFail("HTTP 500 时应抛出错误") + } catch let error as APIError { + guard case let .httpStatus(statusCode, _) = error else { + return XCTFail("期望 httpStatus,实际为 \(error)") + } + XCTAssertEqual(statusCode, 500) + } + } + + func testTokenInvalidCodePostsSessionDidExpireNotification() async { + let expectation = expectation(forNotification: NotificationName.sessionDidExpire, object: nil) + let responseData = try! TestJSON.errorEnvelope(code: 180024, msg: "token 失效") + let session = MockURLSession(responses: [responseData]) + let client = APIClient(environment: .testing, session: session) + + _ = try? await client.send(APIRequest(method: .get, path: "/api/app/config")) + + await fulfillment(of: [expectation], timeout: 1) + } +} diff --git a/suixinkanTests/APIErrorTests.swift b/suixinkanTests/APIErrorTests.swift new file mode 100644 index 0000000..ccdd10a --- /dev/null +++ b/suixinkanTests/APIErrorTests.swift @@ -0,0 +1,29 @@ +// +// APIErrorTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 网络错误测试,覆盖登录凭证失效判断。 +final class APIErrorTests: XCTestCase { + func testAuthenticationExpiredForUnauthorizedHTTPStatus() { + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(401, "Unauthorized"))) + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(403, "Forbidden"))) + } + + func testAuthenticationExpiredForAndroidTokenInvalidCodes() { + for code in [180024, 100091, 100090, 100060] { + XCTAssertTrue( + APIError.isAuthenticationExpired(APIError.serverCode(code, "token 失效")), + "code \(code) 应识别为 token 失效" + ) + } + } + + func testNetworkErrorIsNotAuthenticationExpired() { + XCTAssertFalse(APIError.isAuthenticationExpired(APIError.networkFailed("请求超时,请稍后重试"))) + XCTAssertFalse(APIError.isAuthenticationExpired(APIError.serverCode(100001, "密码错误"))) + } +} diff --git a/suixinkanTests/AuthModelsTests.swift b/suixinkanTests/AuthModelsTests.swift new file mode 100644 index 0000000..7699944 --- /dev/null +++ b/suixinkanTests/AuthModelsTests.swift @@ -0,0 +1,129 @@ +// +// AuthModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// Auth 模块模型测试,覆盖 v9 登录响应解码和账号转换规则。 +final class AuthModelsTests: XCTestCase { + func testV9AuthResponseDecodesMultiAccountJSON() throws { + let json = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + ], + "store_users": [ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "李四", + "phone": "13900139000", + "avatar": "", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "is_current": false + } + ] + } + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: json) + let response = try XCTUnwrap(envelope.data) + + XCTAssertEqual(response.token, "person-temp-token") + XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) + } + + func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws { + let json = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "only-token" + } + } + """.data(using: .utf8)! + + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: json) + let response = try XCTUnwrap(envelope.data) + + XCTAssertEqual(response.token, "only-token") + XCTAssertTrue(response.scenicUsers.isEmpty) + XCTAssertTrue(response.storeUsers.isEmpty) + } + + func testV9ScenicUserBusinessUserIdPrefersSsUserId() throws { + let json = """ + { + "account_type": "scenic_user", + "id": "1", + "user_id": 2, + "scenic_user_id": 3, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + """.data(using: .utf8)! + let user = try JSONDecoder().decode(V9ScenicUser.self, from: json) + + XCTAssertEqual(user.businessUserId, 101) + XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101)) + } + + func testV9StoreUserBusinessUserIdPrefersStoreUserId() throws { + let json = """ + { + "account_type": "store_user", + "id": 1, + "user_id": 2, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "张三", + "phone": "13800138000", + "avatar": "", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "is_current": false + } + """.data(using: .utf8)! + let user = try JSONDecoder().decode(V9StoreUser.self, from: json) + + XCTAssertEqual(user.businessUserId, 201) + XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) + } +} diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift new file mode 100644 index 0000000..12b0e6d --- /dev/null +++ b/suixinkanTests/LoginViewModelTests.swift @@ -0,0 +1,202 @@ +// +// LoginViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。 +final class LoginViewModelTests: XCTestCase { + func testNormalizeUsernameCountryCodeRemovesChinaPrefix() { + let viewModel = LoginViewModel() + viewModel.updateAccount("+86 186 5185 7230") + + viewModel.normalizeUsernameCountryCodeIfNeeded() + + XCTAssertEqual(viewModel.normalizedUsername, "18651857230") + XCTAssertTrue(viewModel.isValidPhone) + } + + func testIsLoginEnabledRequiresValidPhoneAndNonEmptyPassword() { + let viewModel = LoginViewModel() + viewModel.updateAccount("18651857230") + viewModel.updatePassword(" secret123 ") + + XCTAssertTrue(viewModel.isLoginEnabled) + XCTAssertEqual(viewModel.trimmedPassword, "secret123") + } + + func testValidateForLoginReturnsExpectedErrorsInOrder() { + let viewModel = LoginViewModel() + viewModel.updateAccount("12345") + viewModel.updatePassword("secret123") + viewModel.togglePrivacyChecked() + XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone) + + viewModel.updateAccount("18651857230") + viewModel.updatePassword(" ") + XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword) + + viewModel.updatePassword("secret123") + viewModel.togglePrivacyChecked() + XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked) + } + + func testLoginWithSingleAccountAutoCompletesSetUser() async throws { + let loginJSON = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "temp-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + ], + "store_users": [] + } + } + """.data(using: .utf8)! + + let setUserJSON = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "business-token-scenic", + "scenic_users": [], + "store_users": [] + } + } + """.data(using: .utf8)! + + let session = MockURLSession(responses: [loginJSON, setUserJSON]) + let api = AuthAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = LoginViewModel() + viewModel.updateAccount("18651857230") + viewModel.updatePassword("secret123") + viewModel.togglePrivacyChecked() + + let resolution = try await viewModel.login(authAPI: api) + + guard case .completed(let response) = resolution else { + return XCTFail("单账号登录应直接完成") + } + XCTAssertEqual(response.token, "business-token-scenic") + XCTAssertFalse(viewModel.isLoading) + XCTAssertNil(viewModel.pendingAccountSelection) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/v9/login", + "/api/app/v9/set-user", + ]) + } + + func testLoginWithMultipleAccountsNeedsSelection() async throws { + let loginJSON = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + ], + "store_users": [ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "李四", + "phone": "13900139000", + "avatar": "", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "is_current": false + } + ] + } + } + """.data(using: .utf8)! + + let session = MockURLSession(responses: [loginJSON]) + let api = AuthAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = LoginViewModel() + viewModel.updateAccount("18651857230") + viewModel.updatePassword("secret123") + viewModel.togglePrivacyChecked() + + let resolution = try await viewModel.login(authAPI: api) + + guard case .needsAccountSelection(let payload) = resolution else { + return XCTFail("多账号登录应进入账号选择") + } + XCTAssertEqual(payload.tempToken, "person-temp-token") + XCTAssertEqual(payload.accounts.map(\.businessUserId), [101, 201]) + XCTAssertNotNil(viewModel.pendingAccountSelection) + XCTAssertEqual(session.requests.count, 1) + } + + func testLoginThrowsWhenNoAvailableAccounts() async { + let loginJSON = """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [], + "store_users": [] + } + } + """.data(using: .utf8)! + + let session = MockURLSession(responses: [loginJSON]) + let api = AuthAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = LoginViewModel() + viewModel.updateAccount("18651857230") + viewModel.updatePassword("secret123") + viewModel.togglePrivacyChecked() + + do { + _ = try await viewModel.login(authAPI: api) + XCTFail("没有可用账号时应抛出错误") + } catch let error as LoginFlowError { + guard case .noAvailableAccount = error else { + return XCTFail("期望 noAvailableAccount,实际为 \(error)") + } + } catch { + XCTFail("意外错误: \(error)") + } + } +} diff --git a/suixinkanTests/MockURLSession.swift b/suixinkanTests/MockURLSession.swift new file mode 100644 index 0000000..96faa88 --- /dev/null +++ b/suixinkanTests/MockURLSession.swift @@ -0,0 +1,63 @@ +// +// MockURLSession.swift +// suixinkanTests +// + +import Foundation +@testable import suixinkan + +/// 按顺序返回预设响应的 URLSession 测试替身。 +final class MockURLSession: URLSessionProtocol { + private(set) var requests: [URLRequest] = [] + private var responses: [Result<(Data, URLResponse), Error>] + private var responseIndex = 0 + + init(responses: [Data], statusCode: Int = 200) { + self.responses = responses.map { data in + .success(( + data, + HTTPURLResponse( + url: URL(string: "https://api-test.zhifly.cn/mock")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + )) + } + } + + init(results: [Result<(Data, URLResponse), Error>]) { + self.responses = results + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + guard responseIndex < responses.count else { + throw URLError(.badServerResponse) + } + defer { responseIndex += 1 } + return try responses[responseIndex].get() + } +} + +enum TestJSON { + static func envelope(data: T) throws -> Data { + let payload = try JSONEncoder().encode(data) + let payloadObject = try JSONSerialization.jsonObject(with: payload) + let envelope: [String: Any] = [ + "code": 100000, + "msg": "success", + "data": payloadObject, + ] + return try JSONSerialization.data(withJSONObject: envelope) + } + + static func errorEnvelope(code: Int, msg: String) throws -> Data { + let envelope: [String: Any] = [ + "code": code, + "msg": msg, + "data": NSNull(), + ] + return try JSONSerialization.data(withJSONObject: envelope) + } +}