// // APIClient.swift // suixinkan // import Foundation /// URLSession 抽象协议,用于让网络客户端支持测试替身。 protocol URLSessionProtocol { /// 发起 URLRequest 并返回原始数据和响应。 func data(for request: URLRequest) async throws -> (Data, URLResponse) } extension URLSession: URLSessionProtocol {} @MainActor /// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。 final class APIClient { private let session: URLSessionProtocol private let encoder: JSONEncoder private let decoder: JSONDecoder private var authTokenProvider: (() -> String?)? private let environment: APIEnvironment private let appVersion: String private let osType: String /// 初始化网络客户端及其编码、解码和环境配置。 init( environment: APIEnvironment = .current, session: URLSessionProtocol = APIClient.defaultSession, encoder: JSONEncoder = JSONEncoder(), decoder: JSONDecoder = JSONDecoder(), appVersion: String = AppClientInfo.appVersion(), osType: String = AppClientInfo.osType ) { self.environment = environment self.session = session self.encoder = encoder self.decoder = decoder self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0" self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType } nonisolated private static let defaultSession: URLSession = { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 12 configuration.timeoutIntervalForResource = 20 configuration.waitsForConnectivity = false return URLSession(configuration: configuration) }() /// 绑定 token 提供者,让业务 API 不需要直接持有登录状态。 func bindAuthTokenProvider(_ provider: @escaping () -> String?) { authTokenProvider = provider } /// 发送强类型 APIRequest,并返回解包后的业务数据。 func send( _ apiRequest: APIRequest, tokenOverride: String? = nil ) async throws -> Response { let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride) logRequest(request) let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch is CancellationError { logCancelled(for: request, reason: "CancellationError") throw CancellationError() } catch let error as URLError { if error.code == .cancelled { logCancelled(for: request, reason: "URLError.cancelled") throw CancellationError() } throw APIError.networkFailed(networkErrorMessage(for: error)) } catch { throw APIError.networkFailed(error.localizedDescription) } logResponse(for: request, response: response, data: data) do { try validateHTTPResponse(response, data: data) return try decodeEnvelope(Response.self, from: data) } catch let error as APIError { notifySessionExpiredIfNeeded(for: error) throw error } } /// 发送原始 JSON 字符串请求体(对齐 Android `TextContent` 裸 JSON POST)。 func sendRawJSON( method: HTTPMethod, path: String, jsonBody: String, tokenOverride: String? = nil ) async throws -> Response { let apiPath = path.hasPrefix("/") ? path : "/" + path guard let url = URL( string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath ) else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = 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") let token = tokenOverride ?? authTokenProvider?() if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { request.setValue(token, forHTTPHeaderField: "token") } request.httpBody = jsonBody.data(using: .utf8) 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 } } /// 发送单文件 multipart/form-data 请求,并返回解包后的业务数据。 func sendMultipart( path: String, fieldName: String = "file", fileName: String, mimeType: String, data fileData: Data, timeoutInterval: TimeInterval = 60, tokenOverride: String? = nil ) async throws -> Response { let apiPath = path.hasPrefix("/") ? path : "/" + path guard let url = URL( string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath ) else { throw APIError.invalidURL } let boundary = "Boundary-\(UUID().uuidString)" var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append( "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n" .data(using: .utf8)! ) body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) body.append(fileData) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) var request = URLRequest(url: url) request.httpMethod = HTTPMethod.post.rawValue request.timeoutInterval = timeoutInterval request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION") request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE") request.setValue(String(body.count), forHTTPHeaderField: "Content-Length") let token = tokenOverride ?? authTokenProvider?() if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { request.setValue(token, forHTTPHeaderField: "token") } request.httpBody = body logRequest(request) let responseData: Data let response: URLResponse do { (responseData, 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: responseData) do { try validateHTTPResponse(response, data: responseData) return try decodeEnvelope(Response.self, from: responseData) } catch let error as APIError { notifySessionExpiredIfNeeded(for: error) throw error } } /// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。 private func makeURLRequest( _ apiRequest: APIRequest, tokenOverride: String? ) throws -> URLRequest { let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path guard var components = URLComponents( string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path ) else { throw APIError.invalidURL } if !apiRequest.queryItems.isEmpty { components.queryItems = apiRequest.queryItems } guard let url = components.url else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = apiRequest.method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION") request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE") apiRequest.headers.forEach { key, value in request.setValue(value, forHTTPHeaderField: key) } let token = tokenOverride ?? authTokenProvider?() if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { request.setValue(token, forHTTPHeaderField: "token") } if let body = apiRequest.body { request.httpBody = try encoder.encode(body) } return request } /// 校验 HTTP 层响应状态码,非 2xx 时提取错误信息。 private func validateHTTPResponse(_ response: URLResponse, data: Data) throws { guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse } guard 200 ..< 300 ~= httpResponse.statusCode else { throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data)) } } /// 解码后端统一 Envelope,并返回内部 data 数据。 private func decodeEnvelope(_ responseType: Response.Type, from data: Data) throws -> Response { let envelope: APIEnvelope do { envelope = try decoder.decode(APIEnvelope.self, from: data) } catch { throw APIError.decodeFailed(error.localizedDescription) } guard envelope.isSuccess else { throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败") } if responseType == EmptyPayload.self { return EmptyPayload() as! Response } guard let payload = envelope.data else { throw APIError.emptyData } return payload } /// Token 失效时广播 sessionDidExpire,触发全局登出。 private func notifySessionExpiredIfNeeded(for error: APIError) { guard APIError.isAuthenticationExpired(error) else { return } NotificationCenter.default.post(name: NotificationName.sessionDidExpire, object: nil) } /// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。 private func parseHTTPErrorMessage(data: Data) -> String { if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) { if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { return msg } if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { return message } if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty { return error } } if let plainText = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !plainText.isEmpty { return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText } return "服务端返回错误" } /// 将 URLError 转换成中文网络错误提示。 private func networkErrorMessage(for error: URLError) -> String { switch error.code { case .timedOut: "请求超时,请稍后重试" case .notConnectedToInternet: "网络不可用,请检查网络连接" case .networkConnectionLost: "网络连接中断,请重试" case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed: "无法连接服务器,请稍后重试" default: error.localizedDescription } } /// 在 Debug 环境打印请求信息(含 GET 查询参数与 POST 请求体,对齐 Android Ktor `LogLevel.BODY`)。 private func logRequest(_ request: URLRequest) { #if DEBUG let method = request.httpMethod ?? "REQUEST" let url = request.url?.absoluteString ?? "" var lines = ["[API][Request] \(method) \(url)"] if let queryItems = URLComponents(url: request.url ?? URL(fileURLWithPath: "/"), resolvingAgainstBaseURL: false)?.queryItems, !queryItems.isEmpty { let params = queryItems .map { item in let value = item.value ?? "" return "\(item.name)=\(value)" } .joined(separator: "&") lines.append("params: \(params)") } let contentType = request.value(forHTTPHeaderField: "Content-Type") if let body = Self.debugRequestBody(from: request.httpBody, contentType: contentType) { lines.append("body:\n\(body)") } print(lines.joined(separator: "\n")) #endif } /// 在 Debug 环境打印响应状态和响应体。 private func logResponse(for request: URLRequest, response: URLResponse, data: Data) { #if DEBUG let method = request.httpMethod ?? "REQUEST" let url = request.url?.absoluteString ?? "" let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown" let body = Self.debugResponseBody(from: data) print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)") #endif } /// 在 Debug 环境打印被取消的请求信息。 private func logCancelled(for request: URLRequest, reason: String) { #if DEBUG let method = request.httpMethod ?? "REQUEST" let url = request.url?.absoluteString ?? "" print("[API][Cancelled] \(method) \(url) reason=\(reason)") #endif } #if DEBUG /// 将请求体格式化为便于调试阅读的字符串。 private static func debugRequestBody(from data: Data?, contentType: String?) -> String? { guard let data, !data.isEmpty else { return nil } if let contentType, contentType.lowercased().contains("multipart/form-data") { return "" } if let object = try? JSONSerialization.jsonObject(with: data), let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), let prettyJSON = String(data: prettyData, encoding: .utf8) { return prettyJSON } if let text = String(data: data, encoding: .utf8), !text.isEmpty { return text.count > 2_000 ? String(text.prefix(2_000)) + "..." : text } return "" } /// 将响应体格式化为便于调试阅读的字符串。 private static func debugResponseBody(from data: Data) -> String { guard !data.isEmpty else { return "" } if let object = try? JSONSerialization.jsonObject(with: data), let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), let prettyJSON = String(data: prettyData, encoding: .utf8) { return prettyJSON } return String(data: data, encoding: .utf8) ?? "" } #endif } private extension String { var nonEmpty: String? { isEmpty ? nil : self } }