diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 45a585d..f20a244 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,15 +64,11 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanUITests; sourceTree = ""; }; @@ -328,10 +324,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/Core/CameraTethering/CameraTethering.md b/suixinkan/Core/CameraTethering/CameraTethering.md index acec950..bcde8fe 100644 --- a/suixinkan/Core/CameraTethering/CameraTethering.md +++ b/suixinkan/Core/CameraTethering/CameraTethering.md @@ -12,13 +12,13 @@ | `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 | | `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 | | `SonyPTPCommands` | Sony SDIO/PTP 命令封装 | -| `CameraDownloadStorage` | 本地下载目录与文件名消毒 | +| `CameraDownloadStorage` | 本地下载作用域、目录与文件名消毒 | ## 业务流程 1. `SonyCameraService.connect()` 启动 USB 设备搜索。 2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。 -3. 拍摄事件触发 PTP 下载,文件写入 `Documents/CameraDownloads/`,持久化路径为相对路径(如 `CameraDownloads/1730_DSC.JPG`)。 +3. 拍摄事件触发 PTP 下载,底层先写入临时目录,再由传输管道按账号与相册移入业务目录。 4. 通过 `onNewAsset` 回调通知上层有新照片。 ## 依赖 diff --git a/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift b/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift index fc3ccc4..dc1df2d 100644 --- a/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift +++ b/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift @@ -612,7 +612,7 @@ final class ImageCaptureDeviceManager: NSObject { lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)" let assetID = "ptp_\(handle)_\(result.filename)_\(result.data.count)" - let destination = CameraDownloadStorage.uniqueLocalURL(for: result.filename) + let destination = CameraDownloadStorage.uniqueTemporaryURL(for: result.filename) do { try result.data.write(to: destination, options: .atomic) } catch { diff --git a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift index a2642d3..f7028b0 100644 --- a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift +++ b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift @@ -95,7 +95,7 @@ final class SonyCameraService: CameraServiceProtocol { throw CameraServiceError.assetNotFound } - let directory = CameraDownloadStorage.downloadsDirectory + let directory = CameraDownloadStorage.temporaryDownloadsDirectory return try await deviceManager.download(file: file, to: directory) } diff --git a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift index e4e18ab..7aa8b64 100644 --- a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift +++ b/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift @@ -9,10 +9,30 @@ import Foundation import ImageIO import UniformTypeIdentifiers -/// 相机下载文件本地存储工具,负责目录创建、相对路径持久化与安全文件名处理。 +/// 相机下载文件本地存储工具,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。 enum CameraDownloadStorage { static let cameraDownloadsFolderName = "CameraDownloads" + /// 相机下载文件的业务隔离作用域。 + struct Scope: Equatable { + let accountKey: String + let albumID: Int + + /// 初始化账号与相册维度的下载作用域。 + init(accountKey: String, albumID: Int) { + self.accountKey = accountKey + self.albumID = albumID + } + + var accountDirectoryName: String { + CameraDownloadStorage.sanitizePathComponent(accountKey, fallback: "guest") + } + + var albumDirectoryName: String { + "\(max(albumID, 0))" + } + } + /// Documents 根目录。 static var documentsDirectory: URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] @@ -25,24 +45,56 @@ enum CameraDownloadStorage { return directory } - /// 相机照片列表缩略图目录。 - static var thumbnailsDirectory: URL { - let directory = downloadsDirectory.appendingPathComponent("Thumbnails", isDirectory: true) + /// 相机底层下载临时目录,最终业务记录不会持久化这里的路径。 + static var temporaryDownloadsDirectory: URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(cameraDownloadsFolderName, isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) return directory } - /// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/1730_DSC.JPG`。 - static func relativePath(for url: URL) -> String { - let standardizedURL = url.standardizedFileURL - let thumbnailsPath = thumbnailsDirectory.standardizedFileURL.path - if standardizedURL.path.hasPrefix(thumbnailsPath) { - return "\(cameraDownloadsFolderName)/Thumbnails/\(url.lastPathComponent)" - } - return "\(cameraDownloadsFolderName)/\(url.lastPathComponent)" + /// 当前账号与相册的下载根目录。 + static func scopeDirectory(for scope: Scope) -> URL { + let directory = downloadsDirectory + .appendingPathComponent(scope.accountDirectoryName, isDirectory: true) + .appendingPathComponent(scope.albumDirectoryName, isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory } - /// 将持久化的相对或旧版绝对路径解析为当前沙盒内的文件 URL。 + /// 当前账号与相册的原图目录。 + static func originalsDirectory(for scope: Scope) -> URL { + let directory = scopeDirectory(for: scope).appendingPathComponent("originals", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + /// 当前账号与相册的缩略图目录。 + static func thumbnailsDirectory(for scope: Scope) -> URL { + let directory = scopeDirectory(for: scope).appendingPathComponent("thumbnails", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + /// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/user_101/6603/originals/1730_DSC.JPG`。 + static func relativePath(for url: URL) -> String { + let standardizedURL = url.standardizedFileURL + let documentsPath = documentsDirectory.standardizedFileURL.path + let filePath = standardizedURL.path + if filePath == documentsPath { + return "" + } + if filePath.hasPrefix(documentsPath + "/") { + return String(filePath.dropFirst(documentsPath.count + 1)) + } + let privateDocumentsPath = "/private" + documentsPath + if filePath.hasPrefix(privateDocumentsPath + "/") { + return String(filePath.dropFirst(privateDocumentsPath.count + 1)) + } + return filePath + } + + /// 将持久化的相对路径、绝对路径或网络地址解析为可访问 URL。 static func resolveLocalURL(from storedPath: String) -> URL? { guard !storedPath.isEmpty else { return nil } @@ -51,56 +103,46 @@ enum CameraDownloadStorage { } if storedPath.hasPrefix("/") { - let legacyURL = URL(fileURLWithPath: storedPath) - if FileManager.default.fileExists(atPath: legacyURL.path) { - return legacyURL - } - let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent) - if FileManager.default.fileExists(atPath: candidate.path) { - return candidate - } - return legacyURL + return URL(fileURLWithPath: storedPath) } return documentsDirectory.appendingPathComponent(storedPath) } - /// 将旧版绝对路径迁移为相对路径;若文件已在当前 CameraDownloads 中则更新引用。 - static func migrateStoredPath(_ storedPath: String) -> String? { - guard storedPath.hasPrefix("/") else { return storedPath } - - let legacyURL = URL(fileURLWithPath: storedPath) - if FileManager.default.fileExists(atPath: legacyURL.path) { - return relativePath(for: legacyURL) - } - - let candidate = downloadsDirectory.appendingPathComponent(legacyURL.lastPathComponent) - if FileManager.default.fileExists(atPath: candidate.path) { - return relativePath(for: candidate) - } - - return relativePath(for: legacyURL) - } - - /// 判断文件是否位于 CameraDownloads 目录内。 - static func isInDownloadsDirectory(_ url: URL) -> Bool { - let downloadsPath = downloadsDirectory.standardizedFileURL.path + /// 判断文件是否位于当前账号与相册的原图目录内。 + static func isInOriginalsDirectory(_ url: URL, scope: Scope) -> Bool { + let originalsPath = originalsDirectory(for: scope).standardizedFileURL.path let filePath = url.standardizedFileURL.path - return filePath.hasPrefix(downloadsPath) || filePath.hasPrefix("/private" + downloadsPath) + return filePath.hasPrefix(originalsPath + "/") || filePath.hasPrefix("/private" + originalsPath + "/") } - /// 生成带时间戳的唯一本地文件 URL,避免文件名冲突。 - static func uniqueLocalURL(for filename: String) -> URL { + /// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。 + static func uniqueLocalURL(for filename: String, scope: Scope) -> URL { let timestamp = Int(Date().timeIntervalSince1970) let safeName = sanitizeFilename(filename) - return downloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)") + return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)") } - /// 生成带 asset ID 的唯一缩略图文件 URL。 - static func uniqueThumbnailURL(for assetID: String) -> URL { + /// 生成相机底层临时下载 URL。 + static func uniqueTemporaryURL(for filename: String) -> URL { + let timestamp = Int(Date().timeIntervalSince1970) + let safeName = sanitizeFilename(filename) + return temporaryDownloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)") + } + + /// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。 + static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL { let safeName = sanitizeFilename(assetID) .replacingOccurrences(of: ".", with: "_") - return thumbnailsDirectory.appendingPathComponent("\(safeName)_thumb.jpg") + return thumbnailsDirectory(for: scope).appendingPathComponent("\(safeName)_thumb.jpg") + } + + /// 删除当前账号与相册的本地文件目录。 + static func removeScopeDirectory(for scope: Scope) { + let directory = scopeDirectory(for: scope) + if FileManager.default.fileExists(atPath: directory.path) { + try? FileManager.default.removeItem(at: directory) + } } /// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。 @@ -124,6 +166,21 @@ enum CameraDownloadStorage { return name } + /// 将账号等动态字符串转为安全目录片段。 + static func sanitizePathComponent(_ value: String, fallback: String) -> String { + let trimmed = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + + let ascii = trimmed.unicodeScalars.filter { scalar in + scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "_" || scalar == "-") + } + + let component = String(String.UnicodeScalarView(ascii)) + return component.isEmpty ? fallback : component + } + /// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。 static func previewURLString(from storedPath: String) -> String { guard let url = resolveLocalURL(from: storedPath), @@ -147,7 +204,7 @@ enum CameraThumbnailGenerator { private static let maxPixelSize: CGFloat = 160 /// 为本地照片生成列表缩略图,成功时返回相对 Documents 的持久化路径。 - static func generateThumbnail(for imageURL: URL, assetID: String) async -> String? { + static func generateThumbnail(for imageURL: URL, assetID: String, scope: CameraDownloadStorage.Scope) async -> String? { await Task.detached(priority: .utility) { guard FileManager.default.fileExists(atPath: imageURL.path), let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { @@ -164,7 +221,7 @@ enum CameraThumbnailGenerator { return nil } - let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID) + let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID, scope: scope) if FileManager.default.fileExists(atPath: destination.path) { try? FileManager.default.removeItem(at: destination) } diff --git a/suixinkan/Core/CameraTransfer/CameraTransfer.md b/suixinkan/Core/CameraTransfer/CameraTransfer.md index 2291ab0..e1638a8 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransfer.md +++ b/suixinkan/Core/CameraTransfer/CameraTransfer.md @@ -11,16 +11,19 @@ | `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 | | `CameraTransferTask` | 单条传输任务及状态 | | `CameraAssetUploadSink` | 上传协议,由业务模块实现 | +| `CameraDownloadStorage.Scope` | 账号 + 相册维度的本地文件隔离作用域 | ## 业务流程 1. 相机服务回调 `onNewAsset`。 -2. Pipeline 下载文件到本地并更新任务状态。 +2. Pipeline 下载文件到当前 scope 的 `originals` 目录并更新任务状态。 3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。 4. 上传成功后任务状态变为 `uploaded`。 `syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。 +本地文件路径按 `Documents/CameraDownloads///originals/` 存放原图,缩略图由业务层生成到同 scope 的 `thumbnails/`。持久化只记录相对 Documents 的 scoped 路径,不保留未上线旧目录兼容。 + ## 与业务层关系 - 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。 diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index d50bf73..e8ae30a 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -16,6 +16,7 @@ final class CameraTransferPipeline { private let cameraService: any CameraServiceProtocol private var uploadSink: (any CameraAssetUploadSink)? private var uploadEnabled = true + private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0) private var activeUploadAssetIDs: Set = [] private var autoUploadEligibleAssetIDs: Set = [] private var pendingDeferredNotifyTask: Task? @@ -36,10 +37,17 @@ final class CameraTransferPipeline { } } - /// 配置上传 Sink 与是否自动上传。 - func configure(uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool) { + /// 配置上传 Sink、自动上传开关与可选下载作用域。 + func configure( + uploadSink: (any CameraAssetUploadSink)?, + uploadEnabled: Bool, + downloadScope: CameraDownloadStorage.Scope? = nil + ) { self.uploadSink = uploadSink self.uploadEnabled = uploadEnabled + if let downloadScope { + self.downloadScope = downloadScope + } } /// 启动相机连接。 @@ -243,10 +251,10 @@ final class CameraTransferPipeline { let localURL = try await cameraService.downloadAsset(resolvedAsset) let destination: URL - if CameraDownloadStorage.isInDownloadsDirectory(localURL) { + if CameraDownloadStorage.isInOriginalsDirectory(localURL, scope: downloadScope) { destination = localURL } else { - destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename) + destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename, scope: downloadScope) if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } diff --git a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift index 3bd0c82..2d576bb 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferTask.swift @@ -76,7 +76,7 @@ struct CameraTransferTask: Identifiable, Equatable, Codable { return formatter }() - /// 本地文件 URL,支持相对路径与旧版绝对路径。 + /// 本地文件 URL,支持相对 Documents 的 scoped 路径与绝对路径。 var localURL: URL? { guard let localPath, !localPath.isEmpty else { return nil } return CameraDownloadStorage.resolveLocalURL(from: localPath) diff --git a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift index e9c9d5b..9a61a2c 100644 --- a/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/Services/WiredTransferPhotoStore.swift @@ -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 diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 4410e3d..c591d4c 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -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///originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录 ## 有线传图性能策略 -- 本地下载原图只用于上传与大图预览;列表缩略图在后台下采样生成到 `CameraDownloads/Thumbnails/`,由 Kingfisher 统一加载与缓存。 +- 本地下载原图只用于上传与大图预览,按 `accountCachePrefix + albumId` 写入 `CameraDownloads///originals/`;列表缩略图在后台下采样生成到同 scope 的 `thumbnails/`,由 Kingfisher 统一加载与缓存。 - 连拍上传进度采用节流通知和任务签名去重,下载/上传关键状态立即刷新,普通进度合并后再更新 UI;边拍边传、指定上传和勾选上传最多 3 张照片并发上传,避免滚动时高频触发整页重绘或网络资源被打满。 - `WiredCameraTransferViewModel` 缓存当前 Tab、时间侧栏和 30 分钟分组;新增/删除/切换 Tab 才重建分组,单张进度变化只替换对应行数据。 -- 本地记录以相册和用户维度持久化,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存。 +- 本地记录和物理文件都以账号上下文和相册维度隔离,状态终态、路径变化立即保存,普通进度按步长或时间间隔降频保存;删除相册会清理当前账号当前相册的 scoped 文件目录,不影响同手机号下其他账号或其他相册。 ## 解耦关系 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index a8fd0ec..ff0af56 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -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 = [] 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 ?? "") diff --git a/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift b/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift index b62f484..e7c984b 100644 --- a/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift +++ b/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift @@ -8,36 +8,73 @@ import XCTest /// CameraDownloadStorage 相对路径持久化测试。 final class CameraDownloadStorageTests: XCTestCase { - /// 测试相对路径前缀。 - func testRelativePathUsesCameraDownloadsPrefix() { - let fileURL = CameraDownloadStorage.downloadsDirectory.appendingPathComponent("1730_test.JPG") - XCTAssertEqual(CameraDownloadStorage.relativePath(for: fileURL), "CameraDownloads/1730_test.JPG") + /// 测试 scoped 原图相对路径前缀。 + func testRelativePathUsesScopedOriginalsPrefix() { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + + let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG") + XCTAssertEqual( + CameraDownloadStorage.relativePath(for: fileURL), + "CameraDownloads/user_101/6603/originals/1730_test.JPG" + ) } /// 测试相对路径解析。 func testResolveLocalURLFromRelativePath() { - let stored = "CameraDownloads/1730_test.JPG" + let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG" let resolved = CameraDownloadStorage.resolveLocalURL(from: stored) let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored) XCTAssertEqual(resolved, expected) } - /// 测试旧版绝对路径迁移。 - func testMigrateStoredPathFromLegacyAbsolutePath() throws { - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy.jpg") - try Data("jpeg".utf8).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } + /// 测试账号或相册不同不会生成相同目录。 + func testScopedDirectoriesDoNotCollideAcrossAccountOrAlbum() { + let accountScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + let otherAccountScope = CameraDownloadStorage.Scope(accountKey: "user_102", albumID: 6603) + let otherAlbumScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604) + defer { + CameraDownloadStorage.removeScopeDirectory(for: accountScope) + CameraDownloadStorage.removeScopeDirectory(for: otherAccountScope) + CameraDownloadStorage.removeScopeDirectory(for: otherAlbumScope) + } - let migrated = CameraDownloadStorage.migrateStoredPath(fileURL.path) - XCTAssertEqual(migrated, "CameraDownloads/\(fileURL.lastPathComponent)") - XCTAssertEqual(CameraDownloadStorage.resolveLocalURL(from: migrated ?? "")?.path, fileURL.path) + XCTAssertNotEqual( + CameraDownloadStorage.originalsDirectory(for: accountScope).path, + CameraDownloadStorage.originalsDirectory(for: otherAccountScope).path + ) + XCTAssertNotEqual( + CameraDownloadStorage.originalsDirectory(for: accountScope).path, + CameraDownloadStorage.originalsDirectory(for: otherAlbumScope).path + ) + } + + /// 测试 scoped 目录清理只影响目标相册。 + func testRemoveScopeDirectoryOnlyDeletesTargetScope() throws { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + let otherScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604) + defer { + CameraDownloadStorage.removeScopeDirectory(for: scope) + CameraDownloadStorage.removeScopeDirectory(for: otherScope) + } + + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "target.jpg", scope: scope) + let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "other.jpg", scope: otherScope) + try Data("target".utf8).write(to: fileURL) + try Data("other".utf8).write(to: otherFileURL) + + CameraDownloadStorage.removeScopeDirectory(for: scope) + + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path)) } /// 测试预览 URL 字符串生成。 func testPreviewURLStringFromRelativePath() throws { - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg") + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg", scope: scope) try Data("jpeg".utf8).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } let stored = CameraDownloadStorage.relativePath(for: fileURL) let preview = CameraDownloadStorage.previewURLString(from: stored) diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift index 2786fbe..3c72f4f 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -16,7 +16,9 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: true) + let scope = makeScope("new_asset") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) var latestTasks: [CameraTransferTask] = [] pipeline.onTasksUpdated = { latestTasks = $0 } @@ -30,7 +32,10 @@ final class CameraTransferPipelineTests: XCTestCase { XCTAssertEqual(sink.uploadCount, 1) let savedTask = latestTasks.first { $0.assetID == asset.id } - XCTAssertEqual(savedTask?.localPath?.hasPrefix("CameraDownloads/"), true) + XCTAssertEqual( + savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"), + true + ) XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false) if let localURL = savedTask?.localURL { try? FileManager.default.removeItem(at: localURL) @@ -43,9 +48,11 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: false) + let scope = makeScope("retry") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope) - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG") + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope) try Data(repeating: 0xAB, count: 16).write(to: fileURL) defer { try? FileManager.default.removeItem(at: fileURL) } @@ -65,7 +72,9 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockCameraService() let sink = MockSlowUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: true) + let scope = makeScope("manual") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) camera.listedAssets = (1 ... 6).map { index in CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024) } @@ -83,7 +92,9 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: false) + let scope = makeScope("mode_switch") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope) camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024)) try await Task.sleep(nanoseconds: 400_000_000) @@ -91,7 +102,7 @@ final class CameraTransferPipelineTests: XCTestCase { XCTAssertEqual(sink.uploadedFileNames, []) XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) - pipeline.configure(uploadSink: sink, uploadEnabled: true) + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024)) try await Task.sleep(nanoseconds: 500_000_000) @@ -105,7 +116,9 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockCameraService() let sink = MockUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: true) + let scope = makeScope("sync_existing") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) camera.listedAssets = [ CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024) ] @@ -122,7 +135,9 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockBurstCameraService() let sink = MockSlowUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: true) + let scope = makeScope("burst") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) for index in 1 ... 10 { let filename = "DSC_\(index).JPG" @@ -149,7 +164,9 @@ final class CameraTransferPipelineTests: XCTestCase { let camera = MockCameraService() let sink = MockChattyUploadSink() let pipeline = CameraTransferPipeline(cameraService: camera) - pipeline.configure(uploadSink: sink, uploadEnabled: true) + let scope = makeScope("chatty") + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) var updateCount = 0 pipeline.onTasksUpdated = { _ in updateCount += 1 } @@ -160,6 +177,13 @@ final class CameraTransferPipelineTests: XCTestCase { XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded) XCTAssertLessThan(updateCount, 18) } + + private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope { + let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in + (partial + Int(scalar.value)) % 10_000 + } + return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID) + } } @MainActor diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 393ae8b..a735eb6 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -173,9 +173,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { ), ]) - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG") + let scope = downloadScope(account: account, albumID: context.albumId) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG", scope: scope) try Data(repeating: 0xAB, count: 16).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } camera.downloadURL = fileURL camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16)) @@ -253,9 +254,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG") + let scope = downloadScope(account: account, albumID: context.albumId) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_failed.JPG", scope: scope) try Data(repeating: 0xCD, count: 32).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } let photoID = "persist_failed_001" let record = WiredTransferPhotoRecord( @@ -304,12 +306,13 @@ final class WiredCameraTransferViewModelTests: XCTestCase { account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试")) let fileURLs = try (1 ... 5).map { index in - let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG") + let scope = downloadScope(account: account, albumID: albumID) + let url = CameraDownloadStorage.uniqueLocalURL(for: "batch_persist_\(index).JPG", scope: scope) try Data(repeating: UInt8(0xA0 + index), count: 32).write(to: url) return url } defer { - fileURLs.forEach { try? FileManager.default.removeItem(at: $0) } + CameraDownloadStorage.removeScopeDirectory(for: downloadScope(account: account, albumID: albumID)) } let records = fileURLs.enumerated().map { offset, url in @@ -410,18 +413,19 @@ final class WiredCameraTransferViewModelTests: XCTestCase { accountContext: account ) - let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG") + let scope = downloadScope(account: account, albumID: albumID) + let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG", scope: scope) try makeJPEGData().write(to: imageURL) - defer { try? FileManager.default.removeItem(at: imageURL) } + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } camera.downloadURL = imageURL camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128)) try await Task.sleep(nanoseconds: 900_000_000) let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" }) - XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/Thumbnails/")) + XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/\(scope.accountDirectoryName)/\(albumID)/thumbnails/")) XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/")) - XCTAssertFalse(photo.previewURL.contains("/Thumbnails/")) + XCTAssertFalse(photo.previewURL.contains("/thumbnails/")) } /// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。 @@ -429,9 +433,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { let albumID = 6602 let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "legacy-user", displayName: "测试")) - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG") + let scope = downloadScope(account: account, albumID: albumID) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "legacy_only.JPG", scope: scope) try makeJPEGData().write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } let record = WiredTransferPhotoRecord( id: "legacy_photo_001", @@ -471,6 +476,114 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertFalse(photo.previewURL.isEmpty) } + /// 测试清空相册时只删除当前账号当前相册的物理目录。 + func testClearAlbumRemovesOnlyCurrentAlbumScopedDirectory() throws { + let userID = "clear-scope-user" + let account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: userID, displayName: "测试"), accountType: "store") + let albumID = 8801 + let otherAlbumID = 8802 + let scope = downloadScope(account: account, albumID: albumID) + let otherScope = downloadScope(account: account, albumID: otherAlbumID) + let scopeDirectoryPath = CameraDownloadStorage.scopeDirectory(for: scope).path + let otherScopeDirectoryPath = CameraDownloadStorage.scopeDirectory(for: otherScope).path + defer { + CameraDownloadStorage.removeScopeDirectory(for: scope) + CameraDownloadStorage.removeScopeDirectory(for: otherScope) + } + + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_target.JPG", scope: scope) + let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_other.JPG", scope: otherScope) + try Data(repeating: 0x01, count: 8).write(to: fileURL) + try Data(repeating: 0x02, count: 8).write(to: otherFileURL) + + let store = WiredTransferPhotoStore( + accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, + userIDProvider: { userID } + ) + store.save(albumID: albumID, records: [ + makePhotoRecord( + id: "clear_target", + fileName: "clear_target.JPG", + localPath: CameraDownloadStorage.relativePath(for: fileURL), + albumID: albumID, + userID: userID + ) + ]) + store.save(albumID: otherAlbumID, records: [ + makePhotoRecord( + id: "clear_other", + fileName: "clear_other.JPG", + localPath: CameraDownloadStorage.relativePath(for: otherFileURL), + albumID: otherAlbumID, + userID: userID + ) + ]) + + let deletedIDs = store.clearAlbum(albumID: albumID) + + XCTAssertEqual(deletedIDs, Set(["clear_target"])) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath)) + XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath)) + } + + /// 测试同一用户 ID 在不同账号类型下的有线传图记录与物理目录隔离。 + func testSameUserIDDifferentAccountTypesAreIsolated() throws { + let userID = "multi-account-user" + let albumID = 8803 + let storeAccount = AccountContext() + storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store") + let photographerAccount = AccountContext() + photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer") + let storeScope = downloadScope(account: storeAccount, albumID: albumID) + let photographerScope = downloadScope(account: photographerAccount, albumID: albumID) + defer { + CameraDownloadStorage.removeScopeDirectory(for: storeScope) + CameraDownloadStorage.removeScopeDirectory(for: photographerScope) + } + + let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope) + let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope) + try Data(repeating: 0x03, count: 8).write(to: storeURL) + try Data(repeating: 0x04, count: 8).write(to: photographerURL) + + let store = WiredTransferPhotoStore( + accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" }, + userIDProvider: { userID } + ) + let photographerStore = WiredTransferPhotoStore( + accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" }, + userIDProvider: { userID } + ) + store.save(albumID: albumID, records: [ + makePhotoRecord( + id: "store_photo", + fileName: "store_photo.JPG", + localPath: CameraDownloadStorage.relativePath(for: storeURL), + albumID: albumID, + userID: userID + ) + ]) + photographerStore.save(albumID: albumID, records: [ + makePhotoRecord( + id: "photographer_photo", + fileName: "photographer_photo.JPG", + localPath: CameraDownloadStorage.relativePath(for: photographerURL), + albumID: albumID, + userID: userID + ) + ]) + + XCTAssertEqual(store.load(albumID: albumID).map(\.id), ["store_photo"]) + XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"]) + XCTAssertNotEqual( + CameraDownloadStorage.scopeDirectory(for: storeScope).path, + CameraDownloadStorage.scopeDirectory(for: photographerScope).path + ) + } + private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel { let viewModel = WiredCameraTransferViewModel( context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), @@ -536,6 +649,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) } } + + private func downloadScope(account: AccountContext, albumID: Int) -> CameraDownloadStorage.Scope { + CameraDownloadStorage.Scope(accountKey: account.accountCachePrefix ?? "guest_", albumID: albumID) + } } @MainActor