修复边拍边传自动上传范围,并避免上传状态被旧快照回退。
拍后传输下载的历史照片切换模式后不再自动补传;刷新相机文件仅同步本地。重试上传后刷新单张状态,并忽略乱序 pipeline 通知导致的已上传回退。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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` 登记。
|
||||
|
||||
@ -17,6 +17,7 @@ final class CameraTransferPipeline {
|
||||
private var uploadSink: (any CameraAssetUploadSink)?
|
||||
private var uploadEnabled = true
|
||||
private var activeUploadAssetIDs: Set<String> = []
|
||||
private var autoUploadEligibleAssetIDs: Set<String> = []
|
||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||
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
|
||||
|
||||
@ -62,6 +62,8 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
|
||||
5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展
|
||||
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
|
||||
- 「边拍边传」只自动上传该模式下由相机新照片事件发现的照片
|
||||
- 「拍后传输」下载到 App 的旧照片切换模式后不会自动补传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
|
||||
|
||||
## 业务流程
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user