Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload. Co-authored-by: Cursor <cursoragent@cursor.com>
286 lines
14 KiB
Swift
286 lines
14 KiB
Swift
//
|
||
// 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 uploadAliveAlbumFile(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
|
||
|
||
/// 上传飞手认证证件图片,并返回可访问的 OSS 文件 URL。
|
||
func uploadPilotCertificateImage(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 uploadAliveAlbumFile(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)
|
||
}
|
||
|
||
/// 上传任务附件,并返回可访问的 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 文件 URL。
|
||
func uploadPilotCertificateImage(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: "pilot_cert", 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 "alive_album":
|
||
return "live_albums/\(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)"
|
||
case "pilot_cert":
|
||
return "pilot_cert/\(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格式"
|
||
}
|
||
}
|
||
}
|