560 lines
20 KiB
Swift
560 lines
20 KiB
Swift
import SwiftUI
|
||
import PhotosUI
|
||
import CommonCrypto
|
||
|
||
/// 处理图片上传到远程服务器的类
|
||
/// 支持上传图片并获取服务器返回的file_id
|
||
public class ImageUploaderGetID: ObservableObject {
|
||
// MARK: - 类型定义
|
||
private let session: URLSession
|
||
private let networkService: NetworkServiceProtocol
|
||
private let networkHandler: (_ path: String, _ parameters: [String: Any], _ completion: @escaping (Result<Data, UploadError>) -> Void) -> Void
|
||
|
||
public init(session: URLSession = .shared, networkService: NetworkServiceProtocol? = nil) {
|
||
self.session = session
|
||
let service = networkService ?? NetworkService.shared
|
||
self.networkService = service
|
||
self.networkHandler = { path, parameters, completion in
|
||
service.postWithToken(
|
||
path: path,
|
||
parameters: parameters,
|
||
completion: { (result: Result<UploadURLResponse, NetworkError>) in
|
||
switch result {
|
||
case .success(let response):
|
||
// Convert the response back to Data for the completion handler
|
||
do {
|
||
let data = try JSONEncoder().encode(response)
|
||
completion(.success(data))
|
||
} catch {
|
||
completion(.failure(.invalidResponseData))
|
||
}
|
||
case .failure(let error):
|
||
completion(.failure(.networkError(error)))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 测试专用的初始化方法
|
||
/// - Parameters:
|
||
/// - session: 用于测试的URLSession
|
||
/// - networkService: 用于测试的NetworkService
|
||
/// - networkHandler: 用于测试的网络请求处理器
|
||
internal init(
|
||
session: URLSession,
|
||
networkService: NetworkServiceProtocol? = nil,
|
||
networkHandler: @escaping (String, [String: Any], @escaping (Result<Data, UploadError>) -> Void) -> Void
|
||
) {
|
||
self.session = session
|
||
self.networkService = networkService ?? NetworkService.shared
|
||
self.networkHandler = networkHandler
|
||
}
|
||
|
||
/// 上传结果
|
||
public struct UploadResult: Codable {
|
||
public let fileUrl: String
|
||
public let fileName: String
|
||
public let fileSize: Int
|
||
public let fileId: String
|
||
|
||
public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String) {
|
||
self.fileUrl = fileUrl
|
||
self.fileName = fileName
|
||
self.fileSize = fileSize
|
||
self.fileId = fileId
|
||
}
|
||
}
|
||
|
||
/// 上传过程中可能发生的错误
|
||
public enum UploadError: LocalizedError {
|
||
case invalidImageData
|
||
case invalidURL
|
||
case serverError(String)
|
||
case invalidResponse
|
||
case uploadFailed(Error?)
|
||
case invalidFileId
|
||
case invalidResponseData
|
||
case networkError(Error)
|
||
case decodingError(Error)
|
||
case unauthorized
|
||
|
||
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 "无效的响应数据"
|
||
case .networkError(let error):
|
||
return "网络错误: \(error.localizedDescription)"
|
||
case .decodingError(let error):
|
||
return "解码错误: \(error.localizedDescription)"
|
||
case .unauthorized:
|
||
return "认证失败,需要重新登录"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 公开方法
|
||
|
||
/// 上传图片到服务器
|
||
public func uploadImage(
|
||
_ image: UIImage,
|
||
progress: @escaping (Double) -> Void,
|
||
completion: @escaping (Result<UploadResult, UploadError>) -> Void
|
||
) {
|
||
print("🔄 开始准备上传图片...")
|
||
|
||
guard let imageData = image.jpegData(compressionQuality: 0.7) else {
|
||
let error = UploadError.invalidImageData
|
||
print("❌ 错误:\(error.localizedDescription)")
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
requestUploadURL(fileName: "image_\(UUID().uuidString).jpg", fileData: imageData) { [weak self] result in
|
||
guard let self = self else { return }
|
||
|
||
switch result {
|
||
case .success(let response):
|
||
print("📤 获取到上传URL,开始上传文件...")
|
||
|
||
guard let uploadURL = URL(string: response.data.uploadUrl) else {
|
||
let error = UploadError.invalidURL
|
||
print("❌ [ImageUploader] 上传URL格式无效: \(response.data.uploadUrl)")
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
self.uploadFile(
|
||
fileData: imageData,
|
||
to: uploadURL,
|
||
mimeType: "image/jpeg",
|
||
onProgress: { uploadProgress in
|
||
print("📊 上传进度: \(Int(uploadProgress * 100))%")
|
||
progress(uploadProgress)
|
||
},
|
||
completion: { [weak self] uploadResult in
|
||
switch uploadResult {
|
||
case .success:
|
||
self?.confirmUpload(
|
||
fileId: response.data.fileId,
|
||
fileName: "image_\(UUID().uuidString).jpg",
|
||
fileSize: imageData.count,
|
||
completion: completion
|
||
)
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
)
|
||
|
||
case .failure(let error):
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 上传视频文件到服务器
|
||
/// - Parameters:
|
||
/// - videoURL: 视频文件的本地URL
|
||
/// - progress: 上传进度回调 (0.0 到 1.0)
|
||
/// - completion: 完成回调,返回上传结果或错误
|
||
public func uploadVideo(
|
||
_ videoURL: URL,
|
||
progress: @escaping (Double) -> Void,
|
||
completion: @escaping (Result<UploadResult, UploadError>) -> Void
|
||
) {
|
||
print("🎥 开始准备上传视频...")
|
||
|
||
do {
|
||
let videoData = try Data(contentsOf: videoURL)
|
||
let fileName = "video_\(UUID().uuidString).mp4"
|
||
|
||
requestUploadURL(
|
||
fileName: fileName,
|
||
fileData: videoData
|
||
) { [weak self] result in
|
||
guard let self = self else { return }
|
||
|
||
switch result {
|
||
case .success(let response):
|
||
print("📤 获取到视频上传URL,开始上传文件...")
|
||
|
||
guard let uploadURL = URL(string: response.data.uploadUrl) else {
|
||
let error = UploadError.invalidURL
|
||
print("❌ [ImageUploader] 视频上传URL格式无效: \(response.data.uploadUrl)")
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
self.uploadFile(
|
||
fileData: videoData,
|
||
to: uploadURL,
|
||
mimeType: "video/mp4",
|
||
onProgress: progress,
|
||
completion: { [weak self] uploadResult in
|
||
guard let self = self else { return }
|
||
|
||
switch uploadResult {
|
||
case .success:
|
||
self.confirmUpload(
|
||
fileId: response.data.fileId,
|
||
fileName: fileName,
|
||
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(.uploadFailed(error)))
|
||
}
|
||
}
|
||
|
||
// MARK: - 私有方法
|
||
|
||
/// 发起网络请求
|
||
private func request<T: Decodable>(
|
||
path: String,
|
||
parameters: [String: Any],
|
||
completion: @escaping (Result<T, UploadError>) -> Void
|
||
) {
|
||
networkHandler(path, parameters) { (result: Result<Data, UploadError>) in
|
||
switch result {
|
||
case .success(let data):
|
||
do {
|
||
let decoded = try JSONDecoder().decode(T.self, from: data)
|
||
completion(.success(decoded))
|
||
} catch {
|
||
print("❌ [ImageUploader] 解析响应失败: \(error)")
|
||
completion(.failure(.invalidResponseData))
|
||
}
|
||
case .failure(let error):
|
||
print("❌ [ImageUploader] 网络请求失败: \(error)")
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 请求上传URL
|
||
private func requestUploadURL(
|
||
fileName: String,
|
||
fileData: Data,
|
||
completion: @escaping (Result<UploadURLResponse, UploadError>) -> Void
|
||
) {
|
||
let parameters: [String: Any] = [
|
||
"filename": fileName,
|
||
"content_type": "image/jpeg",
|
||
"file_size": fileData.count
|
||
]
|
||
|
||
print("""
|
||
📝 [ImageUploader] 开始请求上传URL
|
||
文件名: \(fileName)
|
||
📏 文件大小: \(fileData.count) 字节
|
||
📋 参数: \(parameters)
|
||
""")
|
||
|
||
request(path: "/file/generate-upload-url", parameters: parameters, completion: completion)
|
||
}
|
||
|
||
/// 上传文件到指定URL
|
||
public func uploadFile(
|
||
fileData: Data,
|
||
to uploadURL: URL,
|
||
mimeType: String = "application/octet-stream",
|
||
onProgress: @escaping (Double) -> Void,
|
||
completion: @escaping (Result<Void, UploadError>) -> Void
|
||
) -> URLSessionUploadTask? {
|
||
print("""
|
||
⬆️ [ImageUploader] 开始上传文件
|
||
🔗 目标URL: \(uploadURL.absoluteString)
|
||
📏 文件大小: \(fileData.count) 字节
|
||
📋 MIME类型: \(mimeType)
|
||
""")
|
||
|
||
// 创建请求
|
||
var request = URLRequest(url: uploadURL)
|
||
request.httpMethod = "PUT"
|
||
|
||
// 设置请求头
|
||
var headers: [String: String] = [
|
||
"Content-Type": mimeType,
|
||
"Content-Length": String(fileData.count)
|
||
]
|
||
|
||
// 添加认证头
|
||
if let token = KeychainHelper.getAccessToken() {
|
||
headers["Authorization"] = "Bearer \(token)"
|
||
print("🔑 [ImageUploader] 添加认证头,Token: \(token.prefix(10))...")
|
||
} else {
|
||
print("⚠️ [ImageUploader] 未找到认证Token")
|
||
}
|
||
|
||
// 设置所有请求头
|
||
headers.forEach { key, value in
|
||
request.setValue(value, forHTTPHeaderField: key)
|
||
}
|
||
|
||
// 创建上传任务
|
||
let task = networkService.upload(
|
||
request: request,
|
||
fileData: fileData,
|
||
onProgress: onProgress,
|
||
completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
|
||
switch result {
|
||
case .success(let data, let response):
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
print("❌ [ImageUploader] 无效的响应")
|
||
completion(.failure(.invalidResponse))
|
||
return
|
||
}
|
||
|
||
let statusCode = httpResponse.statusCode
|
||
let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||
|
||
print("""
|
||
📡 [ImageUploader] 服务器响应
|
||
🔢 状态码: \(statusCode)
|
||
🔗 请求URL: \(uploadURL.absoluteString)
|
||
📦 响应: \(responseBody)
|
||
""")
|
||
|
||
switch statusCode {
|
||
case 200...299:
|
||
print("✅ [ImageUploader] 文件上传成功")
|
||
completion(.success(()))
|
||
|
||
case 401:
|
||
print("🔑 [ImageUploader] 认证失败,需要重新登录")
|
||
completion(.failure(.unauthorized))
|
||
|
||
case 403:
|
||
let errorMessage = "访问被拒绝 (403) - URL: \(uploadURL.absoluteString)"
|
||
print("❌ [ImageUploader] \(errorMessage)")
|
||
completion(.failure(.serverError(errorMessage)))
|
||
|
||
default:
|
||
let errorMessage = "服务器返回错误: \(statusCode)"
|
||
print("❌ [ImageUploader] \(errorMessage)")
|
||
completion(.failure(.serverError(errorMessage)))
|
||
}
|
||
|
||
case .failure(let error):
|
||
print("❌ [ImageUploader] 上传失败: \(error.localizedDescription)")
|
||
completion(.failure(.networkError(error)))
|
||
}
|
||
}
|
||
)
|
||
|
||
// 开始上传
|
||
task?.resume()
|
||
return task
|
||
}
|
||
|
||
/// 确认上传完成
|
||
private func confirmUpload(
|
||
fileId: String,
|
||
fileName: String,
|
||
fileSize: Int,
|
||
completion: @escaping (Result<UploadResult, UploadError>) -> Void
|
||
) {
|
||
let parameters: [String: Any] = [
|
||
"file_id": fileId,
|
||
"file_name": fileName,
|
||
"file_size": fileSize
|
||
]
|
||
|
||
print("""
|
||
📨 [ImageUploader] 开始确认上传完成
|
||
📁 文件ID: \(fileId)
|
||
📝 文件名: \(fileName)
|
||
📏 文件大小: \(fileSize) 字节
|
||
📋 参数: \(parameters)
|
||
""")
|
||
|
||
struct ConfirmUploadResponse: Codable {
|
||
let code: Int
|
||
let message: String
|
||
let data: [String: String]?
|
||
}
|
||
|
||
request(path: "/file/confirm-upload", parameters: parameters) { (result: Result<ConfirmUploadResponse, UploadError>) in
|
||
switch result {
|
||
case .success(_):
|
||
let uploadResult = UploadResult(
|
||
fileUrl: "\(APIConfig.baseURL)/files/\(fileId)",
|
||
fileName: fileName,
|
||
fileSize: fileSize,
|
||
fileId: fileId
|
||
)
|
||
print("""
|
||
✅ [ImageUploader] 文件上传确认成功
|
||
📁 文件ID: \(fileId)
|
||
🔗 文件URL: \(uploadResult.fileUrl)
|
||
📝 文件名: \(fileName)
|
||
📏 文件大小: \(fileSize) 字节
|
||
""")
|
||
completion(.success(uploadResult))
|
||
|
||
case .failure(let error):
|
||
print("❌ [ImageUploader] 文件上传确认失败: \(error.localizedDescription)")
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 辅助方法
|
||
|
||
private func calculateSpeed(bytes: Int, seconds: TimeInterval) -> String {
|
||
guard seconds > 0 else { return "0 KB/s" }
|
||
let bytesPerSecond = Double(bytes) / seconds
|
||
if bytesPerSecond >= 1024 * 1024 {
|
||
return String(format: "%.1f MB/s", bytesPerSecond / (1024 * 1024))
|
||
} else {
|
||
return String(format: "%.1f KB/s", bytesPerSecond / 1024)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Data Extension for MD5
|
||
|
||
extension Data {
|
||
func md5Base64EncodedString() -> String? {
|
||
#if canImport(CommonCrypto)
|
||
var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
||
_ = self.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in
|
||
if let baseAddress = ptr.baseAddress, ptr.count > 0 {
|
||
CC_MD5(baseAddress, CC_LONG(self.count), &digest)
|
||
}
|
||
}
|
||
return Data(digest).base64EncodedString()
|
||
#else
|
||
return nil
|
||
#endif
|
||
}
|
||
}
|
||
|
||
// MARK: - 响应模型
|
||
|
||
private struct UploadURLResponse: Codable {
|
||
let code: Int
|
||
let message: String?
|
||
let data: UploadData
|
||
|
||
struct UploadData: Codable {
|
||
let fileId: String
|
||
let filePath: String
|
||
let uploadUrl: String
|
||
let expiresIn: Int
|
||
|
||
enum CodingKeys: String, CodingKey {
|
||
case fileId = "file_id"
|
||
case filePath = "file_path"
|
||
case uploadUrl = "upload_url"
|
||
case expiresIn = "expires_in"
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct EmptyResponse: Codable {}
|
||
|
||
// 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 {
|
||
let name = NSNotification.Name("TaskCompleted\(self.taskIdentifier)")
|
||
NotificationCenter.default.addObserver(
|
||
forName: name,
|
||
object: self,
|
||
queue: .main
|
||
) { _ in
|
||
handler()
|
||
}
|
||
}
|
||
}
|
||
}
|