// // APIClient.swift - 通用 HTTP 客户端 // import Foundation actor APIClient { static let shared = APIClient() private let session: URLSession private var token: String? private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = APIConfig.timeout config.timeoutIntervalForResource = 60 session = URLSession(configuration: config) } func setToken(_ token: String?) { self.token = token } func getToken() -> String? { return token } // MARK: - Generic request func request( _ path: String, method: String = "GET", body: Encodable? = nil, queryItems: [URLQueryItem]? = nil ) async throws -> T { try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: false) } private func performRequest( _ path: String, method: String, body: Encodable?, queryItems: [URLQueryItem]?, isRetry: Bool ) async throws -> T { var components = URLComponents(url: APIConfig.url(path), resolvingAgainstBaseURL: true)! if let queryItems { components.queryItems = queryItems } var request = URLRequest(url: components.url!) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body { let encoded = try JSONEncoder().encode(AnyEncodable(body)) request.httpBody = encoded print("[APIClient] \(method) \(path) bodySize=\(encoded.count)") } else { print("[APIClient] \(method) \(path)") } let (data, response) = try await session.data(for: request) print("[APIClient] \(method) \(path) -> response received") guard let httpResponse = response as? HTTPURLResponse else { throw APIError.networkError(NSError(domain: "", code: -1)) } switch httpResponse.statusCode { case 200, 201: return try decodeResponse(data) case 401 where !isRetry: if let newToken = await refreshAccessToken() { self.token = newToken return try await performRequest(path, method: method, body: body, queryItems: queryItems, isRetry: true) } let info = extractErrorInfo(data) await notifyTokenExpired(errorCode: info.errorCode) throw decodeServerError(data, fallback: APIError.unauthorized) case 401: let info = extractErrorInfo(data) throw APIError.serverError(info.message ?? "未授权", code: info.errorCode) case 400..<500: let msg = String(data: data, encoding: .utf8) ?? "" throw APIError.serverError(msg) default: throw APIError.requestFailed(httpResponse.statusCode) } } private func refreshAccessToken() async -> String? { guard let refreshToken = KeychainHelper.getRefreshToken() else { return nil } do { let body = RefreshRequest(refreshToken: refreshToken) let resp: AuthResponse = try await performRequest( "/auth/refresh", method: "POST", body: body, queryItems: nil, isRetry: true ) KeychainHelper.save( accessToken: resp.accessToken, refreshToken: resp.refreshToken, userId: resp.user?.id ?? "" ) return resp.accessToken } catch { return nil } } private func notifyTokenExpired(errorCode: String? = nil) async { var userInfo: [AnyHashable: Any]? = nil if let code = errorCode { userInfo = ["errorCode": code] } await MainActor.run { NotificationCenter.default.post(name: .tokenExpired, object: nil, userInfo: userInfo) } } // MARK: - Server error decoding /// 从服务端错误响应中提取 message 和 errorCode func extractErrorInfo(_ data: Data) -> (message: String?, errorCode: String?) { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (nil, nil) } return ( message: json["message"] as? String, errorCode: json["errorCode"] as? String ) } private func decodeServerError(_ data: Data, fallback: APIError) -> APIError { let info = extractErrorInfo(data) if let serverMsg = info.message { return APIError.serverError(serverMsg, code: info.errorCode) } return fallback } } // MARK: - Server error info (public for AuthManager) struct ServerErrorInfo { let message: String let errorCode: String? } extension APIClient { nonisolated func parseErrorInfo(from data: Data) -> ServerErrorInfo? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let msg = json["message"] as? String else { return nil } return ServerErrorInfo(message: msg, errorCode: json["errorCode"] as? String) } // MARK: - Decoding private func decodeResponse(_ data: Data) throws -> T { let decoder = JSONDecoder() // Try unwrapped response first (no envelope), then wrapped if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], json["data"] == nil && json["success"] == nil { return try decoder.decode(T.self, from: data) } // Has envelope wrapper do { let envelope = try decoder.decode(APIEnvelope.self, from: data) return envelope.data } catch { // Fallback: try decoding T directly (e.g. server returns unwrapped on some endpoints) return try decoder.decode(T.self, from: data) } } } // MARK: - Helper for encoding arbitrary Encodable struct AnyEncodable: Encodable { let value: Encodable init(_ value: Encodable) { self.value = value } func encode(to encoder: Encoder) throws { try value.encode(to: encoder) } }