| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488 |
- 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..<upload.totalChunks {
- let offset = UInt64(chunkIndex) * UInt64(upload.chunkSize)
- let preparedChunk = try await prepareUploadChunk(
- fileURL: fileURL,
- uploadID: upload.uploadId,
- chunkIndex: chunkIndex,
- chunkSize: upload.chunkSize,
- offset: offset
- )
- let chunkStart = Double(offset) / Double(fileSize)
- let chunkSpan = Double(preparedChunk.byteCount) / Double(fileSize)
- do {
- let _: ChunkUploadProgressResponse = try await client.upload(
- "sessions/\(sessionID)/assets/chunk",
- fileURL: preparedChunk.url,
- fileName: "chunk-\(chunkIndex)",
- mimeType: "application/octet-stream",
- fields: [
- "uploadId": upload.uploadId,
- "chunkIndex": String(chunkIndex)
- ]
- ) { fraction in
- progress(min(max(chunkStart + chunkSpan * fraction, 0), 1))
- }
- try? FileManager.default.removeItem(at: preparedChunk.url)
- } catch {
- try? FileManager.default.removeItem(at: preparedChunk.url)
- throw error
- }
- }
- let completeBody = ChunkUploadCompleteBody(
- uploadId: upload.uploadId,
- totalChunks: upload.totalChunks
- )
- let payload: AssetUploadPayload = try await client.request(
- "sessions/\(sessionID)/assets/complete",
- method: .post,
- body: try encoder.encode(completeBody),
- authenticated: true
- )
- progress(1)
- return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
- }
- private func prepareUploadChunk(
- fileURL: URL,
- uploadID: String,
- chunkIndex: Int,
- chunkSize: Int,
- offset: UInt64
- ) async throws -> (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<Bool, Never>?
- private var automaticSyncTasks: [UUID: Task<Void, Never>] = [:]
- 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<CelestiaSession>())
- 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..<batchEnd])
- batch.forEach { _ in onDownloadStarted() }
- let tasks = batch.map { job in
- Task { @MainActor [self] in
- try await downloadAssetJob(job, recordLabel: recordLabel)
- }
- }
- do {
- try await withTaskCancellationHandler {
- for task in tasks {
- let completedJob = try await task.value
- applyDownloadedAsset(completedJob, to: session)
- downloadedAssets = true
- }
- } onCancel: {
- tasks.forEach { $0.cancel() }
- }
- } catch {
- tasks.forEach { $0.cancel() }
- throw error
- }
- }
- return downloadedAssets
- }
- private func downloadAssetJob(
- _ job: AssetDownloadJob,
- recordLabel: String
- ) async throws -> 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<ObjectIdentifier> = []
- var deletedFileURLs: Set<URL> = []
- 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<URL> {
- 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)
- }
- }
- }
|