RemoteNetworkService.swift 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488
  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 {
  18. let asset: RemoteAsset
  19. let sessionRevision: Int64?
  20. }
  21. struct StorageQuota: Decodable {
  22. let totalBytes: Int64
  23. let usedBytes: Int64
  24. let remainingBytes: Int64
  25. }
  26. struct RemoteEvent: Decodable {
  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 {
  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 {
  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: CelestiaSession,
  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. @MainActor
  404. final class SyncManager: ObservableObject {
  405. static let shared = SyncManager()
  406. private static let maxConcurrentAssetDownloads = 4
  407. @Published private(set) var isSyncing = false
  408. @Published private(set) var lastSyncDate: Date?
  409. @Published private(set) var lastErrorMessage: String?
  410. @Published private(set) var completedCount = 0
  411. @Published private(set) var totalCount = 0
  412. @Published private(set) var activeSessionID: UUID?
  413. @Published private(set) var syncProgress: Double = 0
  414. private let service: NetworkServiceProtocol
  415. private var activeSyncTask: Task<Bool, Never>?
  416. private var automaticSyncTasks: [UUID: Task<Void, Never>] = [:]
  417. private var automaticSyncGenerations: [UUID: UUID] = [:]
  418. private var queuedSyncRequest: SyncRequest?
  419. private static let diagnosticsByteFormatter: ByteCountFormatter = {
  420. let formatter = ByteCountFormatter()
  421. formatter.countStyle = .file
  422. formatter.allowedUnits = [.useKB, .useMB, .useGB]
  423. formatter.isAdaptive = true
  424. return formatter
  425. }()
  426. private struct SyncRequest {
  427. let sessions: [CelestiaSession]
  428. let modelContext: ModelContext
  429. let userID: String
  430. }
  431. private enum AssetDownloadTarget: Sendable {
  432. case audio
  433. case photo(eventID: UUID)
  434. }
  435. private struct AssetDownloadJob: Sendable {
  436. let sessionID: String
  437. let asset: RemoteAsset
  438. let destination: URL
  439. let target: AssetDownloadTarget
  440. let displayName: String
  441. }
  442. init(service: NetworkServiceProtocol = RemoteNetworkService()) {
  443. self.service = service
  444. lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
  445. }
  446. func estimatedUploadBytes(for session: CelestiaSession) -> Int64 {
  447. var urls: [URL] = []
  448. if let url = AudioPathHelper.resolveURL(for: session.localAudioPath) {
  449. urls.append(url)
  450. }
  451. urls.append(contentsOf: session.events.compactMap { event in
  452. guard event.eventType == "PHOTO" else { return nil }
  453. return AudioPathHelper.resolveURL(for: event.localFilePath)
  454. })
  455. return urls.reduce(into: 0) { total, url in
  456. let values = try? url.resourceValues(forKeys: [.fileSizeKey])
  457. total += Int64(values?.fileSize ?? 0)
  458. }
  459. }
  460. func scheduleAutomaticSync(
  461. for session: CelestiaSession,
  462. modelContext: ModelContext,
  463. delay: Duration = .seconds(1.5)
  464. ) {
  465. guard session.endTime != nil,
  466. session.syncState != .conflict,
  467. AuthManager.shared.currentUser?.id != nil else { return }
  468. let sessionID = session.id
  469. let generation = UUID()
  470. automaticSyncGenerations[sessionID] = generation
  471. automaticSyncTasks[sessionID]?.cancel()
  472. if isSyncing, activeSessionID == sessionID {
  473. activeSyncTask?.cancel()
  474. }
  475. automaticSyncTasks[sessionID] = Task { @MainActor [weak self] in
  476. do {
  477. try await Task.sleep(for: delay)
  478. while let self,
  479. (self.isSyncing || !NetworkStatusMonitor.shared.isConnected) {
  480. try Task.checkCancellation()
  481. try await Task.sleep(for: .seconds(0.75))
  482. }
  483. guard let self,
  484. !Task.isCancelled,
  485. self.automaticSyncGenerations[sessionID] == generation,
  486. session.needsCloudSync,
  487. let userID = AuthManager.shared.currentUser?.id else { return }
  488. _ = await self.sync(
  489. sessions: [session],
  490. modelContext: modelContext,
  491. userID: userID
  492. )
  493. if self.automaticSyncGenerations[sessionID] == generation {
  494. self.automaticSyncTasks[sessionID] = nil
  495. self.automaticSyncGenerations[sessionID] = nil
  496. }
  497. } catch {
  498. guard let self,
  499. self.automaticSyncGenerations[sessionID] == generation else { return }
  500. self.automaticSyncTasks[sessionID] = nil
  501. self.automaticSyncGenerations[sessionID] = nil
  502. }
  503. }
  504. }
  505. func sync(
  506. sessions: [CelestiaSession],
  507. modelContext: ModelContext,
  508. userID: String
  509. ) async -> Bool {
  510. guard !isSyncing else {
  511. queuedSyncRequest = SyncRequest(
  512. sessions: sessions,
  513. modelContext: modelContext,
  514. userID: userID
  515. )
  516. DeveloperLogStore.log("现场记录同步", "已有同步任务正在运行,已排队再次检查时间戳")
  517. return false
  518. }
  519. DeveloperLogStore.log("现场记录同步", "开始同步,本地共 \(sessions.count) 条记录")
  520. let syncStartedAt = diagnosticsNow
  521. diagnosticsLog("整轮开始:本地记录=\(sessions.count)")
  522. isSyncing = true
  523. let task = Task { @MainActor [self] in
  524. await performSync(
  525. sessions: sessions,
  526. modelContext: modelContext,
  527. userID: userID
  528. )
  529. }
  530. activeSyncTask = task
  531. let result = await withTaskCancellationHandler {
  532. await task.value
  533. } onCancel: {
  534. task.cancel()
  535. }
  536. activeSyncTask = nil
  537. isSyncing = false
  538. activeSessionID = nil
  539. DeveloperLogStore.log(
  540. "现场记录同步",
  541. result ? "同步任务完成" : "同步任务未完成",
  542. level: result ? .success : .warning
  543. )
  544. diagnosticsLog(
  545. "整轮\(result ? "完成" : "未完成"):总耗时 \(diagnosticsElapsed(since: syncStartedAt))",
  546. level: result ? .success : .warning
  547. )
  548. if let queuedRequest = queuedSyncRequest {
  549. queuedSyncRequest = nil
  550. Task { @MainActor [weak self] in
  551. guard AuthManager.shared.currentUser?.id == queuedRequest.userID else { return }
  552. _ = await self?.sync(
  553. sessions: queuedRequest.sessions,
  554. modelContext: queuedRequest.modelContext,
  555. userID: queuedRequest.userID
  556. )
  557. }
  558. }
  559. return result
  560. }
  561. func pauseSync(sessionID: UUID) {
  562. guard isSyncing, activeSessionID == sessionID else { return }
  563. queuedSyncRequest = nil
  564. activeSyncTask?.cancel()
  565. }
  566. private func performSync(
  567. sessions: [CelestiaSession],
  568. modelContext: ModelContext,
  569. userID: String
  570. ) async -> Bool {
  571. lastErrorMessage = nil
  572. completedCount = 0
  573. activeSessionID = nil
  574. syncProgress = 0
  575. let queuedSessions = deduplicatedSessions(sessions.filter {
  576. ($0.ownerUserID == nil || $0.ownerUserID == userID)
  577. && $0.needsCloudSync
  578. && $0.syncState != .conflict
  579. })
  580. totalCount = queuedSessions.count
  581. DeveloperLogStore.log("现场记录同步", "待上传 \(queuedSessions.count) 条记录,正在获取云端索引")
  582. // Give the UI immediate feedback while the remote index is being fetched.
  583. // The persisted state is changed only when this session actually begins syncing.
  584. activeSessionID = queuedSessions.first?.id
  585. var pendingRemoteDownloads: [CelestiaSession] = []
  586. do {
  587. let indexStartedAt = diagnosticsNow
  588. diagnosticsLog("阶段 1/5 开始:请求服务器时间戳索引", level: .receive)
  589. let remoteIndex = try await service.fetchSessionIndex()
  590. diagnosticsLog(
  591. "阶段 1/5 完成:云端记录=\(remoteIndex.count),耗时 \(diagnosticsElapsed(since: indexStartedAt))",
  592. level: .success
  593. )
  594. DeveloperLogStore.log("现场记录同步", "获取到 \(remoteIndex.count) 条云端时间戳", level: .success)
  595. try Task.checkCancellation()
  596. let mergeStartedAt = diagnosticsNow
  597. let allLocalSessions = try modelContext.fetch(FetchDescriptor<CelestiaSession>())
  598. let indexedSessions = try await mergeIndex(
  599. remoteIndex,
  600. into: allLocalSessions,
  601. modelContext: modelContext,
  602. userID: userID
  603. )
  604. diagnosticsLog(
  605. "阶段 2/5 完成:读取并合并本地记录=\(allLocalSessions.count),索引匹配=\(indexedSessions.count),耗时 \(diagnosticsElapsed(since: mergeStartedAt))"
  606. )
  607. var failures: [String] = []
  608. var remoteDetailsByID: [String: RemoteSession] = [:]
  609. // mergeIndex has already persisted list metadata. Only timestamp
  610. // changes proceed to the second phase that fetches concrete content.
  611. let sessionsRequiringDetail = indexedSessions.filter { $0.needsDetail }
  612. pendingRemoteDownloads = sessionsRequiringDetail.map(\.local)
  613. if let firstDownload = sessionsRequiringDetail.first?.local {
  614. activeSessionID = firstDownload.id
  615. }
  616. await Task.yield()
  617. let detailStageStartedAt = diagnosticsNow
  618. diagnosticsLog("阶段 3/5 开始:需要拉取详情=\(sessionsRequiringDetail.count)")
  619. for (detailIndex, match) in sessionsRequiringDetail.enumerated() {
  620. let local = match.local
  621. let recordLabel = diagnosticsRecordLabel(
  622. local,
  623. position: detailIndex + 1,
  624. total: sessionsRequiringDetail.count
  625. )
  626. let recordStartedAt = diagnosticsNow
  627. do {
  628. activeSessionID = local.id
  629. let detailStartedAt = diagnosticsNow
  630. let remote = try await service.fetchSession(sessionID: match.index.id)
  631. let remoteAssets = remote.assets ?? []
  632. let remoteBytes = remoteAssets.reduce(Int64(0)) { $0 + $1.sizeBytes }
  633. diagnosticsLog(
  634. "\(recordLabel) 详情响应:事件=\(remote.events?.count ?? 0),资源=\(remoteAssets.count)(\(diagnosticsBytes(remoteBytes))),耗时 \(diagnosticsElapsed(since: detailStartedAt))",
  635. level: .receive
  636. )
  637. remoteDetailsByID[remote.id] = remote
  638. let applyStartedAt = diagnosticsNow
  639. await apply(
  640. remote,
  641. to: local,
  642. userID: userID,
  643. syncState: .syncing
  644. )
  645. try modelContext.save()
  646. diagnosticsLog(
  647. "\(recordLabel) 详情写入本地:耗时 \(diagnosticsElapsed(since: applyStartedAt))"
  648. )
  649. await Task.yield()
  650. let assetStartedAt = diagnosticsNow
  651. let downloadedAssets = try await downloadMissingAssets(
  652. for: local,
  653. remote: remote
  654. ) {
  655. guard local.hasConfirmedCloudSync else { return }
  656. activeSessionID = local.id
  657. local.syncState = .syncing
  658. local.lastSyncError = nil
  659. try? modelContext.save()
  660. }
  661. try Task.checkCancellation()
  662. local.serverUpdatedAt = remote.updatedAt
  663. local.syncState = .synced
  664. local.lastSyncError = nil
  665. local.lastSyncedAt = Date()
  666. let finalSaveStartedAt = diagnosticsNow
  667. try modelContext.save()
  668. diagnosticsLog(
  669. "\(recordLabel) 资源阶段:\(downloadedAssets ? "发生下载" : "无需下载"),耗时 \(diagnosticsElapsed(since: assetStartedAt));最终保存 \(diagnosticsElapsed(since: finalSaveStartedAt))",
  670. level: .success
  671. )
  672. diagnosticsLog(
  673. "\(recordLabel) 云端拉取完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
  674. level: .success
  675. )
  676. await Task.yield()
  677. } catch where Task.isCancelled {
  678. diagnosticsLog(
  679. "\(recordLabel) 云端拉取取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
  680. level: .warning
  681. )
  682. if local.hasConfirmedCloudSync,
  683. local.syncState == .syncing {
  684. local.syncState = .failed
  685. local.lastSyncError = "已暂停从云端下载"
  686. try? modelContext.save()
  687. }
  688. throw CancellationError()
  689. } catch {
  690. if local.hasConfirmedCloudSync {
  691. local.syncState = .failed
  692. local.lastSyncError = "从云端下载失败:\(error.localizedDescription)"
  693. try? modelContext.save()
  694. }
  695. diagnosticsLog(
  696. "\(recordLabel) 云端拉取失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
  697. level: .error
  698. )
  699. failures.append("\(local.title):\(error.localizedDescription)")
  700. DeveloperLogStore.log(
  701. "现场记录同步",
  702. "\(local.title) 从云端下载失败:\(error.localizedDescription)",
  703. level: .error
  704. )
  705. }
  706. }
  707. try modelContext.save()
  708. diagnosticsLog(
  709. "阶段 3/5 完成:详情记录=\(sessionsRequiringDetail.count),总耗时 \(diagnosticsElapsed(since: detailStageStartedAt))"
  710. )
  711. let eligible = deduplicatedSessions(sessions.filter {
  712. ($0.ownerUserID == nil || $0.ownerUserID == userID)
  713. && $0.needsCloudSync
  714. && $0.syncState != .conflict
  715. })
  716. let eligibleCount = max(eligible.count, 1)
  717. let uploadStageStartedAt = diagnosticsNow
  718. diagnosticsLog("阶段 4/5 开始:需要上传=\(eligible.count)")
  719. for (index, session) in eligible.enumerated() {
  720. try Task.checkCancellation()
  721. await Task.yield()
  722. activeSessionID = session.id
  723. let sessionStartProgress = Double(index) / Double(eligibleCount)
  724. let sessionProgressSpan = 1.0 / Double(eligibleCount)
  725. syncProgress = sessionStartProgress
  726. session.ownerUserID = userID
  727. session.syncState = .syncing
  728. session.lastSyncError = nil
  729. DeveloperLogStore.log(
  730. "现场记录同步",
  731. "正在同步 \(index + 1)/\(eligible.count):\(session.title)"
  732. )
  733. let recordLabel = diagnosticsRecordLabel(
  734. session,
  735. position: index + 1,
  736. total: eligible.count
  737. )
  738. let recordStartedAt = diagnosticsNow
  739. do {
  740. var previousRemote: RemoteSession?
  741. if let cloudID = session.cloudSessionId {
  742. if let cached = remoteDetailsByID[cloudID] {
  743. previousRemote = cached
  744. } else if remoteIndex.contains(where: { $0.id == cloudID }) {
  745. let previousDetailStartedAt = diagnosticsNow
  746. let fetched = try await service.fetchSession(sessionID: cloudID)
  747. diagnosticsLog(
  748. "\(recordLabel) 上传前详情:耗时 \(diagnosticsElapsed(since: previousDetailStartedAt))",
  749. level: .receive
  750. )
  751. remoteDetailsByID[cloudID] = fetched
  752. previousRemote = fetched
  753. }
  754. }
  755. let localEventIDs = Set(session.events.map { $0.id.uuidString.lowercased() })
  756. let deletedEventClientIDs = previousRemote?.events?
  757. .compactMap(\.clientId)
  758. .filter { !localEventIDs.contains($0.lowercased()) } ?? []
  759. let metadataStartedAt = diagnosticsNow
  760. let remote = try await service.syncSession(
  761. session,
  762. deletedEventClientIDs: deletedEventClientIDs
  763. )
  764. diagnosticsLog(
  765. "\(recordLabel) 元数据上传:耗时 \(diagnosticsElapsed(since: metadataStartedAt))",
  766. level: .transmit
  767. )
  768. try Task.checkCancellation()
  769. syncProgress = sessionStartProgress + sessionProgressSpan * 0.12
  770. session.cloudSessionId = remote.id
  771. session.serverRevision = remote.revision
  772. session.serverUpdatedAt = remote.updatedAt
  773. try await uploadLocalAssets(for: session, remote: remote) { [weak self] assetProgress in
  774. Task { @MainActor [weak self] in
  775. self?.syncProgress = sessionStartProgress
  776. + sessionProgressSpan * (0.12 + assetProgress * 0.83)
  777. }
  778. }
  779. try Task.checkCancellation()
  780. let finalDetailStartedAt = diagnosticsNow
  781. let finalRemote = try await service.fetchSession(sessionID: remote.id)
  782. diagnosticsLog(
  783. "\(recordLabel) 上传后详情确认:耗时 \(diagnosticsElapsed(since: finalDetailStartedAt))",
  784. level: .receive
  785. )
  786. remoteDetailsByID[finalRemote.id] = finalRemote
  787. session.serverRevision = finalRemote.revision
  788. session.serverUpdatedAt = finalRemote.updatedAt
  789. session.isSynced = true
  790. session.syncState = .synced
  791. session.lastSyncedAt = Date()
  792. completedCount += 1
  793. syncProgress = sessionStartProgress + sessionProgressSpan
  794. DeveloperLogStore.log(
  795. "现场记录同步",
  796. "\(session.title) 同步成功,云端版本 v\(session.serverRevision)",
  797. level: .success
  798. )
  799. diagnosticsLog(
  800. "\(recordLabel) 上传链路完成:总耗时 \(diagnosticsElapsed(since: recordStartedAt))",
  801. level: .success
  802. )
  803. } catch where Task.isCancelled {
  804. diagnosticsLog(
  805. "\(recordLabel) 上传链路取消:已运行 \(diagnosticsElapsed(since: recordStartedAt))",
  806. level: .warning
  807. )
  808. session.isSynced = false
  809. session.syncState = .pending
  810. session.lastSyncError = nil
  811. syncProgress = 0
  812. try? modelContext.save()
  813. return false
  814. } catch APIError.server(let code, _) where code == 409 {
  815. diagnosticsLog(
  816. "\(recordLabel) 上传链路冲突:耗时 \(diagnosticsElapsed(since: recordStartedAt))",
  817. level: .error
  818. )
  819. session.isSynced = false
  820. session.syncState = .conflict
  821. session.lastSyncError = "本地版本 \(session.serverRevision) 与云端版本不一致"
  822. DeveloperLogStore.log(
  823. "现场记录同步",
  824. "\(session.title) 发生版本冲突",
  825. level: .error
  826. )
  827. } catch {
  828. diagnosticsLog(
  829. "\(recordLabel) 上传链路失败:耗时 \(diagnosticsElapsed(since: recordStartedAt));\(error.localizedDescription)",
  830. level: .error
  831. )
  832. session.isSynced = false
  833. session.syncState = .failed
  834. session.lastSyncError = error.localizedDescription
  835. failures.append("\(session.title):\(error.localizedDescription)")
  836. DeveloperLogStore.log(
  837. "现场记录同步",
  838. "\(session.title) 同步失败:\(error.localizedDescription)",
  839. level: .error
  840. )
  841. }
  842. try modelContext.save()
  843. }
  844. diagnosticsLog(
  845. "阶段 4/5 完成:上传记录=\(eligible.count),总耗时 \(diagnosticsElapsed(since: uploadStageStartedAt))"
  846. )
  847. let conflicts = sessions.filter { $0.syncState == .conflict }
  848. if !conflicts.isEmpty {
  849. failures.append(contentsOf: conflicts.map {
  850. "\($0.title):本地和云端版本不一致"
  851. })
  852. }
  853. guard failures.isEmpty else {
  854. lastErrorMessage = failures.joined(separator: "\n")
  855. DeveloperLogStore.log("现场记录同步", "同步结束,存在 \(failures.count) 个问题", level: .error)
  856. return false
  857. }
  858. let checkpointStartedAt = diagnosticsNow
  859. try await service.recordSyncCheckpoint()
  860. diagnosticsLog(
  861. "阶段 5/5 完成:提交同步检查点,耗时 \(diagnosticsElapsed(since: checkpointStartedAt))",
  862. level: .success
  863. )
  864. let now = Date()
  865. lastSyncDate = now
  866. UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
  867. DeveloperLogStore.log("现场记录同步", "云端检查点已更新", level: .success)
  868. return true
  869. } catch where Task.isCancelled {
  870. for session in pendingRemoteDownloads where session.syncState == .syncing {
  871. session.syncState = .failed
  872. session.lastSyncError = "已暂停从云端下载"
  873. }
  874. try? modelContext.save()
  875. syncProgress = 0
  876. return false
  877. } catch {
  878. for session in pendingRemoteDownloads where session.syncState == .syncing {
  879. session.syncState = .failed
  880. session.lastSyncError = "从云端下载中断:\(error.localizedDescription)"
  881. }
  882. try? modelContext.save()
  883. lastErrorMessage = error.localizedDescription
  884. DeveloperLogStore.log("现场记录同步", "同步中断:\(error.localizedDescription)", level: .error)
  885. return false
  886. }
  887. }
  888. private func uploadLocalAssets(
  889. for session: CelestiaSession,
  890. remote: RemoteSession,
  891. progress: @escaping @Sendable (Double) -> Void
  892. ) async throws {
  893. guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
  894. let stageStartedAt = diagnosticsNow
  895. let recordLabel = diagnosticsRecordLabel(session)
  896. let existingIDs = Set((remote.assets ?? []).map(\.clientId))
  897. var uploads: [(clientID: String, kind: String, url: URL, size: Int64)] = []
  898. if let path = session.localAudioPath {
  899. guard let url = AudioPathHelper.resolveURL(for: path) else {
  900. throw APIError.transport("本地录音文件不存在:\(path)")
  901. }
  902. let sha256 = try await fileSHA256(
  903. of: url,
  904. purpose: "\(recordLabel) 音频上传校验"
  905. )
  906. let hasMatchingAudio = (remote.assets ?? []).contains {
  907. $0.kind.uppercased() == "AUDIO"
  908. && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame
  909. }
  910. if !hasMatchingAudio {
  911. let clientID = "\(session.id.uuidString)-audio-\(sha256.prefix(16))"
  912. uploads.append((clientID, "AUDIO", url, fileSize(of: url)))
  913. }
  914. }
  915. for event in session.events where event.eventType == "PHOTO" {
  916. guard let path = event.localFilePath else { continue }
  917. guard let url = AudioPathHelper.resolveURL(for: path) else {
  918. throw APIError.transport("照片文件不存在:\(path)")
  919. }
  920. let clientID = "\(event.id.uuidString)-photo"
  921. if !existingIDs.contains(clientID) {
  922. uploads.append((clientID, "PHOTO", url, fileSize(of: url)))
  923. }
  924. }
  925. let totalBytes = max(uploads.reduce(Int64(0)) { $0 + $1.size }, 1)
  926. let requiredBytes = uploads.reduce(Int64(0)) { $0 + $1.size }
  927. if requiredBytes > 0 {
  928. let quotaStartedAt = diagnosticsNow
  929. let quota = try await service.fetchStorageQuota()
  930. diagnosticsLog(
  931. "\(recordLabel) 存储配额查询:耗时 \(diagnosticsElapsed(since: quotaStartedAt))",
  932. level: .receive
  933. )
  934. guard requiredBytes <= quota.remainingBytes else {
  935. throw APIError.storageQuotaExceeded(
  936. requiredBytes: requiredBytes,
  937. remainingBytes: quota.remainingBytes
  938. )
  939. }
  940. }
  941. var completedBytes: Int64 = 0
  942. progress(uploads.isEmpty ? 1 : 0)
  943. if uploads.isEmpty {
  944. diagnosticsLog(
  945. "\(recordLabel) 本地资源上传:无需上传,检查耗时 \(diagnosticsElapsed(since: stageStartedAt))"
  946. )
  947. }
  948. for (uploadIndex, upload) in uploads.enumerated() {
  949. let bytesBeforeUpload = completedBytes
  950. let uploadStartedAt = diagnosticsNow
  951. let assetKind = upload.kind.uppercased() == "AUDIO" ? "音频" : "照片"
  952. diagnosticsLog(
  953. "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传开始:\(diagnosticsBytes(upload.size))",
  954. level: .transmit
  955. )
  956. let result = try await service.uploadAsset(
  957. sessionID: cloudID,
  958. clientID: upload.clientID,
  959. kind: upload.kind,
  960. fileURL: upload.url
  961. ) { fraction in
  962. let sentBytes = Double(bytesBeforeUpload) + Double(upload.size) * fraction
  963. progress(min(max(sentBytes / Double(totalBytes), 0), 1))
  964. }
  965. if let revision = result.sessionRevision {
  966. session.serverRevision = revision
  967. }
  968. completedBytes += upload.size
  969. progress(Double(completedBytes) / Double(totalBytes))
  970. let uploadDuration = diagnosticsDuration(since: uploadStartedAt)
  971. diagnosticsLog(
  972. "\(recordLabel) \(assetKind) \(uploadIndex + 1)/\(uploads.count) 上传完成:耗时 \(diagnosticsDurationText(uploadDuration)),平均 \(diagnosticsRate(bytes: upload.size, duration: uploadDuration))",
  973. level: .success
  974. )
  975. }
  976. if !uploads.isEmpty {
  977. diagnosticsLog(
  978. "\(recordLabel) 本地资源上传完成:文件=\(uploads.count),总大小=\(diagnosticsBytes(requiredBytes)),总耗时 \(diagnosticsElapsed(since: stageStartedAt))",
  979. level: .success
  980. )
  981. }
  982. }
  983. private func fileSize(of url: URL) -> Int64 {
  984. let values = try? url.resourceValues(forKeys: [.fileSizeKey])
  985. return Int64(values?.fileSize ?? 0)
  986. }
  987. private func fileSHA256(of url: URL, purpose: String) async throws -> String {
  988. let sizeBytes = fileSize(of: url)
  989. let startedAt = diagnosticsNow
  990. diagnosticsLog("\(purpose) 开始:\(diagnosticsBytes(sizeBytes))")
  991. let task = Task.detached(priority: .utility) {
  992. let input = try FileHandle(forReadingFrom: url)
  993. defer { try? input.close() }
  994. var hasher = SHA256()
  995. while let data = try input.read(upToCount: 1_024 * 1_024),
  996. !data.isEmpty {
  997. try Task.checkCancellation()
  998. hasher.update(data: data)
  999. }
  1000. return hasher.finalize().map { String(format: "%02x", $0) }.joined()
  1001. }
  1002. let digest = try await withTaskCancellationHandler {
  1003. try await task.value
  1004. } onCancel: {
  1005. task.cancel()
  1006. }
  1007. diagnosticsLog("\(purpose) 完成:耗时 \(diagnosticsElapsed(since: startedAt))")
  1008. return digest
  1009. }
  1010. private func downloadMissingAssets(
  1011. for session: CelestiaSession,
  1012. remote: RemoteSession,
  1013. onDownloadStarted: () -> Void
  1014. ) async throws -> Bool {
  1015. let assets = remote.assets ?? []
  1016. var jobs: [AssetDownloadJob] = []
  1017. var downloadedAssets = false
  1018. let recordLabel = diagnosticsRecordLabel(session)
  1019. if let audio = assets
  1020. .filter({ $0.kind.uppercased() == "AUDIO" })
  1021. .max(by: {
  1022. ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast)
  1023. }) {
  1024. let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
  1025. let localHash: String?
  1026. if let localURL {
  1027. localHash = try? await fileSHA256(
  1028. of: localURL,
  1029. purpose: "\(recordLabel) 音频本地校验"
  1030. )
  1031. } else {
  1032. localHash = nil
  1033. }
  1034. let needsDownload = localURL == nil
  1035. || (session.isSynced
  1036. && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame)
  1037. if needsDownload {
  1038. let destination = downloadDestination(for: audio)
  1039. jobs.append(
  1040. AssetDownloadJob(
  1041. sessionID: remote.id,
  1042. asset: audio,
  1043. destination: destination,
  1044. target: .audio,
  1045. displayName: "音频"
  1046. )
  1047. )
  1048. }
  1049. }
  1050. let photoAssets = Dictionary(
  1051. assets
  1052. .filter { $0.kind.uppercased() == "PHOTO" }
  1053. .map { ($0.clientId.lowercased(), $0) },
  1054. uniquingKeysWith: { current, candidate in
  1055. (candidate.createdAt ?? .distantPast) > (current.createdAt ?? .distantPast)
  1056. ? candidate
  1057. : current
  1058. }
  1059. )
  1060. let remotePhotoCount = assets.lazy.filter { $0.kind.uppercased() == "PHOTO" }.count
  1061. if photoAssets.count < remotePhotoCount {
  1062. DeveloperLogStore.log(
  1063. "现场记录同步",
  1064. "云端照片资源存在重复 clientId,已选择最新资源继续同步",
  1065. level: .warning
  1066. )
  1067. }
  1068. for event in session.events where event.eventType == "PHOTO" {
  1069. guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
  1070. let key = "\(event.id.uuidString)-photo".lowercased()
  1071. guard let asset = photoAssets[key] else { continue }
  1072. let destination = downloadDestination(for: asset)
  1073. jobs.append(
  1074. AssetDownloadJob(
  1075. sessionID: remote.id,
  1076. asset: asset,
  1077. destination: destination,
  1078. target: .photo(eventID: event.id),
  1079. displayName: "照片"
  1080. )
  1081. )
  1082. }
  1083. guard !jobs.isEmpty else { return false }
  1084. diagnosticsLog(
  1085. "\(recordLabel) 资源并发下载:文件=\(jobs.count),并发上限=\(Self.maxConcurrentAssetDownloads)",
  1086. level: .receive
  1087. )
  1088. for batchStart in stride(
  1089. from: 0,
  1090. to: jobs.count,
  1091. by: Self.maxConcurrentAssetDownloads
  1092. ) {
  1093. try Task.checkCancellation()
  1094. let batchEnd = min(batchStart + Self.maxConcurrentAssetDownloads, jobs.count)
  1095. let batch = Array(jobs[batchStart..<batchEnd])
  1096. batch.forEach { _ in onDownloadStarted() }
  1097. let tasks = batch.map { job in
  1098. Task { @MainActor [self] in
  1099. try await downloadAssetJob(job, recordLabel: recordLabel)
  1100. }
  1101. }
  1102. do {
  1103. try await withTaskCancellationHandler {
  1104. for task in tasks {
  1105. let completedJob = try await task.value
  1106. applyDownloadedAsset(completedJob, to: session)
  1107. downloadedAssets = true
  1108. }
  1109. } onCancel: {
  1110. tasks.forEach { $0.cancel() }
  1111. }
  1112. } catch {
  1113. tasks.forEach { $0.cancel() }
  1114. throw error
  1115. }
  1116. }
  1117. return downloadedAssets
  1118. }
  1119. private func downloadAssetJob(
  1120. _ job: AssetDownloadJob,
  1121. recordLabel: String
  1122. ) async throws -> AssetDownloadJob {
  1123. try Task.checkCancellation()
  1124. let downloadStartedAt = diagnosticsNow
  1125. diagnosticsLog(
  1126. "\(recordLabel) \(job.displayName)下载开始:\(diagnosticsBytes(job.asset.sizeBytes))",
  1127. level: .receive
  1128. )
  1129. try await service.downloadAsset(
  1130. sessionID: job.sessionID,
  1131. asset: job.asset,
  1132. destinationURL: job.destination
  1133. )
  1134. try Task.checkCancellation()
  1135. let downloadDuration = diagnosticsDuration(since: downloadStartedAt)
  1136. diagnosticsLog(
  1137. "\(recordLabel) \(job.displayName)下载完成:耗时 \(diagnosticsDurationText(downloadDuration)),平均 \(diagnosticsRate(bytes: job.asset.sizeBytes, duration: downloadDuration))",
  1138. level: .success
  1139. )
  1140. return job
  1141. }
  1142. private func applyDownloadedAsset(
  1143. _ job: AssetDownloadJob,
  1144. to session: CelestiaSession
  1145. ) {
  1146. let relativePath = AudioPathHelper.relativePath(from: job.destination.path)
  1147. switch job.target {
  1148. case .audio:
  1149. session.localAudioPath = relativePath
  1150. Task.detached(priority: .utility) {
  1151. await SilenceDetector.warmCache(for: job.destination)
  1152. }
  1153. case .photo(let eventID):
  1154. session.events.first(where: { $0.id == eventID })?.localFilePath = relativePath
  1155. }
  1156. }
  1157. private var diagnosticsNow: TimeInterval {
  1158. ProcessInfo.processInfo.systemUptime
  1159. }
  1160. private func diagnosticsDuration(since startedAt: TimeInterval) -> TimeInterval {
  1161. max(0, diagnosticsNow - startedAt)
  1162. }
  1163. private func diagnosticsElapsed(since startedAt: TimeInterval) -> String {
  1164. diagnosticsDurationText(diagnosticsDuration(since: startedAt))
  1165. }
  1166. private func diagnosticsDurationText(_ duration: TimeInterval) -> String {
  1167. if duration < 1 {
  1168. return "\(Int((duration * 1_000).rounded())) 毫秒"
  1169. }
  1170. return String(format: "%.2f 秒", duration)
  1171. }
  1172. private func diagnosticsBytes(_ bytes: Int64) -> String {
  1173. Self.diagnosticsByteFormatter.string(fromByteCount: max(0, bytes))
  1174. }
  1175. private func diagnosticsRate(bytes: Int64, duration: TimeInterval) -> String {
  1176. guard duration > 0 else { return "—" }
  1177. return "\(diagnosticsBytes(Int64(Double(bytes) / duration)))/秒"
  1178. }
  1179. private func diagnosticsRecordLabel(
  1180. _ session: CelestiaSession,
  1181. position: Int? = nil,
  1182. total: Int? = nil
  1183. ) -> String {
  1184. let shortID = session.id.uuidString.lowercased().prefix(8)
  1185. if let position, let total {
  1186. return "记录 \(position)/\(total) [\(shortID)]"
  1187. }
  1188. return "记录 [\(shortID)]"
  1189. }
  1190. private func diagnosticsLog(
  1191. _ message: String,
  1192. level: DeveloperLogEntry.Level = .info
  1193. ) {
  1194. DeveloperLogStore.log("同步性能", message, level: level)
  1195. }
  1196. private func downloadDestination(for asset: RemoteAsset) -> URL {
  1197. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  1198. let fileExtension = (asset.fileName as NSString).pathExtension
  1199. let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
  1200. return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
  1201. }
  1202. private struct IndexedSessionMatch {
  1203. let index: RemoteSessionIndex
  1204. let local: CelestiaSession
  1205. let needsDetail: Bool
  1206. }
  1207. private func mergeIndex(
  1208. _ remoteSessions: [RemoteSessionIndex],
  1209. into localSessions: [CelestiaSession],
  1210. modelContext: ModelContext,
  1211. userID: String
  1212. ) async throws -> [IndexedSessionMatch] {
  1213. let scopedLocalSessions = localSessions.filter {
  1214. $0.ownerUserID == nil || $0.ownerUserID == userID
  1215. }
  1216. var byClientID = Dictionary(
  1217. scopedLocalSessions.map { ($0.id.uuidString.lowercased(), $0) },
  1218. uniquingKeysWith: preferredLocalSession
  1219. )
  1220. var byCloudID = Dictionary(
  1221. scopedLocalSessions.compactMap { session in
  1222. session.cloudSessionId.map { ($0, session) }
  1223. },
  1224. uniquingKeysWith: preferredLocalSession
  1225. )
  1226. if byClientID.count < scopedLocalSessions.count {
  1227. DeveloperLogStore.log(
  1228. "现场记录同步",
  1229. "检测到 \(scopedLocalSessions.count - byClientID.count) 条重复本地记录,已选择云端版本较新的记录继续同步",
  1230. level: .warning
  1231. )
  1232. }
  1233. let activeRemoteSessions = remoteSessions.filter { $0.deletedAt == nil }
  1234. let activeRemoteIDs = Set(activeRemoteSessions.map(\.id))
  1235. let activeRemoteClientIDs = Set(activeRemoteSessions.compactMap { $0.clientId?.lowercased() })
  1236. var deletedLocalObjects: Set<ObjectIdentifier> = []
  1237. var deletedFileURLs: Set<URL> = []
  1238. var merged: [IndexedSessionMatch] = []
  1239. for (index, remote) in remoteSessions.enumerated() {
  1240. if index.isMultiple(of: 20) {
  1241. await Task.yield()
  1242. }
  1243. let clientID = remote.clientId?.lowercased()
  1244. let existingLocal = clientID.flatMap { byClientID[$0] } ?? byCloudID[remote.id]
  1245. if let local = existingLocal {
  1246. if remote.deletedAt != nil, local.hasConfirmedCloudSync {
  1247. deletedFileURLs.formUnion(localFileURLs(for: local))
  1248. deletedLocalObjects.insert(ObjectIdentifier(local))
  1249. modelContext.delete(local)
  1250. DeveloperLogStore.log(
  1251. "现场记录同步",
  1252. "云端记录已删除,同步移除本地记录:\(local.title)"
  1253. )
  1254. continue
  1255. }
  1256. if !local.isSynced,
  1257. remote.revision > local.serverRevision {
  1258. local.syncState = .conflict
  1259. local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本 \(remote.revision) 不一致"
  1260. continue
  1261. }
  1262. if remote.deletedAt == nil {
  1263. let needsDetail =
  1264. local.isSynced
  1265. && (local.serverUpdatedAt.map { remote.updatedAt > $0 } ?? true
  1266. || remote.revision > local.serverRevision
  1267. || local.syncState == .failed
  1268. || local.syncState == .syncing)
  1269. local.cloudSessionId = remote.id
  1270. local.ownerUserID = userID
  1271. if local.isSynced {
  1272. local.title = remote.title
  1273. local.startTime = remote.startTime
  1274. local.endTime = remote.endTime
  1275. }
  1276. if needsDetail {
  1277. local.serverRevision = remote.revision
  1278. local.syncState = .syncing
  1279. local.lastSyncError = nil
  1280. }
  1281. merged.append(IndexedSessionMatch(
  1282. index: remote,
  1283. local: local,
  1284. needsDetail: needsDetail
  1285. ))
  1286. }
  1287. } else if remote.deletedAt == nil {
  1288. let id = clientID.flatMap(UUID.init(uuidString:)) ?? UUID()
  1289. let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
  1290. session.endTime = remote.endTime
  1291. session.cloudSessionId = remote.id
  1292. session.ownerUserID = userID
  1293. session.serverRevision = remote.revision
  1294. session.isSynced = true
  1295. session.syncState = .syncing
  1296. modelContext.insert(session)
  1297. if let clientID {
  1298. byClientID[clientID] = session
  1299. }
  1300. byCloudID[remote.id] = session
  1301. merged.append(IndexedSessionMatch(
  1302. index: remote,
  1303. local: session,
  1304. needsDetail: true
  1305. ))
  1306. }
  1307. }
  1308. // Some servers may permanently remove a record instead of returning a
  1309. // tombstone. A previously confirmed cloud record that is absent by both
  1310. // server ID and client ID should mirror that deletion locally. Records
  1311. // with pending/failed/conflicting local content are deliberately kept.
  1312. for (index, local) in localSessions.enumerated() {
  1313. if index.isMultiple(of: 20) {
  1314. await Task.yield()
  1315. }
  1316. guard !deletedLocalObjects.contains(ObjectIdentifier(local)),
  1317. local.ownerUserID == nil || local.ownerUserID == userID,
  1318. local.hasConfirmedCloudSync else { continue }
  1319. let existsRemotely =
  1320. local.cloudSessionId.map(activeRemoteIDs.contains) == true
  1321. || activeRemoteClientIDs.contains(local.id.uuidString.lowercased())
  1322. guard !existsRemotely else { continue }
  1323. deletedFileURLs.formUnion(localFileURLs(for: local))
  1324. deletedLocalObjects.insert(ObjectIdentifier(local))
  1325. modelContext.delete(local)
  1326. DeveloperLogStore.log(
  1327. "现场记录同步",
  1328. "云端不存在已同步记录,同步移除本地记录:\(local.title)"
  1329. )
  1330. }
  1331. try modelContext.save()
  1332. for fileURL in deletedFileURLs {
  1333. try? FileManager.default.removeItem(at: fileURL)
  1334. }
  1335. return merged
  1336. }
  1337. private func localFileURLs(for session: CelestiaSession) -> Set<URL> {
  1338. let storedPaths =
  1339. [session.localAudioPath].compactMap { $0 }
  1340. + session.audioChunks.map(\.localFilePath)
  1341. + session.events.compactMap(\.localFilePath)
  1342. return Set(storedPaths.compactMap { AudioPathHelper.resolveURL(for: $0) })
  1343. }
  1344. private func deduplicatedSessions(_ sessions: [CelestiaSession]) -> [CelestiaSession] {
  1345. Array(Dictionary(
  1346. sessions.map { ($0.id, $0) },
  1347. uniquingKeysWith: preferredLocalSession
  1348. ).values)
  1349. }
  1350. private func preferredLocalSession(
  1351. _ current: CelestiaSession,
  1352. _ candidate: CelestiaSession
  1353. ) -> CelestiaSession {
  1354. if (current.cloudSessionId == nil) != (candidate.cloudSessionId == nil) {
  1355. return current.cloudSessionId == nil ? candidate : current
  1356. }
  1357. if current.serverRevision != candidate.serverRevision {
  1358. return current.serverRevision > candidate.serverRevision ? current : candidate
  1359. }
  1360. return (current.lastSyncedAt ?? .distantPast) >= (candidate.lastSyncedAt ?? .distantPast)
  1361. ? current
  1362. : candidate
  1363. }
  1364. private func apply(
  1365. _ remote: RemoteSession,
  1366. to local: CelestiaSession,
  1367. userID: String,
  1368. syncState: SessionSyncState = .synced
  1369. ) async {
  1370. local.title = remote.title
  1371. local.startTime = remote.startTime
  1372. local.endTime = remote.endTime
  1373. local.cloudSessionId = remote.id
  1374. local.ownerUserID = userID
  1375. local.serverRevision = remote.revision
  1376. local.isSynced = true
  1377. local.syncState = syncState
  1378. local.lastSyncError = nil
  1379. local.events.removeAll()
  1380. for (index, remoteEvent) in (remote.events ?? []).enumerated() {
  1381. if index.isMultiple(of: 50) {
  1382. await Task.yield()
  1383. }
  1384. let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
  1385. let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
  1386. event.textContent = remoteEvent.textContent
  1387. event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
  1388. event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
  1389. event.locationName = remoteEvent.locationName
  1390. event.locationAddress = remoteEvent.locationAddress
  1391. event.latitude = remoteEvent.latitude
  1392. event.longitude = remoteEvent.longitude
  1393. local.events.append(event)
  1394. }
  1395. }
  1396. }