// // TravelAlbumOTGPhotoStore.swift // suixinkan // import Foundation /// 生成并规范化旅拍照片的客户端唯一标识。 enum TravelAlbumClientPhotoID { /// 生成小写、带连字符的 UUID v4 字符串。 static func make() -> String { UUID().uuidString.lowercased() } /// 规范化服务端或本地保存的客户端照片 ID。 static func normalize(_ value: String?) -> String? { guard let normalized = value? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased(), !normalized.isEmpty else { return nil } return normalized } } /// OTG 传输照片上传状态,对齐 Android `WiredTransferUploadStatus`。 enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable { case pending = "PENDING" case transferring = "TRANSFERRING" case uploading = "UPLOADING" case uploaded = "UPLOADED" case failed = "FAILED" } /// OTG 照片格式上传偏好。 enum TravelAlbumOTGPhotoFormatOption: String, CaseIterable, Equatable, Sendable { case jpg = "JPG" case raw = "RAW" case jpegRaw = "JPEG+RAW" } /// OTG 本地照片记录,按账号、景区、门店与服务端相册 ID 隔离保存。 struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { let id: String let sourceId: String var clientPhotoId: String let fileName: String var localPath: String var thumbnailPath: String let capturedAt: String let fileSizeBytes: Int64 var status: TravelAlbumOTGUploadStatus var progress: Int var errorMessage: String? let albumId: Int let userId: String var remoteUrl: String var updatedAt: Int64 /// 创建 OTG 本地照片记录。 init( id: String, sourceId: String? = nil, clientPhotoId: String = TravelAlbumClientPhotoID.make(), fileName: String, localPath: String, thumbnailPath: String = "", capturedAt: String, fileSizeBytes: Int64, status: TravelAlbumOTGUploadStatus, progress: Int = 0, errorMessage: String? = nil, albumId: Int, userId: String, remoteUrl: String = "", updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) ) { self.id = id self.sourceId = sourceId ?? id self.clientPhotoId = TravelAlbumClientPhotoID.normalize(clientPhotoId) ?? TravelAlbumClientPhotoID.make() self.fileName = fileName self.localPath = localPath self.thumbnailPath = thumbnailPath self.capturedAt = capturedAt self.fileSizeBytes = fileSizeBytes self.status = status self.progress = progress self.errorMessage = errorMessage self.albumId = albumId self.userId = userId self.remoteUrl = remoteUrl self.updatedAt = updatedAt } private enum CodingKeys: String, CodingKey { case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt } /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) sourceId = try container.decodeIfPresent(String.self, forKey: .sourceId) ?? id clientPhotoId = try container.decodeIfPresent(String.self, forKey: .clientPhotoId) ?? "" fileName = try container.decode(String.self, forKey: .fileName) localPath = try container.decode(String.self, forKey: .localPath) thumbnailPath = try container.decodeIfPresent(String.self, forKey: .thumbnailPath) ?? "" capturedAt = try container.decode(String.self, forKey: .capturedAt) fileSizeBytes = try container.decodeIfPresent(Int64.self, forKey: .fileSizeBytes) ?? 0 status = try container.decode(TravelAlbumOTGUploadStatus.self, forKey: .status) progress = try container.decodeIfPresent(Int.self, forKey: .progress) ?? 0 errorMessage = try container.decodeIfPresent(String.self, forKey: .errorMessage) albumId = try container.decode(Int.self, forKey: .albumId) userId = try container.decode(String.self, forKey: .userId) remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0 } /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { guard status == .transferring || status == .uploading else { return self } var copy = self copy.status = .pending copy.progress = 0 copy.errorMessage = nil copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) return copy } } /// 相册内照片的持久化读写接口,供 OTG 边拍边传服务写入。 protocol PhotoRepositoryProtocol { /// 读取指定服务端相册 ID 的本地照片记录。 func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord] /// 添加一张已下载到本地的照片。 @discardableResult func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord /// 删除指定照片及本地文件。 func deletePhoto(photoID: String, albumID: Int) throws } /// OTG 本地照片仓库,使用 JSON 索引和隔离目录保存照片。 final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol { private let storage: TravelAlbumOTGPhotoStore /// 初始化 OTG 本地照片仓库。 init(storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore()) { self.storage = storage } /// 读取指定服务端相册 ID 的本地照片记录。 func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord] { storage.load(albumId: albumID) } /// 添加一张已下载到本地的照片。 @discardableResult func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws -> TravelAlbumOTGPhotoRecord { let fileURL = URL(fileURLWithPath: localPath) let attributes = try? FileManager.default.attributesOfItem(atPath: localPath) let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 let userId = storage.context.userId guard !userId.isEmpty else { throw CameraError.connectionFailed("用户信息缺失,无法保存照片") } let record = TravelAlbumOTGPhotoRecord( id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt), fileName: fileURL.lastPathComponent, localPath: storage.relativePath(for: fileURL, albumId: albumID), capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt), fileSizeBytes: size, status: .pending, albumId: albumID, userId: userId ) storage.upsert(record, albumId: albumID) return record } /// 删除指定照片及本地文件。 func deletePhoto(photoID: String, albumID: Int) throws { storage.remove(albumId: albumID, photoId: photoID) } } /// OTG 本地存储上下文,决定照片和索引的隔离目录。 struct TravelAlbumOTGStorageContext: Equatable, Sendable { let accountCachePrefix: String let userId: String let scenicId: Int let storeId: Int /// 使用当前登录账号创建存储上下文。 static var current: TravelAlbumOTGStorageContext { TravelAlbumOTGStorageContext( accountCachePrefix: AppStore.shared.session.accountCachePrefix, userId: AppStore.shared.session.userId, scenicId: AppStore.shared.session.currentScenicId, storeId: AppStore.shared.session.currentStoreId ) } } /// 旅拍相册 OTG 本地照片存储,负责路径隔离、索引读写与文件清理。 final class TravelAlbumOTGPhotoStore { let context: TravelAlbumOTGStorageContext private let fileManager: FileManager private let applicationSupportDirectory: URL private let cachesDirectory: URL /// 初始化旅拍相册 OTG 存储。 init( context: TravelAlbumOTGStorageContext = .current, fileManager: FileManager = .default, applicationSupportDirectory: URL? = nil, cachesDirectory: URL? = nil ) { self.context = context self.fileManager = fileManager self.applicationSupportDirectory = applicationSupportDirectory ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] self.cachesDirectory = cachesDirectory ?? fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] } /// 读取相册照片记录。 func load(albumId: Int) -> [TravelAlbumOTGPhotoRecord] { guard albumId > 0, !context.userId.isEmpty else { return [] } guard let data = try? Data(contentsOf: indexURL(albumId: albumId)), let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else { return [] } let scoped = decoded.filter { $0.albumId == albumId && $0.userId == context.userId } let migrated = scoped.map { record -> TravelAlbumOTGPhotoRecord in var copy = record copy.clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId) ?? TravelAlbumClientPhotoID.make() return copy.normalizedAfterInterruptedTransfer() } if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) { save(migrated, albumId: albumId) } return migrated .filter { isDisplayable($0, albumId: albumId) } .sorted { $0.capturedAt > $1.capturedAt } } /// 保存相册照片记录。 func save(_ records: [TravelAlbumOTGPhotoRecord], albumId: Int) { guard albumId > 0, !context.userId.isEmpty else { return } let normalized = records .filter { $0.albumId == albumId && $0.userId == context.userId } .map { recordByPersistingRelativePaths($0, albumId: albumId) } .sorted { $0.capturedAt > $1.capturedAt } do { try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true) let data = try JSONEncoder().encode(normalized) try data.write(to: indexURL(albumId: albumId), options: .atomic) } catch { OTGLog.error(.connection, "save OTG photo records failed: \(error.localizedDescription)") } } /// 插入或更新单条照片记录。 func upsert(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) { var records = load(albumId: albumId).filter { $0.id != record.id } records.append(record) save(records, albumId: albumId) } /// 删除单张照片记录并清理本地文件。 func remove(albumId: Int, photoId: String) { guard albumId > 0, !photoId.isEmpty else { return } let records = load(albumId: albumId) records.filter { $0.id == photoId }.forEach(deleteFiles) save(records.filter { $0.id != photoId }, albumId: albumId) } /// 清理整个相册的 OTG 本地缓存。 func clearAlbum(albumId: Int) { guard albumId > 0 else { return } try? fileManager.removeItem(at: albumRoot(albumId: albumId)) try? fileManager.removeItem(at: previewRoot(albumId: albumId)) } /// 原片目录。 func originalsDirectory(albumId: Int) throws -> URL { let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true) try fileManager.createDirectory(at: url, withIntermediateDirectories: true) try excludeFromBackup(url) return url } /// 预览目录。 func previewsDirectory(albumId: Int) throws -> URL { let url = previewRoot(albumId: albumId) try fileManager.createDirectory(at: url, withIntermediateDirectories: true) return url } /// 在隔离原片目录下分配不冲突文件 URL。 func uniqueOriginalFileURL(filename: String, albumId: Int) throws -> URL { let directory = try originalsDirectory(albumId: albumId) let sanitized = PTPHelper.sanitizeFilename(filename) let baseURL = directory.appendingPathComponent(sanitized) if !fileManager.fileExists(atPath: baseURL.path) { return baseURL } let stem = (sanitized as NSString).deletingPathExtension let ext = (sanitized as NSString).pathExtension var counter = 1 while counter < 10_000 { let suffix = ext.isEmpty ? "\(stem)_\(counter)" : "\(stem)_\(counter).\(ext)" let candidate = directory.appendingPathComponent(suffix) if !fileManager.fileExists(atPath: candidate.path) { return candidate } counter += 1 } throw CameraError.connectionFailed("无法分配唯一文件名") } /// 写入原片数据,返回隔离目录中的文件 URL。 func writeImage(_ data: Data, filename: String, albumId: Int) throws -> URL { let url = try uniqueOriginalFileURL(filename: filename, albumId: albumId) try data.write(to: url, options: .atomic) return url } /// 返回写入索引用的相对路径。 func relativePath(for url: URL, albumId: Int) -> String { let path = url.standardizedFileURL.path let originalRoot = albumRoot(albumId: albumId) .appendingPathComponent("originals", isDirectory: true) .standardizedFileURL .path if let relative = pathComponent(path, relativeTo: originalRoot) { return "originals/\(relative)" } let previewPath = previewRoot(albumId: albumId).standardizedFileURL.path if let relative = pathComponent(path, relativeTo: previewPath) { return "previews/\(relative)" } return url.lastPathComponent } /// 把索引中的相对路径解析到当前沙盒绝对 URL。 func absoluteURL(for relativePath: String, albumId: Int) -> URL? { let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !trimmed.hasPrefix("/") else { return nil } if trimmed.hasPrefix("originals/") { return albumRoot(albumId: albumId).appendingPathComponent(trimmed) } if trimmed.hasPrefix("previews/") { return previewRoot(albumId: albumId) .deletingLastPathComponent() .appendingPathComponent(trimmed) } return albumRoot(albumId: albumId).appendingPathComponent(trimmed) } /// 判断索引相对路径对应的文件是否存在。 func fileExists(relativePath: String, albumId: Int) -> Bool { guard let url = absoluteURL(for: relativePath, albumId: albumId) else { return false } return fileManager.fileExists(atPath: url.path) } /// 返回可直接用于文件读取的记录副本。 func recordWithResolvedFilePaths(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> TravelAlbumOTGPhotoRecord { var resolved = record if let localURL = absoluteURL(for: record.localPath, albumId: albumId) { resolved.localPath = localURL.path } if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) { resolved.thumbnailPath = thumbnailURL.path } return resolved } /// 生成稳定照片 ID。 static func photoId(for fileName: String, createdAt: Date) -> String { let timestamp = Int64(createdAt.timeIntervalSince1970) return "\(fileName.uppercased())_\(timestamp)" } /// Android 同款时间格式。 static func transferTimeText(_ date: Date) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" return formatter.string(from: date) } /// 格式化文件大小。 static func fileSizeText(_ bytes: Int64) -> String { guard bytes > 0 else { return "--" } let mb = Double(bytes) / 1024.0 / 1024.0 if mb >= 1024 { return String(format: "%.2f GB", locale: Locale(identifier: "en_US_POSIX"), mb / 1024.0) } return String(format: "%.2f MB", locale: Locale(identifier: "en_US_POSIX"), mb) } private func albumRoot(albumId: Int) -> URL { applicationSupportDirectory .appendingPathComponent("TravelAlbumOTG", isDirectory: true) .appendingPathComponent(safePathComponent(context.accountCachePrefix), isDirectory: true) .appendingPathComponent("scenic_\(context.scenicId)", isDirectory: true) .appendingPathComponent("store_\(context.storeId)", isDirectory: true) .appendingPathComponent("album_\(albumId)", isDirectory: true) } private func previewRoot(albumId: Int) -> URL { cachesDirectory .appendingPathComponent("TravelAlbumOTG", isDirectory: true) .appendingPathComponent(safePathComponent(context.accountCachePrefix), isDirectory: true) .appendingPathComponent("scenic_\(context.scenicId)", isDirectory: true) .appendingPathComponent("store_\(context.storeId)", isDirectory: true) .appendingPathComponent("album_\(albumId)", isDirectory: true) .appendingPathComponent("previews", isDirectory: true) } private func indexURL(albumId: Int) -> URL { albumRoot(albumId: albumId).appendingPathComponent("records.json") } private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { [record.localPath, record.thumbnailPath].forEach { path in guard !path.isEmpty else { return } guard let url = absoluteURL(for: path, albumId: record.albumId) else { return } try? fileManager.removeItem(at: url) } } private func isDisplayable(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> Bool { if record.status == .uploaded { return true } if !record.remoteUrl.isEmpty { return true } if fileExists(relativePath: record.localPath, albumId: albumId) { return true } if fileExists(relativePath: record.thumbnailPath, albumId: albumId) { return true } return false } private func recordByPersistingRelativePaths( _ record: TravelAlbumOTGPhotoRecord, albumId: Int ) -> TravelAlbumOTGPhotoRecord { var normalized = record if !record.localPath.isEmpty { normalized.localPath = record.localPath.hasPrefix("/") ? relativePath(for: URL(fileURLWithPath: record.localPath), albumId: albumId) : record.localPath } if !record.thumbnailPath.isEmpty { normalized.thumbnailPath = record.thumbnailPath.hasPrefix("/") ? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId) : record.thumbnailPath } return normalized } private func pathComponent(_ path: String, relativeTo root: String) -> String? { let prefix = root.hasSuffix("/") ? root : "\(root)/" guard path.hasPrefix(prefix) else { return nil } return String(path.dropFirst(prefix.count)) } private func excludeFromBackup(_ url: URL) throws { var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true var mutableURL = url try mutableURL.setResourceValues(resourceValues) } private func safePathComponent(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "guest" } let unsafe = CharacterSet(charactersIn: "/\\:?%*|\"<>") return trimmed.unicodeScalars.reduce(into: "") { result, scalar in result += unsafe.contains(scalar) ? "_" : String(scalar) } } } /// 按服务端客户端照片 ID 快照回写本地持久状态。 enum TravelAlbumOTGServerStatusPolicy { /// 只处理请求发起时已存在且期间未变化的非活动记录。 static func reconcile( records: [TravelAlbumOTGPhotoRecord], baselineRecordsById: [String: TravelAlbumOTGPhotoRecord], activePhotoIds: Set, serverClientPhotoIds: [String], now: Int64 ) -> [TravelAlbumOTGPhotoRecord] { let serverIds = Set(serverClientPhotoIds.compactMap(TravelAlbumClientPhotoID.normalize)) return records.map { record in guard baselineRecordsById[record.id] == record, !activePhotoIds.contains(record.id) else { return record } var copy = record if let clientPhotoId = TravelAlbumClientPhotoID.normalize(record.clientPhotoId), serverIds.contains(clientPhotoId) { copy.status = .uploaded copy.progress = 100 copy.errorMessage = nil copy.updatedAt = now } else if record.status == .uploaded { copy.status = .pending copy.progress = 0 copy.errorMessage = nil copy.updatedAt = now } return copy } } } /// 兼容从 `otg_swift` 同步来的边拍边传写入调用。 enum AlbumPhotoStorage { /// 写入原片数据到当前账号与相册隔离目录。 static func writeImage(_ data: Data, filename: String, albumID: Int) throws -> URL { try TravelAlbumOTGPhotoStore().writeImage(data, filename: filename, albumId: albumID) } }