589 lines
20 KiB
Swift
589 lines
20 KiB
Swift
import SwiftUI
|
||
import PhotosUI
|
||
|
||
/// 处理图片上传到远程服务器的类
|
||
/// 支持上传图片并获取服务器返回的file_id
|
||
public class ImageUploaderGetID: ObservableObject {
|
||
// MARK: - 类型定义
|
||
|
||
/// 上传结果
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
public struct UploadResult: Codable {
|
||
=======
|
||
public struct UploadResult {
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
public struct UploadResult: Codable {
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
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
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
case invalidResponseData
|
||
=======
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
case invalidResponseData
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
|
||
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"
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
case .invalidResponseData:
|
||
return "无效的响应数据"
|
||
=======
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
case .invalidResponseData:
|
||
return "无效的响应数据"
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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: 要上传的图片
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
/// - progress: 上传进度回调 (0.0 到 1.0)
|
||
/// - completion: 完成回调
|
||
public func uploadImage(
|
||
_ image: UIImage,
|
||
progress: @escaping (Double) -> Void,
|
||
completion: @escaping (Result<UploadResult, Error>) -> Void
|
||
) {
|
||
<<<<<<< HEAD
|
||
=======
|
||
/// - completion: 完成回调,返回Result类型的结果
|
||
public func uploadImage(_ image: UIImage, completion: @escaping (Result<UploadResult, Error>) -> Void) {
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
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) { [weak self] result in
|
||
switch result {
|
||
case .success((let fileId, let uploadURL)):
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
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: "avatar_\(UUID().uuidString).jpg",
|
||
fileSize: imageData.count,
|
||
completion: completion
|
||
)
|
||
|
||
case .failure(let error):
|
||
print("❌ 文件上传失败: \(error.localizedDescription)")
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
)
|
||
|
||
<<<<<<< HEAD
|
||
case .failure(let error):
|
||
print("❌ 获取上传URL失败: \(error.localizedDescription)")
|
||
=======
|
||
// 3. 确认上传
|
||
self?.confirmUpload(fileId: fileId, fileName: "avatar_\(UUID().uuidString).jpg", fileSize: imageData.count) { confirmResult in
|
||
completion(confirmResult)
|
||
}
|
||
case .failure(let error):
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
case .failure(let error):
|
||
print("❌ 获取上传URL失败: \(error.localizedDescription)")
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 私有方法
|
||
|
||
/// 获取上传URL
|
||
private func getUploadURL(for imageData: Data, completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void) {
|
||
let fileName = "avatar_\(UUID().uuidString).jpg"
|
||
let parameters: [String: Any] = [
|
||
"filename": fileName,
|
||
"content_type": "image/jpeg",
|
||
"file_size": imageData.count
|
||
]
|
||
|
||
let urlString = "\(apiConfig.baseURL)/file/generate-upload-url"
|
||
guard let url = URL(string: urlString) else {
|
||
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)
|
||
print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(imageData.count) / 1024.0) KB")
|
||
} catch {
|
||
print("❌ 序列化请求参数失败: \(error.localizedDescription)")
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
let task = session.dataTask(with: request) { data, response, error in
|
||
if let error = error {
|
||
completion(.failure(UploadError.uploadFailed(error)))
|
||
return
|
||
}
|
||
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
completion(.failure(UploadError.invalidResponse))
|
||
return
|
||
}
|
||
|
||
guard let data = data else {
|
||
completion(.failure(UploadError.invalidResponse))
|
||
return
|
||
}
|
||
|
||
// 打印调试信息
|
||
if let responseString = String(data: data, encoding: .utf8) {
|
||
print("📥 获取上传URL响应: \(responseString)")
|
||
}
|
||
|
||
do {
|
||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
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 {
|
||
throw UploadError.invalidResponse
|
||
}
|
||
|
||
completion(.success((fileId: fileId, uploadURL: uploadURL)))
|
||
} catch {
|
||
completion(.failure(UploadError.invalidResponse))
|
||
}
|
||
}
|
||
|
||
task.resume()
|
||
}
|
||
|
||
/// 确认上传
|
||
private func confirmUpload(fileId: String, fileName: String, fileSize: Int, completion: @escaping (Result<UploadResult, Error>) -> Void) {
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
let endpoint = "\(apiConfig.baseURL)/file/confirm-upload"
|
||
guard let url = URL(string: endpoint) else {
|
||
=======
|
||
let urlString = "\(apiConfig.baseURL)/file/confirm-upload"
|
||
guard let url = URL(string: urlString) else {
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
let endpoint = "\(apiConfig.baseURL)/file/confirm-upload"
|
||
guard let url = URL(string: endpoint) else {
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
completion(.failure(UploadError.invalidURL))
|
||
return
|
||
}
|
||
|
||
var request = URLRequest(url: url)
|
||
request.httpMethod = "POST"
|
||
request.allHTTPHeaderFields = apiConfig.authHeaders
|
||
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
let body: [String: Any] = [
|
||
"file_id": fileId,
|
||
"file_name": fileName,
|
||
"file_size": fileSize
|
||
]
|
||
<<<<<<< HEAD
|
||
|
||
do {
|
||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||
print("📤 确认上传请求,fileId: \(fileId), 文件名: \(fileName)")
|
||
} catch {
|
||
print("❌ 序列化确认上传参数失败: \(error.localizedDescription)")
|
||
=======
|
||
let requestBody: [String: Any] = ["file_id": fileId]
|
||
=======
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
|
||
do {
|
||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||
print("📤 确认上传请求,fileId: \(fileId), 文件名: \(fileName)")
|
||
} catch {
|
||
<<<<<<< HEAD
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
print("❌ 序列化确认上传参数失败: \(error.localizedDescription)")
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
let task = session.dataTask(with: request) { data, response, error in
|
||
if let error = error {
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
print("❌ 确认上传请求失败: \(error.localizedDescription)")
|
||
=======
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
print("❌ 确认上传请求失败: \(error.localizedDescription)")
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
completion(.failure(UploadError.uploadFailed(error)))
|
||
return
|
||
}
|
||
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
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)))
|
||
<<<<<<< HEAD
|
||
=======
|
||
guard let httpResponse = response as? HTTPURLResponse,
|
||
(200...299).contains(httpResponse.statusCode) else {
|
||
completion(.failure(UploadError.serverError("确认上传失败,状态码: \((response as? HTTPURLResponse)?.statusCode ?? -1)")))
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
return
|
||
}
|
||
|
||
// 创建上传结果
|
||
let uploadResult = UploadResult(
|
||
fileUrl: "\(self.apiConfig.baseURL)/files/\(fileId)",
|
||
fileName: fileName,
|
||
fileSize: fileSize,
|
||
fileId: fileId
|
||
)
|
||
|
||
print("✅ 图片上传并确认成功,fileId: \(fileId)")
|
||
completion(.success(uploadResult))
|
||
}
|
||
|
||
task.resume()
|
||
}
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
|
||
/// 上传文件到指定URL
|
||
/// - Parameters:
|
||
/// - fileData: 要上传的文件数据
|
||
/// - uploadURL: 上传URL
|
||
/// - mimeType: 文件MIME类型
|
||
/// - onProgress: 进度回调,0.0 到 1.0
|
||
/// - completion: 完成回调
|
||
public func uploadFile(
|
||
fileData: Data,
|
||
to uploadURL: URL,
|
||
mimeType: String = "application/octet-stream",
|
||
onProgress: @escaping (Double) -> Void,
|
||
completion: @escaping (Result<Void, Error>) -> Void
|
||
) -> URLSessionUploadTask {
|
||
var request = URLRequest(url: uploadURL)
|
||
request.httpMethod = "PUT"
|
||
request.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||
|
||
let task = session.uploadTask(
|
||
with: request,
|
||
from: fileData
|
||
) { _, response, error in
|
||
if let error = error {
|
||
completion(.failure(error))
|
||
return
|
||
}
|
||
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
guard let httpResponse = response as? HTTPURLResponse else {
|
||
completion(.failure(UploadError.invalidResponse))
|
||
return
|
||
}
|
||
|
||
guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else {
|
||
let statusCode = httpResponse.statusCode
|
||
<<<<<<< HEAD
|
||
=======
|
||
guard let httpResponse = response as? HTTPURLResponse,
|
||
(200...299).contains(httpResponse.statusCode) else {
|
||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
=======
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)")))
|
||
return
|
||
}
|
||
|
||
completion(.success(()))
|
||
}
|
||
|
||
// 添加进度观察
|
||
if #available(iOS 11.0, *) {
|
||
let progressObserver = task.progress.observe(\.fractionCompleted) { (progressValue, _) in
|
||
DispatchQueue.main.async {
|
||
onProgress(progressValue.fractionCompleted)
|
||
}
|
||
}
|
||
|
||
task.addCompletionHandler { [weak task] in
|
||
progressObserver.invalidate()
|
||
task?.progress.cancel()
|
||
}
|
||
} else {
|
||
<<<<<<< HEAD
|
||
<<<<<<< HEAD
|
||
// iOS 11 以下版本使用通知
|
||
=======
|
||
// Fallback for earlier iOS versions
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
=======
|
||
// iOS 11 以下版本使用通知
|
||
>>>>>>> 5edee64 (feat: 文件上传成功)
|
||
var lastProgress: Double = 0
|
||
let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
|
||
let bytesSent = task.countOfBytesSent
|
||
let totalBytes = task.countOfBytesExpectedToSend
|
||
let currentProgress = totalBytes > 0 ? Double(bytesSent) / Double(totalBytes) : 0
|
||
|
||
// 只有当进度有显著变化时才回调,避免频繁更新UI
|
||
if abs(currentProgress - lastProgress) > 0.01 || currentProgress >= 1.0 {
|
||
lastProgress = currentProgress
|
||
DispatchQueue.main.async {
|
||
onProgress(min(currentProgress, 1.0))
|
||
}
|
||
}
|
||
|
||
if currentProgress >= 1.0 {
|
||
timer.invalidate()
|
||
}
|
||
}
|
||
|
||
task.addCompletionHandler {
|
||
timer.invalidate()
|
||
}
|
||
}
|
||
|
||
task.resume()
|
||
return task
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
}
|
||
}
|
||
<<<<<<< HEAD
|
||
=======
|
||
>>>>>>> a207b78 (feat: 确认上传)
|
||
=======
|
||
>>>>>>> 8e641fd (feat: 上传进度)
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
}
|
||
}
|