新增 OSS 上传与 Kingfisher 图片加载支持

This commit is contained in:
2026-06-22 16:30:05 +08:00
parent 08465fffde
commit 726f19c124
20 changed files with 1399 additions and 134 deletions

View File

@ -0,0 +1,245 @@
//
// 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
}
@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
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)"
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格式"
}
}
}

View File

@ -0,0 +1,51 @@
# 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`
- `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 保存,上传模块不直接读取或保存登录态。

View File

@ -0,0 +1,41 @@
//
// UploadAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import Foundation
import Observation
/// OSS STS token 便
@MainActor
protocol OSSConfigServing {
/// bucket OSS
func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse
}
@MainActor
@Observable
/// API
final class UploadAPI {
@ObservationIgnored 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 {}

View 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))
}
}
}

View 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 ""
}
/// IntDouble
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
}
}