// // CloudStorage.swift // Crush // // Created by Leon on 2025/7/21. // import AWSS3 import Foundation import SwiftDate // MARK: - CloudStorage class CloudStorage { static let shared = CloudStorage() private(set) var uploadAlbumItems: [UploadPhotoM] = [] private var uploadRoleItems: [UploadPhotoM] = [] private var uploadHeadImagesItems: [UploadPhotoM] = [] private var uploadIMImageItems: [UploadPhotoM] = [] private var uploadAudiosItems: [UploadModel] = [] var authAudioData: S3AuthData? var requestAudioSysTokenData: Date? var audioTransferUtility: AWSS3TransferUtility? private init() { NotificationCenter.default.addObserver(self, selector: #selector(receiveNetStatusChanged(_:)), name: AppNotificationName.networkChanged.notificationName, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } private func needCheckImage(for bucket: BucketS3Enum) -> Bool { switch bucket { case .HEAD_IMAGE: // , .ALBUM, .ROLE return true default: return false } } // MARK: - Public Methods func s3BatchAddPhotos(_ photos: [UploadPhotoM], bucket: BucketS3Enum, callback: ((Bool) -> Void)?) { guard !photos.isEmpty else { callback?(false) return } var uploadItems: [Any] = [] switch bucket { case .HEAD_IMAGE: uploadItems = uploadHeadImagesItems case .ALBUM: uploadItems = uploadAlbumItems case .ROLE: uploadItems = uploadRoleItems case .IM_IMG: uploadItems = uploadIMImageItems case .SOUND, .SOUND_PATH: uploadItems = uploadAudiosItems default: callback?(false) return } for photo in photos { photo.isUploading = true photo.bucketEnum = bucket if !uploadItems.contains(where: { $0 as? UploadPhotoM === photo }) { uploadItems.append(photo) } } doS3UploadPhotos(bucket, photos: photos, block: callback) } func s3AddPhotos(_ photos: [UploadPhotoM], bucket: BucketS3Enum) { guard !photos.isEmpty else { return } var uploadItems: [Any] = [] switch bucket { case .HEAD_IMAGE: uploadItems = uploadHeadImagesItems case .ALBUM: uploadItems = uploadAlbumItems case .ROLE: uploadItems = uploadRoleItems case .IM_IMG: uploadItems = uploadIMImageItems case .SOUND, .SOUND_PATH: uploadItems = uploadAudiosItems default: return } for photo in photos { photo.isUploading = true photo.bucketEnum = bucket if !uploadItems.contains(where: { $0 as? UploadPhotoM === photo }) { uploadItems.append(photo) } } doS3UploadPhotos(bucket, photos: photos, block: nil) } func s3AddUploadAudio(_ model: UploadModel, callback: ((Bool) -> Void)?) { guard let fileData = model.fileData, fileData.count > 0 else { assert(false, "fileData should be valid") callback?(false) return } model.isUploading = true if !uploadAudiosItems.contains(where: { $0 === model }) { uploadAudiosItems.append(model) } if judgeTokenValid(requestAudioSysTokenData){ guard let authData = authAudioData, let utility = audioTransferUtility else{ callback?(false) return } dlog("☁️利用之前的token上传Audio: \(model)") self.s3UploadModel(utility, object: model, auth: authData) { result in callback?(result) } }else{ requestAuthToken(.SOUND_PATH, suffix: .mp3) { [weak self] result, authData, utility in guard let self = self, !self.uploadAudiosItems.isEmpty, let s3Utility = utility, let s3AuthData = authData else { callback?(false) return } if result { self.requestAudioSysTokenData = Date() self.s3UploadModel(s3Utility, object: model, auth: s3AuthData) { result in callback?(result) } } else { model.isUploading = false DispatchQueue.main.async { model.mm_uploadFailed() } callback?(false) } } } } func cancelTasks(for bucket: BucketS3Enum) { switch bucket { case .ALBUM: for photo in uploadAlbumItems { cancelTask(for: photo) uploadAlbumItems.removeAll { $0 === photo } } case .HEAD_IMAGE: for photo in uploadHeadImagesItems { cancelTask(for: photo) uploadHeadImagesItems.removeAll { $0 === photo } } case .ROLE: for model in uploadRoleItems { cancelTask(for: model) uploadRoleItems.removeAll { $0 === model } } case .IM_IMG: for model in uploadIMImageItems { cancelTask(for: model) uploadIMImageItems.removeAll { $0 === model } } case .SOUND, .SOUND_PATH: for model in uploadAudiosItems{ cancelTask(for: model) uploadAudiosItems.removeAll {$0 === model} } default: break } } // MARK: - Private Methods private func doS3UploadPhotos(_ bucket: BucketS3Enum, photos: [UploadPhotoM], block: ((Bool) -> Void)?) { guard !photos.isEmpty else { assert(false, "photos count 0") block?(false) return } let enableGCDGroup = block != nil var group: DispatchGroup? var isAllOKInGroup = true if enableGCDGroup { group = DispatchGroup() } var tempPhotos = photos // ⚠️ // let checkImageNeedOfBucket = needCheckImage(for: bucket) for photo in photos { if let tempString = photo.remoteImageUrlString, tempString.count > 0 { tempPhotos.removeAll { $0 === photo } removeObjFromUploadingItems(by: photo) photo.mm_uploadDone(tempString) continue } guard photo.image != nil || photo.imageData != nil else { dlog("❌❌❌: 错误路径,图片应不能为空【CloudStorage】") continue } if enableGCDGroup { group?.enter() } if photo.imageData == nil { photo.imageData = convertImageToData(photo.image!, suffix: photo.suffixEnum) } let sizeInMB = Float(photo.imageData!.count) / (1000.0 * 1000.0) if sizeInMB > 10 { photo.setupPhotoOversizeLimmit() isAllOKInGroup = false DispatchQueue.main.async { let message = photo.suffixEnum == .gif ? "Photo must be .JPG, .JPEG, .GIF or .PNG and cannot exceed 10MB" : "Photo must be .JPG, .JPEG or .PNG and cannot exceed 10MB" photo.setupErrorMsg(message) // Assuming Hud is a utility for showing alerts/toasts // Unknown: Hud ShowPureTip implementation dlog(message) // Placeholder for Hud.ShowPureTip photo.mm_uploadFailed() } if enableGCDGroup { group?.leave() } continue } // && checkImageNeedOfBucket let isCheckImageInCallback = block != nil && photo.isAutoCheckImage if isCheckImageInCallback { photo.banCheckImageInDelegateMethod = true } let suffixType = photo.imageData != nil ? photo.suffixEnum : .jpeg requestAuthToken(bucket, suffix: suffixType) { [weak self] result, authData, utility in guard let self = self, let s3AuthData = authData, let s3Utility = utility else { return } if result { self.s3Upload(s3Utility, photoM: photo, authData: s3AuthData) { success in photo.isUploading = false if success { if isCheckImageInCallback { photo.checkImageOK { result2 in if !result2 { // 鉴定失败❌ isAllOKInGroup = false } if enableGCDGroup { group?.leave() } } } else { // 不需要鉴黄,直接返回 if enableGCDGroup { group?.leave() } } } else { // 上传失败❌ isAllOKInGroup = false if enableGCDGroup { group?.leave() } } } } else { // 获取token失败❌ isAllOKInGroup = false if enableGCDGroup { group?.leave() } DispatchQueue.main.async { photo.mm_uploadFailed() } } } } if enableGCDGroup, let validGroup = group { // DispatchQueue.main.async(group: group) { validGroup.notify(queue: .main) { block?(isAllOKInGroup) } } } private func s3Upload(_ utility: AWSS3TransferUtility, photoM im: UploadPhotoM, authData: S3AuthData, result: @escaping (Bool) -> Void) { guard !authData.path.isEmpty else { DispatchQueue.main.async { dlog("☁️❌authData.path is empty") im.mm_uploadFailed() result(false) } return } if LTNetworkManage.ltManage.reachable == false { // Placeholder for [EGApiManager shared].status == .notReachable DispatchQueue.main.async { im.mm_uploadFailed() result(false) } return } im.s3AuthData = authData im.utility = utility let key = authData.path dlog("☁️uplading key: \(key)") let expression = AWSS3TransferUtilityUploadExpression() expression.progressBlock = { _, progress in dlog("🚀 \(key) taskprogress: \(progress)") } expression.setValue("temp=1", forRequestHeader: "x-amz-tagging") guard let imageData = convertImageToData(im.image!, suffix: im.suffixEnum) ?? im.imageData else { return } let contentType = im.contentType() utility.uploadData(imageData, bucket: authData.bucket, key: key, contentType: contentType, expression: expression) { task, error in self.removeObjFromUploadingItems(by: im) if let error = error { dlog("❌S3上传任务失败: \(error.localizedDescription)") DispatchQueue.main.async { im.mm_uploadFailed() result(false) } return } switch task.status { case .completed: im.remoteFullPath = authData.urlPath im.remoteImageUrlString = key dlog("💹 s3 image uploaded full: \(im.remoteFullPath ?? "")") DispatchQueue.main.async { im.mm_uploadDone(key) result(true) } case .error, .cancelled: DispatchQueue.main.async { im.mm_uploadFailed() result(false) } default: assert(false, "上传异常分支、需要查查原因") } } } private func s3UploadModel(_ utility: AWSS3TransferUtility, object model: UploadModel, auth: S3AuthData, result: @escaping (Bool) -> Void) { guard !auth.path.isEmpty, let fileData = model.fileData else { DispatchQueue.main.async { model.mm_uploadFailed() } result(false) return } // Assuming EGApiManager is a network status manager // Unknown: EGApiManager shared status implementation if false { // Placeholder for [EGApiManager shared].status == .notReachable DispatchQueue.main.async { model.mm_uploadFailed() result(false) } return } model.s3AuthData = auth model.utility = utility var key = auth.path /// dev/main/sound/0/* if key.hasSuffix("/*") { // 移除最后的 "/*" key.removeLast(2) // 拼接时间戳 + .mp3 key += "/\(model.addThisItemTimeStamp).mp3" } var urlPath = auth.urlPath ?? "" if urlPath.hasSuffix("/*") { // 移除最后的 "/*" urlPath.removeLast(2) // 拼接时间戳 + .mp3 urlPath += "/\(model.addThisItemTimeStamp).mp3" } dlog("☁️uplading file key: \(key) full: \(urlPath)") let expression = AWSS3TransferUtilityUploadExpression() expression.progressBlock = { _, progress in dlog("🚀 \(key) file taskprogress: \(progress)") } expression.setValue("temp=1", forRequestHeader: "x-amz-tagging") let contentType = model.contentType() utility.uploadData(fileData, bucket: auth.bucket, key: key, contentType: contentType, expression: expression) { task, error in self.uploadAudiosItems.removeAll { $0 === model } if let error = error { dlog("❌S3上传任务失败: \(error.localizedDescription)") result(false) return } switch task.status { case .completed: model.remoteFullPath = urlPath // auth.urlPath model.remoteImageUrlString = key dlog("💹 s3 file uploaded full: \(model.remoteFullPath ?? "")") DispatchQueue.main.async { model.mm_uploadDone(key) result(true) } case .error, .cancelled: DispatchQueue.main.async { model.mm_uploadFailed() result(false) } default: assert(false, "上传异常分支、需要查查原因") } } } private func requestAuthToken(_ bucket: BucketS3Enum, suffix: SuffixS3Enum, callback: @escaping (Bool, S3AuthData?, AWSS3TransferUtility?) -> Void) { OssProvider.request(.getS3Token(bucketNameEnum: bucket, suffix: suffix), modelType: S3AuthData.self) {[weak self] result in switch result { case let .success(success): if let authData = success { let utility = self?.generateTransferUtility(bucket, authData: authData) callback(true, authData, utility) if bucket == .SOUND_PATH{ self?.authAudioData = success } } else { callback(false, nil, nil) } case .failure: callback(false, nil, nil) } } } private func generateTransferUtility(_ bucket: BucketS3Enum, authData: S3AuthData) -> AWSS3TransferUtility? { guard let accessKeyId = authData.accessKeyId, !accessKeyId.isEmpty, let accessKeySecret = authData.accessKeySecret, !accessKeySecret.isEmpty, let securityToken = authData.securityToken, !securityToken.isEmpty else { assert(false, "invalid token") return nil } /// Region(⚠️String) from server // let region = authData.region let provider = AWSBasicSessionCredentialsProvider(accessKey: accessKeyId, secretKey: accessKeySecret, sessionToken: securityToken) // ⚠️(Integer)支持哪些region,需要转化。目前强制 .USWest2 let regionType: AWSRegionType = .USWest2; guard let config = AWSServiceConfiguration(region: regionType, credentialsProvider: provider) else { assert(false, "Invalid AWSServiceConfiguration") return nil } config.timeoutIntervalForRequest = 30 config.maxRetryCount = 1 let key = authData.fileName AWSS3TransferUtility.remove(forKey: key) // AWSS3TransferUtility.removeS3TransferUtility(forKey: key) AWSS3TransferUtility.register(with: config, forKey: key) let utility = AWSS3TransferUtility.s3TransferUtility(forKey: key) // AWSS3TransferUtility(forKey: key) if utility == nil { assert(false, "invalid utility") } switch bucket{ case .SOUND_PATH: audioTransferUtility = utility default: break } return utility } private func judgeTokenValid(_ lastRequestDate: Date?) -> Bool { guard let date = lastRequestDate else { return false } let expireDate = date + 59.minutes if expireDate.milliStamp > Date().milliStamp { return true } return false } private func removeObjFromUploadingItems(by obj: UploadPhotoM) { uploadAlbumItems.removeAll { $0 === obj } uploadHeadImagesItems.removeAll { $0 === obj } uploadRoleItems.removeAll { $0 === obj } uploadAudiosItems.removeAll { $0 === obj } } private func cancelTask(for item: T) { // Unknown: AWSTask and AWSS3TransferUtilityUploadTask implementation if let utility = item.utility { // Placeholder for task cancellation dlog("🈲🈲, upload task canceled : \(utility)") DispatchQueue.main.async { item.mm_uploadFailed() } } } // MARK: - Notification Handling @objc private func receiveNetStatusChanged(_ notification: Notification) { let status = LTNetworkManage.ltManage.status if status == .notReachable { cancelTasks(for: .ALBUM) cancelTasks(for: .HEAD_IMAGE) cancelTasks(for: .ROLE) cancelTasks(for: .SOUND) cancelTasks(for: .IM_IMG) } } // MARK: - Helper Methods private func convertImageToData(_ image: UIImage, suffix: SuffixS3Enum) -> Data? { // Unknown: SDWebImage or UIImage conversion implementation switch suffix { case .gif: assert(false, "imageData应该不为空") return nil // return image.sd_imageData(as: .gif) // Placeholder case .png: return image.pngData() default: return image.jpegData(compressionQuality: 0.8) } } }