重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
398 lines
14 KiB
Swift
398 lines
14 KiB
Swift
//
|
|
// OSSUploadService.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
import UniformTypeIdentifiers
|
|
|
|
#if canImport(AlibabaCloudOSS)
|
|
import AlibabaCloudOSS
|
|
#endif
|
|
|
|
/// OSS 上传服务协议,定义 Profile 模块可复用的上传入口。
|
|
@MainActor
|
|
protocol OSSUploadServing {
|
|
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
|
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
|
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String
|
|
}
|
|
|
|
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
|
@MainActor
|
|
final class OSSUploadService {
|
|
private let configService: any OSSConfigServing
|
|
|
|
init(configService: any OSSConfigServing) {
|
|
self.configService = configService
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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_auth", onProgress: onProgress)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// 上传景区申请图片,对齐 Android `moduleType = scenic_apply`。
|
|
func uploadScenicApplyImage(
|
|
data: Data,
|
|
fileName: String,
|
|
scenicId: Int,
|
|
onProgress: @escaping @MainActor (Int) -> Void
|
|
) async throws -> String {
|
|
try await uploadFile(
|
|
data: data,
|
|
fileName: fileName,
|
|
fileType: 2,
|
|
scenicId: scenicId,
|
|
moduleType: "scenic_apply",
|
|
onProgress: { progress in
|
|
Task { @MainActor in
|
|
onProgress(progress)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
/// 上传任务素材,对齐 Android `moduleType = task_upload`。
|
|
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
|
|
)
|
|
}
|
|
|
|
/// 上传旅拍相册素材,对齐 Android `moduleType = album`。
|
|
func uploadTravelAlbumMaterial(
|
|
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: "album",
|
|
onProgress: onProgress
|
|
)
|
|
}
|
|
|
|
/// 上传样片素材,对齐 Android `moduleType = sample`。
|
|
func uploadSampleFile(
|
|
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: "sample",
|
|
onProgress: onProgress
|
|
)
|
|
}
|
|
|
|
/// 上传素材管理素材,对齐 Android `moduleType = material`。
|
|
func uploadMaterialFile(
|
|
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: "material",
|
|
onProgress: onProgress
|
|
)
|
|
}
|
|
|
|
/// 上传打卡点图片,对齐 Android `moduleType = punch_point`。
|
|
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
|
|
)
|
|
}
|
|
|
|
/// 上传直播封面,对齐 Android `moduleType = live_covers`。
|
|
func uploadLiveCover(
|
|
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: "live_covers",
|
|
onProgress: onProgress
|
|
)
|
|
}
|
|
|
|
/// 上传直播相册素材,对齐 Android `moduleType = alive_album`。
|
|
func uploadLiveAlbumFile(
|
|
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: "alive_album",
|
|
onProgress: onProgress
|
|
)
|
|
}
|
|
|
|
/// 上传相册云盘文件,对齐 Android `cloud_driver` / `cloud_driver_test` 路径。
|
|
func uploadCloudDriveFile(
|
|
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
|
|
)
|
|
}
|
|
|
|
/// 上传举报摄影师附件,对齐举报提交接口的证据与支付截图。
|
|
func uploadWildReportAttachment(
|
|
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: "photog_report",
|
|
onProgress: onProgress
|
|
)
|
|
}
|
|
|
|
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 {}
|
|
|
|
extension OSSUploadService: SampleOSSUploading {}
|
|
|
|
extension OSSUploadService: MaterialOSSUploading {}
|
|
|
|
extension OSSUploadService: PunchPointImageUploading {}
|
|
|
|
enum OSSUploadPolicy {
|
|
static let maxFileSize = 2_048 * 1_024 * 1_024
|
|
private static let allowedExtensions: Set<String> = ["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 }
|
|
}
|
|
|
|
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: "").lowercased()
|
|
let safeName = sanitizedFileName(fileName)
|
|
switch moduleType {
|
|
case "user_avatar":
|
|
return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "real_name_auth":
|
|
let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let ownerId = userId.isEmpty ? String(scenicId) : userId
|
|
return "real_name_auth/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)"
|
|
case "bank_card":
|
|
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "task_upload":
|
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "album":
|
|
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "sample":
|
|
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "material":
|
|
// Android 素材任务固定 folderId = 0,后端会按此层级解析对象信息并写入视频元数据。
|
|
return "material/\(scenicId)/0/\(uploadId)_\(safeName)"
|
|
case "punch_point":
|
|
let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let ownerId = userId.isEmpty ? String(scenicId) : userId
|
|
return "punch_point/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)"
|
|
case "scenic_apply":
|
|
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "live_covers":
|
|
return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "alive_album":
|
|
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
case "cloud_driver":
|
|
let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let ownerId = userId.isEmpty ? String(scenicId) : userId
|
|
let month = String(currentDate.dropFirst(4).prefix(2))
|
|
#if DEBUG
|
|
let root = "cloud_driver_test"
|
|
#else
|
|
let root = "cloud_driver"
|
|
#endif
|
|
return "\(root)/\(ownerId)/\(String(currentDate.prefix(4)))/\(month)/\(safeName)"
|
|
case "photog_report":
|
|
return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
default:
|
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
static func joinURL(baseURL: String, objectKey: String) -> String {
|
|
if baseURL.hasSuffix("/") {
|
|
return baseURL + objectKey
|
|
}
|
|
return baseURL + "/" + 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
|
|
}
|
|
}
|
|
|
|
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格式"
|
|
}
|
|
}
|
|
}
|