Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
283
suixinkan_ios/Core/Upload/OSSUploadService.swift
Normal file
283
suixinkan_ios/Core/Upload/OSSUploadService.swift
Normal file
@ -0,0 +1,283 @@
|
||||
//
|
||||
// OSSUploadService.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
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
|
||||
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
||||
final class OSSUploadService {
|
||||
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格式"
|
||||
}
|
||||
}
|
||||
}
|
||||
52
suixinkan_ios/Core/Upload/Upload.md
Normal file
52
suixinkan_ios/Core/Upload/Upload.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Upload 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Upload 模块负责 App 内通用文件上传能力,当前主要服务个人头像和实名认证证件图片,后续云盘、相册、任务、打卡点等模块迁移时复用同一套 OSS 上传入口。
|
||||
|
||||
该模块不负责业务表单提交,只负责:
|
||||
- 获取阿里云 OSS STS 临时配置。
|
||||
- 校验待上传文件大小和扩展名。
|
||||
- 生成按业务模块隔离的 OSS objectKey。
|
||||
- 调用阿里云 OSS Swift SDK 上传文件。
|
||||
- 返回最终可访问的文件 URL。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `UploadAPI`:封装 `/api/app/config/get-sts-token`,只负责获取 STS 临时上传配置。
|
||||
- `OSSUploadService`:统一上传服务,封装 SDK 调用和进度回调。
|
||||
- `OSSUploadPolicy`:上传策略,管理大小限制、扩展名白名单、路径规则、MIME 类型和 URL 拼接。
|
||||
- `AvatarImageProcessor`:头像图片处理器,上传前把图片压缩为 JPEG。
|
||||
- `RealNameImageProcessor`:实名认证证件图片处理器,上传前把证件图压缩为 JPEG。
|
||||
- `RemoteImage`:Kingfisher 网络图片组件,统一远程图片加载、缓存和失败占位。
|
||||
|
||||
## 上传流程
|
||||
|
||||
1. 页面或 ViewModel 将用户选择的本地图片处理成上传数据。
|
||||
2. ViewModel 调用 `OSSUploadService` 的业务上传方法。
|
||||
3. `OSSUploadService` 调用 `UploadAPI.aliyunOSSBucket(bucket:)` 获取 STS 配置。
|
||||
4. `OSSUploadPolicy` 校验文件并生成 objectKey。
|
||||
5. `OSSUploadService` 使用 `AlibabaCloudOSS` SDK 上传数据。
|
||||
6. 上传成功后返回 `base_url + objectKey`。
|
||||
7. 业务 ViewModel 再把 URL 提交给对应业务接口。
|
||||
|
||||
## 路径规则
|
||||
|
||||
当前模块路径:
|
||||
- `user_avatar`:`avatar/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `real_name`:`real_name/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `task_upload`:`task_upload/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `cloud_driver`:`cloud_driver/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `album_upload`:`album/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `alive_album`:`live_albums/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `punch_point`:`punch_point/yyyyMMdd/scenicId/uuid_fileName`
|
||||
- `scenic_apply`:`scenic_apply/yyyyMMdd/scenicId/uuid_fileName`
|
||||
|
||||
文件名会清理控制字符、`/` 和 `\`,避免生成非法 objectKey。
|
||||
|
||||
## 缓存边界
|
||||
|
||||
- OSS STS token 不落盘,只在一次上传流程中临时使用。
|
||||
- 原始图片 Data、压缩后图片 Data、上传进度不落盘。
|
||||
- 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。
|
||||
- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。
|
||||
39
suixinkan_ios/Core/Upload/UploadAPI.swift
Normal file
39
suixinkan_ios/Core/Upload/UploadAPI.swift
Normal file
@ -0,0 +1,39 @@
|
||||
//
|
||||
// UploadAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。
|
||||
@MainActor
|
||||
protocol OSSConfigServing {
|
||||
/// 获取指定 bucket 的阿里云 OSS 临时上传配置。
|
||||
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 上传 API,封装文件上传前需要的服务端配置接口。
|
||||
final class UploadAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化上传 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取指定 bucket 的阿里云 OSS 临时上传配置。
|
||||
func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/config/get-sts-token",
|
||||
queryItems: [URLQueryItem(name: "bucket", value: bucket)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension UploadAPI: OSSConfigServing {}
|
||||
121
suixinkan_ios/Core/Upload/UploadImageProcessors.swift
Normal file
121
suixinkan_ios/Core/Upload/UploadImageProcessors.swift
Normal file
@ -0,0 +1,121 @@
|
||||
//
|
||||
// UploadImageProcessors.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 头像图片处理器,负责压缩和规范化用户选择的头像图片。
|
||||
enum AvatarImageProcessor {
|
||||
static let maxPixelLength: CGFloat = 1024
|
||||
static let jpegCompressionQuality: CGFloat = 0.82
|
||||
|
||||
/// 处理后的上传图片实体,包含数据、文件名和内容类型。
|
||||
struct ProcessedImage: Equatable {
|
||||
let data: Data
|
||||
let fileName: String
|
||||
let contentType: UTType
|
||||
}
|
||||
|
||||
/// 将原始图片数据转换为适合头像上传的 JPEG 数据。
|
||||
static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage {
|
||||
try UploadImageRenderer.process(
|
||||
data: data,
|
||||
fileNamePrefix: "avatar",
|
||||
maxPixelLength: maxPixelLength,
|
||||
jpegCompressionQuality: jpegCompressionQuality,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名证件图片处理器,负责压缩和规范化身份证正反面图片。
|
||||
enum RealNameImageProcessor {
|
||||
static let maxPixelLength: CGFloat = 1800
|
||||
static let jpegCompressionQuality: CGFloat = 0.88
|
||||
|
||||
/// 将原始图片数据转换为适合实名认证上传的 JPEG 数据。
|
||||
static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage {
|
||||
try UploadImageRenderer.process(
|
||||
data: data,
|
||||
fileNamePrefix: "real_name_\(side.rawValue)",
|
||||
maxPixelLength: maxPixelLength,
|
||||
jpegCompressionQuality: jpegCompressionQuality,
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证证件图片面向实体,区分人像面和国徽面。
|
||||
enum RealNameImageSide: String {
|
||||
case front
|
||||
case back
|
||||
}
|
||||
|
||||
/// 上传图片处理错误实体,表示图片无效或编码失败。
|
||||
enum UploadImageProcessingError: LocalizedError, Equatable {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImage:
|
||||
"请选择有效的图片"
|
||||
case .encodingFailed:
|
||||
"图片处理失败,请重新选择"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传图片渲染器,提供通用图片缩放、铺底和 JPEG 编码能力。
|
||||
private enum UploadImageRenderer {
|
||||
/// 将原始图片渲染为指定大小限制的 JPEG 上传数据。
|
||||
static func process(
|
||||
data: Data,
|
||||
fileNamePrefix: String,
|
||||
maxPixelLength: CGFloat,
|
||||
jpegCompressionQuality: CGFloat,
|
||||
timestamp: TimeInterval
|
||||
) throws -> AvatarImageProcessor.ProcessedImage {
|
||||
guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else {
|
||||
throw UploadImageProcessingError.invalidImage
|
||||
}
|
||||
|
||||
let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength)
|
||||
guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else {
|
||||
throw UploadImageProcessingError.encodingFailed
|
||||
}
|
||||
|
||||
return AvatarImageProcessor.ProcessedImage(
|
||||
data: jpegData,
|
||||
fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg",
|
||||
contentType: .jpeg
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
/// 按最长边限制缩放图片,并用白色背景消除透明通道。
|
||||
func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage {
|
||||
let longestSide = max(size.width, size.height)
|
||||
let scale = min(1, maxPixelLength / longestSide)
|
||||
let targetSize = CGSize(
|
||||
width: max(1, (size.width * scale).rounded()),
|
||||
height: max(1, (size.height * scale).rounded())
|
||||
)
|
||||
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
format.opaque = true
|
||||
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
||||
return renderer.image { context in
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: targetSize))
|
||||
draw(in: CGRect(origin: .zero, size: targetSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
100
suixinkan_ios/Core/Upload/UploadModels.swift
Normal file
100
suixinkan_ios/Core/Upload/UploadModels.swift
Normal file
@ -0,0 +1,100 @@
|
||||
//
|
||||
// UploadModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 阿里云 OSS STS 配置响应实体,表示一次临时上传授权。
|
||||
struct AliyunOSSResponse: Decodable, Equatable {
|
||||
let baseUrl: String
|
||||
let endpoint: String
|
||||
let region: String
|
||||
let bucket: String
|
||||
let expireSeconds: Int
|
||||
let credentials: AliyunOSSCredentials
|
||||
|
||||
/// 阿里云 OSS STS 配置响应 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case baseUrl = "base_url"
|
||||
case endpoint
|
||||
case region
|
||||
case bucket
|
||||
case expireSeconds = "expire_seconds"
|
||||
case credentials
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
baseUrl = try container.decodeLossyString(forKey: .baseUrl)
|
||||
endpoint = try container.decodeLossyString(forKey: .endpoint)
|
||||
region = try container.decodeLossyString(forKey: .region)
|
||||
bucket = try container.decodeLossyString(forKey: .bucket)
|
||||
expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0
|
||||
credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials)
|
||||
}
|
||||
}
|
||||
|
||||
/// 阿里云 OSS 临时凭证实体,表示 SDK 上传所需的访问密钥和安全 token。
|
||||
struct AliyunOSSCredentials: Decodable, Equatable {
|
||||
let accessKeyId: String
|
||||
let accessKeySecret: String
|
||||
let securityToken: String
|
||||
|
||||
/// 阿里云 OSS 临时凭证 JSON 字段映射。
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessKeyId = "access_key_id"
|
||||
case accessKeySecret = "access_key_secret"
|
||||
case securityToken = "security_token"
|
||||
}
|
||||
|
||||
/// 自定义解码,兼容后端字段类型不稳定的情况。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
accessKeyId = try container.decodeLossyString(forKey: .accessKeyId)
|
||||
accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret)
|
||||
securityToken = try container.decodeLossyString(forKey: .securityToken)
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将 String、数字和 Bool 宽松解码为字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double 或数字字符串宽松解码为整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user