From 660852ffadd8bca5f72139f8d36d42e36a21d1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 30 Jun 2026 15:10:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E6=9C=89=E7=BA=BF=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E6=89=8B=E5=8A=A8=E4=B8=8A=E4=BC=A0=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=8C=E5=B9=B6=E7=A8=B3=E5=AE=9A=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=A1=8C=E5=B8=83=E5=B1=80=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指定/勾选/批量上传支持恢复仅本地持久化的待传照片,手动上传与 pipeline 均按 3 路并发;pipeline 任务签名去重避免状态回退,照片行固定高度并预留进度条占位。 Co-authored-by: Cursor --- .../CameraTransferPipeline.swift | 43 +++++-- suixinkan/Features/TravelAlbum/TravelAlbum.md | 3 +- .../ViewModels/TravelAlbumViewModels.swift | 84 +++++++++++++- .../WiredTransfer/WiredTransferDesign.swift | 5 +- .../WiredTransfer/WiredTransferPhotoRow.swift | 105 +++++++++--------- .../CameraTransferPipelineTests.swift | 18 +++ .../WiredCameraTransferViewModelTests.swift | 97 ++++++++++++++++ 7 files changed, 283 insertions(+), 72 deletions(-) diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index bf8c3b1..d50bf73 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -80,19 +80,40 @@ final class CameraTransferPipeline { /// 手动上传指定 asset 列表(拍完再传模式)。 func uploadAssets(withIDs assetIDs: [String]) async { guard uploadSink != nil else { return } - for assetID in assetIDs { - if activeUploadAssetIDs.contains(assetID) { continue } - guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue } - if tasks[index].status == .uploaded { continue } - if tasks[index].localURL == nil { - await downloadAsset(assetID: assetID) + var seenAssetIDs: Set = [] + let uniqueAssetIDs = assetIDs.filter { seenAssetIDs.insert($0).inserted } + var currentIndex = 0 + while currentIndex < uniqueAssetIDs.count { + let upperBound = min(currentIndex + maxConcurrentUploads, uniqueAssetIDs.count) + let batch = uniqueAssetIDs[currentIndex ..< upperBound] + let uploadTasks = batch.map { assetID in + Task { @MainActor in + await self.uploadAssetIfNeeded(assetID: assetID) + } } - guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { continue } - if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed { - tasks[refreshedIndex].status = .downloaded - tasks[refreshedIndex].errorMessage = nil - await uploadTask(assetID: assetID) + for task in uploadTasks { + await task.value } + currentIndex = upperBound + } + } + + private func uploadAssetIfNeeded(assetID: String) async { + if activeUploadAssetIDs.contains(assetID) { return } + guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } + if tasks[index].status == .uploaded { return } + + activeUploadAssetIDs.insert(assetID) + defer { activeUploadAssetIDs.remove(assetID) } + + if tasks[index].localURL == nil { + await downloadAsset(assetID: assetID) + } + guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } + if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed { + tasks[refreshedIndex].status = .downloaded + tasks[refreshedIndex].errorMessage = nil + await uploadTask(assetID: assetID) } } diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 3f49604..4410e3d 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -59,6 +59,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 - 有选中项 → 上传选中照片 - 无选中项 → 取消选择 3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」 + - 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材 4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section 5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展 6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复 @@ -76,7 +77,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 ## 有线传图性能策略 - 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。 -- 连拍上传进度采用节流通知,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传自动队列最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。 +- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。 - `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。 - 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index 80b5241..a8fd0ec 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -417,6 +417,7 @@ final class WiredCameraTransferViewModel: ObservableObject { static let specifyUploadAllPending = "上传所有未上传的照片" static let specifyUploadTodayCaptured = "上传所有今日拍摄的照片" static let helpDocURL = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink")! + private static let manualUploadConcurrency = 3 @Published private(set) var photos: [WiredTransferPhotoItem] = [] @Published private(set) var visiblePhotos: [WiredTransferPhotoItem] = [] @@ -444,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject { private var userIDProvider: (() -> String)? private var sessionNewPhotoIDs: Set = [] private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:] + private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:] private var lastProgressPersistDates: [String: Date] = [:] private var wasConnected = false private var disconnectAlertTask: Task? @@ -639,7 +641,7 @@ final class WiredCameraTransferViewModel: ObservableObject { errorMessage = "暂无未上传的照片" return } - await pipeline.uploadAssets(withIDs: pendingIDs) + await uploadPendingPhotoIDs(pendingIDs) } /// 上传今日拍摄且未上传的照片。 @@ -650,13 +652,13 @@ final class WiredCameraTransferViewModel: ObservableObject { errorMessage = "暂无今日拍摄且未上传的照片" return } - await pipeline.uploadAssets(withIDs: pendingIDs) + await uploadPendingPhotoIDs(pendingIDs) } /// 上传选中照片。 func uploadSelected(api: any TravelAlbumServing, ossService: any OSSUploadServing, scenicID: Int) async { ensureUploadEnabled(api: api, ossService: ossService, scenicID: scenicID) - await pipeline.uploadAssets(withIDs: Array(selectedPhotoIDs)) + await uploadPendingPhotoIDs(Array(selectedPhotoIDs)) exitSelectUploadMode() } @@ -699,10 +701,57 @@ final class WiredCameraTransferViewModel: ObservableObject { await refreshPhotoFromPipelineTask(id: id) } + private func uploadPendingPhotoIDs(_ ids: [String]) async { + let pendingIDs = ids.filter { id in + photos.first(where: { $0.id == id })?.isNotUploaded == true + } + var currentIndex = 0 + while currentIndex < pendingIDs.count { + let upperBound = min(currentIndex + Self.manualUploadConcurrency, pendingIDs.count) + let batch = pendingIDs[currentIndex ..< upperBound] + let uploadTasks = batch.map { id in + Task { @MainActor in + await self.uploadSinglePendingPhoto(id: id) + } + } + for task in uploadTasks { + await task.value + } + currentIndex = upperBound + } + } + + private func uploadSinglePendingPhoto(id: String) async { + updatePhotoStatus(id: id, status: .uploading, progress: 0, errorMessage: nil) + + if pipeline.task(forAssetID: id) != nil { + await pipeline.uploadAssets(withIDs: [id]) + await refreshPhotoFromPipelineTask(id: id) + return + } + + guard let record = persistedRecord(for: id), + let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath), + FileManager.default.fileExists(atPath: localURL.path) else { + let message = "本地文件不存在" + updatePhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message) + persistPhotoStatus(id: id, status: .failed, progress: 0, errorMessage: message) + return + } + + await pipeline.retryUpload( + assetID: id, + filename: record.fileName, + localPath: record.localPath + ) + await refreshPhotoFromPipelineTask(id: id) + } + /// 删除本地照片记录。 func deletePhoto(id: String) { photoStore?.remove(albumID: context.albumId, photoID: id) persistedRecordsByID.removeValue(forKey: id) + lastAppliedPipelineSignatures.removeValue(forKey: id) lastProgressPersistDates.removeValue(forKey: id) setPhotos(photos.filter { $0.id != id }, rebuildGroups: true) } @@ -905,16 +954,24 @@ final class WiredCameraTransferViewModel: ObservableObject { for task in tasks { guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue } + let signature = PipelineTaskSignature(task: task) + guard lastAppliedPipelineSignatures[task.assetID] != signature else { continue } + let status = mapPipelineStatus(task.status) let existing = existingByID[task.assetID] ?? existingByFileName[task.filename] if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) { + lastAppliedPipelineSignatures[task.assetID] = signature continue } let storedLocalPath = task.localPath ?? "" let localURL = task.localURL - let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL) - let existingRecord = persistedRecordsByID[task.assetID] + let fileSizeBytes: Int64 + if let existingRecord, existingRecord.fileSizeBytes > 0 { + fileSizeBytes = existingRecord.fileSizeBytes + } else { + fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL) + } let capturedAt = await resolveCapturedAt( existing: existing, taskCapturedAt: task.capturedAt, @@ -973,6 +1030,7 @@ final class WiredCameraTransferViewModel: ObservableObject { ) persistRecord(record) } + lastAppliedPipelineSignatures[task.assetID] = signature } } @@ -1091,3 +1149,19 @@ final class WiredCameraTransferViewModel: ObservableObject { return bound == context.albumId } } + +private struct PipelineTaskSignature: Equatable { + let status: CameraTransferStatus + let progress: Int + let localPath: String? + let remoteURL: String? + let errorMessage: String? + + init(task: CameraTransferTask) { + status = task.status + progress = task.progress + localPath = task.localPath + remoteURL = task.remoteURL + errorMessage = task.errorMessage + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift index b5cfe24..4c16df2 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferDesign.swift @@ -25,11 +25,12 @@ enum WiredTransferDesign { static let filterChipHeight: CGFloat = 34 static let filterChipCornerRadius: CGFloat = 8 static let tabRowHeight: CGFloat = 68 - static let thumbnailSize: CGFloat = 48 + static let photoRowHeight: CGFloat = 74 + static let thumbnailSize: CGFloat = 56 static let thumbnailCornerRadius: CGFloat = 6 static let bottomButtonHeight: CGFloat = 44 static let bottomButtonCornerRadius: CGFloat = 10 - static let progressBarHeight: CGFloat = 2 + static let progressBarHeight: CGFloat = 3 static let cardHorizontalPadding: CGFloat = 16 static let cardOverlap: CGFloat = 10 } diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift index f3e664d..72205ad 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferPhotoRow.swift @@ -20,71 +20,63 @@ struct WiredTransferPhotoRow: View { private var canSelect: Bool { photo.canSelectForSpecifyUpload } private var canRetry: Bool { photo.status == .failed || photo.status == .pending } private var rowOpacity: Double { selectUploadMode && !canSelect ? 0.45 : 1 } + private var showsProgress: Bool { photo.status == .transferring || photo.status == .uploading } var body: some View { - VStack(spacing: 0) { + HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) { - HStack(alignment: .center, spacing: 8) { - if selectUploadMode { - WiredTransferSelectIndicator(selected: selected, enabled: canSelect) - } + if selectUploadMode { + WiredTransferSelectIndicator(selected: selected, enabled: canSelect) + } - thumbnailView - .frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize) - .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius)) - .opacity(rowOpacity) + thumbnailView + .frame(width: WiredTransferDesign.thumbnailSize, height: WiredTransferDesign.thumbnailSize) + .clipShape(RoundedRectangle(cornerRadius: WiredTransferDesign.thumbnailCornerRadius)) + .opacity(rowOpacity) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 4) { - Text(photo.fileName) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity)) - .lineLimit(1) - Spacer(minLength: 0) - WiredTransferStatusBadge(status: photo.status) - .opacity(rowOpacity) - } - Text(metaText) - .font(.system(size: 10)) - .foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity)) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Text(photo.fileName) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(WiredTransferDesign.text333.opacity(rowOpacity)) .lineLimit(1) + Spacer(minLength: 0) + WiredTransferStatusBadge(status: photo.status) + .opacity(rowOpacity) } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - if selectUploadMode { - if canSelect { onToggleSelection() } - } else { - onPhotoClick() - } - } - - if !selectUploadMode { - Menu { - Button("重传") { onRetry() } - .disabled(!canRetry) - Button("删除", role: .destructive) { onDelete() } - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 16)) - .foregroundStyle(WiredTransferDesign.text666) - .frame(width: 32, height: 32) - .rotationEffect(.degrees(90)) - } + Text(metaText) + .font(.system(size: 10)) + .foregroundStyle(WiredTransferDesign.text999.opacity(rowOpacity)) + .lineLimit(1) + progressSlot + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + if selectUploadMode { + if canSelect { onToggleSelection() } + } else { + onPhotoClick() } } - .padding(.horizontal, 10) - .padding(.vertical, 8) - if photo.status == .transferring || photo.status == .uploading { - ProgressView(value: Double(photo.progress), total: 100) - .tint(AppDesign.primary) - .frame(height: WiredTransferDesign.progressBarHeight) - .padding(.horizontal, 10) - .padding(.bottom, 8) + if !selectUploadMode { + Menu { + Button("重传") { onRetry() } + .disabled(!canRetry) + Button("删除", role: .destructive) { onDelete() } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 16)) + .foregroundStyle(WiredTransferDesign.text666) + .frame(width: 32, height: 32) + .rotationEffect(.degrees(90)) + } } } + .frame(height: WiredTransferDesign.photoRowHeight) + .padding(.horizontal, 10) } private var metaText: String { @@ -92,6 +84,13 @@ struct WiredTransferPhotoRow: View { return "\(photo.fileSizeText) \(photo.resolutionText)" } + private var progressSlot: some View { + ProgressView(value: showsProgress ? Double(photo.progress) : 0, total: 100) + .tint(AppDesign.primary) + .opacity(showsProgress ? 1 : 0) + .frame(height: WiredTransferDesign.progressBarHeight) + } + @ViewBuilder private var thumbnailView: some View { if let url = URL(string: photo.thumbnailURL), !photo.thumbnailURL.isEmpty { diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift index faa33b9..2786fbe 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -60,6 +60,24 @@ final class CameraTransferPipelineTests: XCTestCase { XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded) } + /// 测试手动上传多张照片时按并发上限上传,而不是串行一张张上传。 + func testManualUploadAssetsUsesConcurrentLimit() async throws { + let camera = MockCameraService() + let sink = MockSlowUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: true) + camera.listedAssets = (1 ... 6).map { index in + CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024) + } + + await pipeline.syncExistingPhotos() + await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id)) + + XCTAssertEqual(sink.uploadCount, 6) + XCTAssertGreaterThan(sink.maxActiveUploads, 1) + XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3) + } + /// 测试拍后传输下载的旧照片,切到边拍边传后不会被新照片触发自动上传。 func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws { let camera = MockCameraService() diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 0457c06..393ae8b 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -296,6 +296,65 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(viewModel.photos.first?.status, .uploaded) } + /// 测试指定上传“所有未上传”会恢复本地持久化照片并全部上传。 + func testBatchUploadAllUploadsPersistedPendingFiles() async throws { + let albumID = 7711 + let userID = "batch-persist-user" + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试")) + + let fileURLs = try (1 ... 5).map { index in + let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG") + try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url) + return url + } + defer { + fileURLs.forEach { try? FileManager.default.removeItem(at: $0) } + } + + let records = fileURLs.enumerated().map { offset, url in + let index = offset + 1 + return makePhotoRecord( + id: "batch_persist_\(index)", + fileName: "batch_persist_\(index).JPG", + localPath: CameraDownloadStorage.relativePath(for: url), + albumID: albumID, + userID: userID + ) + } + let store = WiredTransferPhotoStore( + accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, + userIDProvider: { userID } + ) + store.save(albumID: albumID, records: records) + + let viewModel = WiredCameraTransferViewModel( + context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""), + cameraService: MockWiredCameraService() + ) + let oss = WiredTransferMockOSSUploadService() + oss.uploadDelayNanoseconds = 150_000_000 + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: oss, + scenicID: 1, + accountContext: account + ) + + XCTAssertEqual(viewModel.photos.filter(\.isNotUploaded).count, 5) + + await viewModel.batchUploadAll( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: oss, + scenicID: 1 + ) + + XCTAssertEqual(Set(oss.uploadedFileNames), Set((1 ... 5).map { "batch_persist_\($0).JPG" })) + XCTAssertGreaterThan(oss.maxActiveUploads, 1) + XCTAssertLessThanOrEqual(oss.maxActiveUploads, 3) + XCTAssertTrue(viewModel.photos.allSatisfy { $0.status == .uploaded }) + } + /// 测试切换传输模式后会按账号缓存选项。 func testSelectTransferModePersistsOption() async { let account = makeTransferModeTestAccount() @@ -444,6 +503,32 @@ final class WiredCameraTransferViewModelTests: XCTestCase { ) } + private func makePhotoRecord( + id: String, + fileName: String, + localPath: String, + albumID: Int, + userID: String, + status: WiredTransferUploadStatus = .pending + ) -> WiredTransferPhotoRecord { + WiredTransferPhotoRecord( + id: id, + sourceId: id, + fileName: fileName, + localPath: localPath, + thumbnailPath: "", + capturedAt: "2026-05-20 12:05:18", + fileSizeBytes: 32, + status: status.rawValue, + progress: 0, + errorMessage: nil, + albumId: albumID, + userId: userID, + remoteURL: "", + updatedAt: Date().timeIntervalSince1970 + ) + } + private func makeJPEGData() -> Data { let renderer = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)) return renderer.jpegData(withCompressionQuality: 0.9) { context in @@ -516,10 +601,22 @@ private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing { @MainActor private final class WiredTransferMockOSSUploadService: OSSUploadServing { + private(set) var uploadedFileNames: [String] = [] + var uploadDelayNanoseconds: UInt64 = 0 + private(set) var activeUploads = 0 + private(set) var maxActiveUploads = 0 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + activeUploads += 1 + maxActiveUploads = max(maxActiveUploads, activeUploads) + if uploadDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: uploadDelayNanoseconds) + } + activeUploads -= 1 + uploadedFileNames.append(fileName) onProgress(100) return "https://cdn/mock/\(fileName)" }