| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711 |
- 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: Sendable {
- let asset: RemoteAsset
- let sessionRevision: Int64?
- }
- struct StorageQuota: Decodable, Sendable {
- let totalBytes: Int64
- let usedBytes: Int64
- let remainingBytes: Int64
- }
- struct RemoteEvent: Decodable, Sendable {
- 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, Sendable {
- 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, Sendable {
- 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: SyncSessionSnapshot,
- 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 ? "当前网络" : "网络"
- }
- }
- struct SyncEventSnapshot: Sendable {
- let id: UUID
- let relativeTimeMs: Int64
- let eventType: String
- let textContent: String?
- let localFilePath: String?
- let voiceStartOffsetMs: Int64?
- let voiceEndOffsetMs: Int64?
- let locationName: String?
- let locationAddress: String?
- let latitude: Double?
- let longitude: Double?
- }
- struct SyncSessionSnapshot: Sendable {
- let id: UUID
- let title: String
- let startTime: Date
- let endTime: Date?
- let durationMs: Int64
- let localAudioPath: String?
- let cloudSessionId: String?
- let serverRevision: Int64
- let isSynced: Bool
- let events: [SyncEventSnapshot]
- }
- private struct SyncIndexMatch: Sendable {
- let remote: RemoteSessionIndex
- let localSessionID: UUID
- let title: String
- let needsDetail: Bool
- }
- private struct SyncIndexPreparation: Sendable {
- let localSessionCount: Int
- let matches: [SyncIndexMatch]
- }
- 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
- }
- private enum SyncPersistedFailure: Sendable {
- case pending
- case conflict
- case failed(String)
- case remoteDownloadFailed(String)
- }
- @ModelActor
- private actor SyncBackgroundActor {
- func mergeIndex(
- _ remoteSessions: [RemoteSessionIndex],
- userID: String
- ) async throws -> SyncIndexPreparation {
- let localSessions = try modelContext.fetch(FetchDescriptor<CelestiaSession>())
- 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 matches: [SyncIndexMatch] = []
- for (index, remote) in remoteSessions.enumerated() {
- if index.isMultiple(of: 20) {
- try Task.checkCancellation()
- 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
- }
- matches.append(
- SyncIndexMatch(
- remote: remote,
- localSessionID: local.id,
- title: local.title,
- 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
- matches.append(
- SyncIndexMatch(
- remote: remote,
- localSessionID: session.id,
- title: session.title,
- needsDetail: true
- )
- )
- }
- }
- for (index, local) in localSessions.enumerated() {
- if index.isMultiple(of: 20) {
- try Task.checkCancellation()
- 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 SyncIndexPreparation(
- localSessionCount: localSessions.count,
- matches: matches
- )
- }
- func applyRemoteDetail(
- _ remote: RemoteSession,
- to sessionID: UUID,
- userID: String
- ) async throws -> SyncSessionSnapshot {
- guard let local = try session(withID: sessionID) else {
- throw APIError.missingData
- }
- apply(remote, to: local, userID: userID, syncState: .syncing)
- try modelContext.save()
- return snapshot(of: local)
- }
- func finishRemoteDetail(
- sessionID: UUID,
- remoteUpdatedAt: Date,
- downloadedAssets: [AssetDownloadJob]
- ) throws {
- guard let local = try session(withID: sessionID) else {
- throw APIError.missingData
- }
- for job in downloadedAssets {
- let relativePath = AudioPathHelper.relativePath(from: job.destination.path)
- switch job.target {
- case .audio:
- local.localAudioPath = relativePath
- case .photo(let eventID):
- local.events.first(where: { $0.id == eventID })?.localFilePath = relativePath
- }
- }
- local.serverUpdatedAt = remoteUpdatedAt
- local.syncState = .synced
- local.lastSyncError = nil
- local.lastSyncedAt = Date()
- try modelContext.save()
- }
- func prepareUploads(
- requestedSessionIDs: [UUID],
- userID: String
- ) throws -> [SyncSessionSnapshot] {
- let requestedIDs = Set(requestedSessionIDs)
- return try modelContext.fetch(FetchDescriptor<CelestiaSession>())
- .filter {
- requestedIDs.contains($0.id)
- && ($0.ownerUserID == nil || $0.ownerUserID == userID)
- && $0.needsCloudSync
- && $0.syncState != .conflict
- }
- .reduce(into: [UUID: CelestiaSession]()) { result, session in
- if let current = result[session.id] {
- result[session.id] = preferredLocalSession(current, session)
- } else {
- result[session.id] = session
- }
- }
- .values
- .sorted { lhs, rhs in
- let lhsIndex = requestedSessionIDs.firstIndex(of: lhs.id) ?? .max
- let rhsIndex = requestedSessionIDs.firstIndex(of: rhs.id) ?? .max
- return lhsIndex < rhsIndex
- }
- .map(snapshot)
- }
- func markUploadStarted(sessionID: UUID, userID: String) throws -> SyncSessionSnapshot {
- guard let local = try session(withID: sessionID) else {
- throw APIError.missingData
- }
- local.ownerUserID = userID
- local.syncState = .syncing
- local.lastSyncError = nil
- try modelContext.save()
- return snapshot(of: local)
- }
- func markUploadSucceeded(sessionID: UUID, remote: RemoteSession) throws -> Int64 {
- guard let local = try session(withID: sessionID) else {
- throw APIError.missingData
- }
- local.cloudSessionId = remote.id
- local.serverRevision = remote.revision
- local.serverUpdatedAt = remote.updatedAt
- local.isSynced = true
- local.syncState = .synced
- local.lastSyncError = nil
- local.lastSyncedAt = Date()
- try modelContext.save()
- return local.serverRevision
- }
- func markFailure(sessionID: UUID, failure: SyncPersistedFailure) throws {
- guard let local = try session(withID: sessionID) else { return }
- switch failure {
- case .pending:
- local.isSynced = false
- local.syncState = .pending
- local.lastSyncError = nil
- case .conflict:
- local.isSynced = false
- local.syncState = .conflict
- local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本不一致"
- case .failed(let message):
- local.isSynced = false
- local.syncState = .failed
- local.lastSyncError = message
- case .remoteDownloadFailed(let message):
- guard local.hasConfirmedCloudSync, local.syncState == .syncing else { return }
- local.syncState = .failed
- local.lastSyncError = message
- }
- try modelContext.save()
- }
- func conflictMessages(for requestedSessionIDs: [UUID]) throws -> [String] {
- let requestedIDs = Set(requestedSessionIDs)
- return try modelContext.fetch(FetchDescriptor<CelestiaSession>())
- .filter { requestedIDs.contains($0.id) && $0.syncState == .conflict }
- .map { "\($0.title):本地和云端版本不一致" }
- }
- private func session(withID id: UUID) throws -> CelestiaSession? {
- try modelContext.fetch(FetchDescriptor<CelestiaSession>())
- .first(where: { $0.id == id })
- }
- private func snapshot(of session: CelestiaSession) -> SyncSessionSnapshot {
- SyncSessionSnapshot(
- id: session.id,
- title: session.title,
- startTime: session.startTime,
- endTime: session.endTime,
- durationMs: session.durationMs,
- localAudioPath: session.localAudioPath,
- cloudSessionId: session.cloudSessionId,
- serverRevision: session.serverRevision,
- isSynced: session.isSynced,
- events: session.events.map {
- SyncEventSnapshot(
- id: $0.id,
- relativeTimeMs: $0.relativeTimeMs,
- eventType: $0.eventType,
- textContent: $0.textContent,
- localFilePath: $0.localFilePath,
- voiceStartOffsetMs: $0.voiceStartOffsetMs,
- voiceEndOffsetMs: $0.voiceEndOffsetMs,
- locationName: $0.locationName,
- locationAddress: $0.locationAddress,
- latitude: $0.latitude,
- longitude: $0.longitude
- )
- }
- )
- }
- private func apply(
- _ remote: RemoteSession,
- to local: CelestiaSession,
- userID: String,
- syncState: SessionSyncState
- ) {
- 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 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
- event.locationName = remoteEvent.locationName
- event.locationAddress = remoteEvent.locationAddress
- event.latitude = remoteEvent.latitude
- event.longitude = remoteEvent.longitude
- local.events.append(event)
- }
- }
- 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 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
- }
- }
- @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 sessionIDs: [UUID]
- let modelContainer: ModelContainer
- let userID: 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 {
- let sessionIDs = deduplicatedSessions(sessions).map(\.id)
- guard !isSyncing else {
- queuedSyncRequest = SyncRequest(
- sessionIDs: sessionIDs,
- modelContainer: modelContext.container,
- 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(
- sessionIDs: sessionIDs,
- modelContainer: modelContext.container,
- 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(
- sessionIDs: queuedRequest.sessionIDs,
- modelContainer: queuedRequest.modelContainer,
- userID: queuedRequest.userID
- )
- }
- }
- return result
- }
- private func sync(
- sessionIDs: [UUID],
- modelContainer: ModelContainer,
- userID: String
- ) async -> Bool {
- guard !isSyncing else {
- queuedSyncRequest = SyncRequest(
- sessionIDs: sessionIDs,
- modelContainer: modelContainer,
- userID: userID
- )
- return false
- }
- isSyncing = true
- let task = Task { @MainActor [self] in
- await performSync(
- sessionIDs: sessionIDs,
- modelContainer: modelContainer,
- userID: userID
- )
- }
- activeSyncTask = task
- let result = await withTaskCancellationHandler {
- await task.value
- } onCancel: {
- task.cancel()
- }
- activeSyncTask = nil
- isSyncing = false
- activeSessionID = nil
- if let queuedRequest = queuedSyncRequest {
- queuedSyncRequest = nil
- Task { @MainActor [weak self] in
- guard AuthManager.shared.currentUser?.id == queuedRequest.userID else { return }
- _ = await self?.sync(
- sessionIDs: queuedRequest.sessionIDs,
- modelContainer: queuedRequest.modelContainer,
- userID: queuedRequest.userID
- )
- }
- }
- return result
- }
- func pauseSync(sessionID: UUID) {
- guard isSyncing, activeSessionID == sessionID else { return }
- queuedSyncRequest = nil
- activeSyncTask?.cancel()
- }
- private func performSync(
- sessionIDs: [UUID],
- modelContainer: ModelContainer,
- userID: String
- ) async -> Bool {
- lastErrorMessage = nil
- completedCount = 0
- activeSessionID = nil
- syncProgress = 0
- let worker = SyncBackgroundActor(modelContainer: modelContainer)
- totalCount = sessionIDs.count
- DeveloperLogStore.log("现场记录同步", "待检查 \(sessionIDs.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 = sessionIDs.first
- var pendingRemoteDownloadIDs: [UUID] = []
- 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 indexPreparation = try await worker.mergeIndex(
- remoteIndex,
- userID: userID
- )
- diagnosticsLog(
- "阶段 2/5 完成:后台读取并合并本地记录=\(indexPreparation.localSessionCount),索引匹配=\(indexPreparation.matches.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 = indexPreparation.matches.filter(\.needsDetail)
- pendingRemoteDownloadIDs = sessionsRequiringDetail.map(\.localSessionID)
- activeSessionID = sessionsRequiringDetail.first?.localSessionID
- await Task.yield()
- let detailStageStartedAt = diagnosticsNow
- diagnosticsLog("阶段 3/5 开始:需要拉取详情=\(sessionsRequiringDetail.count)")
- for (detailIndex, match) in sessionsRequiringDetail.enumerated() {
- let recordLabel = diagnosticsRecordLabel(
- sessionID: match.localSessionID,
- position: detailIndex + 1,
- total: sessionsRequiringDetail.count
- )
- let recordStartedAt = diagnosticsNow
- do {
- activeSessionID = match.localSessionID
- let detailStartedAt = diagnosticsNow
- let remote = try await service.fetchSession(sessionID: match.remote.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
- let snapshot = try await worker.applyRemoteDetail(
- remote,
- to: match.localSessionID,
- userID: userID
- )
- diagnosticsLog(
- "\(recordLabel) 详情由后台 Actor 写入本地:耗时 \(diagnosticsElapsed(since: applyStartedAt))"
- )
- await Task.yield()
- let assetStartedAt = diagnosticsNow
- let downloadedAssets = try await downloadMissingAssets(
- for: snapshot,
- remote: remote
- )
- try Task.checkCancellation()
- let finalSaveStartedAt = diagnosticsNow
- try await worker.finishRemoteDetail(
- sessionID: match.localSessionID,
- remoteUpdatedAt: remote.updatedAt,
- downloadedAssets: downloadedAssets
- )
- for job in downloadedAssets {
- if case .audio = job.target {
- Task.detached(priority: .utility) {
- await SilenceDetector.warmCache(for: job.destination)
- }
- }
- }
- diagnosticsLog(
- "\(recordLabel) 资源阶段:\(downloadedAssets.isEmpty ? "无需下载" : "下载 \(downloadedAssets.count) 个文件"),耗时 \(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
- )
- try? await worker.markFailure(
- sessionID: match.localSessionID,
- failure: .remoteDownloadFailed("已暂停从云端下载")
- )
- throw CancellationError()
- } catch {
- try? await worker.markFailure(
- sessionID: match.localSessionID,
- failure: .remoteDownloadFailed("从云端下载失败:\(error.localizedDescription)")
- )
- diagnosticsLog(
- "\(recordLabel) 云端拉取失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
- level: .error
- )
- failures.append("\(match.title):\(error.localizedDescription)")
- DeveloperLogStore.log(
- "现场记录同步",
- "\(match.title) 从云端下载失败:\(error.localizedDescription)",
- level: .error
- )
- }
- }
- diagnosticsLog(
- "阶段 3/5 完成:详情记录=\(sessionsRequiringDetail.count),总耗时 \(diagnosticsElapsed(since: detailStageStartedAt))"
- )
- let eligible = try await worker.prepareUploads(
- requestedSessionIDs: sessionIDs,
- userID: userID
- )
- let eligibleCount = max(eligible.count, 1)
- totalCount = eligible.count
- let uploadStageStartedAt = diagnosticsNow
- diagnosticsLog("阶段 4/5 开始:需要上传=\(eligible.count)")
- for (index, preparedSession) in eligible.enumerated() {
- try Task.checkCancellation()
- await Task.yield()
- let session = try await worker.markUploadStarted(
- sessionID: preparedSession.id,
- userID: userID
- )
- activeSessionID = session.id
- let sessionStartProgress = Double(index) / Double(eligibleCount)
- let sessionProgressSpan = 1.0 / Double(eligibleCount)
- syncProgress = sessionStartProgress
- DeveloperLogStore.log(
- "现场记录同步",
- "正在同步 \(index + 1)/\(eligible.count):\(session.title)"
- )
- let recordLabel = diagnosticsRecordLabel(
- sessionID: session.id,
- 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
- try await uploadLocalAssets(
- for: session,
- cloudSessionID: remote.id,
- 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
- let finalRevision = try await worker.markUploadSucceeded(
- sessionID: session.id,
- remote: finalRemote
- )
- completedCount += 1
- syncProgress = sessionStartProgress + sessionProgressSpan
- DeveloperLogStore.log(
- "现场记录同步",
- "\(session.title) 同步成功,云端版本 v\(finalRevision)",
- level: .success
- )
- diagnosticsLog(
- "\(recordLabel) 上传链路完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
- level: .success
- )
- } catch where Task.isCancelled {
- diagnosticsLog(
- "\(recordLabel) 上传链路取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
- level: .warning
- )
- try? await worker.markFailure(sessionID: session.id, failure: .pending)
- syncProgress = 0
- return false
- } catch APIError.server(let code, _) where code == 409 {
- diagnosticsLog(
- "\(recordLabel) 上传链路冲突:耗时 \(diagnosticsElapsed(since: recordStartedAt))",
- level: .error
- )
- try? await worker.markFailure(sessionID: session.id, failure: .conflict)
- DeveloperLogStore.log(
- "现场记录同步",
- "\(session.title) 发生版本冲突",
- level: .error
- )
- } catch {
- diagnosticsLog(
- "\(recordLabel) 上传链路失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
- level: .error
- )
- try? await worker.markFailure(
- sessionID: session.id,
- failure: .failed(error.localizedDescription)
- )
- failures.append("\(session.title):\(error.localizedDescription)")
- DeveloperLogStore.log(
- "现场记录同步",
- "\(session.title) 同步失败:\(error.localizedDescription)",
- level: .error
- )
- }
- }
- diagnosticsLog(
- "阶段 4/5 完成:上传记录=\(eligible.count),总耗时 \(diagnosticsElapsed(since: uploadStageStartedAt))"
- )
- failures.append(contentsOf: try await worker.conflictMessages(for: sessionIDs))
- 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 sessionID in pendingRemoteDownloadIDs {
- try? await worker.markFailure(
- sessionID: sessionID,
- failure: .remoteDownloadFailed("已暂停从云端下载")
- )
- }
- syncProgress = 0
- return false
- } catch {
- for sessionID in pendingRemoteDownloadIDs {
- try? await worker.markFailure(
- sessionID: sessionID,
- failure: .remoteDownloadFailed("从云端下载中断:\(error.localizedDescription)")
- )
- }
- lastErrorMessage = error.localizedDescription
- DeveloperLogStore.log("现场记录同步", "同步中断:\(error.localizedDescription)", level: .error)
- return false
- }
- }
- private func uploadLocalAssets(
- for session: SyncSessionSnapshot,
- cloudSessionID: String,
- remote: RemoteSession,
- progress: @escaping @Sendable (Double) -> Void
- ) async throws {
- let stageStartedAt = diagnosticsNow
- let recordLabel = diagnosticsRecordLabel(sessionID: session.id)
- 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
- )
- _ = try await service.uploadAsset(
- sessionID: cloudSessionID,
- 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))
- }
- 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: SyncSessionSnapshot,
- remote: RemoteSession
- ) async throws -> [AssetDownloadJob] {
- let assets = remote.assets ?? []
- var jobs: [AssetDownloadJob] = []
- var downloadedAssets: [AssetDownloadJob] = []
- let recordLabel = diagnosticsRecordLabel(sessionID: session.id)
- 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 [] }
- 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])
- 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
- downloadedAssets.append(completedJob)
- }
- } 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 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(
- sessionID: UUID,
- position: Int? = nil,
- total: Int? = nil
- ) -> String {
- let shortID = sessionID.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 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
- }
- }
|