import Foundation import SwiftData import UniformTypeIdentifiers import Network import CryptoKit // Remote contract: Docs/RemoteAPI.openapi.yaml struct RemoteAsset: Decodable, Sendable { let id: String let clientId: String let kind: String let fileName: String let mimeType: String let sizeBytes: Int64 let sha256: String let createdAt: Date? } struct AssetUploadResult { let asset: RemoteAsset let sessionRevision: Int64? } struct StorageQuota: Decodable { let totalBytes: Int64 let usedBytes: Int64 let remainingBytes: Int64 } struct RemoteEvent: Decodable { let id: String let clientId: String? let relativeTimeMs: Int64 let eventType: String let textContent: String? let voiceStartOffsetMs: Int64? let voiceEndOffsetMs: Int64? let locationName: String? let locationAddress: String? let latitude: Double? let longitude: Double? } struct RemoteSessionIndex: Decodable { let id: String let clientId: String? let title: String let startTime: Date let endTime: Date? let durationMs: Int64 let photoCount: Int let noteCount: Int let revision: Int64 let deletedAt: Date? let updatedAt: Date } 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 updatedAt: 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] let baseRevision: Int64? let deletedEventClientIds: [String] } private struct EventUpload: Encodable { let clientId: String let relativeTimeMs: Int64 let eventType: String let textContent: String? let voiceStartOffsetMs: Int64? let voiceEndOffsetMs: Int64? let locationName: String? let locationAddress: String? let latitude: Double? let longitude: Double? } private struct SyncCheckpoint: Decodable { let syncedAt: Date } private struct AssetUploadPayload: Decodable { let asset: RemoteAsset let sessionRevision: Int64? private enum CodingKeys: String, CodingKey { case asset case sessionRevision } init(from decoder: Decoder) throws { if let container = try? decoder.container(keyedBy: CodingKeys.self), container.contains(.asset) { asset = try container.decode(RemoteAsset.self, forKey: .asset) sessionRevision = try container.decodeIfPresent(Int64.self, forKey: .sessionRevision) } else { asset = try RemoteAsset(from: decoder) sessionRevision = nil } } } private struct ChunkUploadInitBody: Encodable { let clientId: String let kind: String let fileName: String let mimeType: String let fileSize: Int64 let chunkSize: Int } private struct ChunkUploadInitResponse: Decodable { let uploadId: String let totalChunks: Int let chunkSize: Int } private struct ChunkUploadProgressResponse: Decodable { let uploadedChunks: Int let totalChunks: Int } private struct ChunkUploadCompleteBody: Encodable { let uploadId: String let totalChunks: Int } final class RemoteNetworkService: NetworkServiceProtocol { private static let chunkedUploadThreshold: Int64 = 16 * 1024 * 1024 private static let preferredChunkSize = 8 * 1024 * 1024 private let client: APIClient init(client: APIClient = .shared) { self.client = client } func fetchSessionIndex() async throws -> [RemoteSessionIndex] { try await client.request("sessions?includeDeleted=true", authenticated: true) } func fetchSession(sessionID: String) async throws -> RemoteSession { try await client.request("sessions/\(sessionID)", authenticated: true) } func syncSession( _ session: CelestiaSession, deletedEventClientIDs: [String] ) 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, locationName: $0.locationName, locationAddress: $0.locationAddress, latitude: $0.latitude, longitude: $0.longitude ) }, baseRevision: session.serverRevision > 0 ? session.serverRevision : nil, deletedEventClientIds: deletedEventClientIDs ) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true) } func deleteSession(sessionID: String) async throws { try await client.requestVoid( "sessions/\(sessionID)", method: .delete, authenticated: true ) } func fetchStorageQuota() async throws -> StorageQuota { try await client.request("storage/quota", authenticated: true) } func uploadAsset( sessionID: String, clientID: String, kind: String, fileURL: URL, progress: @escaping @Sendable (Double) -> Void ) async throws -> AssetUploadResult { let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream" let size = try fileSize(of: fileURL) if size >= Self.chunkedUploadThreshold { return try await uploadAssetInChunks( sessionID: sessionID, clientID: clientID, kind: kind, fileURL: fileURL, fileSize: size, mimeType: mimeType, progress: progress ) } let payload: AssetUploadPayload = try await client.upload( "sessions/\(sessionID)/assets", fileURL: fileURL, fileName: fileURL.lastPathComponent, mimeType: mimeType, fields: ["clientId": clientID, "kind": kind], progress: progress ) return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision) } 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) } private func uploadAssetInChunks( sessionID: String, clientID: String, kind: String, fileURL: URL, fileSize: Int64, mimeType: String, progress: @escaping @Sendable (Double) -> Void ) async throws -> AssetUploadResult { let body = ChunkUploadInitBody( clientId: clientID, kind: kind, fileName: fileURL.lastPathComponent, mimeType: mimeType, fileSize: fileSize, chunkSize: Self.preferredChunkSize ) let encoder = JSONEncoder() let upload: ChunkUploadInitResponse = try await client.request( "sessions/\(sessionID)/assets/init", method: .post, body: try encoder.encode(body), authenticated: true ) for chunkIndex in 0.. (url: URL, byteCount: Int) { let task = Task.detached(priority: .utility) { try Task.checkCancellation() let input = try FileHandle(forReadingFrom: fileURL) defer { try? input.close() } try input.seek(toOffset: offset) guard let data = try input.read(upToCount: chunkSize), !data.isEmpty else { throw APIError.transport("读取上传分片失败") } try Task.checkCancellation() let temporaryURL = FileManager.default.temporaryDirectory .appendingPathComponent("celestia-\(uploadID)-\(chunkIndex).chunk") try data.write(to: temporaryURL, options: .atomic) return (temporaryURL, data.count) } return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } } private func fileSize(of url: URL) throws -> Int64 { let values = try url.resourceValues(forKeys: [.fileSizeKey]) guard let size = values.fileSize else { throw APIError.transport("无法读取待上传文件大小") } return Int64(size) } } 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) } } final class NetworkStatusMonitor: ObservableObject, @unchecked Sendable { static let shared = NetworkStatusMonitor() @Published private(set) var isConnected = true @Published private(set) var isWiFi = false @Published private(set) var isCellular = false private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "com.celestia.trace.network-path") private init() { monitor.pathUpdateHandler = { [weak self] path in let connected = path.status == .satisfied let usesWiFi = path.usesInterfaceType(.wifi) let usesCellular = path.usesInterfaceType(.cellular) DispatchQueue.main.async { self?.isConnected = connected self?.isWiFi = usesWiFi self?.isCellular = usesCellular } } monitor.start(queue: queue) } var connectionName: String { if isWiFi { return "Wi-Fi" } if isCellular { return "蜂窝网络" } return isConnected ? "当前网络" : "网络" } } @MainActor final class SyncManager: ObservableObject { static let shared = SyncManager() private static let maxConcurrentAssetDownloads = 4 @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 @Published private(set) var activeSessionID: UUID? @Published private(set) var syncProgress: Double = 0 private let service: NetworkServiceProtocol private var activeSyncTask: Task? private var automaticSyncTasks: [UUID: Task] = [:] private var automaticSyncGenerations: [UUID: UUID] = [:] private var queuedSyncRequest: SyncRequest? private static let diagnosticsByteFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() formatter.countStyle = .file formatter.allowedUnits = [.useKB, .useMB, .useGB] formatter.isAdaptive = true return formatter }() private struct SyncRequest { let sessions: [CelestiaSession] let modelContext: ModelContext let userID: String } private enum AssetDownloadTarget: Sendable { case audio case photo(eventID: UUID) } private struct AssetDownloadJob: Sendable { let sessionID: String let asset: RemoteAsset let destination: URL let target: AssetDownloadTarget let displayName: String } init(service: NetworkServiceProtocol = RemoteNetworkService()) { self.service = service lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date } func estimatedUploadBytes(for session: CelestiaSession) -> Int64 { var urls: [URL] = [] if let url = AudioPathHelper.resolveURL(for: session.localAudioPath) { urls.append(url) } urls.append(contentsOf: session.events.compactMap { event in guard event.eventType == "PHOTO" else { return nil } return AudioPathHelper.resolveURL(for: event.localFilePath) }) return urls.reduce(into: 0) { total, url in let values = try? url.resourceValues(forKeys: [.fileSizeKey]) total += Int64(values?.fileSize ?? 0) } } func scheduleAutomaticSync( for session: CelestiaSession, modelContext: ModelContext, delay: Duration = .seconds(1.5) ) { guard session.endTime != nil, session.syncState != .conflict, AuthManager.shared.currentUser?.id != nil else { return } let sessionID = session.id let generation = UUID() automaticSyncGenerations[sessionID] = generation automaticSyncTasks[sessionID]?.cancel() if isSyncing, activeSessionID == sessionID { activeSyncTask?.cancel() } automaticSyncTasks[sessionID] = Task { @MainActor [weak self] in do { try await Task.sleep(for: delay) while let self, (self.isSyncing || !NetworkStatusMonitor.shared.isConnected) { try Task.checkCancellation() try await Task.sleep(for: .seconds(0.75)) } guard let self, !Task.isCancelled, self.automaticSyncGenerations[sessionID] == generation, session.needsCloudSync, let userID = AuthManager.shared.currentUser?.id else { return } _ = await self.sync( sessions: [session], modelContext: modelContext, userID: userID ) if self.automaticSyncGenerations[sessionID] == generation { self.automaticSyncTasks[sessionID] = nil self.automaticSyncGenerations[sessionID] = nil } } catch { guard let self, self.automaticSyncGenerations[sessionID] == generation else { return } self.automaticSyncTasks[sessionID] = nil self.automaticSyncGenerations[sessionID] = nil } } } func sync( sessions: [CelestiaSession], modelContext: ModelContext, userID: String ) async -> Bool { guard !isSyncing else { queuedSyncRequest = SyncRequest( sessions: sessions, modelContext: modelContext, userID: userID ) DeveloperLogStore.log("现场记录同步", "已有同步任务正在运行,已排队再次检查时间戳") return false } DeveloperLogStore.log("现场记录同步", "开始同步,本地共 \(sessions.count) 条记录") let syncStartedAt = diagnosticsNow diagnosticsLog("整轮开始:本地记录=\(sessions.count)") isSyncing = true let task = Task { @MainActor [self] in await performSync( sessions: sessions, modelContext: modelContext, userID: userID ) } activeSyncTask = task let result = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() } activeSyncTask = nil isSyncing = false activeSessionID = nil DeveloperLogStore.log( "现场记录同步", result ? "同步任务完成" : "同步任务未完成", level: result ? .success : .warning ) diagnosticsLog( "整轮\(result ? "完成" : "未完成"):总耗时 \(diagnosticsElapsed(since: syncStartedAt))", level: result ? .success : .warning ) if let queuedRequest = queuedSyncRequest { queuedSyncRequest = nil Task { @MainActor [weak self] in guard AuthManager.shared.currentUser?.id == queuedRequest.userID else { return } _ = await self?.sync( sessions: queuedRequest.sessions, modelContext: queuedRequest.modelContext, userID: queuedRequest.userID ) } } return result } func pauseSync(sessionID: UUID) { guard isSyncing, activeSessionID == sessionID else { return } queuedSyncRequest = nil activeSyncTask?.cancel() } private func performSync( sessions: [CelestiaSession], modelContext: ModelContext, userID: String ) async -> Bool { lastErrorMessage = nil completedCount = 0 activeSessionID = nil syncProgress = 0 let queuedSessions = deduplicatedSessions(sessions.filter { ($0.ownerUserID == nil || $0.ownerUserID == userID) && $0.needsCloudSync && $0.syncState != .conflict }) totalCount = queuedSessions.count DeveloperLogStore.log("现场记录同步", "待上传 \(queuedSessions.count) 条记录,正在获取云端索引") // Give the UI immediate feedback while the remote index is being fetched. // The persisted state is changed only when this session actually begins syncing. activeSessionID = queuedSessions.first?.id var pendingRemoteDownloads: [CelestiaSession] = [] do { let indexStartedAt = diagnosticsNow diagnosticsLog("阶段 1/5 开始:请求服务器时间戳索引", level: .receive) let remoteIndex = try await service.fetchSessionIndex() diagnosticsLog( "阶段 1/5 完成:云端记录=\(remoteIndex.count),耗时 \(diagnosticsElapsed(since: indexStartedAt))", level: .success ) DeveloperLogStore.log("现场记录同步", "获取到 \(remoteIndex.count) 条云端时间戳", level: .success) try Task.checkCancellation() let mergeStartedAt = diagnosticsNow let allLocalSessions = try modelContext.fetch(FetchDescriptor()) let indexedSessions = try await mergeIndex( remoteIndex, into: allLocalSessions, modelContext: modelContext, userID: userID ) diagnosticsLog( "阶段 2/5 完成:读取并合并本地记录=\(allLocalSessions.count),索引匹配=\(indexedSessions.count),耗时 \(diagnosticsElapsed(since: mergeStartedAt))" ) var failures: [String] = [] var remoteDetailsByID: [String: RemoteSession] = [:] // mergeIndex has already persisted list metadata. Only timestamp // changes proceed to the second phase that fetches concrete content. let sessionsRequiringDetail = indexedSessions.filter { $0.needsDetail } pendingRemoteDownloads = sessionsRequiringDetail.map(\.local) if let firstDownload = sessionsRequiringDetail.first?.local { activeSessionID = firstDownload.id } await Task.yield() let detailStageStartedAt = diagnosticsNow diagnosticsLog("阶段 3/5 开始:需要拉取详情=\(sessionsRequiringDetail.count)") for (detailIndex, match) in sessionsRequiringDetail.enumerated() { let local = match.local let recordLabel = diagnosticsRecordLabel( local, position: detailIndex + 1, total: sessionsRequiringDetail.count ) let recordStartedAt = diagnosticsNow do { activeSessionID = local.id let detailStartedAt = diagnosticsNow let remote = try await service.fetchSession(sessionID: match.index.id) let remoteAssets = remote.assets ?? [] let remoteBytes = remoteAssets.reduce(Int64(0)) { $0 + $1.sizeBytes } diagnosticsLog( "\(recordLabel) 详情响应:事件=\(remote.events?.count ?? 0),资源=\(remoteAssets.count)(\(diagnosticsBytes(remoteBytes))),耗时 \(diagnosticsElapsed(since: detailStartedAt))", level: .receive ) remoteDetailsByID[remote.id] = remote let applyStartedAt = diagnosticsNow await apply( remote, to: local, userID: userID, syncState: .syncing ) try modelContext.save() diagnosticsLog( "\(recordLabel) 详情写入本地:耗时 \(diagnosticsElapsed(since: applyStartedAt))" ) await Task.yield() let assetStartedAt = diagnosticsNow let downloadedAssets = try await downloadMissingAssets( for: local, remote: remote ) { guard local.hasConfirmedCloudSync else { return } activeSessionID = local.id local.syncState = .syncing local.lastSyncError = nil try? modelContext.save() } try Task.checkCancellation() local.serverUpdatedAt = remote.updatedAt local.syncState = .synced local.lastSyncError = nil local.lastSyncedAt = Date() let finalSaveStartedAt = diagnosticsNow try modelContext.save() diagnosticsLog( "\(recordLabel) 资源阶段:\(downloadedAssets ? "发生下载" : "无需下载"),耗时 \(diagnosticsElapsed(since: assetStartedAt));最终保存 \(diagnosticsElapsed(since: finalSaveStartedAt))", level: .success ) diagnosticsLog( "\(recordLabel) 云端拉取完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))", level: .success ) await Task.yield() } catch where Task.isCancelled { diagnosticsLog( "\(recordLabel) 云端拉取取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))", level: .warning ) if local.hasConfirmedCloudSync, local.syncState == .syncing { local.syncState = .failed local.lastSyncError = "已暂停从云端下载" try? modelContext.save() } throw CancellationError() } catch { if local.hasConfirmedCloudSync { local.syncState = .failed local.lastSyncError = "从云端下载失败:\(error.localizedDescription)" try? modelContext.save() } diagnosticsLog( "\(recordLabel) 云端拉取失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)", level: .error ) failures.append("\(local.title):\(error.localizedDescription)") DeveloperLogStore.log( "现场记录同步", "\(local.title) 从云端下载失败:\(error.localizedDescription)", level: .error ) } } try modelContext.save() diagnosticsLog( "阶段 3/5 完成:详情记录=\(sessionsRequiringDetail.count),总耗时 \(diagnosticsElapsed(since: detailStageStartedAt))" ) let eligible = deduplicatedSessions(sessions.filter { ($0.ownerUserID == nil || $0.ownerUserID == userID) && $0.needsCloudSync && $0.syncState != .conflict }) let eligibleCount = max(eligible.count, 1) let uploadStageStartedAt = diagnosticsNow diagnosticsLog("阶段 4/5 开始:需要上传=\(eligible.count)") for (index, session) in eligible.enumerated() { try Task.checkCancellation() await Task.yield() activeSessionID = session.id let sessionStartProgress = Double(index) / Double(eligibleCount) let sessionProgressSpan = 1.0 / Double(eligibleCount) syncProgress = sessionStartProgress session.ownerUserID = userID session.syncState = .syncing session.lastSyncError = nil DeveloperLogStore.log( "现场记录同步", "正在同步 \(index + 1)/\(eligible.count):\(session.title)" ) let recordLabel = diagnosticsRecordLabel( session, position: index + 1, total: eligible.count ) let recordStartedAt = diagnosticsNow do { var previousRemote: RemoteSession? if let cloudID = session.cloudSessionId { if let cached = remoteDetailsByID[cloudID] { previousRemote = cached } else if remoteIndex.contains(where: { $0.id == cloudID }) { let previousDetailStartedAt = diagnosticsNow let fetched = try await service.fetchSession(sessionID: cloudID) diagnosticsLog( "\(recordLabel) 上传前详情:耗时 \(diagnosticsElapsed(since: previousDetailStartedAt))", level: .receive ) remoteDetailsByID[cloudID] = fetched previousRemote = fetched } } let localEventIDs = Set(session.events.map { $0.id.uuidString.lowercased() }) let deletedEventClientIDs = previousRemote?.events? .compactMap(\.clientId) .filter { !localEventIDs.contains($0.lowercased()) } ?? [] let metadataStartedAt = diagnosticsNow let remote = try await service.syncSession( session, deletedEventClientIDs: deletedEventClientIDs ) diagnosticsLog( "\(recordLabel) 元数据上传:耗时 \(diagnosticsElapsed(since: metadataStartedAt))", level: .transmit ) try Task.checkCancellation() syncProgress = sessionStartProgress + sessionProgressSpan * 0.12 session.cloudSessionId = remote.id session.serverRevision = remote.revision session.serverUpdatedAt = remote.updatedAt try await uploadLocalAssets(for: session, remote: remote) { [weak self] assetProgress in Task { @MainActor [weak self] in self?.syncProgress = sessionStartProgress + sessionProgressSpan * (0.12 + assetProgress * 0.83) } } try Task.checkCancellation() let finalDetailStartedAt = diagnosticsNow let finalRemote = try await service.fetchSession(sessionID: remote.id) diagnosticsLog( "\(recordLabel) 上传后详情确认:耗时 \(diagnosticsElapsed(since: finalDetailStartedAt))", level: .receive ) remoteDetailsByID[finalRemote.id] = finalRemote session.serverRevision = finalRemote.revision session.serverUpdatedAt = finalRemote.updatedAt session.isSynced = true session.syncState = .synced session.lastSyncedAt = Date() completedCount += 1 syncProgress = sessionStartProgress + sessionProgressSpan DeveloperLogStore.log( "现场记录同步", "\(session.title) 同步成功,云端版本 v\(session.serverRevision)", level: .success ) diagnosticsLog( "\(recordLabel) 上传链路完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))", level: .success ) } catch where Task.isCancelled { diagnosticsLog( "\(recordLabel) 上传链路取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))", level: .warning ) session.isSynced = false session.syncState = .pending session.lastSyncError = nil syncProgress = 0 try? modelContext.save() return false } catch APIError.server(let code, _) where code == 409 { diagnosticsLog( "\(recordLabel) 上传链路冲突:耗时 \(diagnosticsElapsed(since: recordStartedAt))", level: .error ) session.isSynced = false session.syncState = .conflict session.lastSyncError = "本地版本 \(session.serverRevision) 与云端版本不一致" DeveloperLogStore.log( "现场记录同步", "\(session.title) 发生版本冲突", level: .error ) } catch { diagnosticsLog( "\(recordLabel) 上传链路失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)", level: .error ) session.isSynced = false session.syncState = .failed session.lastSyncError = error.localizedDescription failures.append("\(session.title):\(error.localizedDescription)") DeveloperLogStore.log( "现场记录同步", "\(session.title) 同步失败:\(error.localizedDescription)", level: .error ) } try modelContext.save() } diagnosticsLog( "阶段 4/5 完成:上传记录=\(eligible.count),总耗时 \(diagnosticsElapsed(since: uploadStageStartedAt))" ) let conflicts = sessions.filter { $0.syncState == .conflict } if !conflicts.isEmpty { failures.append(contentsOf: conflicts.map { "\($0.title):本地和云端版本不一致" }) } guard failures.isEmpty else { lastErrorMessage = failures.joined(separator: "\n") DeveloperLogStore.log("现场记录同步", "同步结束,存在 \(failures.count) 个问题", level: .error) return false } let checkpointStartedAt = diagnosticsNow try await service.recordSyncCheckpoint() diagnosticsLog( "阶段 5/5 完成:提交同步检查点,耗时 \(diagnosticsElapsed(since: checkpointStartedAt))", level: .success ) let now = Date() lastSyncDate = now UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync") DeveloperLogStore.log("现场记录同步", "云端检查点已更新", level: .success) return true } catch where Task.isCancelled { for session in pendingRemoteDownloads where session.syncState == .syncing { session.syncState = .failed session.lastSyncError = "已暂停从云端下载" } try? modelContext.save() syncProgress = 0 return false } catch { for session in pendingRemoteDownloads where session.syncState == .syncing { session.syncState = .failed session.lastSyncError = "从云端下载中断:\(error.localizedDescription)" } try? modelContext.save() lastErrorMessage = error.localizedDescription DeveloperLogStore.log("现场记录同步", "同步中断:\(error.localizedDescription)", level: .error) return false } } private func uploadLocalAssets( for session: CelestiaSession, remote: RemoteSession, progress: @escaping @Sendable (Double) -> Void ) async throws { guard let cloudID = session.cloudSessionId else { throw APIError.missingData } let stageStartedAt = diagnosticsNow let recordLabel = diagnosticsRecordLabel(session) let existingIDs = Set((remote.assets ?? []).map(\.clientId)) var uploads: [(clientID: String, kind: String, url: URL, size: Int64)] = [] if let path = session.localAudioPath { guard let url = AudioPathHelper.resolveURL(for: path) else { throw APIError.transport("本地录音文件不存在:\(path)") } let sha256 = try await fileSHA256( of: url, purpose: "\(recordLabel) 音频上传校验" ) let hasMatchingAudio = (remote.assets ?? []).contains { $0.kind.uppercased() == "AUDIO" && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame } if !hasMatchingAudio { let clientID = "\(session.id.uuidString)-audio-\(sha256.prefix(16))" uploads.append((clientID, "AUDIO", url, fileSize(of: 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) { uploads.append((clientID, "PHOTO", url, fileSize(of: url))) } } let totalBytes = max(uploads.reduce(Int64(0)) { $0 + $1.size }, 1) let requiredBytes = uploads.reduce(Int64(0)) { $0 + $1.size } if requiredBytes > 0 { let quotaStartedAt = diagnosticsNow let quota = try await service.fetchStorageQuota() diagnosticsLog( "\(recordLabel) 存储配额查询:耗时 \(diagnosticsElapsed(since: quotaStartedAt))", level: .receive ) guard requiredBytes <= quota.remainingBytes else { throw APIError.storageQuotaExceeded( requiredBytes: requiredBytes, remainingBytes: quota.remainingBytes ) } } var completedBytes: Int64 = 0 progress(uploads.isEmpty ? 1 : 0) if uploads.isEmpty { diagnosticsLog( "\(recordLabel) 本地资源上传:无需上传,检查耗时 \(diagnosticsElapsed(since: stageStartedAt))" ) } for (uploadIndex, upload) in uploads.enumerated() { let bytesBeforeUpload = completedBytes let uploadStartedAt = diagnosticsNow let assetKind = upload.kind.uppercased() == "AUDIO" ? "音频" : "照片" diagnosticsLog( "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传开始:\(diagnosticsBytes(upload.size))", level: .transmit ) let result = try await service.uploadAsset( sessionID: cloudID, clientID: upload.clientID, kind: upload.kind, fileURL: upload.url ) { fraction in let sentBytes = Double(bytesBeforeUpload) + Double(upload.size) * fraction progress(min(max(sentBytes / Double(totalBytes), 0), 1)) } if let revision = result.sessionRevision { session.serverRevision = revision } completedBytes += upload.size progress(Double(completedBytes) / Double(totalBytes)) let uploadDuration = diagnosticsDuration(since: uploadStartedAt) diagnosticsLog( "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传完成:耗时 \(diagnosticsDurationText(uploadDuration)),平均 \(diagnosticsRate(bytes: upload.size, duration: uploadDuration))", level: .success ) } if !uploads.isEmpty { diagnosticsLog( "\(recordLabel) 本地资源上传完成:文件=\(uploads.count),总大小=\(diagnosticsBytes(requiredBytes)),总耗时 \(diagnosticsElapsed(since: stageStartedAt))", level: .success ) } } private func fileSize(of url: URL) -> Int64 { let values = try? url.resourceValues(forKeys: [.fileSizeKey]) return Int64(values?.fileSize ?? 0) } private func fileSHA256(of url: URL, purpose: String) async throws -> String { let sizeBytes = fileSize(of: url) let startedAt = diagnosticsNow diagnosticsLog("\(purpose) 开始:\(diagnosticsBytes(sizeBytes))") let task = Task.detached(priority: .utility) { let input = try FileHandle(forReadingFrom: url) defer { try? input.close() } var hasher = SHA256() while let data = try input.read(upToCount: 1_024 * 1_024), !data.isEmpty { try Task.checkCancellation() hasher.update(data: data) } return hasher.finalize().map { String(format: "%02x", $0) }.joined() } let digest = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } diagnosticsLog("\(purpose) 完成:耗时 \(diagnosticsElapsed(since: startedAt))") return digest } private func downloadMissingAssets( for session: CelestiaSession, remote: RemoteSession, onDownloadStarted: () -> Void ) async throws -> Bool { let assets = remote.assets ?? [] var jobs: [AssetDownloadJob] = [] var downloadedAssets = false let recordLabel = diagnosticsRecordLabel(session) if let audio = assets .filter({ $0.kind.uppercased() == "AUDIO" }) .max(by: { ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast) }) { let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath) let localHash: String? if let localURL { localHash = try? await fileSHA256( of: localURL, purpose: "\(recordLabel) 音频本地校验" ) } else { localHash = nil } let needsDownload = localURL == nil || (session.isSynced && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame) if needsDownload { let destination = downloadDestination(for: audio) jobs.append( AssetDownloadJob( sessionID: remote.id, asset: audio, destination: destination, target: .audio, displayName: "音频" ) ) } } let photoAssets = Dictionary( assets .filter { $0.kind.uppercased() == "PHOTO" } .map { ($0.clientId.lowercased(), $0) }, uniquingKeysWith: { current, candidate in (candidate.createdAt ?? .distantPast) > (current.createdAt ?? .distantPast) ? candidate : current } ) let remotePhotoCount = assets.lazy.filter { $0.kind.uppercased() == "PHOTO" }.count if photoAssets.count < remotePhotoCount { DeveloperLogStore.log( "现场记录同步", "云端照片资源存在重复 clientId,已选择最新资源继续同步", level: .warning ) } 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) jobs.append( AssetDownloadJob( sessionID: remote.id, asset: asset, destination: destination, target: .photo(eventID: event.id), displayName: "照片" ) ) } guard !jobs.isEmpty else { return false } diagnosticsLog( "\(recordLabel) 资源并发下载:文件=\(jobs.count),并发上限=\(Self.maxConcurrentAssetDownloads)", level: .receive ) for batchStart in stride( from: 0, to: jobs.count, by: Self.maxConcurrentAssetDownloads ) { try Task.checkCancellation() let batchEnd = min(batchStart + Self.maxConcurrentAssetDownloads, jobs.count) let batch = Array(jobs[batchStart.. AssetDownloadJob { try Task.checkCancellation() let downloadStartedAt = diagnosticsNow diagnosticsLog( "\(recordLabel) \(job.displayName)下载开始:\(diagnosticsBytes(job.asset.sizeBytes))", level: .receive ) try await service.downloadAsset( sessionID: job.sessionID, asset: job.asset, destinationURL: job.destination ) try Task.checkCancellation() let downloadDuration = diagnosticsDuration(since: downloadStartedAt) diagnosticsLog( "\(recordLabel) \(job.displayName)下载完成:耗时 \(diagnosticsDurationText(downloadDuration)),平均 \(diagnosticsRate(bytes: job.asset.sizeBytes, duration: downloadDuration))", level: .success ) return job } private func applyDownloadedAsset( _ job: AssetDownloadJob, to session: CelestiaSession ) { let relativePath = AudioPathHelper.relativePath(from: job.destination.path) switch job.target { case .audio: session.localAudioPath = relativePath Task.detached(priority: .utility) { await SilenceDetector.warmCache(for: job.destination) } case .photo(let eventID): session.events.first(where: { $0.id == eventID })?.localFilePath = relativePath } } private var diagnosticsNow: TimeInterval { ProcessInfo.processInfo.systemUptime } private func diagnosticsDuration(since startedAt: TimeInterval) -> TimeInterval { max(0, diagnosticsNow - startedAt) } private func diagnosticsElapsed(since startedAt: TimeInterval) -> String { diagnosticsDurationText(diagnosticsDuration(since: startedAt)) } private func diagnosticsDurationText(_ duration: TimeInterval) -> String { if duration < 1 { return "\(Int((duration * 1_000).rounded())) 毫秒" } return String(format: "%.2f 秒", duration) } private func diagnosticsBytes(_ bytes: Int64) -> String { Self.diagnosticsByteFormatter.string(fromByteCount: max(0, bytes)) } private func diagnosticsRate(bytes: Int64, duration: TimeInterval) -> String { guard duration > 0 else { return "—" } return "\(diagnosticsBytes(Int64(Double(bytes) / duration)))/秒" } private func diagnosticsRecordLabel( _ session: CelestiaSession, position: Int? = nil, total: Int? = nil ) -> String { let shortID = session.id.uuidString.lowercased().prefix(8) if let position, let total { return "记录 \(position)/\(total) [\(shortID)]" } return "记录 [\(shortID)]" } private func diagnosticsLog( _ message: String, level: DeveloperLogEntry.Level = .info ) { DeveloperLogStore.log("同步性能", message, level: level) } 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 struct IndexedSessionMatch { let index: RemoteSessionIndex let local: CelestiaSession let needsDetail: Bool } private func mergeIndex( _ remoteSessions: [RemoteSessionIndex], into localSessions: [CelestiaSession], modelContext: ModelContext, userID: String ) async throws -> [IndexedSessionMatch] { let scopedLocalSessions = localSessions.filter { $0.ownerUserID == nil || $0.ownerUserID == userID } var byClientID = Dictionary( scopedLocalSessions.map { ($0.id.uuidString.lowercased(), $0) }, uniquingKeysWith: preferredLocalSession ) var byCloudID = Dictionary( scopedLocalSessions.compactMap { session in session.cloudSessionId.map { ($0, session) } }, uniquingKeysWith: preferredLocalSession ) if byClientID.count < scopedLocalSessions.count { DeveloperLogStore.log( "现场记录同步", "检测到 \(scopedLocalSessions.count - byClientID.count) 条重复本地记录,已选择云端版本较新的记录继续同步", level: .warning ) } let activeRemoteSessions = remoteSessions.filter { $0.deletedAt == nil } let activeRemoteIDs = Set(activeRemoteSessions.map(\.id)) let activeRemoteClientIDs = Set(activeRemoteSessions.compactMap { $0.clientId?.lowercased() }) var deletedLocalObjects: Set = [] var deletedFileURLs: Set = [] var merged: [IndexedSessionMatch] = [] for (index, remote) in remoteSessions.enumerated() { if index.isMultiple(of: 20) { await Task.yield() } let clientID = remote.clientId?.lowercased() let existingLocal = clientID.flatMap { byClientID[$0] } ?? byCloudID[remote.id] if let local = existingLocal { if remote.deletedAt != nil, local.hasConfirmedCloudSync { deletedFileURLs.formUnion(localFileURLs(for: local)) deletedLocalObjects.insert(ObjectIdentifier(local)) modelContext.delete(local) DeveloperLogStore.log( "现场记录同步", "云端记录已删除,同步移除本地记录:\(local.title)" ) continue } if !local.isSynced, remote.revision > local.serverRevision { local.syncState = .conflict local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本 \(remote.revision) 不一致" continue } if remote.deletedAt == nil { let needsDetail = local.isSynced && (local.serverUpdatedAt.map { remote.updatedAt > $0 } ?? true || remote.revision > local.serverRevision || local.syncState == .failed || local.syncState == .syncing) local.cloudSessionId = remote.id local.ownerUserID = userID if local.isSynced { local.title = remote.title local.startTime = remote.startTime local.endTime = remote.endTime } if needsDetail { local.serverRevision = remote.revision local.syncState = .syncing local.lastSyncError = nil } merged.append(IndexedSessionMatch( index: remote, local: local, needsDetail: needsDetail )) } } else if remote.deletedAt == nil { let id = clientID.flatMap(UUID.init(uuidString:)) ?? UUID() let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime) session.endTime = remote.endTime session.cloudSessionId = remote.id session.ownerUserID = userID session.serverRevision = remote.revision session.isSynced = true session.syncState = .syncing modelContext.insert(session) if let clientID { byClientID[clientID] = session } byCloudID[remote.id] = session merged.append(IndexedSessionMatch( index: remote, local: session, needsDetail: true )) } } // Some servers may permanently remove a record instead of returning a // tombstone. A previously confirmed cloud record that is absent by both // server ID and client ID should mirror that deletion locally. Records // with pending/failed/conflicting local content are deliberately kept. for (index, local) in localSessions.enumerated() { if index.isMultiple(of: 20) { await Task.yield() } guard !deletedLocalObjects.contains(ObjectIdentifier(local)), local.ownerUserID == nil || local.ownerUserID == userID, local.hasConfirmedCloudSync else { continue } let existsRemotely = local.cloudSessionId.map(activeRemoteIDs.contains) == true || activeRemoteClientIDs.contains(local.id.uuidString.lowercased()) guard !existsRemotely else { continue } deletedFileURLs.formUnion(localFileURLs(for: local)) deletedLocalObjects.insert(ObjectIdentifier(local)) modelContext.delete(local) DeveloperLogStore.log( "现场记录同步", "云端不存在已同步记录,同步移除本地记录:\(local.title)" ) } try modelContext.save() for fileURL in deletedFileURLs { try? FileManager.default.removeItem(at: fileURL) } return merged } private func localFileURLs(for session: CelestiaSession) -> Set { let storedPaths = [session.localAudioPath].compactMap { $0 } + session.audioChunks.map(\.localFilePath) + session.events.compactMap(\.localFilePath) return Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) }) } private func deduplicatedSessions(_ sessions: [CelestiaSession]) -> [CelestiaSession] { Array(Dictionary( sessions.map { ($0.id, $0) }, uniquingKeysWith: preferredLocalSession ).values) } private func preferredLocalSession( _ current: CelestiaSession, _ candidate: CelestiaSession ) -> CelestiaSession { if (current.cloudSessionId == nil) != (candidate.cloudSessionId == nil) { return current.cloudSessionId == nil ? candidate : current } if current.serverRevision != candidate.serverRevision { return current.serverRevision > candidate.serverRevision ? current : candidate } return (current.lastSyncedAt ?? .distantPast) >= (candidate.lastSyncedAt ?? .distantPast) ? current : candidate } private func apply( _ remote: RemoteSession, to local: CelestiaSession, userID: String, syncState: SessionSyncState = .synced ) async { 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 = syncState local.lastSyncError = nil local.events.removeAll() for (index, remoteEvent) in (remote.events ?? []).enumerated() { if index.isMultiple(of: 50) { await Task.yield() } 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 event.locationName = remoteEvent.locationName event.locationAddress = remoteEvent.locationAddress event.latitude = remoteEvent.latitude event.longitude = remoteEvent.longitude local.events.append(event) } } }