Files
suixinkan_uikit/suixinkan/Core/Upload/OSSUploadService.swift
汉秋 5138c1c11a 完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:28:10 +08:00

394 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 (Int) -> Void
) async throws -> String {
try await uploadFile(
data: data,
fileName: fileName,
fileType: 2,
scenicId: scenicId,
moduleType: "scenic_apply",
onProgress: onProgress
)
}
/// 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格式"
}
}
}