From 19d681192c072ec10d308560c732ea8e8648cfc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 1 Jul 2026 15:58:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20OTG=20=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=85=A7=E7=89=87=E5=90=8E=E8=BE=B9=E6=8B=8D?= =?UTF-8?q?=E8=BE=B9=E4=BC=A0=E4=BB=8D=E4=BC=9A=E5=B8=A6=E5=9B=9E=E5=B7=B2?= =?UTF-8?q?=E5=88=A0=E9=A1=B9=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除时同步清理管道任务并维护 excludedAssetIDs,避免 notify 将无本地文件的旧任务重新展示为仅文件名的待上传项。 Co-authored-by: Cursor --- .../CameraTransferPipeline.swift | 19 +++++++ .../ViewModels/TravelAlbumViewModels.swift | 7 +++ .../CameraTransferPipelineTests.swift | 31 +++++++++++ .../WiredCameraTransferViewModelTests.swift | 52 +++++++++++++++++++ 4 files changed, 109 insertions(+) diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index 9935db5..82d7a96 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -27,6 +27,8 @@ final class CameraTransferPipeline { private let deferredNotifyInterval: TimeInterval = 0.15 private let maxConcurrentUploads = 3 private var isUIAttached = false + /// 用户已从列表删除、不应再同步或展示的 asset ID。 + private(set) var excludedAssetIDs: Set = [] /// 初始化传输管道,并注入相机服务。 init(cameraService: any CameraServiceProtocol) { @@ -224,7 +226,24 @@ final class CameraTransferPipeline { tasks.first { $0.assetID == assetID } } + /// 同步用户已删除的 asset ID,防止 sync/notify 再次带回。 + func setExcludedAssetIDs(_ ids: Set) { + excludedAssetIDs = ids + } + + /// 从管道移除 asset,并阻止后续自动同步。 + func removeAsset(assetID: String) { + guard !assetID.isEmpty else { return } + excludedAssetIDs.insert(assetID) + tasks.removeAll { $0.assetID == assetID } + autoUploadEligibleAssetIDs.remove(assetID) + activeUploadAssetIDs.remove(assetID) + inFlightAssetIDs.remove(assetID) + lastNotifiedProgressByAssetID.removeValue(forKey: assetID) + } + private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async { + if excludedAssetIDs.contains(asset.id) { return } if skipIfExists, let existing = tasks.first(where: { $0.assetID == asset.id }), existing.status == .uploaded || existing.status == .downloaded { diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index f0842d4..e60854f 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -449,6 +449,7 @@ final class WiredCameraTransferViewModel: ObservableObject { private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:] private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:] private var lastProgressPersistDates: [String: Date] = [:] + private var deletedPhotoIDs: Set = [] private var wasConnected = false private var disconnectAlertTask: Task? private var isTearingDownCamera = false @@ -799,9 +800,12 @@ final class WiredCameraTransferViewModel: ObservableObject { /// 删除本地照片记录。 func deletePhoto(id: String) { photoStore?.remove(albumID: context.albumId, photoID: id) + deletedPhotoIDs.insert(id) + pipeline.removeAsset(assetID: id) persistedRecordsByID.removeValue(forKey: id) lastAppliedPipelineSignatures.removeValue(forKey: id) lastProgressPersistDates.removeValue(forKey: id) + sessionNewPhotoIDs.remove(id) setPhotos(photos.filter { $0.id != id }, rebuildGroups: true) } @@ -984,6 +988,8 @@ final class WiredCameraTransferViewModel: ObservableObject { private func loadPersistedPhotos() { guard context.albumId > 0, let photoStore else { return } + deletedPhotoIDs = photoStore.loadDeletedIDs(albumID: context.albumId) + pipeline.setExcludedAssetIDs(deletedPhotoIDs) let records = photoStore.load(albumID: context.albumId) persistedRecordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) setPhotos(records.map { $0.toPhotoItem() }.sortedByCaptureTimeDescending(), rebuildGroups: true) @@ -1001,6 +1007,7 @@ final class WiredCameraTransferViewModel: ObservableObject { ) for task in tasks { + guard !deletedPhotoIDs.contains(task.assetID) else { continue } guard !enforceAlbumBinding || belongsToCurrentAlbum(task.assetID) else { continue } let signature = PipelineTaskSignature(task: task) diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift index 552e6ea..3a46b29 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -202,6 +202,37 @@ final class CameraTransferPipelineTests: XCTestCase { XCTAssertEqual(camera.shutdownCount, 0) } + /// 测试删除后管道不会在新片 notify 时再次带回已删 asset。 + func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws { + let camera = MockCameraService() + let pipeline = CameraTransferPipeline(cameraService: camera) + let scope = makeScope("removed_asset") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope) + attachPipeline(pipeline) + + let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG") + let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG") + try Data(repeating: 0x01, count: 8).write(to: oldURL) + try Data(repeating: 0x02, count: 8).write(to: newURL) + defer { + try? FileManager.default.removeItem(at: oldURL) + try? FileManager.default.removeItem(at: newURL) + } + + camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8)) + try await Task.sleep(nanoseconds: 300_000_000) + pipeline.removeAsset(assetID: "ptp_old") + XCTAssertNil(pipeline.task(forAssetID: "ptp_old")) + + camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8)) + try await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertNil(pipeline.task(forAssetID: "ptp_old")) + XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new")) + XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old")) + } + private func attachPipeline( _ pipeline: CameraTransferPipeline, onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in } diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 0f7de58..976afdb 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -46,6 +46,58 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG") } + /// 测试删除待上传照片后,边拍边传新片不会把已删照片带回列表。 + func testDeletedPendingPhotoDoesNotReappearAfterLiveCapture() async throws { + let camera = MockWiredCameraService() + let context = WiredTransferContext( + albumId: 7701, + albumName: "删除回归测试", + phone: "", + orderNumber: "" + ) + let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) + + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + + let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD_DELETE.JPG") + try Data(repeating: 0x11, count: 16).write(to: oldURL) + defer { try? FileManager.default.removeItem(at: oldURL) } + + camera.downloadURL = oldURL + camera.onNewAsset?(CameraAsset(id: "ptp_delete_me", filename: "OLD_DELETE.JPG", fileSize: 16)) + try await Task.sleep(nanoseconds: 500_000_000) + XCTAssertEqual(viewModel.photos.count, 1) + + viewModel.deletePhoto(id: "ptp_delete_me") + XCTAssertTrue(viewModel.photos.isEmpty) + + viewModel.selectTransferMode( + WiredCameraTransferViewModel.modeLiveCapture, + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1 + ) + + let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW_LIVE.JPG") + try Data(repeating: 0x22, count: 16).write(to: newURL) + defer { try? FileManager.default.removeItem(at: newURL) } + + camera.downloadURL = newURL + camera.onNewAsset?(CameraAsset(id: "ptp_live_new", filename: "NEW_LIVE.JPG", fileSize: 16)) + try await Task.sleep(nanoseconds: 500_000_000) + + XCTAssertEqual(viewModel.photos.count, 1) + XCTAssertEqual(viewModel.photos.first?.id, "ptp_live_new") + XCTAssertFalse(viewModel.photos.contains { $0.id == "ptp_delete_me" }) + } + /// 测试 start 被取消时 detach UI 回调,但不 shutdown 相机连接。 func testStartCancellationDetachesWithoutShutdown() async { let camera = DisconnectTrackingWiredCameraService()