Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
256 lines
12 KiB
Swift
256 lines
12 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 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格式"
|
||
}
|
||
}
|
||
}
|