From 5ebcec1cfce50a12b03d3fb7d437209709c3ef4d 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 14:50:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BE=B9=E6=8B=8D=E8=BE=B9?= =?UTF-8?q?=E4=BC=A0=E8=87=AA=E5=8A=A8=E4=B8=8A=E4=BC=A0=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E9=81=BF=E5=85=8D=E4=B8=8A=E4=BC=A0=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=A2=AB=E6=97=A7=E5=BF=AB=E7=85=A7=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拍后传输下载的历史照片切换模式后不再自动补传;刷新相机文件仅同步本地。重试上传后刷新单张状态,并忽略乱序 pipeline 通知导致的已上传回退。 Co-authored-by: Cursor --- .../Core/CameraTransfer/CameraTransfer.md | 4 +- .../CameraTransferPipeline.swift | 28 +++++++++--- suixinkan/Features/TravelAlbum/TravelAlbum.md | 2 + .../ViewModels/TravelAlbumViewModels.swift | 31 +++++++++++-- .../CameraTransferPipelineTests.swift | 44 ++++++++++++++++++- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/suixinkan/Core/CameraTransfer/CameraTransfer.md b/suixinkan/Core/CameraTransfer/CameraTransfer.md index 7dd0d21..2291ab0 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransfer.md +++ b/suixinkan/Core/CameraTransfer/CameraTransfer.md @@ -16,9 +16,11 @@ 1. 相机服务回调 `onNewAsset`。 2. Pipeline 下载文件到本地并更新任务状态。 -3. 若开启自动上传,调用 `CameraAssetUploadSink.upload`。 +3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。 4. 上传成功后任务状态变为 `uploaded`。 +`syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。 + ## 与业务层关系 - 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。 diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index fdd79f6..bf8c3b1 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -17,6 +17,7 @@ final class CameraTransferPipeline { private var uploadSink: (any CameraAssetUploadSink)? private var uploadEnabled = true private var activeUploadAssetIDs: Set = [] + private var autoUploadEligibleAssetIDs: Set = [] private var pendingDeferredNotifyTask: Task? private var lastDeferredNotifyDate = Date.distantPast private var lastNotifiedProgressByAssetID: [String: Int] = [:] @@ -67,12 +68,13 @@ final class CameraTransferPipeline { /// 重试所有失败的上传任务。 func retryFailedUploads() async { guard uploadEnabled, uploadSink != nil else { return } + let failedAssetIDs = tasks.filter { $0.status == .failed }.map(\.assetID) for index in tasks.indices where tasks[index].status == .failed { tasks[index].status = .downloaded tasks[index].errorMessage = nil } notify() - processUploadQueue() + await uploadAssets(withIDs: failedAssetIDs) } /// 手动上传指定 asset 列表(拍完再传模式)。 @@ -150,6 +152,7 @@ final class CameraTransferPipeline { try? FileManager.default.removeItem(at: localURL) } tasks.removeAll() + autoUploadEligibleAssetIDs.removeAll() notify() } @@ -167,10 +170,19 @@ final class CameraTransferPipeline { if inFlightAssetIDs.contains(asset.id) { return } if tasks.contains(where: { $0.assetID == asset.id && $0.status == .downloading }) { return } + let isKnownAsset = tasks.contains { $0.assetID == asset.id } + let shouldAutoUpload = !skipIfExists + && uploadEnabled + && uploadSink != nil + && (!isKnownAsset || autoUploadEligibleAssetIDs.contains(asset.id)) + inFlightAssetIDs.insert(asset.id) defer { inFlightAssetIDs.remove(asset.id) } let assetID = asset.id + if shouldAutoUpload { + autoUploadEligibleAssetIDs.insert(assetID) + } if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) { tasks[existingIndex].status = .downloading tasks[existingIndex].errorMessage = nil @@ -187,8 +199,7 @@ final class CameraTransferPipeline { await downloadAsset(assetID: assetID, asset: asset) - if uploadEnabled, - uploadSink != nil, + if shouldAutoUpload, tasks.first(where: { $0.assetID == assetID })?.status == .downloaded { processUploadQueue() } @@ -237,12 +248,16 @@ final class CameraTransferPipeline { } } - /// 并发上传已下载任务;边拍边传时限制并发数,避免网络与内存被打满。 + /// 并发上传边拍边传新照片;拍后传输留下的待上传任务只能由用户显式上传。 private func processUploadQueue() { - guard uploadEnabled, uploadSink != nil else { return } + guard uploadSink != nil else { return } while activeUploadAssetIDs.count < maxConcurrentUploads, - let task = tasks.first(where: { $0.status == .downloaded && !activeUploadAssetIDs.contains($0.assetID) }) { + let task = tasks.first(where: { + $0.status == .downloaded + && !activeUploadAssetIDs.contains($0.assetID) + && autoUploadEligibleAssetIDs.contains($0.assetID) + }) { startConcurrentUpload(assetID: task.assetID) } } @@ -292,6 +307,7 @@ final class CameraTransferPipeline { tasks[taskIndex].status = .uploaded tasks[taskIndex].remoteURL = remoteURL tasks[taskIndex].progress = 100 + autoUploadEligibleAssetIDs.remove(assetID) lastNotifiedProgressByAssetID.removeValue(forKey: assetID) notify() return diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 6721af4..3f49604 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -62,6 +62,8 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section 5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展 6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复 + - 「边拍边传」只自动上传该模式下由相机新照片事件发现的照片 + - 「拍后传输」下载到 App 的旧照片切换模式后不会自动补传,需通过批量上传、指定上传、勾选上传或单张重试显式上传 ## 业务流程 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index b9e611a..80b5241 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -678,6 +678,7 @@ final class WiredCameraTransferViewModel: ObservableObject { if pipeline.task(forAssetID: id) != nil { await pipeline.uploadAssets(withIDs: [id]) + await refreshPhotoFromPipelineTask(id: id) return } @@ -695,6 +696,7 @@ final class WiredCameraTransferViewModel: ObservableObject { filename: record.fileName, localPath: record.localPath ) + await refreshPhotoFromPipelineTask(id: id) } /// 删除本地照片记录。 @@ -764,6 +766,11 @@ final class WiredCameraTransferViewModel: ObservableObject { replacePhoto(updated, rebuildGroups: photo.status != status) } + private func refreshPhotoFromPipelineTask(id: String) async { + guard let task = pipeline.task(forAssetID: id) else { return } + await applyPipelineTasks([task], enforceAlbumBinding: false) + } + private func persistPhotoStatus( id: String, status: WiredTransferUploadStatus, @@ -884,7 +891,7 @@ final class WiredCameraTransferViewModel: ObservableObject { setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true) } - private func applyPipelineTasks(_ tasks: [CameraTransferTask]) async { + private func applyPipelineTasks(_ tasks: [CameraTransferTask], enforceAlbumBinding: Bool = true) async { guard let userIDProvider else { return } let userID = userIDProvider() @@ -896,14 +903,17 @@ final class WiredCameraTransferViewModel: ObservableObject { ) for task in tasks { - guard belongsToCurrentAlbum(task.assetID) else { continue } + guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue } let status = mapPipelineStatus(task.status) + let existing = existingByID[task.assetID] ?? existingByFileName[task.filename] + if shouldIgnoreStalePipelineStatus(existing: existing?.status, incoming: status) { + continue + } let storedLocalPath = task.localPath ?? "" let localURL = task.localURL let fileSizeBytes = await CameraDownloadStorage.fileSizeBytes(for: localURL) - let existing = existingByID[task.assetID] ?? existingByFileName[task.filename] let existingRecord = persistedRecordsByID[task.assetID] let capturedAt = await resolveCapturedAt( existing: existing, @@ -1027,6 +1037,21 @@ final class WiredCameraTransferViewModel: ObservableObject { } } + /// 忽略异步通知乱序产生的旧状态,避免上传中或已上传被旧快照回退成待上传。 + private func shouldIgnoreStalePipelineStatus( + existing: WiredTransferUploadStatus?, + incoming: WiredTransferUploadStatus + ) -> Bool { + switch (existing, incoming) { + case (.uploaded?, .pending), (.uploaded?, .transferring), (.uploaded?, .uploading), (.uploaded?, .failed): + return true + case (.uploading?, .pending), (.uploading?, .transferring): + return true + default: + return false + } + } + /// 仅在状态、路径或远程 URL 变化,或进度跨越大步长时落盘,避免高频 IO。 private func shouldPersistPipelineUpdate( photoID: String, diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift index fc7f430..faa33b9 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -60,6 +60,45 @@ final class CameraTransferPipelineTests: XCTestCase { XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded) } + /// 测试拍后传输下载的旧照片,切到边拍边传后不会被新照片触发自动上传。 + func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws { + let camera = MockCameraService() + let sink = MockUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: false) + + camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024)) + try await Task.sleep(nanoseconds: 400_000_000) + + XCTAssertEqual(sink.uploadedFileNames, []) + XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) + + pipeline.configure(uploadSink: sink, uploadEnabled: true) + camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024)) + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertEqual(sink.uploadedFileNames, ["LIVE_001.JPG"]) + XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) + XCTAssertEqual(pipeline.task(forAssetID: "live_001")?.status, .uploaded) + } + + /// 测试边拍边传模式下刷新历史照片只同步到本地,不授予自动上传资格。 + func testSyncExistingPhotosDoesNotAutoUploadInLiveCaptureMode() async throws { + let camera = MockCameraService() + let sink = MockUploadSink() + let pipeline = CameraTransferPipeline(cameraService: camera) + pipeline.configure(uploadSink: sink, uploadEnabled: true) + camera.listedAssets = [ + CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024) + ] + + await pipeline.syncExistingPhotos() + try await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(sink.uploadedFileNames, []) + XCTAssertEqual(pipeline.task(forAssetID: "history_001")?.status, .downloaded) + } + /// 测试边拍边传连拍时,多张照片会按并发上限同时上传。 func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws { let camera = MockBurstCameraService() @@ -111,12 +150,13 @@ private final class MockCameraService: CameraServiceProtocol { var connectionState: CameraConnectionState = .connected(deviceName: "Mock") var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewAsset: ((CameraAsset) -> Void)? + var listedAssets: [CameraAsset] = [] func connect() async {} func disconnect() async {} func listAssets() async throws -> [CameraAsset] { - [] + listedAssets } func downloadAsset(_ asset: CameraAsset) async throws -> URL { @@ -179,6 +219,7 @@ private final class MockSlowUploadSink: CameraAssetUploadSink { @MainActor private final class MockUploadSink: CameraAssetUploadSink { var uploadCount = 0 + var uploadedFileNames: [String] = [] func upload( localURL: URL, @@ -187,6 +228,7 @@ private final class MockUploadSink: CameraAssetUploadSink { progress: @escaping @Sendable (Int) -> Void ) async throws -> String { uploadCount += 1 + uploadedFileNames.append(fileName) progress(100) return "https://cdn/mock/\(fileName)" }