Add networking layer and wire up v9 login flow with unit tests.

Introduce APIClient/AuthAPI, complete login with multi-account selection, and polish the login card UI with shadow and keyboard dismiss.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 15:33:34 +08:00
parent b7d74905b7
commit 699f1ba7c4
21 changed files with 2387 additions and 18 deletions

View File

@ -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 */

View File

@ -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>

View 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)
}
}

View 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 Headertoken
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
}
}

View File

@ -0,0 +1,60 @@
//
// APIEnvelope.swift
// suixinkan
//
import Foundation
/// codemsg 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?
}

View 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
}
}

View File

@ -0,0 +1,54 @@
//
// APIError.swift
// suixinkan
//
import Foundation
/// URLHTTP
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
}
}
}

View 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)
}
}

View File

@ -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

View File

@ -0,0 +1,47 @@
//
// AuthAPI.swift
// suixinkan
//
import Foundation
@MainActor
/// API configv9
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
)
}
}

View 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)
}
}

View 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
}
}

View File

@ -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 {

View 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
}
}

View File

@ -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
}
}

View File

@ -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?()
}

View 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)
}
}

View 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, "密码错误")))
}
}

View 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))
}
}

View 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)")
}
}
}

View 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)
}
}