import Foundation import Security enum HTTPMethod: String { case get = "GET" case post = "POST" case put = "PUT" case delete = "DELETE" } struct APIEnvelope: Decodable { let code: Int let message: String let data: T? } struct EmptyAPIData: Decodable { } enum APIError: LocalizedError { case invalidConfiguration case invalidResponse case unauthorized case server(code: Int, message: String) case transport(String) case decoding(String) case missingData var errorDescription: String? { switch self { case .invalidConfiguration: return "服务地址配置无效" case .invalidResponse: return "服务器返回了无效响应" case .unauthorized: return "登录状态已失效,请重新登录" case .server(_, let message): return message case .transport(let message): return "网络连接失败:\(message)" case .decoding(let message): return "服务器数据解析失败:\(message)" case .missingData: return "服务器响应缺少必要数据" } } } struct AuthTokens: Codable, Sendable { let accessToken: String let refreshToken: String let expiresAt: Date } final class TokenStore: @unchecked Sendable { static let shared = TokenStore() private let service = "com.celestia.trace.authentication" private let account = "current-session" func load() -> AuthTokens? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var result: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil } return try? JSONDecoder().decode(AuthTokens.self, from: data) } func save(_ tokens: AuthTokens) throws { let data = try JSONEncoder().encode(tokens) let baseQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] let status = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) if status == errSecItemNotFound { var insert = baseQuery attributes.forEach { insert[$0.key] = $0.value } guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else { throw APIError.transport("无法安全保存登录凭据") } } else if status != errSecSuccess { throw APIError.transport("无法更新登录凭据") } } func clear() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account ] SecItemDelete(query as CFDictionary) } } private struct RefreshPayload: Decodable { let token: String let refreshToken: String let expiresAt: Date } actor APIClient { static let shared = APIClient() private let baseURL: URL private let session: URLSession private let tokenStore: TokenStore init( baseURL: URL? = nil, session: URLSession? = nil, tokenStore: TokenStore = .shared ) { let configured = baseURL ?? (Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String).flatMap(URL.init(string:)) self.baseURL = configured ?? URL(string: "https://celestia-trace.ccdw.life/v1")! if let session { self.session = session } else { let configuration = URLSessionConfiguration.default configuration.waitsForConnectivity = true configuration.timeoutIntervalForRequest = 30 configuration.timeoutIntervalForResource = 300 self.session = URLSession(configuration: configuration) } self.tokenStore = tokenStore } func hasStoredSession() -> Bool { tokenStore.load() != nil } func storeTokens(accessToken: String, refreshToken: String, expiresAt: Date) throws { try tokenStore.save(AuthTokens(accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt)) } func clearSession() { tokenStore.clear() } func request( _ path: String, method: HTTPMethod = .get, body: Data? = nil, authenticated: Bool = false ) async throws -> T { let envelope: APIEnvelope = try await execute( path, method: method, body: body, authenticated: authenticated, canRefresh: true ) guard let data = envelope.data else { throw APIError.missingData } return data } func requestVoid( _ path: String, method: HTTPMethod, body: Data? = nil, authenticated: Bool = false ) async throws { let _: APIEnvelope = try await execute( path, method: method, body: body, authenticated: authenticated, canRefresh: true ) } func upload( _ path: String, fileURL: URL, fileName: String, mimeType: String, fields: [String: String] ) async throws -> T { do { return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields) } catch APIError.unauthorized { try await refreshTokens() return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields) } } func download(_ path: String, to destinationURL: URL) async throws { do { try await performDownload(path, to: destinationURL) } catch APIError.unauthorized { try await refreshTokens() try await performDownload(path, to: destinationURL) } } private func execute( _ path: String, method: HTTPMethod, body: Data?, authenticated: Bool, canRefresh: Bool ) async throws -> APIEnvelope { var request = try makeRequest(path: path, method: method, body: body) if authenticated { guard let accessToken = tokenStore.load()?.accessToken else { throw APIError.unauthorized } request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } let data: Data let response: URLResponse do { (data, response) = try await session.data(for: request) } catch { throw APIError.transport(error.localizedDescription) } guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } if http.statusCode == 401, authenticated, canRefresh { try await refreshTokens() return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false) } let envelope: APIEnvelope do { envelope = try Self.decoder.decode(APIEnvelope.self, from: data) } catch { throw APIError.decoding(error.localizedDescription) } guard (200..<300).contains(http.statusCode), envelope.code == 0 else { if http.statusCode == 401 { throw APIError.unauthorized } throw APIError.server(code: envelope.code, message: envelope.message) } return envelope } private func refreshTokens() async throws { guard let current = tokenStore.load() else { throw APIError.unauthorized } let body = try JSONSerialization.data(withJSONObject: ["refreshToken": current.refreshToken]) let envelope: APIEnvelope = try await execute( "auth/refresh", method: .post, body: body, authenticated: false, canRefresh: false ) guard let payload = envelope.data else { throw APIError.unauthorized } try tokenStore.save(AuthTokens(accessToken: payload.token, refreshToken: payload.refreshToken, expiresAt: payload.expiresAt)) } private func performUpload( _ path: String, fileURL: URL, fileName: String, mimeType: String, fields: [String: String] ) async throws -> T { guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized } let boundary = "CelestiaBoundary-\(UUID().uuidString)" let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".upload") guard FileManager.default.createFile(atPath: temporaryURL.path, contents: nil), let output = try? FileHandle(forWritingTo: temporaryURL) else { throw APIError.transport("无法准备上传文件") } defer { try? output.close() try? FileManager.default.removeItem(at: temporaryURL) } for (key, value) in fields.sorted(by: { $0.key < $1.key }) { try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8)) } try output.write(contentsOf: Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\nContent-Type: \(mimeType)\r\n\r\n".utf8)) let input = try FileHandle(forReadingFrom: fileURL) while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty { try output.write(contentsOf: chunk) } try input.close() try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8)) try output.synchronize() var request = try makeRequest(path: path, method: .post, body: nil) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") let data: Data let response: URLResponse do { (data, response) = try await session.upload(for: request, fromFile: temporaryURL) } catch { throw APIError.transport(error.localizedDescription) } guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } if http.statusCode == 401 { throw APIError.unauthorized } let envelope: APIEnvelope do { envelope = try Self.decoder.decode(APIEnvelope.self, from: data) } catch { throw APIError.decoding(error.localizedDescription) } guard (200..<300).contains(http.statusCode), envelope.code == 0 else { throw APIError.server(code: envelope.code, message: envelope.message) } guard let result = envelope.data else { throw APIError.missingData } return result } private func performDownload(_ path: String, to destinationURL: URL) async throws { guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized } var request = try makeRequest(path: path, method: .get, body: nil) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let temporaryURL: URL let response: URLResponse do { (temporaryURL, response) = try await session.download(for: request) } catch { throw APIError.transport(error.localizedDescription) } guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse } if http.statusCode == 401 { throw APIError.unauthorized } guard (200..<300).contains(http.statusCode) else { throw APIError.server(code: http.statusCode, message: "云端文件下载失败") } let fileManager = FileManager.default try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) if fileManager.fileExists(atPath: destinationURL.path) { return } do { try fileManager.moveItem(at: temporaryURL, to: destinationURL) } catch { throw APIError.transport("无法保存云端文件:\(error.localizedDescription)") } } private func makeRequest(path: String, method: HTTPMethod, body: Data?) throws -> URLRequest { let pathAndQuery = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false) let cleanPath = String(pathAndQuery[0]).trimmingCharacters(in: CharacterSet(charactersIn: "/")) let pathURL = cleanPath.split(separator: "/").reduce(baseURL) { partial, component in partial.appendingPathComponent(String(component)) } var components = URLComponents(url: pathURL, resolvingAgainstBaseURL: false) if pathAndQuery.count == 2 { components?.percentEncodedQuery = String(pathAndQuery[1]) } guard let url = components?.url else { throw APIError.invalidConfiguration } var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Accept") if let body { request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") } return request } static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let value = try container.decode(String.self) let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: value) { return date } formatter.formatOptions = [.withInternetDateTime] if let date = formatter.date(from: value) { return date } throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date") } return decoder }() }