// // WiredTransferPhotoStore.swift // suixinkan // // Created by Codex on 2026/6/29. // import Foundation /// 有线传图本地记录仓库,按相册与用户维度持久化到 UserDefaults。 final class WiredTransferPhotoStore { private let defaults: UserDefaults private let accountPrefixProvider: () -> String private let userIDProvider: () -> String private static let keyPrefix = "wired_transfer_photos_" private static let deletedSuffix = "_deleted" private static let bindingsSuffix = "_photo_bindings" /// 初始化仓库,注入账号前缀与用户 ID 提供者。 init( defaults: UserDefaults = .standard, accountPrefixProvider: @escaping () -> String, userIDProvider: @escaping () -> String ) { self.defaults = defaults self.accountPrefixProvider = accountPrefixProvider self.userIDProvider = userIDProvider } /// 查询照片绑定的相册 ID。 func albumID(forPhoto photoID: String) -> Int? { guard !photoID.isEmpty else { return nil } return loadBindings().first { $0.photoID == photoID }?.albumID } /// 绑定照片到相册。 func bindPhotoToAlbum(photoID: String, albumID: Int) { guard !photoID.isEmpty, albumID > 0, !userIDProvider().isEmpty else { return } var bindings = loadBindings().filter { $0.photoID != photoID } bindings.append(PhotoAlbumBinding(photoID: photoID, albumID: albumID)) saveBindings(bindings) } /// 加载已删除照片 ID。 func loadDeletedIDs(albumID: Int) -> Set { guard albumID > 0, !userIDProvider().isEmpty else { return [] } let ids = defaults.stringArray(forKey: deletedKey(albumID)) ?? [] return Set(ids) } /// 加载相册下的传图记录。 func load(albumID: Int) -> [WiredTransferPhotoRecord] { guard albumID > 0 else { return [] } let userID = userIDProvider() guard !userID.isEmpty else { return [] } let deletedIDs = loadDeletedIDs(albumID: albumID) guard let data = defaults.data(forKey: storageKey(albumID)), let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) else { return [] } let filteredRecords = records.filter { record in !deletedIDs.contains(record.id) && record.userId == userID && record.albumId == albumID } let normalizedRecords = filteredRecords.map { $0.normalizeInterruptedTransfer() } let migratedRecords = normalizeStoredPaths(normalizedRecords, albumID: albumID) return migratedRecords .filter(\.isDisplayable) .sorted { $0.capturedAt > $1.capturedAt } .also { items in items.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) } } } /// 将旧版绝对路径迁移为相对路径并回写持久化。 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 } let userID = userIDProvider() guard !userID.isEmpty else { return } let normalized = records .filter { $0.userId == userID && $0.albumId == albumID } .sorted { $0.capturedAt > $1.capturedAt } normalized.forEach { bindPhotoToAlbum(photoID: $0.id, albumID: albumID) } if let data = try? JSONEncoder().encode(normalized) { defaults.set(data, forKey: storageKey(albumID)) } } /// 标记并移除单张照片。 func remove(albumID: Int, photoID: String) { guard albumID > 0, !photoID.isEmpty else { return } markDeleted(albumID: albumID, photoID: photoID) let deletedIDs = loadDeletedIDs(albumID: albumID) guard let data = defaults.data(forKey: storageKey(albumID)), var records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) else { return } records .filter { $0.id == photoID && $0.userId == userIDProvider() && $0.albumId == albumID } .forEach { deleteRecordFiles($0) } records = records.filter { record in !deletedIDs.contains(record.id) && record.userId == userIDProvider() && record.albumId == albumID } save(albumID: albumID, records: records) } /// 标记照片已删除。 func markDeleted(albumID: Int, photoID: String) { guard albumID > 0, !photoID.isEmpty, !userIDProvider().isEmpty else { return } let updated = loadDeletedIDs(albumID: albumID).union([photoID]) defaults.set(Array(updated), forKey: deletedKey(albumID)) } /// 清空相册关联的本地缓存。 func clearAlbum(albumID: Int) -> Set { guard albumID > 0 else { return [] } let userID = userIDProvider() guard !userID.isEmpty else { return [] } guard let data = defaults.data(forKey: storageKey(albumID)), let records = try? JSONDecoder().decode([WiredTransferPhotoRecord].self, from: data) else { return [] } let userRecords = records.filter { $0.userId == userID } userRecords.forEach { deleteRecordFiles($0) } var bindings = loadBindings().filter { $0.albumID != albumID } saveBindings(bindings) defaults.removeObject(forKey: storageKey(albumID)) defaults.removeObject(forKey: deletedKey(albumID)) return Set(userRecords.map(\.id)) } private func deleteRecordFiles(_ record: WiredTransferPhotoRecord) { deleteFileIfExists(record.localPath) deleteFileIfExists(record.thumbnailPath) } private func deleteFileIfExists(_ path: String) { guard !path.isEmpty else { return } guard let url = CameraDownloadStorage.resolveLocalURL(from: path) else { return } if FileManager.default.fileExists(atPath: url.path) { try? FileManager.default.removeItem(at: url) } } private func storageKey(_ albumID: Int) -> String { "\(accountPrefixProvider())\(Self.keyPrefix)\(albumID)" } private func deletedKey(_ albumID: Int) -> String { "\(storageKey(albumID))\(Self.deletedSuffix)" } private func bindingsKey() -> String { "\(accountPrefixProvider())\(Self.bindingsSuffix)" } private struct PhotoAlbumBinding: Codable { let photoID: String let albumID: Int } private func loadBindings() -> [PhotoAlbumBinding] { guard let data = defaults.data(forKey: bindingsKey()), let bindings = try? JSONDecoder().decode([PhotoAlbumBinding].self, from: data) else { return [] } return bindings } private func saveBindings(_ bindings: [PhotoAlbumBinding]) { if let data = try? JSONEncoder().encode(bindings) { defaults.set(data, forKey: bindingsKey()) } } } private extension WiredTransferPhotoRecord { var isDisplayable: Bool { if !remoteURL.isEmpty { return true } if let url = CameraDownloadStorage.resolveLocalURL(from: localPath), FileManager.default.fileExists(atPath: url.path) { return true } if let url = CameraDownloadStorage.resolveLocalURL(from: thumbnailPath), FileManager.default.fileExists(atPath: url.path) { return true } 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 else { return self } var copy = self copy = WiredTransferPhotoRecord( id: id, sourceId: sourceId, fileName: fileName, localPath: localPath, thumbnailPath: thumbnailPath, capturedAt: capturedAt, fileSizeBytes: fileSizeBytes, status: remoteURL.isEmpty ? WiredTransferUploadStatus.pending.rawValue : WiredTransferUploadStatus.uploaded.rawValue, progress: remoteURL.isEmpty ? 0 : 100, errorMessage: errorMessage, albumId: albumId, userId: userId, remoteURL: remoteURL, updatedAt: updatedAt ) return copy } } private extension Array { @discardableResult func also(_ body: (Self) -> Void) -> Self { body(self) return self } }