562 lines
20 KiB
Swift
562 lines
20 KiB
Swift
//
|
||
// 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<T: UploadEntity>(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)
|
||
}
|
||
}
|
||
}
|