import SwiftUI import PhotosUI /// 处理图片上传到远程服务器的类 /// 支持上传图片并获取服务器返回的file_id public class ImageUploaderGetID: ObservableObject { // MARK: - 类型定义 /// 上传结果 public struct UploadResult: Codable { public let fileUrl: String public let fileName: String public let fileSize: Int public let fileId: String public let previewFileId: String? public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String, previewFileId: String? = nil) { self.fileUrl = fileUrl self.fileName = fileName self.fileSize = fileSize self.fileId = fileId self.previewFileId = previewFileId } } /// 上传过程中可能发生的错误 public enum UploadError: LocalizedError { case invalidImageData case invalidURL case serverError(String) case invalidResponse case uploadFailed(Error?) case invalidFileId case invalidResponseData public var errorDescription: String? { switch self { case .invalidImageData: return "无效的图片数据" case .invalidURL: return "无效的URL" case .serverError(let message): return "服务器错误: \(message)" case .invalidResponse: return "无效的服务器响应" case .uploadFailed(let error): return "上传失败: \(error?.localizedDescription ?? "未知错误")" case .invalidFileId: return "无效的文件ID" case .invalidResponseData: return "无效的响应数据" } } } // MARK: - 属性 private let session: URLSession private let apiConfig: APIConfig.Type // MARK: - 初始化方法 /// 初始化方法 /// - Parameters: /// - session: 可选的URLSession,用于测试依赖注入 /// - apiConfig: 可选的API配置,用于测试依赖注入 public init(session: URLSession = .shared, apiConfig: APIConfig.Type = APIConfig.self) { self.session = session self.apiConfig = apiConfig } // MARK: - 公开方法 /// 上传图片到服务器 /// - Parameters: /// - image: 要上传的图片 /// - progress: 上传进度回调 (0.0 到 1.0) /// - completion: 完成回调 public func uploadImage( _ image: UIImage, progress: @escaping (Double) -> Void, completion: @escaping (Result) -> Void ) { print("🔄 开始准备上传图片...") // 1. 转换图片为Data guard let imageData = image.jpegData(compressionQuality: 0.7) else { let error = UploadError.invalidImageData print("❌ 错误:\(error.localizedDescription)") completion(.failure(error)) return } // 2. 获取上传URL getUploadURL( for: imageData, mimeType: "image/jpeg", originalFilename: "image_\(UUID().uuidString).jpg" ) { [weak self] result in switch result { case .success((let fileId, let uploadURL)): print("📤 获取到上传URL,开始上传文件...") // 3. 上传文件 _ = self?.uploadFile( fileData: imageData, to: uploadURL, mimeType: "image/jpeg", onProgress: { uploadProgress in print("📊 上传进度: \(Int(uploadProgress * 100))%") progress(uploadProgress) }, completion: { uploadResult in switch uploadResult { case .success: // 4. 确认上传 self?.confirmUpload( fileId: fileId, fileName: "image_\(UUID().uuidString).jpg", fileSize: imageData.count, completion: completion ) case .failure(let error): print("❌ 文件上传失败: \(error.localizedDescription)") completion(.failure(error)) } } ) case .failure(let error): print("❌ 获取上传URL失败: \(error.localizedDescription)") completion(.failure(error)) } } } // MARK: - Video Upload /// 上传视频文件到服务器 /// - Parameters: /// - videoURL: 要上传的视频文件URL /// - progress: 上传进度回调 (0.0 到 1.0) /// - completion: 完成回调 public func uploadVideo( _ videoURL: URL, progress: @escaping (Double) -> Void, completion: @escaping (Result) -> Void ) { print("🔄 开始准备上传视频...") // 1. 读取视频文件数据 do { let videoData = try Data(contentsOf: videoURL) let fileExtension = videoURL.pathExtension.lowercased() let mimeType: String // 根据文件扩展名设置MIME类型 switch fileExtension { case "mp4": mimeType = "video/mp4" case "mov": mimeType = "video/quicktime" case "m4v": mimeType = "video/x-m4v" case "avi": mimeType = "video/x-msvideo" default: mimeType = "video/mp4" // 默认使用mp4 } // 2. 获取上传URL getUploadURL( for: videoData, mimeType: mimeType, originalFilename: videoURL.lastPathComponent ) { [weak self] result in switch result { case .success((let fileId, let uploadURL)): print("📤 获取到视频上传URL,开始上传文件...") // 3. 上传文件 _ = self?.uploadFile( fileData: videoData, to: uploadURL, mimeType: mimeType, onProgress: progress, completion: { result in switch result { case .success: // 4. 确认上传完成 self?.confirmUpload( fileId: fileId, fileName: videoURL.lastPathComponent, fileSize: videoData.count, completion: completion ) case .failure(let error): completion(.failure(error)) } } ) case .failure(let error): completion(.failure(error)) } } } catch { print("❌ 读取视频文件失败: \(error.localizedDescription)") completion(.failure(error)) } } // MARK: - 私有方法 /// 获取上传URL private func getUploadURL( for fileData: Data, mimeType: String, originalFilename: String? = nil, completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void ) { let fileName = originalFilename ?? "file_\(UUID().uuidString)" let parameters: [String: Any] = [ "filename": fileName, "content_type": mimeType, "file_size": fileData.count ] let urlString = "\(apiConfig.baseURL)/file/generate-upload-url" print("🌐 准备请求上传URL...") print(" - 目标URL: \(urlString)") print(" - 文件名: \(fileName)") print(" - 文件大小: \(Double(fileData.count) / 1024.0) KB") print(" - MIME类型: \(mimeType)") guard let url = URL(string: urlString) else { print("❌ 错误: 无效的URL: \(urlString)") completion(.failure(UploadError.invalidURL)) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.allHTTPHeaderFields = apiConfig.authHeaders do { request.httpBody = try JSONSerialization.data(withJSONObject: parameters) if let bodyString = String(data: request.httpBody ?? Data(), encoding: .utf8) { print("📤 请求体: \(bodyString)") } } catch { print("❌ 序列化请求参数失败: \(error.localizedDescription)") completion(.failure(error)) return } let task = session.dataTask(with: request) { data, response, error in if let error = error { print("❌ 获取上传URL请求失败: \(error.localizedDescription)") completion(.failure(UploadError.uploadFailed(error))) return } guard let httpResponse = response as? HTTPURLResponse else { print("❌ 无效的服务器响应") completion(.failure(UploadError.invalidResponse)) return } print("📥 收到上传URL响应") print(" - 状态码: \(httpResponse.statusCode)") guard let data = data else { print("❌ 响应数据为空") completion(.failure(UploadError.invalidResponse)) return } // 打印响应头 print(" - 响应头: \(httpResponse.allHeaderFields)") // 打印响应体 if let responseString = String(data: data, encoding: .utf8) { print(" - 响应体: \(responseString)") } do { let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] print(" - 解析的JSON: \(String(describing: json))") guard let code = json?["code"] as? Int, code == 0 else { let errorMessage = json?["message"] as? String ?? "未知错误" print("❌ 服务器返回错误: \(errorMessage)") completion(.failure(UploadError.serverError(errorMessage))) return } guard let dataDict = json?["data"] as? [String: Any], let fileId = dataDict["file_id"] as? String, let uploadURLString = dataDict["upload_url"] as? String, let uploadURL = URL(string: uploadURLString) else { print("❌ 响应数据格式错误") completion(.failure(UploadError.invalidResponse)) return } print("✅ 成功获取上传URL") print(" - 文件ID: \(fileId)") print(" - 上传URL: \(uploadURLString)") completion(.success((fileId: fileId, uploadURL: uploadURL))) } catch { print("❌ 解析响应数据失败: \(error.localizedDescription)") completion(.failure(UploadError.invalidResponse)) } } task.resume() } /// 确认上传 private func confirmUpload( fileId: String, fileName: String, fileSize: Int, completion: @escaping (Result) -> Void ) { let endpoint = "\(apiConfig.baseURL)/file/confirm-upload" guard let url = URL(string: endpoint) else { completion(.failure(UploadError.invalidURL)) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.allHTTPHeaderFields = apiConfig.authHeaders let body: [String: Any] = [ "file_id": fileId, "file_name": fileName, "file_size": fileSize ] do { request.httpBody = try JSONSerialization.data(withJSONObject: body) print("📤 确认上传请求,fileId: \(fileId), 文件名: \(fileName)") } catch { print("❌ 序列化确认上传参数失败: \(error.localizedDescription)") completion(.failure(error)) return } let task = session.dataTask(with: request) { data, response, error in if let error = error { print("❌ 确认上传请求失败: \(error.localizedDescription)") completion(.failure(UploadError.uploadFailed(error))) return } guard let httpResponse = response as? HTTPURLResponse else { print("❌ 无效的服务器响应") completion(.failure(UploadError.invalidResponse)) return } guard (200...299).contains(httpResponse.statusCode) else { let statusCode = httpResponse.statusCode let errorMessage = "确认上传失败,状态码: \(statusCode)" print("❌ \(errorMessage)") completion(.failure(UploadError.serverError(errorMessage))) return } // 创建上传结果 let uploadResult = UploadResult( fileUrl: "\(self.apiConfig.baseURL)/files/\(fileId)", fileName: fileName, fileSize: fileSize, fileId: fileId ) print("✅ 图片上传并确认成功,fileId: \(fileId)") completion(.success(uploadResult)) } task.resume() } /// 上传文件到指定URL public func uploadFile( fileData: Data, to uploadURL: URL, mimeType: String, onProgress: @escaping (Double) -> Void, completion: @escaping (Result) -> Void ) -> URLSessionUploadTask { print("📤 开始上传文件...") var request = URLRequest(url: uploadURL) request.httpMethod = "PUT" request.setValue(mimeType, forHTTPHeaderField: "Content-Type") let task = session.uploadTask( with: request, from: fileData ) { data, response, error in if let error = error { print("❌ 文件上传失败: \(error.localizedDescription)") completion(.failure(UploadError.uploadFailed(error))) return } guard let httpResponse = response as? HTTPURLResponse else { print("❌ 无效的响应") completion(.failure(UploadError.invalidResponse)) return } guard (200...299).contains(httpResponse.statusCode) else { let statusCode = httpResponse.statusCode print("❌ 服务器返回错误状态码: \(statusCode)") completion(.failure(UploadError.serverError("HTTP \(statusCode)"))) return } print("✅ 文件上传成功") completion(.success(())) } // 添加进度观察 let progressObserver = task.progress.observe(\.fractionCompleted) { progress, _ in let percentComplete = progress.fractionCompleted print("📊 文件上传进度: \(Int(percentComplete * 100))%") onProgress(percentComplete) } // 存储观察者以避免提前释放 objc_setAssociatedObject(task, &AssociatedKeys.progressObserver, progressObserver, .OBJC_ASSOCIATION_RETAIN) task.resume() return task } private struct AssociatedKeys { static var progressObserver = "progressObserver" } // MARK: - 文件上传状态 /// 文件上传状态 public struct FileStatus { public let file: Data public var status: UploadStatus public var progress: Double public enum UploadStatus { case pending case uploading case completed case failed(Error) } public init(file: Data, status: UploadStatus = .pending, progress: Double = 0) { self.file = file self.status = status self.progress = progress } } } // MARK: - URLSessionTask 扩展 private class TaskObserver: NSObject { private weak var task: URLSessionTask? private var handlers: [() -> Void] = [] init(task: URLSessionTask) { self.task = task super.init() task.addObserver(self, forKeyPath: #keyPath(URLSessionTask.state), options: .new, context: nil) } func addHandler(_ handler: @escaping () -> Void) { handlers.append(handler) } override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer? ) { guard keyPath == #keyPath(URLSessionTask.state), let task = task, task.state == .completed else { return } // 调用所有完成处理器 DispatchQueue.main.async { [weak self] in self?.handlers.forEach { $0() } self?.cleanup() } } private func cleanup() { task?.removeObserver(self, forKeyPath: #keyPath(URLSessionTask.state)) handlers.removeAll() } deinit { cleanup() } } private extension URLSessionTask { private static var taskObserverKey: UInt8 = 0 private var taskObserver: TaskObserver? { get { return objc_getAssociatedObject(self, &Self.taskObserverKey) as? TaskObserver } set { objc_setAssociatedObject(self, &Self.taskObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } func addCompletionHandler(_ handler: @escaping () -> Void) { if #available(iOS 11.0, *) { if let observer = taskObserver { observer.addHandler(handler) } else { let observer = TaskObserver(task: self) observer.addHandler(handler) taskObserver = observer } } else { // iOS 11 以下版本使用通知 let name = NSNotification.Name("TaskCompleted\(self.taskIdentifier)") NotificationCenter.default.addObserver( forName: name, object: self, queue: .main ) { _ in handler() } } } } // MARK: - 响应模型 struct UploadURLResponse: Codable { let code: Int let message: String let data: UploadData struct UploadData: Codable { let fileId: String let uploadUrl: String enum CodingKeys: String, CodingKey { case fileId = "file_id" case uploadUrl = "upload_url" } } }