RemoteNetworkService.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import Foundation
  2. import SwiftData
  3. import UniformTypeIdentifiers
  4. struct RemoteAsset: Decodable {
  5. let id: String
  6. let clientId: String
  7. let kind: String
  8. let fileName: String
  9. let mimeType: String
  10. let sizeBytes: Int64
  11. let sha256: String
  12. }
  13. struct RemoteEvent: Decodable {
  14. let id: String
  15. let clientId: String?
  16. let relativeTimeMs: Int64
  17. let eventType: String
  18. let textContent: String?
  19. let voiceStartOffsetMs: Int64?
  20. let voiceEndOffsetMs: Int64?
  21. }
  22. struct RemoteSession: Decodable {
  23. let id: String
  24. let clientId: String?
  25. let title: String
  26. let startTime: Date
  27. let endTime: Date?
  28. let durationMs: Int64
  29. let revision: Int64
  30. let deletedAt: Date?
  31. let events: [RemoteEvent]?
  32. let assets: [RemoteAsset]?
  33. }
  34. private struct SessionUpload: Encodable {
  35. let clientId: String
  36. let title: String
  37. let startTime: Date
  38. let endTime: Date?
  39. let durationMs: Int64
  40. let events: [EventUpload]
  41. }
  42. private struct EventUpload: Encodable {
  43. let clientId: String
  44. let relativeTimeMs: Int64
  45. let eventType: String
  46. let textContent: String?
  47. let voiceStartOffsetMs: Int64?
  48. let voiceEndOffsetMs: Int64?
  49. }
  50. private struct SyncCheckpoint: Decodable {
  51. let syncedAt: Date
  52. }
  53. final class RemoteNetworkService: NetworkServiceProtocol {
  54. private let client: APIClient
  55. init(client: APIClient = .shared) {
  56. self.client = client
  57. }
  58. func fetchSessions() async throws -> [RemoteSession] {
  59. try await client.request("sessions?includeDeleted=true", authenticated: true)
  60. }
  61. func syncSession(_ session: CelestiaSession) async throws -> RemoteSession {
  62. let payload = SessionUpload(
  63. clientId: session.id.uuidString,
  64. title: session.title,
  65. startTime: session.startTime,
  66. endTime: session.endTime,
  67. durationMs: session.durationMs,
  68. events: session.events.map {
  69. EventUpload(
  70. clientId: $0.id.uuidString,
  71. relativeTimeMs: $0.relativeTimeMs,
  72. eventType: $0.eventType,
  73. textContent: $0.textContent,
  74. voiceStartOffsetMs: $0.voiceStartOffsetMs,
  75. voiceEndOffsetMs: $0.voiceEndOffsetMs
  76. )
  77. }
  78. )
  79. let encoder = JSONEncoder()
  80. encoder.dateEncodingStrategy = .iso8601
  81. return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
  82. }
  83. func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset {
  84. let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
  85. return try await client.upload(
  86. "sessions/\(sessionID)/assets",
  87. fileURL: fileURL,
  88. fileName: fileURL.lastPathComponent,
  89. mimeType: mimeType,
  90. fields: ["clientId": clientID, "kind": kind]
  91. )
  92. }
  93. func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
  94. try await client.download("sessions/\(sessionID)/assets/\(asset.id)", to: destinationURL)
  95. }
  96. func recordSyncCheckpoint() async throws {
  97. let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
  98. }
  99. }
  100. struct RemoteBoundDevice: Decodable {
  101. let id: String
  102. let name: String
  103. let peripheralUUID: String
  104. }
  105. private struct BindDeviceBody: Encodable {
  106. let name: String
  107. let peripheralUUID: String
  108. let hardwareMAC: String?
  109. let firmwareVersion: String?
  110. let batteryLevel: Int?
  111. let freeStorageMB: Int?
  112. let totalStorageMB: Int?
  113. }
  114. private struct UpdateDeviceBody: Encodable {
  115. let name: String?
  116. let batteryLevel: Int?
  117. let isConnected: Bool?
  118. let firmwareVersion: String?
  119. let freeStorageMB: Int?
  120. let totalStorageMB: Int?
  121. }
  122. final class DeviceCloudService {
  123. static let shared = DeviceCloudService()
  124. private let client: APIClient
  125. init(client: APIClient = .shared) {
  126. self.client = client
  127. }
  128. func register(_ device: BoundDevice) async throws -> RemoteBoundDevice {
  129. let body = BindDeviceBody(
  130. name: device.name,
  131. peripheralUUID: device.peripheralUUID,
  132. hardwareMAC: device.hardwareMAC,
  133. firmwareVersion: device.firmwareVersion,
  134. batteryLevel: device.batteryLevel,
  135. freeStorageMB: device.freeStorageMB,
  136. totalStorageMB: device.totalStorageMB
  137. )
  138. return try await client.request("devices", method: .post, body: try JSONEncoder().encode(body), authenticated: true)
  139. }
  140. func update(_ device: BoundDevice) async throws -> RemoteBoundDevice {
  141. guard let cloudID = device.cloudID else { return try await register(device) }
  142. let body = UpdateDeviceBody(
  143. name: device.name,
  144. batteryLevel: device.batteryLevel,
  145. isConnected: device.isConnected,
  146. firmwareVersion: device.firmwareVersion,
  147. freeStorageMB: device.freeStorageMB,
  148. totalStorageMB: device.totalStorageMB
  149. )
  150. return try await client.request("devices/\(cloudID)", method: .put, body: try JSONEncoder().encode(body), authenticated: true)
  151. }
  152. func remove(cloudID: String) async throws {
  153. try await client.requestVoid("devices/\(cloudID)", method: .delete, authenticated: true)
  154. }
  155. }
  156. @MainActor
  157. final class SyncManager: ObservableObject {
  158. static let shared = SyncManager()
  159. @Published private(set) var isSyncing = false
  160. @Published private(set) var lastSyncDate: Date?
  161. @Published private(set) var lastErrorMessage: String?
  162. @Published private(set) var completedCount = 0
  163. @Published private(set) var totalCount = 0
  164. private let service: NetworkServiceProtocol
  165. init(service: NetworkServiceProtocol = RemoteNetworkService()) {
  166. self.service = service
  167. lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
  168. }
  169. func sync(
  170. sessions: [CelestiaSession],
  171. modelContext: ModelContext,
  172. userID: String
  173. ) async -> Bool {
  174. guard !isSyncing else { return false }
  175. isSyncing = true
  176. lastErrorMessage = nil
  177. completedCount = 0
  178. totalCount = sessions.filter {
  179. ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
  180. }.count
  181. defer { isSyncing = false }
  182. do {
  183. let remoteSessions = try await service.fetchSessions()
  184. let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
  185. for (remote, local) in mergedSessions {
  186. try await downloadMissingAssets(for: local, remote: remote)
  187. }
  188. try modelContext.save()
  189. let eligible = sessions.filter {
  190. ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
  191. }
  192. var failures: [String] = []
  193. for session in eligible {
  194. session.ownerUserID = userID
  195. session.syncState = .syncing
  196. session.lastSyncError = nil
  197. do {
  198. let remote = try await service.syncSession(session)
  199. session.cloudSessionId = remote.id
  200. session.serverRevision = remote.revision
  201. try await uploadLocalAssets(for: session, remote: remote)
  202. session.isSynced = true
  203. session.syncState = .synced
  204. session.lastSyncedAt = Date()
  205. completedCount += 1
  206. } catch {
  207. session.isSynced = false
  208. session.syncState = .failed
  209. session.lastSyncError = error.localizedDescription
  210. failures.append("\(session.title):\(error.localizedDescription)")
  211. }
  212. try modelContext.save()
  213. }
  214. guard failures.isEmpty else {
  215. lastErrorMessage = failures.joined(separator: "\n")
  216. return false
  217. }
  218. try await service.recordSyncCheckpoint()
  219. let now = Date()
  220. lastSyncDate = now
  221. UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
  222. return true
  223. } catch {
  224. lastErrorMessage = error.localizedDescription
  225. return false
  226. }
  227. }
  228. private func uploadLocalAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
  229. guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
  230. let existingIDs = Set((remote.assets ?? []).map(\.clientId))
  231. if let path = session.localAudioPath {
  232. guard let url = AudioPathHelper.resolveURL(for: path) else {
  233. throw APIError.transport("本地录音文件不存在:\(path)")
  234. }
  235. let clientID = "\(session.id.uuidString)-audio"
  236. if !existingIDs.contains(clientID) {
  237. _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "AUDIO", fileURL: url)
  238. }
  239. }
  240. for event in session.events where event.eventType == "PHOTO" {
  241. guard let path = event.localFilePath else { continue }
  242. guard let url = AudioPathHelper.resolveURL(for: path) else {
  243. throw APIError.transport("照片文件不存在:\(path)")
  244. }
  245. let clientID = "\(event.id.uuidString)-photo"
  246. if !existingIDs.contains(clientID) {
  247. _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "PHOTO", fileURL: url)
  248. }
  249. }
  250. }
  251. private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
  252. let assets = remote.assets ?? []
  253. if (session.localAudioPath == nil || AudioPathHelper.resolveURL(for: session.localAudioPath) == nil),
  254. let audio = assets.first(where: { $0.kind.uppercased() == "AUDIO" }) {
  255. let destination = downloadDestination(for: audio)
  256. try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
  257. session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
  258. }
  259. let photoAssets = Dictionary(uniqueKeysWithValues: assets
  260. .filter { $0.kind.uppercased() == "PHOTO" }
  261. .map { ($0.clientId.lowercased(), $0) })
  262. for event in session.events where event.eventType == "PHOTO" {
  263. guard event.localFilePath == nil || AudioPathHelper.resolveURL(for: event.localFilePath) == nil else { continue }
  264. let key = "\(event.id.uuidString)-photo".lowercased()
  265. guard let asset = photoAssets[key] else { continue }
  266. let destination = downloadDestination(for: asset)
  267. try await service.downloadAsset(sessionID: remote.id, asset: asset, destinationURL: destination)
  268. event.localFilePath = AudioPathHelper.relativePath(from: destination.path)
  269. }
  270. }
  271. private func downloadDestination(for asset: RemoteAsset) -> URL {
  272. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  273. let fileExtension = (asset.fileName as NSString).pathExtension
  274. let suffix = fileExtension.isEmpty ? "" : ".\(fileExtension.lowercased())"
  275. return documents.appendingPathComponent("cloud_\(asset.id)\(suffix)")
  276. }
  277. private func merge(
  278. _ remoteSessions: [RemoteSession],
  279. into localSessions: [CelestiaSession],
  280. modelContext: ModelContext,
  281. userID: String
  282. ) -> [(RemoteSession, CelestiaSession)] {
  283. var byClientID = Dictionary(uniqueKeysWithValues: localSessions.map { ($0.id.uuidString.lowercased(), $0) })
  284. var merged: [(RemoteSession, CelestiaSession)] = []
  285. for remote in remoteSessions {
  286. guard let clientID = remote.clientId?.lowercased() else { continue }
  287. if let local = byClientID[clientID] {
  288. if remote.deletedAt != nil, local.isSynced {
  289. modelContext.delete(local)
  290. continue
  291. }
  292. if local.isSynced, remote.revision > local.serverRevision {
  293. apply(remote, to: local, userID: userID)
  294. }
  295. if remote.deletedAt == nil {
  296. merged.append((remote, local))
  297. }
  298. } else if remote.deletedAt == nil {
  299. let id = UUID(uuidString: clientID) ?? UUID()
  300. let session = CelestiaSession(id: id, title: remote.title, startTime: remote.startTime)
  301. apply(remote, to: session, userID: userID)
  302. modelContext.insert(session)
  303. byClientID[clientID] = session
  304. merged.append((remote, session))
  305. }
  306. }
  307. try? modelContext.save()
  308. return merged
  309. }
  310. private func apply(_ remote: RemoteSession, to local: CelestiaSession, userID: String) {
  311. local.title = remote.title
  312. local.startTime = remote.startTime
  313. local.endTime = remote.endTime
  314. local.cloudSessionId = remote.id
  315. local.ownerUserID = userID
  316. local.serverRevision = remote.revision
  317. local.isSynced = true
  318. local.syncState = .synced
  319. local.lastSyncError = nil
  320. local.events.removeAll()
  321. for remoteEvent in remote.events ?? [] {
  322. let id = remoteEvent.clientId.flatMap(UUID.init(uuidString:)) ?? UUID()
  323. let event = CelestiaTimelineEvent(id: id, relativeTimeMs: remoteEvent.relativeTimeMs, eventType: remoteEvent.eventType)
  324. event.textContent = remoteEvent.textContent
  325. event.voiceStartOffsetMs = remoteEvent.voiceStartOffsetMs
  326. event.voiceEndOffsetMs = remoteEvent.voiceEndOffsetMs
  327. local.events.append(event)
  328. }
  329. }
  330. }