Files
suixinkan_ios_new/suixinkan/Core/Upload/OSSUploadService.swift
汉秋 5d4613f317 新增支付与钱包模块,并接入首页路由
引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 17:46:01 +08:00

256 lines
12 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
//
// 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 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 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<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
}
}
/// 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 "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格式"
}
}
}