按账号与相册隔离相机下载目录,并同步更新传输管道与测试。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -92,9 +92,7 @@ final class WiredTransferPhotoStore {
|
||||
}
|
||||
|
||||
let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() }
|
||||
let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID)
|
||||
|
||||
return migratedRecords
|
||||
return normalizedRecords
|
||||
.filter(\.isDisplayable)
|
||||
.sorted { $0.capturedAt > $1.capturedAt }
|
||||
.also { items in
|
||||
@ -102,27 +100,6 @@ final class WiredTransferPhotoStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将旧版绝对路径迁移为相对路径并回写持久化。
|
||||
private func normalizeStoredPaths(
|
||||
_ records: [WiredTransferPhotoRecord],
|
||||
albumID: Int
|
||||
) -> [WiredTransferPhotoRecord] {
|
||||
var migrated = records
|
||||
var needsPersist = false
|
||||
|
||||
for index in migrated.indices {
|
||||
guard let normalized = migrated[index].withNormalizedStoredPaths(),
|
||||
normalized != migrated[index] else { continue }
|
||||
migrated[index] = normalized
|
||||
needsPersist = true
|
||||
}
|
||||
|
||||
if needsPersist {
|
||||
save(albumID: albumID, records: migrated)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
/// 保存相册传图记录。
|
||||
func save(albumID: Int, records: [WiredTransferPhotoRecord]) {
|
||||
guard albumID > 0 else { return }
|
||||
@ -173,12 +150,18 @@ final class WiredTransferPhotoStore {
|
||||
|
||||
guard let data = defaults.data(forKey: storageKey(albumID)),
|
||||
let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data)
|
||||
else { return [] }
|
||||
else {
|
||||
CameraDownloadStorage.removeScopeDirectory(for: downloadScope(albumID: albumID))
|
||||
defaults.removeObject(forKey: storageKey(albumID))
|
||||
defaults.removeObject(forKey: deletedKey(albumID))
|
||||
return []
|
||||
}
|
||||
|
||||
let userRecords = records.filter { $0.userId == userID }
|
||||
userRecords.forEach { deleteRecordFiles($0) }
|
||||
CameraDownloadStorage.removeScopeDirectory(for: downloadScope(albumID: albumID))
|
||||
|
||||
var bindings = loadBindings().filter { $0.albumID != albumID }
|
||||
let bindings = loadBindings().filter { $0.albumID != albumID }
|
||||
saveBindings(bindings)
|
||||
|
||||
defaults.removeObject(forKey: storageKey(albumID))
|
||||
@ -216,6 +199,10 @@ final class WiredTransferPhotoStore {
|
||||
"\(accountPrefixProvider())\(Self.transferModeKeySuffix)"
|
||||
}
|
||||
|
||||
private func downloadScope(albumID: Int) -> CameraDownloadStorage.Scope {
|
||||
CameraDownloadStorage.Scope(accountKey: accountPrefixProvider(), albumID: albumID)
|
||||
}
|
||||
|
||||
private struct PhotoAlbumBinding: Codable {
|
||||
let photoID: String
|
||||
let albumID: Int
|
||||
@ -249,33 +236,6 @@ private extension WiredTransferPhotoRecord {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 将 localPath/thumbnailPath 中的旧版绝对路径迁移为相对路径。
|
||||
func withNormalizedStoredPaths() -> WiredTransferPhotoRecord? {
|
||||
let migratedLocalPath = localPath.isEmpty ? localPath : (CameraDownloadStorage.migrateStoredPath(localPath) ?? localPath)
|
||||
let migratedThumbnailPath = thumbnailPath.isEmpty
|
||||
? thumbnailPath
|
||||
: (CameraDownloadStorage.migrateStoredPath(thumbnailPath) ?? thumbnailPath)
|
||||
|
||||
guard migratedLocalPath != localPath || migratedThumbnailPath != thumbnailPath else { return nil }
|
||||
|
||||
return WiredTransferPhotoRecord(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
fileName: fileName,
|
||||
localPath: migratedLocalPath,
|
||||
thumbnailPath: migratedThumbnailPath,
|
||||
capturedAt: capturedAt,
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
status: status,
|
||||
progress: progress,
|
||||
errorMessage: errorMessage,
|
||||
albumId: albumId,
|
||||
userId: userId,
|
||||
remoteURL: remoteURL,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeInterruptedTransfer() -> WiredTransferPhotoRecord {
|
||||
guard status == WiredTransferUploadStatus.transferring.rawValue
|
||||
|| status == WiredTransferUploadStatus.uploading.rawValue
|
||||
|
||||
@ -72,14 +72,14 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`)
|
||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||
4. 素材登记:`POST .../travel-album/upload-material`
|
||||
5. 本地照片路径以相对 Documents 的路径持久化(如 `CameraDownloads/xxx.JPG`),加载时自动迁移旧版绝对路径
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||
|
||||
## 有线传图性能策略
|
||||
|
||||
- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。
|
||||
- 本地下载原图只用于上传与大图预览,按 `accountCachePrefix + albumId` 写入 `CameraDownloads/<accountKey>/<albumID>/originals/`;列表缩略图在后台下采样生成到同 scope 的 `thumbnails/`,由 Kingfisher 统一加载与缓存。
|
||||
- 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。
|
||||
- `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。
|
||||
- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。
|
||||
- 本地记录和物理文件都以账号上下文和相册维度隔离,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存;删除相册会清理当前账号当前相册的 scoped 文件目录,不影响同手机号下其他账号或其他相册。
|
||||
|
||||
## 解耦关系
|
||||
|
||||
|
||||
@ -443,6 +443,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
private let pipeline: CameraTransferPipeline
|
||||
private var photoStore: WiredTransferPhotoStore?
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var downloadScope: CameraDownloadStorage.Scope?
|
||||
private var sessionNewPhotoIDs: Set<String> = []
|
||||
private var persistedRecordsByID: [String: WiredTransferPhotoRecord] = [:]
|
||||
private var lastAppliedPipelineSignatures: [String: PipelineTaskSignature] = [:]
|
||||
@ -477,9 +478,16 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
scenicID: Int,
|
||||
accountContext: AccountContext
|
||||
) async {
|
||||
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
|
||||
let currentDownloadScope = CameraDownloadStorage.Scope(
|
||||
accountKey: accountCachePrefix,
|
||||
albumID: context.albumId
|
||||
)
|
||||
downloadScope = currentDownloadScope
|
||||
|
||||
if photoStore == nil {
|
||||
photoStore = WiredTransferPhotoStore(
|
||||
accountPrefixProvider: { accountContext.accountCachePrefix ?? "guest_" },
|
||||
accountPrefixProvider: { accountCachePrefix },
|
||||
userIDProvider: { accountContext.profile?.userId ?? "" }
|
||||
)
|
||||
userIDProvider = { accountContext.profile?.userId ?? "" }
|
||||
@ -494,7 +502,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
scenicID: scenicID
|
||||
)
|
||||
let autoUpload = transferModeOption == Self.modeLiveCapture
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
await pipeline.connect()
|
||||
@ -555,7 +563,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
albumID: context.albumId,
|
||||
scenicID: scenicID
|
||||
)
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload)
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: downloadScope)
|
||||
}
|
||||
|
||||
/// 清除错误提示。
|
||||
@ -784,7 +792,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
albumID: context.albumId,
|
||||
scenicID: scenicID
|
||||
)
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: true)
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: true, downloadScope: downloadScope)
|
||||
}
|
||||
|
||||
private func persistedRecord(for photoID: String) -> WiredTransferPhotoRecord? {
|
||||
@ -979,9 +987,14 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
)
|
||||
var thumbnailPath = existingRecord?.thumbnailPath ?? ""
|
||||
if thumbnailPath.isEmpty,
|
||||
let downloadScope,
|
||||
let localURL,
|
||||
status != .transferring,
|
||||
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(for: localURL, assetID: task.assetID) {
|
||||
let generatedPath = await CameraThumbnailGenerator.generateThumbnail(
|
||||
for: localURL,
|
||||
assetID: task.assetID,
|
||||
scope: downloadScope
|
||||
) {
|
||||
thumbnailPath = generatedPath
|
||||
}
|
||||
let thumbnailURL = thumbnailURLString(thumbnailPath: thumbnailPath, remoteURL: task.remoteURL ?? existingRecord?.remoteURL ?? "")
|
||||
|
||||
Reference in New Issue
Block a user