RemoteNetworkService.swift 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711
  1. import Foundation
  2. import SwiftData
  3. import UniformTypeIdentifiers
  4. import Network
  5. import CryptoKit
  6. // Remote contract: Docs/RemoteAPI.openapi.yaml
  7. struct RemoteAsset: Decodable, Sendable {
  8. let id: String
  9. let clientId: String
  10. let kind: String
  11. let fileName: String
  12. let mimeType: String
  13. let sizeBytes: Int64
  14. let sha256: String
  15. let createdAt: Date?
  16. }
  17. struct AssetUploadResult: Sendable {
  18. let asset: RemoteAsset
  19. let sessionRevision: Int64?
  20. }
  21. struct StorageQuota: Decodable, Sendable {
  22. let totalBytes: Int64
  23. let usedBytes: Int64
  24. let remainingBytes: Int64
  25. }
  26. struct RemoteEvent: Decodable, Sendable {
  27. let id: String
  28. let clientId: String?
  29. let relativeTimeMs: Int64
  30. let eventType: String
  31. let textContent: String?
  32. let voiceStartOffsetMs: Int64?
  33. let voiceEndOffsetMs: Int64?
  34. let locationName: String?
  35. let locationAddress: String?
  36. let latitude: Double?
  37. let longitude: Double?
  38. }
  39. struct RemoteSessionIndex: Decodable, Sendable {
  40. let id: String
  41. let clientId: String?
  42. let title: String
  43. let startTime: Date
  44. let endTime: Date?
  45. let durationMs: Int64
  46. let photoCount: Int
  47. let noteCount: Int
  48. let revision: Int64
  49. let deletedAt: Date?
  50. let updatedAt: Date
  51. }
  52. struct RemoteSession: Decodable, Sendable {
  53. let id: String
  54. let clientId: String?
  55. let title: String
  56. let startTime: Date
  57. let endTime: Date?
  58. let durationMs: Int64
  59. let revision: Int64
  60. let deletedAt: Date?
  61. let updatedAt: Date
  62. let events: [RemoteEvent]?
  63. let assets: [RemoteAsset]?
  64. }
  65. private struct SessionUpload: Encodable {
  66. let clientId: String
  67. let title: String
  68. let startTime: Date
  69. let endTime: Date?
  70. let durationMs: Int64
  71. let events: [EventUpload]
  72. let baseRevision: Int64?
  73. let deletedEventClientIds: [String]
  74. }
  75. private struct EventUpload: Encodable {
  76. let clientId: String
  77. let relativeTimeMs: Int64
  78. let eventType: String
  79. let textContent: String?
  80. let voiceStartOffsetMs: Int64?
  81. let voiceEndOffsetMs: Int64?
  82. let locationName: String?
  83. let locationAddress: String?
  84. let latitude: Double?
  85. let longitude: Double?
  86. }
  87. private struct SyncCheckpoint: Decodable {
  88. let syncedAt: Date
  89. }
  90. private struct AssetUploadPayload: Decodable {
  91. let asset: RemoteAsset
  92. let sessionRevision: Int64?
  93. private enum CodingKeys: String, CodingKey {
  94. case asset
  95. case sessionRevision
  96. }
  97. init(from decoder: Decoder) throws {
  98. if let container = try? decoder.container(keyedBy: CodingKeys.self),
  99. container.contains(.asset) {
  100. asset = try container.decode(RemoteAsset.self, forKey: .asset)
  101. sessionRevision = try container.decodeIfPresent(Int64.self, forKey: .sessionRevision)
  102. } else {
  103. asset = try RemoteAsset(from: decoder)
  104. sessionRevision = nil
  105. }
  106. }
  107. }
  108. private struct ChunkUploadInitBody: Encodable {
  109. let clientId: String
  110. let kind: String
  111. let fileName: String
  112. let mimeType: String
  113. let fileSize: Int64
  114. let chunkSize: Int
  115. }
  116. private struct ChunkUploadInitResponse: Decodable {
  117. let uploadId: String
  118. let totalChunks: Int
  119. let chunkSize: Int
  120. }
  121. private struct ChunkUploadProgressResponse: Decodable {
  122. let uploadedChunks: Int
  123. let totalChunks: Int
  124. }
  125. private struct ChunkUploadCompleteBody: Encodable {
  126. let uploadId: String
  127. let totalChunks: Int
  128. }
  129. final class RemoteNetworkService: NetworkServiceProtocol {
  130. private static let chunkedUploadThreshold: Int64 = 16 * 1024 * 1024
  131. private static let preferredChunkSize = 8 * 1024 * 1024
  132. private let client: APIClient
  133. init(client: APIClient = .shared) {
  134. self.client = client
  135. }
  136. func fetchSessionIndex() async throws -> [RemoteSessionIndex] {
  137. try await client.request("sessions?includeDeleted=true", authenticated: true)
  138. }
  139. func fetchSession(sessionID: String) async throws -> RemoteSession {
  140. try await client.request("sessions/\(sessionID)", authenticated: true)
  141. }
  142. func syncSession(
  143. _ session: SyncSessionSnapshot,
  144. deletedEventClientIDs: [String]
  145. ) async throws -> RemoteSession {
  146. let payload = SessionUpload(
  147. clientId: session.id.uuidString,
  148. title: session.title,
  149. startTime: session.startTime,
  150. endTime: session.endTime,
  151. durationMs: session.durationMs,
  152. events: session.events.map {
  153. EventUpload(
  154. clientId: $0.id.uuidString,
  155. relativeTimeMs: $0.relativeTimeMs,
  156. eventType: $0.eventType,
  157. textContent: $0.textContent,
  158. voiceStartOffsetMs: $0.voiceStartOffsetMs,
  159. voiceEndOffsetMs: $0.voiceEndOffsetMs,
  160. locationName: $0.locationName,
  161. locationAddress: $0.locationAddress,
  162. latitude: $0.latitude,
  163. longitude: $0.longitude
  164. )
  165. },
  166. baseRevision: session.serverRevision > 0 ? session.serverRevision : nil,
  167. deletedEventClientIds: deletedEventClientIDs
  168. )
  169. let encoder = JSONEncoder()
  170. encoder.dateEncodingStrategy = .iso8601
  171. return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
  172. }
  173. func deleteSession(sessionID: String) async throws {
  174. try await client.requestVoid(
  175. "sessions/\(sessionID)",
  176. method: .delete,
  177. authenticated: true
  178. )
  179. }
  180. func fetchStorageQuota() async throws -> StorageQuota {
  181. try await client.request("storage/quota", authenticated: true)
  182. }
  183. func uploadAsset(
  184. sessionID: String,
  185. clientID: String,
  186. kind: String,
  187. fileURL: URL,
  188. progress: @escaping @Sendable (Double) -> Void
  189. ) async throws -> AssetUploadResult {
  190. let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
  191. let size = try fileSize(of: fileURL)
  192. if size >= Self.chunkedUploadThreshold {
  193. return try await uploadAssetInChunks(
  194. sessionID: sessionID,
  195. clientID: clientID,
  196. kind: kind,
  197. fileURL: fileURL,
  198. fileSize: size,
  199. mimeType: mimeType,
  200. progress: progress
  201. )
  202. }
  203. let payload: AssetUploadPayload = try await client.upload(
  204. "sessions/\(sessionID)/assets",
  205. fileURL: fileURL,
  206. fileName: fileURL.lastPathComponent,
  207. mimeType: mimeType,
  208. fields: ["clientId": clientID, "kind": kind],
  209. progress: progress
  210. )
  211. return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
  212. }
  213. func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
  214. try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL)
  215. }
  216. func recordSyncCheckpoint() async throws {
  217. let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
  218. }
  219. private func uploadAssetInChunks(
  220. sessionID: String,
  221. clientID: String,
  222. kind: String,
  223. fileURL: URL,
  224. fileSize: Int64,
  225. mimeType: String,
  226. progress: @escaping @Sendable (Double) -> Void
  227. ) async throws -> AssetUploadResult {
  228. let body = ChunkUploadInitBody(
  229. clientId: clientID,
  230. kind: kind,
  231. fileName: fileURL.lastPathComponent,
  232. mimeType: mimeType,
  233. fileSize: fileSize,
  234. chunkSize: Self.preferredChunkSize
  235. )
  236. let encoder = JSONEncoder()
  237. let upload: ChunkUploadInitResponse = try await client.request(
  238. "sessions/\(sessionID)/assets/init",
  239. method: .post,
  240. body: try encoder.encode(body),
  241. authenticated: true
  242. )
  243. for chunkIndex in 0..<upload.totalChunks {
  244. let offset = UInt64(chunkIndex) * UInt64(upload.chunkSize)
  245. let preparedChunk = try await prepareUploadChunk(
  246. fileURL: fileURL,
  247. uploadID: upload.uploadId,
  248. chunkIndex: chunkIndex,
  249. chunkSize: upload.chunkSize,
  250. offset: offset
  251. )
  252. let chunkStart = Double(offset) / Double(fileSize)
  253. let chunkSpan = Double(preparedChunk.byteCount) / Double(fileSize)
  254. do {
  255. let _: ChunkUploadProgressResponse = try await client.upload(
  256. "sessions/\(sessionID)/assets/chunk",
  257. fileURL: preparedChunk.url,
  258. fileName: "chunk-\(chunkIndex)",
  259. mimeType: "application/octet-stream",
  260. fields: [
  261. "uploadId": upload.uploadId,
  262. "chunkIndex": String(chunkIndex)
  263. ]
  264. ) { fraction in
  265. progress(min(max(chunkStart + chunkSpan * fraction, 0), 1))
  266. }
  267. try? FileManager.default.removeItem(at: preparedChunk.url)
  268. } catch {
  269. try? FileManager.default.removeItem(at: preparedChunk.url)
  270. throw error
  271. }
  272. }
  273. let completeBody = ChunkUploadCompleteBody(
  274. uploadId: upload.uploadId,
  275. totalChunks: upload.totalChunks
  276. )
  277. let payload: AssetUploadPayload = try await client.request(
  278. "sessions/\(sessionID)/assets/complete",
  279. method: .post,
  280. body: try encoder.encode(completeBody),
  281. authenticated: true
  282. )
  283. progress(1)
  284. return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
  285. }
  286. private func prepareUploadChunk(
  287. fileURL: URL,
  288. uploadID: String,
  289. chunkIndex: Int,
  290. chunkSize: Int,
  291. offset: UInt64
  292. ) async throws -> (url: URL, byteCount: Int) {
  293. let task = Task.detached(priority: .utility) {
  294. try Task.checkCancellation()
  295. let input = try FileHandle(forReadingFrom: fileURL)
  296. defer { try? input.close() }
  297. try input.seek(toOffset: offset)
  298. guard let data = try input.read(upToCount: chunkSize), !data.isEmpty else {
  299. throw APIError.transport("读取上传分片失败")
  300. }
  301. try Task.checkCancellation()
  302. let temporaryURL = FileManager.default.temporaryDirectory
  303. .appendingPathComponent("celestia-\(uploadID)-\(chunkIndex).chunk")
  304. try data.write(to: temporaryURL, options: .atomic)
  305. return (temporaryURL, data.count)
  306. }
  307. return try await withTaskCancellationHandler {
  308. try await task.value
  309. } onCancel: {
  310. task.cancel()
  311. }
  312. }
  313. private func fileSize(of url: URL) throws -> Int64 {
  314. let values = try url.resourceValues(forKeys: [.fileSizeKey])
  315. guard let size = values.fileSize else {
  316. throw APIError.transport("无法读取待上传文件大小")
  317. }
  318. return Int64(size)
  319. }
  320. }
  321. struct RemoteBoundDevice: Decodable {
  322. let id: String
  323. let name: String
  324. let peripheralUUID: String
  325. }
  326. private struct BindDeviceBody: Encodable {
  327. let name: String
  328. let peripheralUUID: String
  329. let hardwareMAC: String?
  330. let firmwareVersion: String?
  331. let batteryLevel: Int?
  332. let freeStorageMB: Int?
  333. let totalStorageMB: Int?
  334. }
  335. private struct UpdateDeviceBody: Encodable {
  336. let name: String?
  337. let batteryLevel: Int?
  338. let isConnected: Bool?
  339. let firmwareVersion: String?
  340. let freeStorageMB: Int?
  341. let totalStorageMB: Int?
  342. }
  343. final class DeviceCloudService {
  344. static let shared = DeviceCloudService()
  345. private let client: APIClient
  346. init(client: APIClient = .shared) {
  347. self.client = client
  348. }
  349. func register(_ device: BoundDevice) async throws -> RemoteBoundDevice {
  350. let body = BindDeviceBody(
  351. name: device.name,
  352. peripheralUUID: device.peripheralUUID,
  353. hardwareMAC: device.hardwareMAC,
  354. firmwareVersion: device.firmwareVersion,
  355. batteryLevel: device.batteryLevel,
  356. freeStorageMB: device.freeStorageMB,
  357. totalStorageMB: device.totalStorageMB
  358. )
  359. return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
  360. }
  361. func update(_ device: BoundDevice) async throws -> RemoteBoundDevice {
  362. guard let cloudID = device.cloudID else { return try await register(device) }
  363. let body = UpdateDeviceBody(
  364. name: device.name,
  365. batteryLevel: device.batteryLevel,
  366. isConnected: device.isConnected,
  367. firmwareVersion: device.firmwareVersion,
  368. freeStorageMB: device.freeStorageMB,
  369. totalStorageMB: device.totalStorageMB
  370. )
  371. return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
  372. }
  373. func remove(cloudID: String) async throws {
  374. try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true)
  375. }
  376. }
  377. final class NetworkStatusMonitor: ObservableObject, @unchecked Sendable {
  378. static let shared = NetworkStatusMonitor()
  379. @Published private(set) var isConnected = true
  380. @Published private(set) var isWiFi = false
  381. @Published private(set) var isCellular = false
  382. private let monitor = NWPathMonitor()
  383. private let queue = DispatchQueue(label: "com.celestia.trace.network-path")
  384. private init() {
  385. monitor.pathUpdateHandler = { [weak self] path in
  386. let connected = path.status == .satisfied
  387. let usesWiFi = path.usesInterfaceType(.wifi)
  388. let usesCellular = path.usesInterfaceType(.cellular)
  389. DispatchQueue.main.async {
  390. self?.isConnected = connected
  391. self?.isWiFi = usesWiFi
  392. self?.isCellular = usesCellular
  393. }
  394. }
  395. monitor.start(queue: queue)
  396. }
  397. var connectionName: String {
  398. if isWiFi { return "Wi-Fi" }
  399. if isCellular { return "蜂窝网络" }
  400. return isConnected ? "当前网络" : "网络"
  401. }
  402. }
  403. struct SyncEventSnapshot: Sendable {
  404. let id: UUID
  405. let relativeTimeMs: Int64
  406. let eventType: String
  407. let textContent: String?
  408. let localFilePath: String?
  409. let voiceStartOffsetMs: Int64?
  410. let voiceEndOffsetMs: Int64?
  411. let locationName: String?
  412. let locationAddress: String?
  413. let latitude: Double?
  414. let longitude: Double?
  415. }
  416. struct SyncSessionSnapshot: Sendable {
  417. let id: UUID
  418. let title: String
  419. let startTime: Date
  420. let endTime: Date?
  421. let durationMs: Int64
  422. let localAudioPath: String?
  423. let cloudSessionId: String?
  424. let serverRevision: Int64
  425. let isSynced: Bool
  426. let events: [SyncEventSnapshot]
  427. }
  428. private struct SyncIndexMatch: Sendable {
  429. let remote: RemoteSessionIndex
  430. let localSessionID: UUID
  431. let title: String
  432. let needsDetail: Bool
  433. }
  434. private struct SyncIndexPreparation: Sendable {
  435. let localSessionCount: Int
  436. let matches: [SyncIndexMatch]
  437. }
  438. private enum AssetDownloadTarget: Sendable {
  439. case audio
  440. case photo(eventID: UUID)
  441. }
  442. private struct AssetDownloadJob: Sendable {
  443. let sessionID: String
  444. let asset: RemoteAsset
  445. let destination: URL
  446. let target: AssetDownloadTarget
  447. let displayName: String
  448. }
  449. private enum SyncPersistedFailure: Sendable {
  450. case pending
  451. case conflict
  452. case failed(String)
  453. case remoteDownloadFailed(String)
  454. }
  455. @ModelActor
  456. private actor SyncBackgroundActor {
  457. func mergeIndex(
  458. _ remoteSessions: [RemoteSessionIndex],
  459. userID: String
  460. ) async throws -> SyncIndexPreparation {
  461. let localSessions = try modelContext.fetch(FetchDescriptor<CelestiaSession>())
  462. let scopedLocalSessions = localSessions.filter {
  463. $0.ownerUserID == nil || $0.ownerUserID == userID
  464. }
  465. var byClientID = Dictionary(
  466. scopedLocalSessions.map { ($0.id.uuidString.lowercased(), $0) },
  467. uniquingKeysWith: preferredLocalSession
  468. )
  469. var byCloudID = Dictionary(
  470. scopedLocalSessions.compactMap { session in
  471. session.cloudSessionId.map { ($0, session) }
  472. },
  473. uniquingKeysWith: preferredLocalSession
  474. )
  475. if byClientID.count < scopedLocalSessions.count {
  476. DeveloperLogStore.log(
  477. "现场记录同步",
  478. "检测到 \(scopedLocalSessions.count - byClientID.count) 条重复本地记录,已选择云端版本较新的记录继续同步",
  479. level: .warning
  480. )
  481. }
  482. let activeRemoteSessions = remoteSessions.filter { $0.deletedAt == nil }
  483. let activeRemoteIDs = Set(activeRemoteSessions.map(\.id))
  484. let activeRemoteClientIDs = Set(activeRemoteSessions.compactMap { $0.clientId?.lowercased() })
  485. var deletedLocalObjects: Set<ObjectIdentifier> = []
  486. var deletedFileURLs: Set<URL> = []
  487. var matches: [SyncIndexMatch] = []
  488. for (index, remote) in remoteSessions.enumerated() {
  489. if index.isMultiple(of: 20) {
  490. try Task.checkCancellation()
  491. await Task.yield()
  492. }
  493. let clientID = remote.clientId?.lowercased()
  494. let existingLocal = clientID.flatMap { byClientID[$0] } ?? byCloudID[remote.id]
  495. if let local = existingLocal {
  496. if remote.deletedAt != nil, local.hasConfirmedCloudSync {
  497. deletedFileURLs.formUnion(localFileURLs(for: local))
  498. deletedLocalObjects.insert(ObjectIdentifier(local))
  499. modelContext.delete(local)
  500. DeveloperLogStore.log(
  501. "现场记录同步",
  502. "云端记录已删除,同步移除本地记录:\(local.title)"
  503. )
  504. continue
  505. }
  506. if !local.isSynced, remote.revision > local.serverRevision {
  507. local.syncState = .conflict
  508. local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本 \(remote.revision) 不一致"
  509. continue
  510. }
  511. if remote.deletedAt == nil {
  512. let needsDetail =
  513. local.isSynced
  514. && (local.serverUpdatedAt.map { remote.updatedAt > $0 } ?? true
  515. || remote.revision > local.serverRevision
  516. || local.syncState == .failed
  517. || local.syncState == .syncing)
  518. local.cloudSessionId = remote.id
  519. local.ownerUserID = userID
  520. if local.isSynced {
  521. local.title = remote.title
  522. local.startTime = remote.startTime
  523. local.endTime = remote.endTime
  524. }
  525. if needsDetail {
  526. local.serverRevision = remote.revision
  527. local.syncState = .syncing
  528. local.lastSyncError = nil
  529. }
  530. matches.append(
  531. SyncIndexMatch(
  532. remote: remote,
  533. localSessionID: local.id,
  534. title: local.title,
  535. needsDetail: needsDetail
  536. )
  537. )
  538. }
  539. } else if remote.deletedAt == nil {
  540. let id = clientID.flatMap(UUID.init(uuidString:)) ?? UUID()
  541. let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
  542. session.endTime = remote.endTime
  543. session.cloudSessionId = remote.id
  544. session.ownerUserID = userID
  545. session.serverRevision = remote.revision
  546. session.isSynced = true
  547. session.syncState = .syncing
  548. modelContext.insert(session)
  549. if let clientID {
  550. byClientID[clientID] = session
  551. }
  552. byCloudID[remote.id] = session
  553. matches.append(
  554. SyncIndexMatch(
  555. remote: remote,
  556. localSessionID: session.id,
  557. title: session.title,
  558. needsDetail: true
  559. )
  560. )
  561. }
  562. }
  563. for (index, local) in localSessions.enumerated() {
  564. if index.isMultiple(of: 20) {
  565. try Task.checkCancellation()
  566. await Task.yield()
  567. }
  568. guard !deletedLocalObjects.contains(ObjectIdentifier(local)),
  569. local.ownerUserID == nil || local.ownerUserID == userID,
  570. local.hasConfirmedCloudSync else { continue }
  571. let existsRemotely =
  572. local.cloudSessionId.map(activeRemoteIDs.contains) == true
  573. || activeRemoteClientIDs.contains(local.id.uuidString.lowercased())
  574. guard !existsRemotely else { continue }
  575. deletedFileURLs.formUnion(localFileURLs(for: local))
  576. deletedLocalObjects.insert(ObjectIdentifier(local))
  577. modelContext.delete(local)
  578. DeveloperLogStore.log(
  579. "现场记录同步",
  580. "云端不存在已同步记录,同步移除本地记录:\(local.title)"
  581. )
  582. }
  583. try modelContext.save()
  584. for fileURL in deletedFileURLs {
  585. try? FileManager.default.removeItem(at: fileURL)
  586. }
  587. return SyncIndexPreparation(
  588. localSessionCount: localSessions.count,
  589. matches: matches
  590. )
  591. }
  592. func applyRemoteDetail(
  593. _ remote: RemoteSession,
  594. to sessionID: UUID,
  595. userID: String
  596. ) async throws -> SyncSessionSnapshot {
  597. guard let local = try session(withID: sessionID) else {
  598. throw APIError.missingData
  599. }
  600. apply(remote, to: local, userID: userID, syncState: .syncing)
  601. try modelContext.save()
  602. return snapshot(of: local)
  603. }
  604. func finishRemoteDetail(
  605. sessionID: UUID,
  606. remoteUpdatedAt: Date,
  607. downloadedAssets: [AssetDownloadJob]
  608. ) throws {
  609. guard let local = try session(withID: sessionID) else {
  610. throw APIError.missingData
  611. }
  612. for job in downloadedAssets {
  613. let relativePath = AudioPathHelper.relativePath(from: job.destination.path)
  614. switch job.target {
  615. case .audio:
  616. local.localAudioPath = relativePath
  617. case .photo(let eventID):
  618. local.events.first(where: { $0.id == eventID })?.localFilePath = relativePath
  619. }
  620. }
  621. local.serverUpdatedAt = remoteUpdatedAt
  622. local.syncState = .synced
  623. local.lastSyncError = nil
  624. local.lastSyncedAt = Date()
  625. try modelContext.save()
  626. }
  627. func prepareUploads(
  628. requestedSessionIDs: [UUID],
  629. userID: String
  630. ) throws -> [SyncSessionSnapshot] {
  631. let requestedIDs = Set(requestedSessionIDs)
  632. return try modelContext.fetch(FetchDescriptor<CelestiaSession>())
  633. .filter {
  634. requestedIDs.contains($0.id)
  635. && ($0.ownerUserID == nil || $0.ownerUserID == userID)
  636. && $0.needsCloudSync
  637. && $0.syncState != .conflict
  638. }
  639. .reduce(into: [UUID: CelestiaSession]()) { result, session in
  640. if let current = result[session.id] {
  641. result[session.id] = preferredLocalSession(current, session)
  642. } else {
  643. result[session.id] = session
  644. }
  645. }
  646. .values
  647. .sorted { lhs, rhs in
  648. let lhsIndex = requestedSessionIDs.firstIndex(of: lhs.id) ?? .max
  649. let rhsIndex = requestedSessionIDs.firstIndex(of: rhs.id) ?? .max
  650. return lhsIndex < rhsIndex
  651. }
  652. .map(snapshot)
  653. }
  654. func markUploadStarted(sessionID: UUID, userID: String) throws -> SyncSessionSnapshot {
  655. guard let local = try session(withID: sessionID) else {
  656. throw APIError.missingData
  657. }
  658. local.ownerUserID = userID
  659. local.syncState = .syncing
  660. local.lastSyncError = nil
  661. try modelContext.save()
  662. return snapshot(of: local)
  663. }
  664. func markUploadSucceeded(sessionID: UUID, remote: RemoteSession) throws -> Int64 {
  665. guard let local = try session(withID: sessionID) else {
  666. throw APIError.missingData
  667. }
  668. local.cloudSessionId = remote.id
  669. local.serverRevision = remote.revision
  670. local.serverUpdatedAt = remote.updatedAt
  671. local.isSynced = true
  672. local.syncState = .synced
  673. local.lastSyncError = nil
  674. local.lastSyncedAt = Date()
  675. try modelContext.save()
  676. return local.serverRevision
  677. }
  678. func markFailure(sessionID: UUID, failure: SyncPersistedFailure) throws {
  679. guard let local = try session(withID: sessionID) else { return }
  680. switch failure {
  681. case .pending:
  682. local.isSynced = false
  683. local.syncState = .pending
  684. local.lastSyncError = nil
  685. case .conflict:
  686. local.isSynced = false
  687. local.syncState = .conflict
  688. local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本不一致"
  689. case .failed(let message):
  690. local.isSynced = false
  691. local.syncState = .failed
  692. local.lastSyncError = message
  693. case .remoteDownloadFailed(let message):
  694. guard local.hasConfirmedCloudSync, local.syncState == .syncing else { return }
  695. local.syncState = .failed
  696. local.lastSyncError = message
  697. }
  698. try modelContext.save()
  699. }
  700. func conflictMessages(for requestedSessionIDs: [UUID]) throws -> [String] {
  701. let requestedIDs = Set(requestedSessionIDs)
  702. return try modelContext.fetch(FetchDescriptor<CelestiaSession>())
  703. .filter { requestedIDs.contains($0.id) && $0.syncState == .conflict }
  704. .map { "\($0.title):本地和云端版本不一致" }
  705. }
  706. private func session(withID id: UUID) throws -> CelestiaSession? {
  707. try modelContext.fetch(FetchDescriptor<CelestiaSession>())
  708. .first(where: { $0.id == id })
  709. }
  710. private func snapshot(of session: CelestiaSession) -> SyncSessionSnapshot {
  711. SyncSessionSnapshot(
  712. id: session.id,
  713. title: session.title,
  714. startTime: session.startTime,
  715. endTime: session.endTime,
  716. durationMs: session.durationMs,
  717. localAudioPath: session.localAudioPath,
  718. cloudSessionId: session.cloudSessionId,
  719. serverRevision: session.serverRevision,
  720. isSynced: session.isSynced,
  721. events: session.events.map {
  722. SyncEventSnapshot(
  723. id: $0.id,
  724. relativeTimeMs: $0.relativeTimeMs,
  725. eventType: $0.eventType,
  726. textContent: $0.textContent,
  727. localFilePath: $0.localFilePath,
  728. voiceStartOffsetMs: $0.voiceStartOffsetMs,
  729. voiceEndOffsetMs: $0.voiceEndOffsetMs,
  730. locationName: $0.locationName,
  731. locationAddress: $0.locationAddress,
  732. latitude: $0.latitude,
  733. longitude: $0.longitude
  734. )
  735. }
  736. )
  737. }
  738. private func apply(
  739. _ remote: RemoteSession,
  740. to local: CelestiaSession,
  741. userID: String,
  742. syncState: SessionSyncState
  743. ) {
  744. local.title = remote.title
  745. local.startTime = remote.startTime
  746. local.endTime = remote.endTime
  747. local.cloudSessionId = remote.id
  748. local.ownerUserID = userID
  749. local.serverRevision = remote.revision
  750. local.isSynced = true
  751. local.syncState = syncState
  752. local.lastSyncError = nil
  753. local.events.removeAll()
  754. for remoteEvent in remote.events ?? [] {
  755. let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
  756. let event = CelestiaTimelineEvent(
  757. id: id,
  758. relativeTimeMs: remoteEvent.relativeTimeMs,
  759. eventType: remoteEvent.eventType
  760. )
  761. event.textContent = remoteEvent.textContent
  762. event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
  763. event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
  764. event.locationName = remoteEvent.locationName
  765. event.locationAddress = remoteEvent.locationAddress
  766. event.latitude = remoteEvent.latitude
  767. event.longitude = remoteEvent.longitude
  768. local.events.append(event)
  769. }
  770. }
  771. private func localFileURLs(for session: CelestiaSession) -> Set<URL> {
  772. let storedPaths =
  773. [session.localAudioPath].compactMap { $0 }
  774. + session.audioChunks.map(\.localFilePath)
  775. + session.events.compactMap(\.localFilePath)
  776. return Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
  777. }
  778. private func preferredLocalSession(
  779. _ current: CelestiaSession,
  780. _ candidate: CelestiaSession
  781. ) -> CelestiaSession {
  782. if (current.cloudSessionId == nil) != (candidate.cloudSessionId == nil) {
  783. return current.cloudSessionId == nil ? candidate : current
  784. }
  785. if current.serverRevision != candidate.serverRevision {
  786. return current.serverRevision > candidate.serverRevision ? current : candidate
  787. }
  788. return (current.lastSyncedAt ?? .distantPast) >= (candidate.lastSyncedAt ?? .distantPast)
  789. ? current
  790. : candidate
  791. }
  792. }
  793. @MainActor
  794. final class SyncManager: ObservableObject {
  795. static let shared = SyncManager()
  796. private static let maxConcurrentAssetDownloads = 4
  797. @Published private(set) var isSyncing = false
  798. @Published private(set) var lastSyncDate: Date?
  799. @Published private(set) var lastErrorMessage: String?
  800. @Published private(set) var completedCount = 0
  801. @Published private(set) var totalCount = 0
  802. @Published private(set) var activeSessionID: UUID?
  803. @Published private(set) var syncProgress: Double = 0
  804. private let service: NetworkServiceProtocol
  805. private var activeSyncTask: Task<Bool, Never>?
  806. private var automaticSyncTasks: [UUID: Task<Void, Never>] = [:]
  807. private var automaticSyncGenerations: [UUID: UUID] = [:]
  808. private var queuedSyncRequest: SyncRequest?
  809. private static let diagnosticsByteFormatter: ByteCountFormatter = {
  810. let formatter = ByteCountFormatter()
  811. formatter.countStyle = .file
  812. formatter.allowedUnits = [.useKB, .useMB, .useGB]
  813. formatter.isAdaptive = true
  814. return formatter
  815. }()
  816. private struct SyncRequest {
  817. let sessionIDs: [UUID]
  818. let modelContainer: ModelContainer
  819. let userID: String
  820. }
  821. init(service: NetworkServiceProtocol = RemoteNetworkService()) {
  822. self.service = service
  823. lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
  824. }
  825. func estimatedUploadBytes(for session: CelestiaSession) -> Int64 {
  826. var urls: [URL] = []
  827. if let url = AudioPathHelper.resolveURL(for: session.localAudioPath) {
  828. urls.append(url)
  829. }
  830. urls.append(contentsOf: session.events.compactMap { event in
  831. guard event.eventType == "PHOTO" else { return nil }
  832. return AudioPathHelper.resolveURL(for: event.localFilePath)
  833. })
  834. return urls.reduce(into: 0) { total, url in
  835. let values = try? url.resourceValues(forKeys: [.fileSizeKey])
  836. total += Int64(values?.fileSize ?? 0)
  837. }
  838. }
  839. func scheduleAutomaticSync(
  840. for session: CelestiaSession,
  841. modelContext: ModelContext,
  842. delay: Duration = .seconds(1.5)
  843. ) {
  844. guard session.endTime != nil,
  845. session.syncState != .conflict,
  846. AuthManager.shared.currentUser?.id != nil else { return }
  847. let sessionID = session.id
  848. let generation = UUID()
  849. automaticSyncGenerations[sessionID] = generation
  850. automaticSyncTasks[sessionID]?.cancel()
  851. if isSyncing, activeSessionID == sessionID {
  852. activeSyncTask?.cancel()
  853. }
  854. automaticSyncTasks[sessionID] = Task { @MainActor [weak self] in
  855. do {
  856. try await Task.sleep(for: delay)
  857. while let self,
  858. (self.isSyncing || !NetworkStatusMonitor.shared.isConnected) {
  859. try Task.checkCancellation()
  860. try await Task.sleep(for: .seconds(0.75))
  861. }
  862. guard let self,
  863. !Task.isCancelled,
  864. self.automaticSyncGenerations[sessionID] == generation,
  865. session.needsCloudSync,
  866. let userID = AuthManager.shared.currentUser?.id else { return }
  867. _ = await self.sync(
  868. sessions: [session],
  869. modelContext: modelContext,
  870. userID: userID
  871. )
  872. if self.automaticSyncGenerations[sessionID] == generation {
  873. self.automaticSyncTasks[sessionID] = nil
  874. self.automaticSyncGenerations[sessionID] = nil
  875. }
  876. } catch {
  877. guard let self,
  878. self.automaticSyncGenerations[sessionID] == generation else { return }
  879. self.automaticSyncTasks[sessionID] = nil
  880. self.automaticSyncGenerations[sessionID] = nil
  881. }
  882. }
  883. }
  884. func sync(
  885. sessions: [CelestiaSession],
  886. modelContext: ModelContext,
  887. userID: String
  888. ) async -> Bool {
  889. let sessionIDs = deduplicatedSessions(sessions).map(\.id)
  890. guard !isSyncing else {
  891. queuedSyncRequest = SyncRequest(
  892. sessionIDs: sessionIDs,
  893. modelContainer: modelContext.container,
  894. userID: userID
  895. )
  896. DeveloperLogStore.log("现场记录同步", "已有同步任务正在运行,已排队再次检查时间戳")
  897. return false
  898. }
  899. DeveloperLogStore.log("现场记录同步", "开始同步,本地共 \(sessions.count) 条记录")
  900. let syncStartedAt = diagnosticsNow
  901. diagnosticsLog("整轮开始:本地记录=\(sessions.count)")
  902. isSyncing = true
  903. let task = Task { @MainActor [self] in
  904. await performSync(
  905. sessionIDs: sessionIDs,
  906. modelContainer: modelContext.container,
  907. userID: userID
  908. )
  909. }
  910. activeSyncTask = task
  911. let result = await withTaskCancellationHandler {
  912. await task.value
  913. } onCancel: {
  914. task.cancel()
  915. }
  916. activeSyncTask = nil
  917. isSyncing = false
  918. activeSessionID = nil
  919. DeveloperLogStore.log(
  920. "现场记录同步",
  921. result ? "同步任务完成" : "同步任务未完成",
  922. level: result ? .success : .warning
  923. )
  924. diagnosticsLog(
  925. "整轮\(result ? "完成" : "未完成"):总耗时 \(diagnosticsElapsed(since: syncStartedAt))",
  926. level: result ? .success : .warning
  927. )
  928. if let queuedRequest = queuedSyncRequest {
  929. queuedSyncRequest = nil
  930. Task { @MainActor [weak self] in
  931. guard AuthManager.shared.currentUser?.id == queuedRequest.userID else { return }
  932. _ = await self?.sync(
  933. sessionIDs: queuedRequest.sessionIDs,
  934. modelContainer: queuedRequest.modelContainer,
  935. userID: queuedRequest.userID
  936. )
  937. }
  938. }
  939. return result
  940. }
  941. private func sync(
  942. sessionIDs: [UUID],
  943. modelContainer: ModelContainer,
  944. userID: String
  945. ) async -> Bool {
  946. guard !isSyncing else {
  947. queuedSyncRequest = SyncRequest(
  948. sessionIDs: sessionIDs,
  949. modelContainer: modelContainer,
  950. userID: userID
  951. )
  952. return false
  953. }
  954. isSyncing = true
  955. let task = Task { @MainActor [self] in
  956. await performSync(
  957. sessionIDs: sessionIDs,
  958. modelContainer: modelContainer,
  959. userID: userID
  960. )
  961. }
  962. activeSyncTask = task
  963. let result = await withTaskCancellationHandler {
  964. await task.value
  965. } onCancel: {
  966. task.cancel()
  967. }
  968. activeSyncTask = nil
  969. isSyncing = false
  970. activeSessionID = nil
  971. if let queuedRequest = queuedSyncRequest {
  972. queuedSyncRequest = nil
  973. Task { @MainActor [weak self] in
  974. guard AuthManager.shared.currentUser?.id == queuedRequest.userID else { return }
  975. _ = await self?.sync(
  976. sessionIDs: queuedRequest.sessionIDs,
  977. modelContainer: queuedRequest.modelContainer,
  978. userID: queuedRequest.userID
  979. )
  980. }
  981. }
  982. return result
  983. }
  984. func pauseSync(sessionID: UUID) {
  985. guard isSyncing, activeSessionID == sessionID else { return }
  986. queuedSyncRequest = nil
  987. activeSyncTask?.cancel()
  988. }
  989. private func performSync(
  990. sessionIDs: [UUID],
  991. modelContainer: ModelContainer,
  992. userID: String
  993. ) async -> Bool {
  994. lastErrorMessage = nil
  995. completedCount = 0
  996. activeSessionID = nil
  997. syncProgress = 0
  998. let worker = SyncBackgroundActor(modelContainer: modelContainer)
  999. totalCount = sessionIDs.count
  1000. DeveloperLogStore.log("现场记录同步", "待检查 \(sessionIDs.count) 条记录,正在获取云端索引")
  1001. // Give the UI immediate feedback while the remote index is being fetched.
  1002. // The persisted state is changed only when this session actually begins syncing.
  1003. activeSessionID = sessionIDs.first
  1004. var pendingRemoteDownloadIDs: [UUID] = []
  1005. do {
  1006. let indexStartedAt = diagnosticsNow
  1007. diagnosticsLog("阶段 1/5 开始:请求服务器时间戳索引", level: .receive)
  1008. let remoteIndex = try await service.fetchSessionIndex()
  1009. diagnosticsLog(
  1010. "阶段 1/5 完成:云端记录=\(remoteIndex.count),耗时 \(diagnosticsElapsed(since: indexStartedAt))",
  1011. level: .success
  1012. )
  1013. DeveloperLogStore.log("现场记录同步", "获取到 \(remoteIndex.count) 条云端时间戳", level: .success)
  1014. try Task.checkCancellation()
  1015. let mergeStartedAt = diagnosticsNow
  1016. let indexPreparation = try await worker.mergeIndex(
  1017. remoteIndex,
  1018. userID: userID
  1019. )
  1020. diagnosticsLog(
  1021. "阶段 2/5 完成:后台读取并合并本地记录=\(indexPreparation.localSessionCount),索引匹配=\(indexPreparation.matches.count),耗时 \(diagnosticsElapsed(since: mergeStartedAt))"
  1022. )
  1023. var failures: [String] = []
  1024. var remoteDetailsByID: [String: RemoteSession] = [:]
  1025. // mergeIndex has already persisted list metadata. Only timestamp
  1026. // changes proceed to the second phase that fetches concrete content.
  1027. let sessionsRequiringDetail = indexPreparation.matches.filter(\.needsDetail)
  1028. pendingRemoteDownloadIDs = sessionsRequiringDetail.map(\.localSessionID)
  1029. activeSessionID = sessionsRequiringDetail.first?.localSessionID
  1030. await Task.yield()
  1031. let detailStageStartedAt = diagnosticsNow
  1032. diagnosticsLog("阶段 3/5 开始:需要拉取详情=\(sessionsRequiringDetail.count)")
  1033. for (detailIndex, match) in sessionsRequiringDetail.enumerated() {
  1034. let recordLabel = diagnosticsRecordLabel(
  1035. sessionID: match.localSessionID,
  1036. position: detailIndex + 1,
  1037. total: sessionsRequiringDetail.count
  1038. )
  1039. let recordStartedAt = diagnosticsNow
  1040. do {
  1041. activeSessionID = match.localSessionID
  1042. let detailStartedAt = diagnosticsNow
  1043. let remote = try await service.fetchSession(sessionID: match.remote.id)
  1044. let remoteAssets = remote.assets ?? []
  1045. let remoteBytes = remoteAssets.reduce(Int64(0)) { $0 + $1.sizeBytes }
  1046. diagnosticsLog(
  1047. "\(recordLabel) 详情响应:事件=\(remote.events?.count ?? 0),资源=\(remoteAssets.count)(\(diagnosticsBytes(remoteBytes))),耗时 \(diagnosticsElapsed(since: detailStartedAt))",
  1048. level: .receive
  1049. )
  1050. remoteDetailsByID[remote.id] = remote
  1051. let applyStartedAt = diagnosticsNow
  1052. let snapshot = try await worker.applyRemoteDetail(
  1053. remote,
  1054. to: match.localSessionID,
  1055. userID: userID
  1056. )
  1057. diagnosticsLog(
  1058. "\(recordLabel) 详情由后台 Actor 写入本地:耗时 \(diagnosticsElapsed(since: applyStartedAt))"
  1059. )
  1060. await Task.yield()
  1061. let assetStartedAt = diagnosticsNow
  1062. let downloadedAssets = try await downloadMissingAssets(
  1063. for: snapshot,
  1064. remote: remote
  1065. )
  1066. try Task.checkCancellation()
  1067. let finalSaveStartedAt = diagnosticsNow
  1068. try await worker.finishRemoteDetail(
  1069. sessionID: match.localSessionID,
  1070. remoteUpdatedAt: remote.updatedAt,
  1071. downloadedAssets: downloadedAssets
  1072. )
  1073. for job in downloadedAssets {
  1074. if case .audio = job.target {
  1075. Task.detached(priority: .utility) {
  1076. await SilenceDetector.warmCache(for: job.destination)
  1077. }
  1078. }
  1079. }
  1080. diagnosticsLog(
  1081. "\(recordLabel) 资源阶段:\(downloadedAssets.isEmpty ? "无需下载" : "下载 \(downloadedAssets.count) 个文件"),耗时 \(diagnosticsElapsed(since: assetStartedAt));后台最终保存 \(diagnosticsElapsed(since: finalSaveStartedAt))",
  1082. level: .success
  1083. )
  1084. diagnosticsLog(
  1085. "\(recordLabel) 云端拉取完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
  1086. level: .success
  1087. )
  1088. await Task.yield()
  1089. } catch where Task.isCancelled {
  1090. diagnosticsLog(
  1091. "\(recordLabel) 云端拉取取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
  1092. level: .warning
  1093. )
  1094. try? await worker.markFailure(
  1095. sessionID: match.localSessionID,
  1096. failure: .remoteDownloadFailed("已暂停从云端下载")
  1097. )
  1098. throw CancellationError()
  1099. } catch {
  1100. try? await worker.markFailure(
  1101. sessionID: match.localSessionID,
  1102. failure: .remoteDownloadFailed("从云端下载失败:\(error.localizedDescription)")
  1103. )
  1104. diagnosticsLog(
  1105. "\(recordLabel) 云端拉取失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
  1106. level: .error
  1107. )
  1108. failures.append("\(match.title):\(error.localizedDescription)")
  1109. DeveloperLogStore.log(
  1110. "现场记录同步",
  1111. "\(match.title) 从云端下载失败:\(error.localizedDescription)",
  1112. level: .error
  1113. )
  1114. }
  1115. }
  1116. diagnosticsLog(
  1117. "阶段 3/5 完成:详情记录=\(sessionsRequiringDetail.count),总耗时 \(diagnosticsElapsed(since: detailStageStartedAt))"
  1118. )
  1119. let eligible = try await worker.prepareUploads(
  1120. requestedSessionIDs: sessionIDs,
  1121. userID: userID
  1122. )
  1123. let eligibleCount = max(eligible.count, 1)
  1124. totalCount = eligible.count
  1125. let uploadStageStartedAt = diagnosticsNow
  1126. diagnosticsLog("阶段 4/5 开始:需要上传=\(eligible.count)")
  1127. for (index, preparedSession) in eligible.enumerated() {
  1128. try Task.checkCancellation()
  1129. await Task.yield()
  1130. let session = try await worker.markUploadStarted(
  1131. sessionID: preparedSession.id,
  1132. userID: userID
  1133. )
  1134. activeSessionID = session.id
  1135. let sessionStartProgress = Double(index) / Double(eligibleCount)
  1136. let sessionProgressSpan = 1.0 / Double(eligibleCount)
  1137. syncProgress = sessionStartProgress
  1138. DeveloperLogStore.log(
  1139. "现场记录同步",
  1140. "正在同步 \(index + 1)/\(eligible.count):\(session.title)"
  1141. )
  1142. let recordLabel = diagnosticsRecordLabel(
  1143. sessionID: session.id,
  1144. position: index + 1,
  1145. total: eligible.count
  1146. )
  1147. let recordStartedAt = diagnosticsNow
  1148. do {
  1149. var previousRemote: RemoteSession?
  1150. if let cloudID = session.cloudSessionId {
  1151. if let cached = remoteDetailsByID[cloudID] {
  1152. previousRemote = cached
  1153. } else if remoteIndex.contains(where: { $0.id == cloudID }) {
  1154. let previousDetailStartedAt = diagnosticsNow
  1155. let fetched = try await service.fetchSession(sessionID: cloudID)
  1156. diagnosticsLog(
  1157. "\(recordLabel) 上传前详情:耗时 \(diagnosticsElapsed(since: previousDetailStartedAt))",
  1158. level: .receive
  1159. )
  1160. remoteDetailsByID[cloudID] = fetched
  1161. previousRemote = fetched
  1162. }
  1163. }
  1164. let localEventIDs = Set(session.events.map { $0.id.uuidString.lowercased() })
  1165. let deletedEventClientIDs = previousRemote?.events?
  1166. .compactMap(\.clientId)
  1167. .filter { !localEventIDs.contains($0.lowercased()) } ?? []
  1168. let metadataStartedAt = diagnosticsNow
  1169. let remote = try await service.syncSession(
  1170. session,
  1171. deletedEventClientIDs: deletedEventClientIDs
  1172. )
  1173. diagnosticsLog(
  1174. "\(recordLabel) 元数据上传:耗时 \(diagnosticsElapsed(since: metadataStartedAt))",
  1175. level: .transmit
  1176. )
  1177. try Task.checkCancellation()
  1178. syncProgress = sessionStartProgress + sessionProgressSpan * 0.12
  1179. try await uploadLocalAssets(
  1180. for: session,
  1181. cloudSessionID: remote.id,
  1182. remote: remote
  1183. ) { [weak self] assetProgress in
  1184. Task { @MainActor [weak self] in
  1185. self?.syncProgress = sessionStartProgress
  1186. + sessionProgressSpan * (0.12 + assetProgress * 0.83)
  1187. }
  1188. }
  1189. try Task.checkCancellation()
  1190. let finalDetailStartedAt = diagnosticsNow
  1191. let finalRemote = try await service.fetchSession(sessionID: remote.id)
  1192. diagnosticsLog(
  1193. "\(recordLabel) 上传后详情确认:耗时 \(diagnosticsElapsed(since: finalDetailStartedAt))",
  1194. level: .receive
  1195. )
  1196. remoteDetailsByID[finalRemote.id] = finalRemote
  1197. let finalRevision = try await worker.markUploadSucceeded(
  1198. sessionID: session.id,
  1199. remote: finalRemote
  1200. )
  1201. completedCount += 1
  1202. syncProgress = sessionStartProgress + sessionProgressSpan
  1203. DeveloperLogStore.log(
  1204. "现场记录同步",
  1205. "\(session.title) 同步成功,云端版本 v\(finalRevision)",
  1206. level: .success
  1207. )
  1208. diagnosticsLog(
  1209. "\(recordLabel) 上传链路完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
  1210. level: .success
  1211. )
  1212. } catch where Task.isCancelled {
  1213. diagnosticsLog(
  1214. "\(recordLabel) 上传链路取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
  1215. level: .warning
  1216. )
  1217. try? await worker.markFailure(sessionID: session.id, failure: .pending)
  1218. syncProgress = 0
  1219. return false
  1220. } catch APIError.server(let code, _) where code == 409 {
  1221. diagnosticsLog(
  1222. "\(recordLabel) 上传链路冲突:耗时 \(diagnosticsElapsed(since: recordStartedAt))",
  1223. level: .error
  1224. )
  1225. try? await worker.markFailure(sessionID: session.id, failure: .conflict)
  1226. DeveloperLogStore.log(
  1227. "现场记录同步",
  1228. "\(session.title) 发生版本冲突",
  1229. level: .error
  1230. )
  1231. } catch {
  1232. diagnosticsLog(
  1233. "\(recordLabel) 上传链路失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
  1234. level: .error
  1235. )
  1236. try? await worker.markFailure(
  1237. sessionID: session.id,
  1238. failure: .failed(error.localizedDescription)
  1239. )
  1240. failures.append("\(session.title):\(error.localizedDescription)")
  1241. DeveloperLogStore.log(
  1242. "现场记录同步",
  1243. "\(session.title) 同步失败:\(error.localizedDescription)",
  1244. level: .error
  1245. )
  1246. }
  1247. }
  1248. diagnosticsLog(
  1249. "阶段 4/5 完成:上传记录=\(eligible.count),总耗时 \(diagnosticsElapsed(since: uploadStageStartedAt))"
  1250. )
  1251. failures.append(contentsOf: try await worker.conflictMessages(for: sessionIDs))
  1252. guard failures.isEmpty else {
  1253. lastErrorMessage = failures.joined(separator: "\n")
  1254. DeveloperLogStore.log("现场记录同步", "同步结束,存在 \(failures.count) 个问题", level: .error)
  1255. return false
  1256. }
  1257. let checkpointStartedAt = diagnosticsNow
  1258. try await service.recordSyncCheckpoint()
  1259. diagnosticsLog(
  1260. "阶段 5/5 完成:提交同步检查点,耗时 \(diagnosticsElapsed(since: checkpointStartedAt))",
  1261. level: .success
  1262. )
  1263. let now = Date()
  1264. lastSyncDate = now
  1265. UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
  1266. DeveloperLogStore.log("现场记录同步", "云端检查点已更新", level: .success)
  1267. return true
  1268. } catch where Task.isCancelled {
  1269. for sessionID in pendingRemoteDownloadIDs {
  1270. try? await worker.markFailure(
  1271. sessionID: sessionID,
  1272. failure: .remoteDownloadFailed("已暂停从云端下载")
  1273. )
  1274. }
  1275. syncProgress = 0
  1276. return false
  1277. } catch {
  1278. for sessionID in pendingRemoteDownloadIDs {
  1279. try? await worker.markFailure(
  1280. sessionID: sessionID,
  1281. failure: .remoteDownloadFailed("从云端下载中断:\(error.localizedDescription)")
  1282. )
  1283. }
  1284. lastErrorMessage = error.localizedDescription
  1285. DeveloperLogStore.log("现场记录同步", "同步中断:\(error.localizedDescription)", level: .error)
  1286. return false
  1287. }
  1288. }
  1289. private func uploadLocalAssets(
  1290. for session: SyncSessionSnapshot,
  1291. cloudSessionID: String,
  1292. remote: RemoteSession,
  1293. progress: @escaping @Sendable (Double) -> Void
  1294. ) async throws {
  1295. let stageStartedAt = diagnosticsNow
  1296. let recordLabel = diagnosticsRecordLabel(sessionID: session.id)
  1297. let existingIDs = Set((remote.assets ?? []).map(\.clientId))
  1298. var uploads: [(clientID: String, kind: String, url: URL, size: Int64)] = []
  1299. if let path = session.localAudioPath {
  1300. guard let url = AudioPathHelper.resolveURL(for: path) else {
  1301. throw APIError.transport("本地录音文件不存在:\(path)")
  1302. }
  1303. let sha256 = try await fileSHA256(
  1304. of: url,
  1305. purpose: "\(recordLabel) 音频上传校验"
  1306. )
  1307. let hasMatchingAudio = (remote.assets ?? []).contains {
  1308. $0.kind.uppercased() == "AUDIO"
  1309. && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame
  1310. }
  1311. if !hasMatchingAudio {
  1312. let clientID = "\(session.id.uuidString)-audio-\(sha256.prefix(16))"
  1313. uploads.append((clientID, "AUDIO", url, fileSize(of: url)))
  1314. }
  1315. }
  1316. for event in session.events where event.eventType == "PHOTO" {
  1317. guard let path = event.localFilePath else { continue }
  1318. guard let url = AudioPathHelper.resolveURL(for: path) else {
  1319. throw APIError.transport("照片文件不存在:\(path)")
  1320. }
  1321. let clientID = "\(event.id.uuidString)-photo"
  1322. if !existingIDs.contains(clientID) {
  1323. uploads.append((clientID, "PHOTO", url, fileSize(of: url)))
  1324. }
  1325. }
  1326. let totalBytes = max(uploads.reduce(Int64(0)) { $0 + $1.size }, 1)
  1327. let requiredBytes = uploads.reduce(Int64(0)) { $0 + $1.size }
  1328. if requiredBytes > 0 {
  1329. let quotaStartedAt = diagnosticsNow
  1330. let quota = try await service.fetchStorageQuota()
  1331. diagnosticsLog(
  1332. "\(recordLabel) 存储配额查询:耗时 \(diagnosticsElapsed(since: quotaStartedAt))",
  1333. level: .receive
  1334. )
  1335. guard requiredBytes <= quota.remainingBytes else {
  1336. throw APIError.storageQuotaExceeded(
  1337. requiredBytes: requiredBytes,
  1338. remainingBytes: quota.remainingBytes
  1339. )
  1340. }
  1341. }
  1342. var completedBytes: Int64 = 0
  1343. progress(uploads.isEmpty ? 1 : 0)
  1344. if uploads.isEmpty {
  1345. diagnosticsLog(
  1346. "\(recordLabel) 本地资源上传:无需上传,检查耗时 \(diagnosticsElapsed(since: stageStartedAt))"
  1347. )
  1348. }
  1349. for (uploadIndex, upload) in uploads.enumerated() {
  1350. let bytesBeforeUpload = completedBytes
  1351. let uploadStartedAt = diagnosticsNow
  1352. let assetKind = upload.kind.uppercased() == "AUDIO" ? "音频" : "照片"
  1353. diagnosticsLog(
  1354. "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传开始:\(diagnosticsBytes(upload.size))",
  1355. level: .transmit
  1356. )
  1357. _ = try await service.uploadAsset(
  1358. sessionID: cloudSessionID,
  1359. clientID: upload.clientID,
  1360. kind: upload.kind,
  1361. fileURL: upload.url
  1362. ) { fraction in
  1363. let sentBytes = Double(bytesBeforeUpload) + Double(upload.size) * fraction
  1364. progress(min(max(sentBytes / Double(totalBytes), 0), 1))
  1365. }
  1366. completedBytes += upload.size
  1367. progress(Double(completedBytes) / Double(totalBytes))
  1368. let uploadDuration = diagnosticsDuration(since: uploadStartedAt)
  1369. diagnosticsLog(
  1370. "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传完成:耗时 \(diagnosticsDurationText(uploadDuration)),平均 \(diagnosticsRate(bytes: upload.size, duration: uploadDuration))",
  1371. level: .success
  1372. )
  1373. }
  1374. if !uploads.isEmpty {
  1375. diagnosticsLog(
  1376. "\(recordLabel) 本地资源上传完成:文件=\(uploads.count),总大小=\(diagnosticsBytes(requiredBytes)),总耗时 \(diagnosticsElapsed(since: stageStartedAt))",
  1377. level: .success
  1378. )
  1379. }
  1380. }
  1381. private func fileSize(of url: URL) -> Int64 {
  1382. let values = try? url.resourceValues(forKeys: [.fileSizeKey])
  1383. return Int64(values?.fileSize ?? 0)
  1384. }
  1385. private func fileSHA256(of url: URL, purpose: String) async throws -> String {
  1386. let sizeBytes = fileSize(of: url)
  1387. let startedAt = diagnosticsNow
  1388. diagnosticsLog("\(purpose) 开始:\(diagnosticsBytes(sizeBytes))")
  1389. let task = Task.detached(priority: .utility) {
  1390. let input = try FileHandle(forReadingFrom: url)
  1391. defer { try? input.close() }
  1392. var hasher = SHA256()
  1393. while let data = try input.read(upToCount: 1_024 * 1_024),
  1394. !data.isEmpty {
  1395. try Task.checkCancellation()
  1396. hasher.update(data: data)
  1397. }
  1398. return hasher.finalize().map { String(format: "%02x", $0) }.joined()
  1399. }
  1400. let digest = try await withTaskCancellationHandler {
  1401. try await task.value
  1402. } onCancel: {
  1403. task.cancel()
  1404. }
  1405. diagnosticsLog("\(purpose) 完成:耗时 \(diagnosticsElapsed(since: startedAt))")
  1406. return digest
  1407. }
  1408. private func downloadMissingAssets(
  1409. for session: SyncSessionSnapshot,
  1410. remote: RemoteSession
  1411. ) async throws -> [AssetDownloadJob] {
  1412. let assets = remote.assets ?? []
  1413. var jobs: [AssetDownloadJob] = []
  1414. var downloadedAssets: [AssetDownloadJob] = []
  1415. let recordLabel = diagnosticsRecordLabel(sessionID: session.id)
  1416. if let audio = assets
  1417. .filter({ $0.kind.uppercased() == "AUDIO" })
  1418. .max(by: {
  1419. ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast)
  1420. }) {
  1421. let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
  1422. let localHash: String?
  1423. if let localURL {
  1424. localHash = try? await fileSHA256(
  1425. of: localURL,
  1426. purpose: "\(recordLabel) 音频本地校验"
  1427. )
  1428. } else {
  1429. localHash = nil
  1430. }
  1431. let needsDownload = localURL == nil
  1432. || (session.isSynced
  1433. && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame)
  1434. if needsDownload {
  1435. let destination = downloadDestination(for: audio)
  1436. jobs.append(
  1437. AssetDownloadJob(
  1438. sessionID: remote.id,
  1439. asset: audio,
  1440. destination: destination,
  1441. target: .audio,
  1442. displayName: "音频"
  1443. )
  1444. )
  1445. }
  1446. }
  1447. let photoAssets = Dictionary(
  1448. assets
  1449. .filter { $0.kind.uppercased() == "PHOTO" }
  1450. .map { ($0.clientId.lowercased(), $0) },
  1451. uniquingKeysWith: { current, candidate in
  1452. (candidate.createdAt ?? .distantPast) > (current.createdAt ?? .distantPast)
  1453. ? candidate
  1454. : current
  1455. }
  1456. )
  1457. let remotePhotoCount = assets.lazy.filter { $0.kind.uppercased() == "PHOTO" }.count
  1458. if photoAssets.count < remotePhotoCount {
  1459. DeveloperLogStore.log(
  1460. "现场记录同步",
  1461. "云端照片资源存在重复 clientId,已选择最新资源继续同步",
  1462. level: .warning
  1463. )
  1464. }
  1465. for event in session.events where event.eventType == "PHOTO" {
  1466. guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
  1467. let key = "\(event.id.uuidString)-photo".lowercased()
  1468. guard let asset = photoAssets[key] else { continue }
  1469. let destination = downloadDestination(for: asset)
  1470. jobs.append(
  1471. AssetDownloadJob(
  1472. sessionID: remote.id,
  1473. asset: asset,
  1474. destination: destination,
  1475. target: .photo(eventID: event.id),
  1476. displayName: "照片"
  1477. )
  1478. )
  1479. }
  1480. guard !jobs.isEmpty else { return [] }
  1481. diagnosticsLog(
  1482. "\(recordLabel) 资源并发下载:文件=\(jobs.count),并发上限=\(Self.maxConcurrentAssetDownloads)",
  1483. level: .receive
  1484. )
  1485. for batchStart in stride(
  1486. from: 0,
  1487. to: jobs.count,
  1488. by: Self.maxConcurrentAssetDownloads
  1489. ) {
  1490. try Task.checkCancellation()
  1491. let batchEnd = min(batchStart + Self.maxConcurrentAssetDownloads, jobs.count)
  1492. let batch = Array(jobs[batchStart..<batchEnd])
  1493. let tasks = batch.map { job in
  1494. Task { @MainActor [self] in
  1495. try await downloadAssetJob(job, recordLabel: recordLabel)
  1496. }
  1497. }
  1498. do {
  1499. try await withTaskCancellationHandler {
  1500. for task in tasks {
  1501. let completedJob = try await task.value
  1502. downloadedAssets.append(completedJob)
  1503. }
  1504. } onCancel: {
  1505. tasks.forEach { $0.cancel() }
  1506. }
  1507. } catch {
  1508. tasks.forEach { $0.cancel() }
  1509. throw error
  1510. }
  1511. }
  1512. return downloadedAssets
  1513. }
  1514. private func downloadAssetJob(
  1515. _ job: AssetDownloadJob,
  1516. recordLabel: String
  1517. ) async throws -> AssetDownloadJob {
  1518. try Task.checkCancellation()
  1519. let downloadStartedAt = diagnosticsNow
  1520. diagnosticsLog(
  1521. "\(recordLabel) \(job.displayName)下载开始:\(diagnosticsBytes(job.asset.sizeBytes))",
  1522. level: .receive
  1523. )
  1524. try await service.downloadAsset(
  1525. sessionID: job.sessionID,
  1526. asset: job.asset,
  1527. destinationURL: job.destination
  1528. )
  1529. try Task.checkCancellation()
  1530. let downloadDuration = diagnosticsDuration(since: downloadStartedAt)
  1531. diagnosticsLog(
  1532. "\(recordLabel) \(job.displayName)下载完成:耗时 \(diagnosticsDurationText(downloadDuration)),平均 \(diagnosticsRate(bytes: job.asset.sizeBytes, duration: downloadDuration))",
  1533. level: .success
  1534. )
  1535. return job
  1536. }
  1537. private var diagnosticsNow: TimeInterval {
  1538. ProcessInfo.processInfo.systemUptime
  1539. }
  1540. private func diagnosticsDuration(since startedAt: TimeInterval) -> TimeInterval {
  1541. max(0, diagnosticsNow - startedAt)
  1542. }
  1543. private func diagnosticsElapsed(since startedAt: TimeInterval) -> String {
  1544. diagnosticsDurationText(diagnosticsDuration(since: startedAt))
  1545. }
  1546. private func diagnosticsDurationText(_ duration: TimeInterval) -> String {
  1547. if duration < 1 {
  1548. return "\(Int((duration * 1_000).rounded())) 毫秒"
  1549. }
  1550. return String(format: "%.2f 秒", duration)
  1551. }
  1552. private func diagnosticsBytes(_ bytes: Int64) -> String {
  1553. Self.diagnosticsByteFormatter.string(fromByteCount: max(0, bytes))
  1554. }
  1555. private func diagnosticsRate(bytes: Int64, duration: TimeInterval) -> String {
  1556. guard duration > 0 else { return "—" }
  1557. return "\(diagnosticsBytes(Int64(Double(bytes) / duration)))/秒"
  1558. }
  1559. private func diagnosticsRecordLabel(
  1560. sessionID: UUID,
  1561. position: Int? = nil,
  1562. total: Int? = nil
  1563. ) -> String {
  1564. let shortID = sessionID.uuidString.lowercased().prefix(8)
  1565. if let position, let total {
  1566. return "记录 \(position)/\(total) [\(shortID)]"
  1567. }
  1568. return "记录 [\(shortID)]"
  1569. }
  1570. private func diagnosticsLog(
  1571. _ message: String,
  1572. level: DeveloperLogEntry.Level = .info
  1573. ) {
  1574. DeveloperLogStore.log("同步性能", message, level: level)
  1575. }
  1576. private func downloadDestination(for asset: RemoteAsset) -> URL {
  1577. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  1578. let fileExtension = (asset.fileName as NSString).pathExtension
  1579. let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
  1580. return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
  1581. }
  1582. private func deduplicatedSessions(_ sessions: [CelestiaSession]) -> [CelestiaSession] {
  1583. Array(Dictionary(
  1584. sessions.map { ($0.id, $0) },
  1585. uniquingKeysWith: preferredLocalSession
  1586. ).values)
  1587. }
  1588. private func preferredLocalSession(
  1589. _ current: CelestiaSession,
  1590. _ candidate: CelestiaSession
  1591. ) -> CelestiaSession {
  1592. if (current.cloudSessionId == nil) != (candidate.cloudSessionId == nil) {
  1593. return current.cloudSessionId == nil ? candidate : current
  1594. }
  1595. if current.serverRevision != candidate.serverRevision {
  1596. return current.serverRevision > candidate.serverRevision ? current : candidate
  1597. }
  1598. return (current.lastSyncedAt ?? .distantPast) >= (candidate.lastSyncedAt ?? .distantPast)
  1599. ? current
  1600. : candidate
  1601. }
  1602. }