// // OSSUploadService.swift // suixinkan // // Created by Codex on 2026/6/22. // import Foundation import Observation import UniformTypeIdentifiers #if canImport(AlibabaCloudOSS) import AlibabaCloudOSS #endif /// OSS 上传服务协议,定义业务模块可复用的上传入口。 @MainActor protocol OSSUploadServing { /// 上传用户头像,并返回可访问的 OSS 文件 URL。 func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。 func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传云盘文件,并返回可访问的 OSS 文件 URL。 func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传相册文件,并返回可访问的 OSS 文件 URL。 func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传任务附件,并返回可访问的 OSS 文件 URL。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传项目图片,并返回可访问的 OSS 文件 URL。 func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String } @MainActor @Observable /// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。 final class OSSUploadService { @ObservationIgnored private let configService: any OSSConfigServing /// 初始化 OSS 上传服务,并注入 STS 配置服务。 init(configService: any OSSConfigServing) { self.configService = configService } /// 上传用户头像,并返回可访问的 OSS 文件 URL。 func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress) } /// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。 func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress) } /// 上传云盘文件,并返回可访问的 OSS 文件 URL。 func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "cloud_driver", onProgress: onProgress) } /// 上传相册文件,并返回可访问的 OSS 文件 URL。 func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress) } /// 上传任务附件,并返回可访问的 OSS 文件 URL。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress) } /// 上传项目图片,并返回可访问的 OSS 文件 URL。 func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "project", onProgress: onProgress) } /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "punch_point", onProgress: onProgress) } /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress) } /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) } /// 执行通用 OSS 上传流程。 private func uploadFile( data: Data, fileName: String, fileType: Int, scenicId: Int, moduleType: String, onProgress: @escaping (Int) -> Void ) async throws -> String { onProgress(1) try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName) let config = try await configService.aliyunOSSBucket(bucket: "vipsky") let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType) onProgress(12) #if canImport(AlibabaCloudOSS) let credentialsProvider = StaticCredentialsProvider( accessKeyId: config.credentials.accessKeyId, accessKeySecret: config.credentials.accessKeySecret, securityToken: config.credentials.securityToken ) let clientConfig = Configuration.default() .withRegion(config.region) .withCredentialsProvider(credentialsProvider) if !config.endpoint.isEmpty { clientConfig.withEndpoint(config.endpoint) } onProgress(25) let client = Client(clientConfig) _ = try await client.putObject( PutObjectRequest( bucket: config.bucket, key: objectKey, contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType), body: .data(data), progress: ProgressClosure { _, transferred, expected in guard expected > 0 else { return } let uploadProgress = Double(transferred) / Double(expected) let progress = max(26, min(99, 25 + Int(uploadProgress * 74))) onProgress(progress) } ) ) onProgress(100) return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey) #else throw OSSUploadError.sdkUnavailable #endif } } extension OSSUploadService: OSSUploadServing {} /// OSS 上传策略实体,集中管理文件限制、路径生成、MIME 类型和 URL 拼接规则。 enum OSSUploadPolicy { static let maxFileSize = 2_048 * 1_024 * 1_024 private static let allowedExtensions: Set = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"] /// 校验待上传文件大小和扩展名。 static func validate(dataSize: Int, fileName: String) throws { guard dataSize > 0 else { throw OSSUploadError.emptyFile } guard dataSize <= maxFileSize else { throw OSSUploadError.fileTooLarge } let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() guard allowedExtensions.contains(ext) else { throw OSSUploadError.unsupportedFileType } } /// 根据业务模块、景区和文件名生成 OSS objectKey。 static func objectKey( fileName: String, scenicId: Int, moduleType: String, date: Date = Date(), uuid: UUID = UUID(), timeZone: TimeZone = .current ) -> String { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.timeZone = timeZone formatter.dateFormat = "yyyyMMdd" let currentDate = formatter.string(from: date) let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "") let safeName = sanitizedFileName(fileName) switch moduleType { case "task_upload": return "task_upload/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "cloud_driver": return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "album_upload": return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "project": return "project/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "punch_point": return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "scenic_apply": return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "user_avatar": return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "real_name": return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "bank_card": return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } } /// 根据文件名扩展名和业务文件类型推断 MIME 类型。 static func contentType(for fileName: String, fileType: Int) -> String { let ext = URL(fileURLWithPath: fileName).pathExtension if let type = UTType(filenameExtension: ext)?.preferredMIMEType { return type } return fileType == 1 ? "video/mp4" : "image/jpeg" } /// 拼接 OSS 访问域名和 objectKey。 static func joinURL(baseURL: String, objectKey: String) -> String { if baseURL.hasSuffix("/") { return baseURL + objectKey } return baseURL + "/" + objectKey } /// 清理文件名中的危险字符,避免生成非法 objectKey。 private static func sanitizedFileName(_ fileName: String) -> String { let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines) var unsafeCharacters = CharacterSet.controlCharacters unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\")) let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in result += unsafeCharacters.contains(scalar) ? "_" : String(scalar) } return safeName.isEmpty ? "upload" : safeName } } /// OSS 上传错误实体,表示本地校验或 SDK 链接状态异常。 enum OSSUploadError: LocalizedError, Equatable { case sdkUnavailable case emptyFile case fileTooLarge case unsupportedFileType var errorDescription: String? { switch self { case .sdkUnavailable: "阿里云 OSS Swift SDK 尚未链接到当前 iOS target" case .emptyFile: "文件内容不能为空" case .fileTooLarge: "文件大小不能超过2048MB" case .unsupportedFileType: "仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式" } } }