添加网络层并接入 v9 登录流程及单元测试
This commit is contained in:
@ -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 = "<group>";
|
||||
};
|
||||
A1B2C3D42FFB84950033C305 /* suixinkanTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = suixinkanTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
@ -61,6 +89,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
93FF17C52FFB84950033C304 /* suixinkan.app */,
|
||||
A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -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 */
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A1B2C3D32FFB84950033C305"
|
||||
BuildableName = "suixinkanTests.xctest"
|
||||
BlueprintName = "suixinkanTests"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
32
suixinkan/App/NetworkServices.swift
Normal file
32
suixinkan/App/NetworkServices.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
264
suixinkan/Core/Networking/APIClient.swift
Normal file
264
suixinkan/Core/Networking/APIClient.swift
Normal file
@ -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<Response: Decodable>(
|
||||
_ apiRequest: APIRequest<Response>,
|
||||
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<Response: Decodable>(
|
||||
_ apiRequest: APIRequest<Response>,
|
||||
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<Response: Decodable>(_ responseType: Response.Type, from data: Data) throws -> Response {
|
||||
let envelope: APIEnvelope<Response>
|
||||
do {
|
||||
envelope = try decoder.decode(APIEnvelope<Response>.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 ?? "<invalid url>"
|
||||
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 ?? "<invalid url>"
|
||||
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 ?? "<invalid url>"
|
||||
print("[API][Cancelled] \(method) \(url) reason=\(reason)")
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 将响应体格式化为便于调试阅读的字符串。
|
||||
private static func debugResponseBody(from data: Data) -> String {
|
||||
guard !data.isEmpty else { return "<empty response>" }
|
||||
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) ?? "<non-utf8 response: \(data.count) bytes>"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
60
suixinkan/Core/Networking/APIEnvelope.swift
Normal file
60
suixinkan/Core/Networking/APIEnvelope.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// APIEnvelope.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 后端统一响应包裹实体,承载业务 code、msg 和真实 data。
|
||||
struct APIEnvelope<T: Decodable>: 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?
|
||||
}
|
||||
60
suixinkan/Core/Networking/APIEnvironment.swift
Normal file
60
suixinkan/Core/Networking/APIEnvironment.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
54
suixinkan/Core/Networking/APIError.swift
Normal file
54
suixinkan/Core/Networking/APIError.swift
Normal file
@ -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<Int> = [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
|
||||
}
|
||||
}
|
||||
}
|
||||
53
suixinkan/Core/Networking/APIRequest.swift
Normal file
53
suixinkan/Core/Networking/APIRequest.swift
Normal file
@ -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<Response: Decodable> {
|
||||
var method: HTTPMethod
|
||||
var path: String
|
||||
var queryItems: [URLQueryItem]
|
||||
var headers: [String: String]
|
||||
var body: AnyEncodable?
|
||||
|
||||
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
|
||||
init<Body: Encodable>(
|
||||
method: HTTPMethod,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
headers: [String: String] = [:],
|
||||
body: Body? = Optional<EmptyPayload>.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: Encodable>(_ value: Value) {
|
||||
encodeValue = { encoder in
|
||||
try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
try encodeValue(encoder)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
47
suixinkan/Features/Auth/API/AuthAPI.swift
Normal file
47
suixinkan/Features/Auth/API/AuthAPI.swift
Normal file
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
26
suixinkan/Features/Auth/AuthSessionHelper.swift
Normal file
26
suixinkan/Features/Auth/AuthSessionHelper.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
402
suixinkan/Features/Auth/Models/AuthModels.swift
Normal file
402
suixinkan/Features/Auth/Models/AuthModels.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
318
suixinkan/UI/Login/AccountSelectionViewController.swift
Normal file
318
suixinkan/UI/Login/AccountSelectionViewController.swift
Normal file
@ -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<AccountSelectionSection, AccountSelectionItem>!
|
||||
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<AccountSelectionSection, AccountSelectionItem>(
|
||||
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<AccountSelectionSection, AccountSelectionItem> {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<AccountSelectionSection, AccountSelectionItem>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?()
|
||||
}
|
||||
|
||||
99
suixinkanTests/APIClientTests.swift
Normal file
99
suixinkanTests/APIClientTests.swift
Normal file
@ -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<AppConfigResponse>(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<AppConfigResponse>(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<AppConfigResponse>(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<AppConfigResponse>(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<AppConfigResponse>(method: .get, path: "/api/app/config"))
|
||||
|
||||
await fulfillment(of: [expectation], timeout: 1)
|
||||
}
|
||||
}
|
||||
29
suixinkanTests/APIErrorTests.swift
Normal file
29
suixinkanTests/APIErrorTests.swift
Normal file
@ -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, "密码错误")))
|
||||
}
|
||||
}
|
||||
129
suixinkanTests/AuthModelsTests.swift
Normal file
129
suixinkanTests/AuthModelsTests.swift
Normal file
@ -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<V9AuthResponse>.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<V9AuthResponse>.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))
|
||||
}
|
||||
}
|
||||
202
suixinkanTests/LoginViewModelTests.swift
Normal file
202
suixinkanTests/LoginViewModelTests.swift
Normal file
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
63
suixinkanTests/MockURLSession.swift
Normal file
63
suixinkanTests/MockURLSession.swift
Normal file
@ -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<T: Encodable>(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user