import Foundation import SwiftData import UniformTypeIdentifiers struct RemoteAsset: Decodable { let id: String let clientId: String let kind: String let fileName: String let mimeType: String let sizeBytes: Int64 let sha256: String } struct RemoteEvent: Decodable { let id: String let clientId: String? let relativeTimeMs: Int64 let eventType: String let textContent: String? let voiceStartOffsetMs: Int64? let voiceEndOffsetMs: Int64? } struct RemoteSession: Decodable { let id: String let clientId: String? let title: String let startTime: Date let endTime: Date? let durationMs: Int64 let revision: Int64 let deletedAt: Date? let events: [RemoteEvent]? let assets: [RemoteAsset]? } private struct SessionUpload: Encodable { let clientId: String let title: String let startTime: Date let endTime: Date? let durationMs: Int64 let events: [EventUpload] } private struct EventUpload: Encodable { let clientId: String let relativeTimeMs: Int64 let eventType: String let textContent: String? let voiceStartOffsetMs: Int64? let voiceEndOffsetMs: Int64? } private struct SyncCheckpoint: Decodable { let syncedAt: Date } final class RemoteNetworkService: NetworkServiceProtocol { private let client: APIClient init(client: APIClient = .shared) { self.client = client } func fetchSessions() async throws -> [RemoteSession] { try await client.request("sessions?includeDeleted=true", authenticated: true) } func syncSession(_ session: CelestiaSession) async throws -> RemoteSession { let payload = SessionUpload( clientId: session.id.uuidString, title: session.title, startTime: session.startTime, endTime: session.endTime, durationMs: session.durationMs, events: session.events.map { EventUpload( clientId: $0.id.uuidString, relativeTimeMs: $0.relativeTimeMs, eventType: $0.eventType, textContent: $0.textContent, voiceStartOffsetMs: $0.voiceStartOffsetMs, voiceEndOffsetMs: $0.voiceEndOffsetMs ) } ) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true) } func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset { let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream" return try await client.upload( "sessions/\(sessionID)/assets", fileURL: fileURL, fileName: fileURL.lastPathComponent, mimeType: mimeType, fields: ["clientId": clientID, "kind": kind] ) } func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws { try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL) } func recordSyncCheckpoint() async throws { let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true) } } struct RemoteBoundDevice: Decodable { let id: String let name: String let peripheralUUID: String } private struct BindDeviceBody: Encodable { let name: String let peripheralUUID: String let hardwareMAC: String? let firmwareVersion: String? let batteryLevel: Int? let freeStorageMB: Int? let totalStorageMB: Int? } private struct UpdateDeviceBody: Encodable { let name: String? let batteryLevel: Int? let isConnected: Bool? let firmwareVersion: String? let freeStorageMB: Int? let totalStorageMB: Int? } final class DeviceCloudService { static let shared = DeviceCloudService() private let client: APIClient init(client: APIClient = .shared) { self.client = client } func register(_ device: BoundDevice) async throws -> RemoteBoundDevice { let body = BindDeviceBody( name: device.name, peripheralUUID: device.peripheralUUID, hardwareMAC: device.hardwareMAC, firmwareVersion: device.firmwareVersion, batteryLevel: device.batteryLevel, freeStorageMB: device.freeStorageMB, totalStorageMB: device.totalStorageMB ) return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true) } func update(_ device: BoundDevice) async throws -> RemoteBoundDevice { guard let cloudID = device.cloudID else { return try await register(device) } let body = UpdateDeviceBody( name: device.name, batteryLevel: device.batteryLevel, isConnected: device.isConnected, firmwareVersion: device.firmwareVersion, freeStorageMB: device.freeStorageMB, totalStorageMB: device.totalStorageMB ) return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true) } func remove(cloudID: String) async throws { try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true) } } @MainActor final class SyncManager: ObservableObject { static let shared = SyncManager() @Published private(set) var isSyncing = false @Published private(set) var lastSyncDate: Date? @Published private(set) var lastErrorMessage: String? @Published private(set) var completedCount = 0 @Published private(set) var totalCount = 0 private let service: NetworkServiceProtocol init(service: NetworkServiceProtocol = RemoteNetworkService()) { self.service = service lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date } func sync( sessions: [CelestiaSession], modelContext: ModelContext, userID: String ) async -> Bool { guard !isSyncing else { return false } isSyncing = true lastErrorMessage = nil completedCount = 0 totalCount = sessions.filter { ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil) }.count defer { isSyncing = false } do { let remoteSessions = try await service.fetchSessions() let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID) for (remote, local) in mergedSessions { try await downloadMissingAssets(for: local, remote: remote) } try modelContext.save() let eligible = sessions.filter { ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil) } var failures: [String] = [] for session in eligible { session.ownerUserID = userID session.syncState = .syncing session.lastSyncError = nil do { let remote = try await service.syncSession(session) session.cloudSessionId = remote.id session.serverRevision = remote.revision try await uploadLocalAssets(for: session, remote: remote) session.isSynced = true session.syncState = .synced session.lastSyncedAt = Date() completedCount += 1 } catch { session.isSynced = false session.syncState = .failed session.lastSyncError = error.localizedDescription failures.append("\(session.title):\(error.localizedDescription)") } try modelContext.save() } guard failures.isEmpty else { lastErrorMessage = failures.joined(separator: "\n") return false } try await service.recordSyncCheckpoint() let now = Date() lastSyncDate = now UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync") return true } catch { lastErrorMessage = error.localizedDescription return false } } private func uploadLocalAssets(for session: CelestiaSession, remote: RemoteSession) async throws { guard let cloudID = session.cloudSessionId else { throw APIError.missingData } let existingIDs = Set((remote.assets ?? []).map(\.clientId)) if let path = session.localAudioPath { guard let url = AudioPathHelper.resolveURL(for: path) else { throw APIError.transport("本地录音文件不存在:\(path)") } let clientID = "\(session.id.uuidString)-audio" if !existingIDs.contains(clientID) { _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "AUDIO", fileURL: url) } } for event in session.events where event.eventType == "PHOTO" { guard let path = event.localFilePath else { continue } guard let url = AudioPathHelper.resolveURL(for: path) else { throw APIError.transport("照片文件不存在:\(path)") } let clientID = "\(event.id.uuidString)-photo" if !existingIDs.contains(clientID) { _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "PHOTO", fileURL: url) } } } private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws { let assets = remote.assets ?? [] if (session.localAudioPath == nil || AudioPathHelper.resolveURL(for: session.localAudioPath) == nil), let audio = assets.first(where: { $0.kind.uppercased() == "AUDIO" }) { let destination = downloadDestination(for: audio) try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination) session.localAudioPath = AudioPathHelper.relativePath(from: destination.path) } let photoAssets = Dictionary(uniqueKeysWithValues: assets .filter { $0.kind.uppercased() == "PHOTO" } .map { ($0.clientId.lowercased(), $0) }) for event in session.events where event.eventType == "PHOTO" { guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue } let key = "\(event.id.uuidString)-photo".lowercased() guard let asset = photoAssets[key] else { continue } let destination = downloadDestination(for: asset) try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination) event.localFilePath = AudioPathHelper.relativePath(from: destination.path) } } private func downloadDestination(for asset: RemoteAsset) -> URL { let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let fileExtension = (asset.fileName as NSString).pathExtension let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())" return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)") } private func merge( _ remoteSessions: [RemoteSession], into localSessions: [CelestiaSession], modelContext: ModelContext, userID: String ) -> [(RemoteSession, CelestiaSession)] { var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) }) var merged: [(RemoteSession, CelestiaSession)] = [] for remote in remoteSessions { guard let clientID = remote.clientId?.lowercased() else { continue } if let local = byClientID[clientID] { if remote.deletedAt != nil, local.isSynced { modelContext.delete(local) continue } if local.isSynced, remote.revision > local.serverRevision { apply(remote, to: local, userID: userID) } if remote.deletedAt == nil { merged.append((remote, local)) } } else if remote.deletedAt == nil { let id = UUID(uuidString: clientID) ?? UUID() let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime) apply(remote, to: session, userID: userID) modelContext.insert(session) byClientID[clientID] = session merged.append((remote, session)) } } try? modelContext.save() return merged } private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) { local.title = remote.title local.startTime = remote.startTime local.endTime = remote.endTime local.cloudSessionId = remote.id local.ownerUserID = userID local.serverRevision = remote.revision local.isSynced = true local.syncState = .synced local.lastSyncError = nil local.events.removeAll() for remoteEvent in remote.events ?? [] { let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID() let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType) event.textContent = remoteEvent.textContent event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs local.events.append(event) } } }