3 Commity b6287f9615 ... 34df40c131

Autor SHA1 Wiadomość Data
  bob.yuxinyang 34df40c131 feat(ui): 增加会话详情页自动同步、大文件传输提示与同步状态UI 1 miesiąc temu
  bob.yuxinyang e1409cb549 feat(sync): 增强同步引擎配额校验、断点进度、SHA256 去重与版本冲突检测 1 miesiąc temu
  bob.yuxinyang d1f2894556 docs(api): 添加 Celestia Trace 后端 RemoteAPI OpenAPI 规范文件 1 miesiąc temu

+ 3 - 0
CelestiaTrace/App/ColorTheme.swift

@@ -24,6 +24,9 @@ extension Color {
     
     /// Precise red indicator for recording
     static let recordingRed = Color(red: 229/255, green: 57/255, blue: 53/255)
+
+    /// Less and Cosmos brand gold (#E6C687)
+    static let lessCosmosGold = Color(red: 230/255, green: 198/255, blue: 135/255)
 }
 
 /// A no-op glow that replaces NeonGlow to avoid compile errors while removing glows.

+ 13 - 1
CelestiaTrace/Models/CelestiaSession.swift

@@ -47,7 +47,10 @@ final class CelestiaSession {
     }
     
     var noteCount: Int {
-        events.filter { $0.eventType == "NOTE" || $0.eventType == "MARKER" }.count
+        events.filter {
+            $0.eventType == "NOTE"
+                || ($0.eventType == "MARKER" && !$0.isContinuationMarker)
+        }.count
     }
     
     var syncState: SessionSyncState {
@@ -55,6 +58,15 @@ final class CelestiaSession {
         set { syncStateRaw = newValue.rawValue }
     }
 
+    /// The revision represented by the current local content.
+    /// Unsynced edits form the next revision based on the last known server revision.
+    var localRevision: Int64 {
+        if isSynced && syncState == .synced {
+            return max(serverRevision, 1)
+        }
+        return max(serverRevision + 1, 1)
+    }
+
     init(id: UUID = UUID(), title: String, localAudioPath: String? = nil, startTime: Date = Date()) {
         self.id = id
         self.title = title

+ 76 - 8
CelestiaTrace/Services/Network/APIClient.swift

@@ -1,6 +1,9 @@
 import Foundation
 import Security
 
+// Remote contract: Docs/RemoteAPI.openapi.yaml
+// Keep the spec and these request/response models in sync.
+
 enum HTTPMethod: String {
     case get = "GET"
     case post = "POST"
@@ -14,6 +17,11 @@ struct APIEnvelope<T: Decodable>: Decodable {
     let data: T?
 }
 
+private struct APIErrorEnvelope: Decodable {
+    let code: Int
+    let message: String
+}
+
 struct EmptyAPIData: Decodable { }
 
 enum APIError: LocalizedError {
@@ -25,6 +33,7 @@ enum APIError: LocalizedError {
     case transport(String)
     case decoding(String)
     case missingData
+    case storageQuotaExceeded(requiredBytes: Int64, remainingBytes: Int64)
 
     var errorDescription: String? {
         switch self {
@@ -36,6 +45,10 @@ enum APIError: LocalizedError {
         case .transport(let message): return "网络连接失败:\(message)"
         case .decoding(let message): return "服务器数据解析失败:\(message)"
         case .missingData: return "服务器响应缺少必要数据"
+        case .storageQuotaExceeded(let requiredBytes, let remainingBytes):
+            let formatter = ByteCountFormatter()
+            formatter.countStyle = .file
+            return "云端空间不足:需要 \(formatter.string(fromByteCount: requiredBytes)),剩余 \(formatter.string(fromByteCount: remainingBytes))"
         }
     }
 }
@@ -182,13 +195,28 @@ actor APIClient {
         fileURL: URL,
         fileName: String,
         mimeType: String,
-        fields: [String: String]
+        fields: [String: String],
+        progress: @escaping @Sendable (Double) -> Void
     ) async throws -> T {
         do {
-            return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields)
+            return try await performUpload(
+                path,
+                fileURL: fileURL,
+                fileName: fileName,
+                mimeType: mimeType,
+                fields: fields,
+                progress: progress
+            )
         } catch APIError.unauthorized {
             try await refreshTokens()
-            return try await performUpload(path, fileURL: fileURL, fileName: fileName, mimeType: mimeType, fields: fields)
+            return try await performUpload(
+                path,
+                fileURL: fileURL,
+                fileName: fileName,
+                mimeType: mimeType,
+                fields: fields,
+                progress: progress
+            )
         }
     }
 
@@ -227,14 +255,16 @@ actor APIClient {
             try await refreshTokens()
             return try await execute(path, method: method, body: body, authenticated: true, canRefresh: false)
         }
+        guard (200..<300).contains(http.statusCode) else {
+            throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
+        }
         let envelope: APIEnvelope<T>
         do {
             envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
         } catch {
             throw APIError.decoding(error.localizedDescription)
         }
-        guard (200..<300).contains(http.statusCode), envelope.code == 0 else {
-            if http.statusCode == 401 { throw APIError.unauthorized }
+        guard envelope.code == 0 else {
             throw APIError.server(code: envelope.code, message: envelope.message)
         }
         return envelope
@@ -259,7 +289,8 @@ actor APIClient {
         fileURL: URL,
         fileName: String,
         mimeType: String,
-        fields: [String: String]
+        fields: [String: String],
+        progress: @escaping @Sendable (Double) -> Void
     ) async throws -> T {
         guard let token = tokenStore.load()?.accessToken else { throw APIError.unauthorized }
         let boundary = "CelestiaBoundary-\(UUID().uuidString)"
@@ -290,19 +321,27 @@ actor APIClient {
         let data: Data
         let response: URLResponse
         do {
-            (data, response) = try await session.upload(for: request, fromFile: temporaryURL)
+            let progressDelegate = UploadProgressDelegate(progress: progress)
+            (data, response) = try await session.upload(
+                for: request,
+                fromFile: temporaryURL,
+                delegate: progressDelegate
+            )
         } catch {
             throw APIError.transport(error.localizedDescription)
         }
         guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
         if http.statusCode == 401 { throw APIError.unauthorized }
+        guard (200..<300).contains(http.statusCode) else {
+            throw Self.serverError(from: data, fallbackStatusCode: http.statusCode)
+        }
         let envelope: APIEnvelope<T>
         do {
             envelope = try Self.decoder.decode(APIEnvelope<T>.self, from: data)
         } catch {
             throw APIError.decoding(error.localizedDescription)
         }
-        guard (200..<300).contains(http.statusCode), envelope.code == 0 else {
+        guard envelope.code == 0 else {
             throw APIError.server(code: envelope.code, message: envelope.message)
         }
         guard let result = envelope.data else { throw APIError.missingData }
@@ -390,4 +429,33 @@ actor APIClient {
             return false
         }
     }
+
+    private static func serverError(from data: Data, fallbackStatusCode: Int) -> APIError {
+        if fallbackStatusCode == 401 {
+            return .unauthorized
+        }
+        if let envelope = try? decoder.decode(APIErrorEnvelope.self, from: data) {
+            return .server(code: envelope.code, message: envelope.message)
+        }
+        return .server(code: fallbackStatusCode, message: "服务器请求失败(\(fallbackStatusCode))")
+    }
+}
+
+private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
+    private let progress: @Sendable (Double) -> Void
+
+    init(progress: @escaping @Sendable (Double) -> Void) {
+        self.progress = progress
+    }
+
+    func urlSession(
+        _ session: URLSession,
+        task: URLSessionTask,
+        didSendBodyData bytesSent: Int64,
+        totalBytesSent: Int64,
+        totalBytesExpectedToSend: Int64
+    ) {
+        guard totalBytesExpectedToSend > 0 else { return }
+        progress(min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1))
+    }
 }

+ 12 - 2
CelestiaTrace/Services/Network/NetworkServiceProtocol.swift

@@ -2,8 +2,18 @@ import Foundation
 
 protocol NetworkServiceProtocol {
     func fetchSessions() async throws -> [RemoteSession]
-    func syncSession(_ session: CelestiaSession) async throws -> RemoteSession
-    func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset
+    func syncSession(
+        _ session: CelestiaSession,
+        deletedEventClientIDs: [String]
+    ) async throws -> RemoteSession
+    func fetchStorageQuota() async throws -> StorageQuota
+    func uploadAsset(
+        sessionID: String,
+        clientID: String,
+        kind: String,
+        fileURL: URL,
+        progress: @escaping @Sendable (Double) -> Void
+    ) async throws -> AssetUploadResult
     func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws
     func recordSyncCheckpoint() async throws
 }

+ 392 - 21
CelestiaTrace/Services/Network/RemoteNetworkService.swift

@@ -1,6 +1,10 @@
 import Foundation
 import SwiftData
 import UniformTypeIdentifiers
+import Network
+import CryptoKit
+
+// Remote contract: Docs/RemoteAPI.openapi.yaml
 
 struct RemoteAsset: Decodable {
     let id: String
@@ -10,6 +14,18 @@ struct RemoteAsset: Decodable {
     let mimeType: String
     let sizeBytes: Int64
     let sha256: String
+    let createdAt: Date?
+}
+
+struct AssetUploadResult {
+    let asset: RemoteAsset
+    let sessionRevision: Int64?
+}
+
+struct StorageQuota: Decodable {
+    let totalBytes: Int64
+    let usedBytes: Int64
+    let remainingBytes: Int64
 }
 
 struct RemoteEvent: Decodable {
@@ -42,6 +58,8 @@ private struct SessionUpload: Encodable {
     let endTime: Date?
     let durationMs: Int64
     let events: [EventUpload]
+    let baseRevision: Int64?
+    let deletedEventClientIds: [String]
 }
 
 private struct EventUpload: Encodable {
@@ -57,7 +75,55 @@ private struct SyncCheckpoint: Decodable {
     let syncedAt: Date
 }
 
+private struct AssetUploadPayload: Decodable {
+    let asset: RemoteAsset
+    let sessionRevision: Int64?
+
+    private enum CodingKeys: String, CodingKey {
+        case asset
+        case sessionRevision
+    }
+
+    init(from decoder: Decoder) throws {
+        if let container = try? decoder.container(keyedBy: CodingKeys.self),
+           container.contains(.asset) {
+            asset = try container.decode(RemoteAsset.self, forKey: .asset)
+            sessionRevision = try container.decodeIfPresent(Int64.self, forKey: .sessionRevision)
+        } else {
+            asset = try RemoteAsset(from: decoder)
+            sessionRevision = nil
+        }
+    }
+}
+
+private struct ChunkUploadInitBody: Encodable {
+    let clientId: String
+    let kind: String
+    let fileName: String
+    let mimeType: String
+    let fileSize: Int64
+    let chunkSize: Int
+}
+
+private struct ChunkUploadInitResponse: Decodable {
+    let uploadId: String
+    let totalChunks: Int
+    let chunkSize: Int
+}
+
+private struct ChunkUploadProgressResponse: Decodable {
+    let uploadedChunks: Int
+    let totalChunks: Int
+}
+
+private struct ChunkUploadCompleteBody: Encodable {
+    let uploadId: String
+    let totalChunks: Int
+}
+
 final class RemoteNetworkService: NetworkServiceProtocol {
+    private static let chunkedUploadThreshold: Int64 = 16 * 1024 * 1024
+    private static let preferredChunkSize = 8 * 1024 * 1024
     private let client: APIClient
 
     init(client: APIClient = .shared) {
@@ -68,7 +134,10 @@ final class RemoteNetworkService: NetworkServiceProtocol {
         try await client.request("sessions?includeDeleted=true", authenticated: true)
     }
 
-    func syncSession(_ session: CelestiaSession) async throws -> RemoteSession {
+    func syncSession(
+        _ session: CelestiaSession,
+        deletedEventClientIDs: [String]
+    ) async throws -> RemoteSession {
         let payload = SessionUpload(
             clientId: session.id.uuidString,
             title: session.title,
@@ -84,22 +153,48 @@ final class RemoteNetworkService: NetworkServiceProtocol {
                     voiceStartOffsetMs: $0.voiceStartOffsetMs,
                     voiceEndOffsetMs: $0.voiceEndOffsetMs
                 )
-            }
+            },
+            baseRevision: session.serverRevision > 0 ? session.serverRevision : nil,
+            deletedEventClientIds: deletedEventClientIDs
         )
         let encoder = JSONEncoder()
         encoder.dateEncodingStrategy = .iso8601
         return try await client.request("sessions", method: .post, body: try encoder.encode(payload), authenticated: true)
     }
 
-    func uploadAsset(sessionID: String, clientID: String, kind: String, fileURL: URL) async throws -> RemoteAsset {
+    func fetchStorageQuota() async throws -> StorageQuota {
+        try await client.request("storage/quota", authenticated: true)
+    }
+
+    func uploadAsset(
+        sessionID: String,
+        clientID: String,
+        kind: String,
+        fileURL: URL,
+        progress: @escaping @Sendable (Double) -> Void
+    ) async throws -> AssetUploadResult {
         let mimeType = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
-        return try await client.upload(
+        let size = try fileSize(of: fileURL)
+        if size >= Self.chunkedUploadThreshold {
+            return try await uploadAssetInChunks(
+                sessionID: sessionID,
+                clientID: clientID,
+                kind: kind,
+                fileURL: fileURL,
+                fileSize: size,
+                mimeType: mimeType,
+                progress: progress
+            )
+        }
+        let payload: AssetUploadPayload = try await client.upload(
             "sessions/\(sessionID)/assets",
             fileURL: fileURL,
             fileName: fileURL.lastPathComponent,
             mimeType: mimeType,
-            fields: ["clientId": clientID, "kind": kind]
+            fields: ["clientId": clientID, "kind": kind],
+            progress: progress
         )
+        return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
     }
 
     func downloadAsset(sessionID: String, asset: RemoteAsset, destinationURL: URL) async throws {
@@ -109,6 +204,82 @@ final class RemoteNetworkService: NetworkServiceProtocol {
     func recordSyncCheckpoint() async throws {
         let _: SyncCheckpoint = try await client.request("sync/trigger", method: .post, authenticated: true)
     }
+
+    private func uploadAssetInChunks(
+        sessionID: String,
+        clientID: String,
+        kind: String,
+        fileURL: URL,
+        fileSize: Int64,
+        mimeType: String,
+        progress: @escaping @Sendable (Double) -> Void
+    ) async throws -> AssetUploadResult {
+        let body = ChunkUploadInitBody(
+            clientId: clientID,
+            kind: kind,
+            fileName: fileURL.lastPathComponent,
+            mimeType: mimeType,
+            fileSize: fileSize,
+            chunkSize: Self.preferredChunkSize
+        )
+        let encoder = JSONEncoder()
+        let upload: ChunkUploadInitResponse = try await client.request(
+            "sessions/\(sessionID)/assets/init",
+            method: .post,
+            body: try encoder.encode(body),
+            authenticated: true
+        )
+
+        let input = try FileHandle(forReadingFrom: fileURL)
+        defer { try? input.close() }
+        for chunkIndex in 0..<upload.totalChunks {
+            let offset = UInt64(chunkIndex) * UInt64(upload.chunkSize)
+            try input.seek(toOffset: offset)
+            guard let data = try input.read(upToCount: upload.chunkSize), !data.isEmpty else {
+                throw APIError.transport("读取上传分片失败")
+            }
+            let temporaryURL = FileManager.default.temporaryDirectory
+                .appendingPathComponent("celestia-\(upload.uploadId)-\(chunkIndex).chunk")
+            try data.write(to: temporaryURL, options: .atomic)
+            defer { try? FileManager.default.removeItem(at: temporaryURL) }
+
+            let chunkStart = Double(offset) / Double(fileSize)
+            let chunkSpan = Double(data.count) / Double(fileSize)
+            let _: ChunkUploadProgressResponse = try await client.upload(
+                "sessions/\(sessionID)/assets/chunk",
+                fileURL: temporaryURL,
+                fileName: "chunk-\(chunkIndex)",
+                mimeType: "application/octet-stream",
+                fields: [
+                    "uploadId": upload.uploadId,
+                    "chunkIndex": String(chunkIndex)
+                ]
+            ) { fraction in
+                progress(min(max(chunkStart + chunkSpan * fraction, 0), 1))
+            }
+        }
+
+        let completeBody = ChunkUploadCompleteBody(
+            uploadId: upload.uploadId,
+            totalChunks: upload.totalChunks
+        )
+        let payload: AssetUploadPayload = try await client.request(
+            "sessions/\(sessionID)/assets/complete",
+            method: .post,
+            body: try encoder.encode(completeBody),
+            authenticated: true
+        )
+        progress(1)
+        return AssetUploadResult(asset: payload.asset, sessionRevision: payload.sessionRevision)
+    }
+
+    private func fileSize(of url: URL) throws -> Int64 {
+        let values = try url.resourceValues(forKeys: [.fileSizeKey])
+        guard let size = values.fileSize else {
+            throw APIError.transport("无法读取待上传文件大小")
+        }
+        return Int64(size)
+    }
 }
 
 struct RemoteBoundDevice: Decodable {
@@ -175,6 +346,37 @@ final class DeviceCloudService {
     }
 }
 
+final class NetworkStatusMonitor: ObservableObject, @unchecked Sendable {
+    static let shared = NetworkStatusMonitor()
+
+    @Published private(set) var isConnected = true
+    @Published private(set) var isWiFi = false
+    @Published private(set) var isCellular = false
+
+    private let monitor = NWPathMonitor()
+    private let queue = DispatchQueue(label: "com.celestia.trace.network-path")
+
+    private init() {
+        monitor.pathUpdateHandler = { [weak self] path in
+            let connected = path.status == .satisfied
+            let usesWiFi = path.usesInterfaceType(.wifi)
+            let usesCellular = path.usesInterfaceType(.cellular)
+            DispatchQueue.main.async {
+                self?.isConnected = connected
+                self?.isWiFi = usesWiFi
+                self?.isCellular = usesCellular
+            }
+        }
+        monitor.start(queue: queue)
+    }
+
+    var connectionName: String {
+        if isWiFi { return "Wi-Fi" }
+        if isCellular { return "蜂窝网络" }
+        return isConnected ? "当前网络" : "网络"
+    }
+}
+
 @MainActor
 final class SyncManager: ObservableObject {
     static let shared = SyncManager()
@@ -184,14 +386,32 @@ final class SyncManager: ObservableObject {
     @Published private(set) var lastErrorMessage: String?
     @Published private(set) var completedCount = 0
     @Published private(set) var totalCount = 0
+    @Published private(set) var activeSessionID: UUID?
+    @Published private(set) var syncProgress: Double = 0
 
     private let service: NetworkServiceProtocol
+    private var activeSyncTask: Task<Bool, Never>?
 
     init(service: NetworkServiceProtocol = RemoteNetworkService()) {
         self.service = service
         lastSyncDate = UserDefaults.standard.object(forKey: "com.celestia.trace.last_server_sync") as? Date
     }
 
+    func estimatedUploadBytes(for session: CelestiaSession) -> Int64 {
+        var urls: [URL] = []
+        if let url = AudioPathHelper.resolveURL(for: session.localAudioPath) {
+            urls.append(url)
+        }
+        urls.append(contentsOf: session.events.compactMap { event in
+            guard event.eventType == "PHOTO" else { return nil }
+            return AudioPathHelper.resolveURL(for: event.localFilePath)
+        })
+        return urls.reduce(into: 0) { total, url in
+            let values = try? url.resourceValues(forKeys: [.fileSizeKey])
+            total += Int64(values?.fileSize ?? 0)
+        }
+    }
+
     func sync(
         sessions: [CelestiaSession],
         modelContext: ModelContext,
@@ -199,38 +419,107 @@ final class SyncManager: ObservableObject {
     ) async -> Bool {
         guard !isSyncing else { return false }
         isSyncing = true
+        let task = Task { @MainActor [self] in
+            await performSync(
+                sessions: sessions,
+                modelContext: modelContext,
+                userID: userID
+            )
+        }
+        activeSyncTask = task
+        let result = await withTaskCancellationHandler {
+            await task.value
+        } onCancel: {
+            task.cancel()
+        }
+        activeSyncTask = nil
+        isSyncing = false
+        activeSessionID = nil
+        return result
+    }
+
+    func pauseSync(sessionID: UUID) {
+        guard isSyncing, activeSessionID == sessionID else { return }
+        activeSyncTask?.cancel()
+    }
+
+    private func performSync(
+        sessions: [CelestiaSession],
+        modelContext: ModelContext,
+        userID: String
+    ) async -> Bool {
         lastErrorMessage = nil
         completedCount = 0
+        activeSessionID = nil
+        syncProgress = 0
         totalCount = sessions.filter {
             ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
         }.count
-        defer { isSyncing = false }
-
         do {
             let remoteSessions = try await service.fetchSessions()
+            try Task.checkCancellation()
             let mergedSessions = merge(remoteSessions, into: sessions, modelContext: modelContext, userID: userID)
             for (remote, local) in mergedSessions {
                 try await downloadMissingAssets(for: local, remote: remote)
+                try Task.checkCancellation()
             }
             try modelContext.save()
 
             let eligible = sessions.filter {
-                ($0.ownerUserID == nil || $0.ownerUserID == userID) && (!$0.isSynced || $0.cloudSessionId == nil)
+                ($0.ownerUserID == nil || $0.ownerUserID == userID)
+                    && (!$0.isSynced || $0.cloudSessionId == nil)
+                    && $0.syncState != .conflict
             }
             var failures: [String] = []
-            for session in eligible {
+            let eligibleCount = max(eligible.count, 1)
+            for (index, session) in eligible.enumerated() {
+                activeSessionID = session.id
+                let sessionStartProgress = Double(index) / Double(eligibleCount)
+                let sessionProgressSpan = 1.0 / Double(eligibleCount)
+                syncProgress = sessionStartProgress
                 session.ownerUserID = userID
                 session.syncState = .syncing
                 session.lastSyncError = nil
                 do {
-                    let remote = try await service.syncSession(session)
+                    let previousRemote = remoteSessions.first {
+                        $0.id == session.cloudSessionId
+                            || $0.clientId?.caseInsensitiveCompare(session.id.uuidString) == .orderedSame
+                    }
+                    let localEventIDs = Set(session.events.map { $0.id.uuidString.lowercased() })
+                    let deletedEventClientIDs = previousRemote?.events?
+                        .compactMap(\.clientId)
+                        .filter { !localEventIDs.contains($0.lowercased()) } ?? []
+                    let remote = try await service.syncSession(
+                        session,
+                        deletedEventClientIDs: deletedEventClientIDs
+                    )
+                    try Task.checkCancellation()
+                    syncProgress = sessionStartProgress + sessionProgressSpan * 0.12
                     session.cloudSessionId = remote.id
                     session.serverRevision = remote.revision
-                    try await uploadLocalAssets(for: session, remote: remote)
+                    try await uploadLocalAssets(for: session, remote: remote) { [weak self] assetProgress in
+                        Task { @MainActor [weak self] in
+                            self?.syncProgress = sessionStartProgress
+                                + sessionProgressSpan * (0.12 + assetProgress * 0.83)
+                        }
+                    }
+                    try Task.checkCancellation()
                     session.isSynced = true
                     session.syncState = .synced
                     session.lastSyncedAt = Date()
                     completedCount += 1
+                    syncProgress = sessionStartProgress + sessionProgressSpan
+                } catch where Task.isCancelled {
+                    session.isSynced = false
+                    session.syncState = .pending
+                    session.lastSyncError = nil
+                    syncProgress = 0
+                    try? modelContext.save()
+                    return false
+                } catch APIError.server(let code, _) where code == 409 {
+                    session.isSynced = false
+                    session.syncState = .conflict
+                    session.lastSyncError = "本地版本 \(session.serverRevision) 与云端版本不一致"
                 } catch {
                     session.isSynced = false
                     session.syncState = .failed
@@ -239,6 +528,12 @@ final class SyncManager: ObservableObject {
                 }
                 try modelContext.save()
             }
+            let conflicts = sessions.filter { $0.syncState == .conflict }
+            if !conflicts.isEmpty {
+                failures.append(contentsOf: conflicts.map {
+                    "\($0.title):本地和云端版本不一致"
+                })
+            }
             guard failures.isEmpty else {
                 lastErrorMessage = failures.joined(separator: "\n")
                 return false
@@ -248,22 +543,35 @@ final class SyncManager: ObservableObject {
             lastSyncDate = now
             UserDefaults.standard.set(now, forKey: "com.celestia.trace.last_server_sync")
             return true
+        } catch where Task.isCancelled {
+            syncProgress = 0
+            return false
         } catch {
             lastErrorMessage = error.localizedDescription
             return false
         }
     }
 
-    private func uploadLocalAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
+    private func uploadLocalAssets(
+        for session: CelestiaSession,
+        remote: RemoteSession,
+        progress: @escaping @Sendable (Double) -> Void
+    ) async throws {
         guard let cloudID = session.cloudSessionId else { throw APIError.missingData }
         let existingIDs = Set((remote.assets ?? []).map(\.clientId))
+        var uploads: [(clientID: String, kind: String, url: URL, size: Int64)] = []
         if let path = session.localAudioPath {
             guard let url = AudioPathHelper.resolveURL(for: path) else {
                 throw APIError.transport("本地录音文件不存在:\(path)")
             }
-            let clientID = "\(session.id.uuidString)-audio"
-            if !existingIDs.contains(clientID) {
-                _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "AUDIO", fileURL: url)
+            let sha256 = try fileSHA256(of: url)
+            let hasMatchingAudio = (remote.assets ?? []).contains {
+                $0.kind.uppercased() == "AUDIO"
+                    && $0.sha256.caseInsensitiveCompare(sha256) == .orderedSame
+            }
+            if !hasMatchingAudio {
+                let clientID = "\(session.id.uuidString)-audio-\(sha256.prefix(16))"
+                uploads.append((clientID, "AUDIO", url, fileSize(of: url)))
             }
         }
         for event in session.events where event.eventType == "PHOTO" {
@@ -273,18 +581,75 @@ final class SyncManager: ObservableObject {
             }
             let clientID = "\(event.id.uuidString)-photo"
             if !existingIDs.contains(clientID) {
-                _ = try await service.uploadAsset(sessionID: cloudID, clientID: clientID, kind: "PHOTO", fileURL: url)
+                uploads.append((clientID, "PHOTO", url, fileSize(of: url)))
+            }
+        }
+
+        let totalBytes = max(uploads.reduce(Int64(0)) { $0 + $1.size }, 1)
+        let requiredBytes = uploads.reduce(Int64(0)) { $0 + $1.size }
+        if requiredBytes > 0 {
+            let quota = try await service.fetchStorageQuota()
+            guard requiredBytes <= quota.remainingBytes else {
+                throw APIError.storageQuotaExceeded(
+                    requiredBytes: requiredBytes,
+                    remainingBytes: quota.remainingBytes
+                )
             }
         }
+        var completedBytes: Int64 = 0
+        progress(uploads.isEmpty ? 1 : 0)
+        for upload in uploads {
+            let bytesBeforeUpload = completedBytes
+            let result = try await service.uploadAsset(
+                sessionID: cloudID,
+                clientID: upload.clientID,
+                kind: upload.kind,
+                fileURL: upload.url
+            ) { fraction in
+                let sentBytes = Double(bytesBeforeUpload) + Double(upload.size) * fraction
+                progress(min(max(sentBytes / Double(totalBytes), 0), 1))
+            }
+            if let revision = result.sessionRevision {
+                session.serverRevision = revision
+            }
+            completedBytes += upload.size
+            progress(Double(completedBytes) / Double(totalBytes))
+        }
+    }
+
+    private func fileSize(of url: URL) -> Int64 {
+        let values = try? url.resourceValues(forKeys: [.fileSizeKey])
+        return Int64(values?.fileSize ?? 0)
+    }
+
+    private func fileSHA256(of url: URL) throws -> String {
+        let input = try FileHandle(forReadingFrom: url)
+        defer { try? input.close() }
+        var hasher = SHA256()
+        while let data = try input.read(upToCount: 1_024 * 1_024),
+              !data.isEmpty {
+            hasher.update(data: data)
+        }
+        return hasher.finalize().map { String(format: "%02x", $0) }.joined()
     }
 
     private func downloadMissingAssets(for session: CelestiaSession, remote: RemoteSession) async throws {
         let assets = remote.assets ?? []
-        if (session.localAudioPath == nil || AudioPathHelper.resolveURL(for: session.localAudioPath) == nil),
-           let audio = assets.first(where: { $0.kind.uppercased() == "AUDIO" }) {
-            let destination = downloadDestination(for: audio)
-            try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
-            session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
+        if let audio = assets
+            .filter({ $0.kind.uppercased() == "AUDIO" })
+            .max(by: {
+                ($0.createdAt ?? .distantPast) < ($1.createdAt ?? .distantPast)
+            }) {
+            let localURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
+            let localHash = localURL.flatMap { try? fileSHA256(of: $0) }
+            let needsDownload = localURL == nil
+                || (session.isSynced
+                    && localHash?.caseInsensitiveCompare(audio.sha256) != .orderedSame)
+            if needsDownload {
+                let destination = downloadDestination(for: audio)
+                try await service.downloadAsset(sessionID: remote.id, asset: audio, destinationURL: destination)
+                session.localAudioPath = AudioPathHelper.relativePath(from: destination.path)
+            }
         }
 
         let photoAssets = Dictionary(uniqueKeysWithValues: assets
@@ -322,6 +687,12 @@ final class SyncManager: ObservableObject {
                     modelContext.delete(local)
                     continue
                 }
+                if !local.isSynced,
+                   remote.revision > local.serverRevision {
+                    local.syncState = .conflict
+                    local.lastSyncError = "本地版本 \(local.serverRevision) 与云端版本 \(remote.revision) 不一致"
+                    continue
+                }
                 if local.isSynced, remote.revision > local.serverRevision {
                     apply(remote, to: local, userID: userID)
                 }

+ 258 - 36
CelestiaTrace/Views/Detail/SessionDetailView.swift

@@ -11,6 +11,8 @@ struct SessionDetailView: View {
     @Environment(\.modelContext) private var modelContext
     @ObservedObject private var bleManager: BLEManager = .shared
     @ObservedObject private var authManager: AuthManager = .shared
+    @ObservedObject private var syncManager: SyncManager = .shared
+    @ObservedObject private var networkMonitor: NetworkStatusMonitor = .shared
 
     let session: CelestiaSession
 
@@ -35,6 +37,11 @@ struct SessionDetailView: View {
     @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
     @State private var selectedTimelineEvent: CelestiaTimelineEvent?
     @State private var timelineEditError: String?
+    @State private var showSyncAuthPrompt = false
+    @State private var showAuthModal = false
+    @State private var showLargeSyncConfirmation = false
+    @State private var hasApprovedLargeTransfer = false
+    @State private var autoSyncTask: Task<Void, Never>?
 
     var body: some View {
         ZStack {
@@ -42,10 +49,13 @@ struct SessionDetailView: View {
 
             ScrollView {
                 VStack(spacing: 20) {
+                    detailHeader
+                        .padding(.horizontal, 16)
+                        .padding(.top, 12)
+
                     // Section 1: Multi-Track Timeline
                     timelineSection
                         .padding(.horizontal, 16)
-                        .padding(.top, 12)
 
                     // Section 2: Playback Controls
                     playbackControls
@@ -60,24 +70,6 @@ struct SessionDetailView: View {
         }
         .navigationBarTitleDisplayMode(.inline)
         .toolbar {
-            ToolbarItem(placement: .principal) {
-                Button {
-                    recordName = session.title
-                    showRecordNameEditor = true
-                } label: {
-                    HStack(spacing: 5) {
-                        Text(session.title)
-                            .font(.system(size: 14, weight: .semibold))
-                            .lineLimit(1)
-
-                        Image(systemName: "pencil")
-                            .font(.system(size: 10, weight: .semibold))
-                    }
-                    .foregroundStyle(Color.primary)
-                    .contentShape(Rectangle())
-                }
-                .buttonStyle(.plain)
-            }
             ToolbarItemGroup(placement: .topBarTrailing) {
                 Button {
                     showSessionInfo = true
@@ -137,6 +129,7 @@ struct SessionDetailView: View {
                 playbackVM.totalDurationMs = Double(session.durationMs)
                 playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
                 playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+                scheduleAutoSync()
             }
         }
         .sheet(isPresented: $showRecordingSourcePicker) {
@@ -167,6 +160,24 @@ struct SessionDetailView: View {
                 deleteTimelineEvent(event)
             }
         }
+        .sheet(isPresented: $showSyncAuthPrompt) {
+            SyncAuthPromptModal {
+                showSyncAuthPrompt = false
+                showAuthModal = true
+            }
+        }
+        .sheet(isPresented: $showAuthModal) {
+            AuthModalView()
+        }
+        .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
+            Button("开始同步") {
+                hasApprovedLargeTransfer = true
+                performSessionSync()
+            }
+            Button("取消", role: .cancel) {}
+        } message: {
+            Text(largeTransferMessage)
+        }
         .confirmationDialog(
             "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
             isPresented: $showPhotoActionDialog,
@@ -192,7 +203,43 @@ struct SessionDetailView: View {
             playbackVM.totalDurationMs = Double(session.durationMs)
             playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
             playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
+            if session.syncState == .pending || session.syncState == .localOnly {
+                scheduleAutoSync()
+            }
+        }
+        .onChange(of: session.syncStateRaw) { _, newValue in
+            guard let state = SessionSyncState(rawValue: newValue),
+                  state == .pending || state == .localOnly else { return }
+            scheduleAutoSync()
+        }
+        .onDisappear {
+            autoSyncTask?.cancel()
+        }
+    }
+
+    private var detailHeader: some View {
+        HStack(spacing: 12) {
+            Button {
+                recordName = session.title
+                showRecordNameEditor = true
+            } label: {
+                Text(session.title)
+                    .font(.system(size: 22, weight: .semibold))
+                    .lineLimit(1)
+                    .minimumScaleFactor(0.75)
+                    .foregroundStyle(Color.primary)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .accessibilityLabel("编辑记录名称")
+            .layoutPriority(1)
+
+            Spacer(minLength: 0)
+
+            compactSyncButton
+                .fixedSize()
         }
+        .frame(maxWidth: .infinity, alignment: .leading)
     }
 
     private func prepareContinuation() {
@@ -225,6 +272,7 @@ struct SessionDetailView: View {
             session.isSynced = false
             session.syncState = .pending
             try? modelContext.save()
+            scheduleAutoSync()
         }
         showRecordNameEditor = false
     }
@@ -301,9 +349,12 @@ struct SessionDetailView: View {
                             .foregroundStyle(Color.secondary)
                     }
 
-                    Text(Self.chineseDateTimeFormatter.string(from: session.startTime))
-                        .font(.system(size: 13, weight: .medium))
-                        .foregroundStyle(Color.primary)
+                    VStack(alignment: .leading, spacing: 1) {
+                        Text(Self.chineseDateFormatter.string(from: session.startTime))
+                        Text(Self.chineseTimeFormatter.string(from: session.startTime))
+                    }
+                    .font(.system(size: 13, weight: .medium))
+                    .foregroundStyle(Color.primary)
                 }
 
                 Spacer()
@@ -319,9 +370,18 @@ struct SessionDetailView: View {
                             .foregroundStyle(Color.secondary)
                     }
 
-                    Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中")
+                    if let endTime = session.endTime {
+                        VStack(alignment: .trailing, spacing: 1) {
+                            Text(Self.chineseDateFormatter.string(from: endTime))
+                            Text(Self.chineseTimeFormatter.string(from: endTime))
+                        }
                         .font(.system(size: 13, weight: .medium))
                         .foregroundStyle(Color.primary)
+                    } else {
+                        Text("进行中")
+                            .font(.system(size: 13, weight: .medium))
+                            .foregroundStyle(Color.primary)
+                    }
                 }
             }
 
@@ -347,18 +407,26 @@ struct SessionDetailView: View {
                     label: "笔记",
                     value: "\(session.noteCount)"
                 )
+            }
 
-                // Sync status
-                VStack(spacing: 4) {
-                    Image(systemName: session.isSynced ? "cloud.fill" : "cloud")
-                        .font(.system(size: 14, weight: .light))
-                        .foregroundStyle(Color.secondary)
+            Divider()
+                .background(Color.lineBorder)
 
-                    Text(session.isSynced ? "已同步" : "未同步")
-                        .font(.system(size: 10, weight: .regular))
-                        .foregroundStyle(Color.secondary)
+            HStack(spacing: 8) {
+                Label {
+                    Text("版本号")
+                        .font(.system(size: 11, weight: .medium))
+                } icon: {
+                    Image(systemName: "number")
+                        .font(.system(size: 11, weight: .medium))
                 }
-                .frame(maxWidth: .infinity)
+                .foregroundStyle(Color.secondary)
+
+                Spacer()
+
+                Text("v\(session.localRevision)")
+                    .font(.system(size: 12, weight: .semibold, design: .monospaced))
+                    .foregroundStyle(Color.primary)
             }
 
             Divider()
@@ -391,14 +459,168 @@ struct SessionDetailView: View {
         .businessBorder(cornerRadius: 10)
     }
 
-    private static let chineseDateTimeFormatter: DateFormatter = {
+    private static let chineseDateFormatter: DateFormatter = {
         let formatter = DateFormatter()
         formatter.locale = Locale(identifier: "zh_CN")
         formatter.calendar = Calendar(identifier: .gregorian)
-        formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
+        formatter.dateFormat = "yyyy年M月d日"
         return formatter
     }()
 
+    private static let chineseTimeFormatter: DateFormatter = {
+        let formatter = DateFormatter()
+        formatter.locale = Locale(identifier: "zh_CN")
+        formatter.calendar = Calendar(identifier: .gregorian)
+        formatter.dateFormat = "HH:mm:ss"
+        return formatter
+    }()
+
+    private var compactSyncButton: some View {
+        VStack(spacing: 3) {
+            Button {
+                if isThisSessionSyncing {
+                    syncManager.pauseSync(sessionID: session.id)
+                } else {
+                    requestSessionSync(isAutomatic: false)
+                }
+            } label: {
+                Group {
+                    if isDisplayedSyncing {
+                        ZStack {
+                            Circle()
+                                .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
+
+                            Circle()
+                                .trim(from: 0, to: max(sessionSyncProgress, 0.04))
+                                .stroke(
+                                    Color.lessCosmosGold,
+                                    style: StrokeStyle(lineWidth: 2, lineCap: .round)
+                                )
+                                .rotationEffect(.degrees(-90))
+
+                            TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
+                                Image(systemName: "arrow.triangle.2.circlepath")
+                                    .font(.system(size: 9, weight: .semibold))
+                                    .foregroundStyle(Color.lessCosmosGold)
+                                    .rotationEffect(syncRotation(at: context.date))
+                            }
+                        }
+                    } else if isDisplayedSynced {
+                        Image(systemName: "checkmark.icloud.fill")
+                            .symbolRenderingMode(.hierarchical)
+                            .foregroundStyle(Color.lessCosmosGold)
+                    } else {
+                        Image(systemName: "icloud.and.arrow.up")
+                            .symbolRenderingMode(.hierarchical)
+                            .foregroundStyle(Color.secondary)
+                    }
+                }
+                .font(.system(size: 17, weight: .medium))
+                .frame(width: 32, height: 32)
+                .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .disabled(syncManager.isSyncing && !isThisSessionSyncing)
+            .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
+            .accessibilityValue(
+                isDisplayedSyncing
+                    ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
+                    : syncStatusText
+            )
+
+            Text(syncStatusText)
+                .font(.system(size: 9, weight: .medium))
+                .foregroundStyle(syncStatusColor)
+        }
+    }
+
+    private var isThisSessionSyncing: Bool {
+        syncManager.isSyncing && syncManager.activeSessionID == session.id
+    }
+
+    private var isDisplayedSyncing: Bool {
+        isThisSessionSyncing || session.syncState == .syncing
+    }
+
+    private var isDisplayedSynced: Bool {
+        !isDisplayedSyncing && session.isSynced && session.syncState == .synced
+    }
+
+    private var sessionSyncProgress: Double {
+        if isThisSessionSyncing {
+            return min(max(syncManager.syncProgress, 0), 1)
+        }
+        return session.syncState == .synced ? 1 : 0
+    }
+
+    private var syncStatusText: String {
+        if isDisplayedSyncing { return "正在同步" }
+        return isDisplayedSynced ? "已同步" : "未同步"
+    }
+
+    private var syncStatusColor: Color {
+        isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
+    }
+
+    private func syncRotation(at date: Date) -> Angle {
+        let cycle = date.timeIntervalSinceReferenceDate
+            .truncatingRemainder(dividingBy: 1.2)
+        return .degrees(cycle / 1.2 * 360)
+    }
+
+    private var largeTransferMessage: String {
+        let bytes = syncManager.estimatedUploadBytes(for: session)
+        let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
+        return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
+    }
+
+    private func requestSessionSync(isAutomatic: Bool) {
+        guard !syncManager.isSyncing else { return }
+        guard networkMonitor.isConnected else {
+            if !isAutomatic {
+                timelineEditError = "当前没有可用网络,请连接网络后重试。"
+            }
+            return
+        }
+        guard authManager.currentUser?.id != nil else {
+            if !isAutomatic {
+                showSyncAuthPrompt = true
+            }
+            return
+        }
+
+        let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
+        if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
+           !hasApprovedLargeTransfer {
+            showLargeSyncConfirmation = true
+            return
+        }
+        performSessionSync()
+    }
+
+    private func performSessionSync() {
+        guard let userID = authManager.currentUser?.id else { return }
+        Task {
+            _ = await syncManager.sync(
+                sessions: [session],
+                modelContext: modelContext,
+                userID: userID
+            )
+        }
+    }
+
+    private func scheduleAutoSync() {
+        guard session.endTime != nil,
+              session.syncState != .conflict,
+              authManager.currentUser?.id != nil else { return }
+        autoSyncTask?.cancel()
+        autoSyncTask = Task { @MainActor in
+            try? await Task.sleep(for: .seconds(1.5))
+            guard !Task.isCancelled else { return }
+            requestSessionSync(isAutomatic: true)
+        }
+    }
+
     private func infoStat(icon: String, label: String, value: String) -> some View {
         VStack(spacing: 4) {
             Image(systemName: icon)
@@ -420,7 +642,6 @@ struct SessionDetailView: View {
 
     private var timelineSection: some View {
         VStack(alignment: .leading, spacing: 8) {
-
             MultiTrackTimeline(
                 events: session.events,
                 currentTimeMs: Binding(
@@ -555,7 +776,7 @@ struct SessionDetailView: View {
 
     private var chronoFeedSection: some View {
         VStack(alignment: .leading, spacing: 10) {
-            sectionHeader(icon: "list.dash", title: "时序事件列表")
+            sectionHeader(icon: "list.dash", title: "事件")
 
             let sorted = playbackVM.sortedEvents(from: session)
 
@@ -773,6 +994,7 @@ struct SessionDetailView: View {
         session.isSynced = false
         session.syncState = .pending
         try modelContext.save()
+        scheduleAutoSync()
     }
 
     private func formattedTimelineTime(_ timeMs: Double) -> String {

+ 1 - 37
CelestiaTrace/Views/History/SessionListView.swift

@@ -12,14 +12,10 @@ struct SessionListView: View {
     var onStartRecording: () -> Void = {}
 
     @State private var searchText = ""
-    @State private var showSyncAuthPrompt = false
-    @State private var showAuthModal = false
     @State private var deletionError: String?
     @State private var sessionPendingDeletion: CelestiaSession?
     @State private var selectedSession: CelestiaSession?
     @State private var showSessionDetail = false
-    @ObservedObject private var authManager: AuthManager = .shared
-    @ObservedObject private var syncManager: SyncManager = .shared
 
     var body: some View {
         NavigationStack {
@@ -34,39 +30,7 @@ struct SessionListView: View {
             }
             .navigationTitle("历史记录")
             .navigationBarTitleDisplayMode(.inline)
-            .toolbar {
-                if !sessions.isEmpty {
-                    ToolbarItem(placement: .topBarTrailing) {
-                        Button {
-                            guard let userID = authManager.currentUser?.id else {
-                                showSyncAuthPrompt = true
-                                return
-                            }
-                            Task {
-                                _ = await syncManager.sync(sessions: sessions, modelContext: modelContext, userID: userID)
-                            }
-                        } label: {
-                            HStack(spacing: 4) {
-                                Image(systemName: "icloud.and.arrow.up")
-                                    .font(.system(size: 13))
-                                Text(syncManager.isSyncing ? "同步中" : "同步")
-                                    .font(.system(size: 13, weight: .medium))
-                            }
-                            .foregroundStyle(Color.primary)
-                        }
-                        .disabled(syncManager.isSyncing)
-                    }
-                }
-            }
             .modifier(SessionSearchModifier(isEnabled: !sessions.isEmpty, searchText: $searchText))
-            .sheet(isPresented: $showSyncAuthPrompt) {
-                SyncAuthPromptModal(onLoginClick: {
-                    showAuthModal = true
-                })
-            }
-            .sheet(isPresented: $showAuthModal) {
-                AuthModalView()
-            }
             .navigationDestination(isPresented: $showSessionDetail) {
                 if let selectedSession {
                     SessionDetailView(session: selectedSession)
@@ -230,7 +194,7 @@ private struct SessionRowCard: View {
         HStack(spacing: 12) {
             // Sync status indicator (plain circular dot)
             Circle()
-                .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.recordingRed.opacity(0.6))
+                .fill(session.isSynced ? Color.secondary.opacity(0.4) : Color.lessCosmosGold)
                 .frame(width: 6, height: 6)
 
             VStack(alignment: .leading, spacing: 4) {

+ 6 - 0
CelestiaTrace/Views/Recording/ActiveRecordingView.swift

@@ -607,6 +607,8 @@ struct ActiveRecordingView: View {
                 let mergedURL = await AudioMerger.mergeAudioFiles(firstURL: existingURL, secondURL: newRecordedURL)
                 session.localAudioPath = AudioPathHelper.relativePath(from: mergedURL.path)
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
+                session.isSynced = false
+                session.syncState = .pending
                 try? modelContext.save()
                 
                 HapticManager.trigger(.recordStop)
@@ -617,9 +619,13 @@ struct ActiveRecordingView: View {
         } else {
             if existingURL != nil {
                 session.endTime = session.startTime.addingTimeInterval(totalRecordedSeconds)
+                session.isSynced = false
+                session.syncState = .pending
                 try? modelContext.save()
             } else {
                 session.endTime = Date()
+                session.isSynced = false
+                session.syncState = .pending
                 try? modelContext.save()
             }
             HapticManager.trigger(.recordStop)

+ 1026 - 0
Docs/RemoteAPI.openapi.yaml

@@ -0,0 +1,1026 @@
+openapi: 3.1.0
+info:
+  title: Celestia Trace iOS Remote API
+  version: 2026-07-24
+  description: |
+    iOS 客户端实际调用的远程接口契约。
+
+    维护规则:
+    1. backend 路由、请求字段或响应字段变化时,先更新本文件,再更新 Swift 模型。
+    2. 所有 JSON 接口使用统一响应 `{code, message, data}`。
+    3. 除登录、注册和刷新令牌外,接口均使用 Bearer access token。
+    4. 公网网关保留 `/celestia-trace/v1`;后端 Gin 路由本身使用 `/v1`。
+    5. 服务端时间使用 RFC 3339 / ISO 8601,客户端同时兼容带或不带小数秒。
+  x-client-sources:
+    - CelestiaTrace/Services/Network/APIClient.swift
+    - CelestiaTrace/Services/Network/RemoteNetworkService.swift
+    - CelestiaTrace/Services/Auth/RemoteAuthService.swift
+servers:
+  - url: https://api.ccdw.life/celestia-trace/v1
+    description: Production
+tags:
+  - name: Health
+  - name: Auth
+  - name: User
+  - name: Devices
+  - name: Sessions
+  - name: Assets
+  - name: Sync
+paths:
+  /health:
+    get:
+      tags: [Health]
+      operationId: getHealth
+      security: []
+      responses:
+        "200":
+          description: API and database are healthy
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/HealthEnvelope"
+        "503":
+          $ref: "#/components/responses/Error"
+
+  /auth/register:
+    post:
+      tags: [Auth]
+      operationId: register
+      security: []
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/RegisterRequest"
+      responses:
+        "200":
+          description: Account and login session created
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/AuthEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "409":
+          $ref: "#/components/responses/Error"
+
+  /auth/login:
+    post:
+      tags: [Auth]
+      operationId: login
+      security: []
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/LoginRequest"
+      responses:
+        "200":
+          description: Login session created
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/AuthEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+
+  /auth/refresh:
+    post:
+      tags: [Auth]
+      operationId: refreshAccessToken
+      security: []
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [refreshToken]
+              properties:
+                refreshToken:
+                  type: string
+      responses:
+        "200":
+          description: Tokens rotated; the old refresh token is no longer valid
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/AuthEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+
+  /auth/logout:
+    post:
+      tags: [Auth]
+      operationId: logout
+      responses:
+        "200":
+          $ref: "#/components/responses/EmptySuccess"
+        "401":
+          $ref: "#/components/responses/Error"
+
+  /user/profile:
+    get:
+      tags: [User]
+      operationId: getProfile
+      responses:
+        "200":
+          description: Current user
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/UserEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+    put:
+      tags: [User]
+      operationId: updateProfile
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/UpdateProfileRequest"
+      responses:
+        "200":
+          description: Updated user
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/UserEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+        "409":
+          $ref: "#/components/responses/Error"
+
+  /user/change-password:
+    post:
+      tags: [User]
+      operationId: changePassword
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [oldPassword, newPassword]
+              properties:
+                oldPassword:
+                  type: string
+                newPassword:
+                  type: string
+                  minLength: 6
+      responses:
+        "200":
+          $ref: "#/components/responses/EmptySuccess"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+
+  /devices:
+    post:
+      tags: [Devices]
+      operationId: bindDevice
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/BindDeviceRequest"
+      responses:
+        "200":
+          description: Bound or updated cloud device
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/DeviceEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+
+  /devices/{deviceId}:
+    parameters:
+      - $ref: "#/components/parameters/DeviceId"
+    put:
+      tags: [Devices]
+      operationId: updateDevice
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/UpdateDeviceRequest"
+      responses:
+        "200":
+          description: Updated cloud device
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/DeviceEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+    delete:
+      tags: [Devices]
+      operationId: unbindDevice
+      responses:
+        "200":
+          description: Device unbound
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/DeleteEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+
+  /sessions:
+    get:
+      tags: [Sessions]
+      operationId: listSessions
+      parameters:
+        - name: includeDeleted
+          in: query
+          schema:
+            type: boolean
+            default: true
+          description: iOS uses true to receive deletion tombstones.
+      responses:
+        "200":
+          description: All sessions visible to the current user
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SessionListEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+    post:
+      tags: [Sessions]
+      operationId: upsertSession
+      description: |
+        Idempotent upsert by `(userId, clientId)`.
+        Existing sessions must send `baseRevision`; a mismatch returns HTTP/code 409.
+        `deletedEventClientIds` removes events deleted locally since the last pull.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/SessionUpsertRequest"
+      responses:
+        "200":
+          description: Created or updated session
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SessionEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+        "409":
+          description: Optimistic-lock conflict
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ConflictEnvelope"
+
+  /sessions/{sessionId}/assets:
+    parameters:
+      - $ref: "#/components/parameters/SessionId"
+    post:
+      tags: [Assets]
+      operationId: uploadAsset
+      description: |
+        Used for files smaller than 16 MiB. `clientId` makes retries idempotent.
+        A new upload returns `{asset, sessionRevision}`. An idempotent replay may
+        return the asset directly; the iOS decoder intentionally accepts both.
+      requestBody:
+        required: true
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              required: [clientId, kind, file]
+              properties:
+                clientId:
+                  type: string
+                kind:
+                  $ref: "#/components/schemas/AssetKind"
+                file:
+                  type: string
+                  format: binary
+      responses:
+        "200":
+          description: Uploaded asset or idempotent existing asset
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/AssetUploadEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+        "413":
+          $ref: "#/components/responses/QuotaError"
+
+  /sessions/{sessionId}/assets/init:
+    parameters:
+      - $ref: "#/components/parameters/SessionId"
+    post:
+      tags: [Assets]
+      operationId: initializeChunkedUpload
+      description: iOS uses this flow for files at least 16 MiB, with 8 MiB chunks.
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/ChunkUploadInitRequest"
+      responses:
+        "200":
+          description: Chunked upload initialized
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ChunkUploadInitEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+        "413":
+          $ref: "#/components/responses/QuotaError"
+
+  /sessions/{sessionId}/assets/chunk:
+    parameters:
+      - $ref: "#/components/parameters/SessionId"
+    post:
+      tags: [Assets]
+      operationId: uploadChunk
+      requestBody:
+        required: true
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              required: [uploadId, chunkIndex, file]
+              properties:
+                uploadId:
+                  type: string
+                  format: uuid
+                chunkIndex:
+                  type: integer
+                  minimum: 0
+                file:
+                  type: string
+                  format: binary
+      responses:
+        "200":
+          description: Chunk accepted
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ChunkProgressEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+        "410":
+          $ref: "#/components/responses/Error"
+
+  /sessions/{sessionId}/assets/complete:
+    parameters:
+      - $ref: "#/components/parameters/SessionId"
+    post:
+      tags: [Assets]
+      operationId: completeChunkedUpload
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              type: object
+              required: [uploadId, totalChunks]
+              properties:
+                uploadId:
+                  type: string
+                  format: uuid
+                totalChunks:
+                  type: integer
+                  minimum: 1
+      responses:
+        "200":
+          description: Chunks merged and asset created
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/AssetUploadEnvelope"
+        "400":
+          $ref: "#/components/responses/Error"
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+        "410":
+          $ref: "#/components/responses/Error"
+
+  /sessions/{sessionId}/assets/{assetId}:
+    parameters:
+      - $ref: "#/components/parameters/SessionId"
+      - $ref: "#/components/parameters/AssetId"
+    get:
+      tags: [Assets]
+      operationId: downloadAsset
+      responses:
+        "200":
+          description: Attachment bytes; Content-Disposition contains the original filename
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+        "401":
+          $ref: "#/components/responses/Error"
+        "404":
+          $ref: "#/components/responses/Error"
+
+  /storage/quota:
+    get:
+      tags: [Sync]
+      operationId: getStorageQuota
+      description: Called before uploading the pending assets for a session.
+      responses:
+        "200":
+          description: Current storage quota in bytes
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/StorageQuotaEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+
+  /sync/trigger:
+    post:
+      tags: [Sync]
+      operationId: recordSyncCheckpoint
+      description: Called only after all session metadata and assets finish syncing.
+      responses:
+        "200":
+          description: Client sync checkpoint recorded
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/SyncCheckpointEnvelope"
+        "401":
+          $ref: "#/components/responses/Error"
+
+components:
+  securitySchemes:
+    bearerAuth:
+      type: http
+      scheme: bearer
+      bearerFormat: JWT
+  parameters:
+    DeviceId:
+      name: deviceId
+      in: path
+      required: true
+      schema:
+        type: string
+        format: uuid
+    SessionId:
+      name: sessionId
+      in: path
+      required: true
+      schema:
+        type: string
+        format: uuid
+    AssetId:
+      name: assetId
+      in: path
+      required: true
+      schema:
+        type: string
+        format: uuid
+  responses:
+    EmptySuccess:
+      description: Success without a data payload
+      content:
+        application/json:
+          schema:
+            $ref: "#/components/schemas/EmptyEnvelope"
+    Error:
+      description: Request failed
+      content:
+        application/json:
+          schema:
+            $ref: "#/components/schemas/ErrorEnvelope"
+    QuotaError:
+      description: Storage quota exceeded
+      content:
+        application/json:
+          schema:
+            $ref: "#/components/schemas/QuotaErrorEnvelope"
+  schemas:
+    EnvelopeBase:
+      type: object
+      required: [code, message]
+      properties:
+        code:
+          type: integer
+        message:
+          type: string
+    EmptyEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          properties:
+            data:
+              type: "null"
+    ErrorEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          properties:
+            data: {}
+    HealthEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: string
+              const: healthy
+    RegisterRequest:
+      type: object
+      required: [username, identifier, password]
+      properties:
+        username:
+          type: string
+          minLength: 2
+        identifier:
+          type: string
+          description: Email address or phone number.
+        password:
+          type: string
+          minLength: 6
+    LoginRequest:
+      type: object
+      required: [identifier, password]
+      properties:
+        identifier:
+          type: string
+          description: Username, email address, or phone number.
+        password:
+          type: string
+    UpdateProfileRequest:
+      type: object
+      properties:
+        username:
+          type: [string, "null"]
+        email:
+          type: [string, "null"]
+        phoneNumber:
+          type: [string, "null"]
+        avatarURL:
+          type: [string, "null"]
+    User:
+      type: object
+      required: [id, username, registeredAt]
+      properties:
+        id:
+          type: string
+          format: uuid
+        username:
+          type: string
+        email:
+          type: string
+        phoneNumber:
+          type: string
+        avatarURL:
+          type: string
+          format: uri
+        registeredAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+    AuthData:
+      type: object
+      required: [user, token, refreshToken, expiresAt]
+      properties:
+        user:
+          $ref: "#/components/schemas/User"
+        token:
+          type: string
+          description: Short-lived access JWT.
+        refreshToken:
+          type: string
+          description: Rotated on every refresh.
+        expiresAt:
+          type: string
+          format: date-time
+    AuthEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              $ref: "#/components/schemas/AuthData"
+    UserEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              $ref: "#/components/schemas/User"
+    BindDeviceRequest:
+      type: object
+      required: [name]
+      properties:
+        name:
+          type: string
+        peripheralUUID:
+          type: string
+        hardwareMAC:
+          type: [string, "null"]
+        firmwareVersion:
+          type: [string, "null"]
+        batteryLevel:
+          type: [integer, "null"]
+          minimum: 0
+          maximum: 100
+        freeStorageMB:
+          type: [integer, "null"]
+        totalStorageMB:
+          type: [integer, "null"]
+    UpdateDeviceRequest:
+      type: object
+      properties:
+        name:
+          type: [string, "null"]
+        batteryLevel:
+          type: [integer, "null"]
+          minimum: 0
+          maximum: 100
+        isConnected:
+          type: [boolean, "null"]
+        firmwareVersion:
+          type: [string, "null"]
+        freeStorageMB:
+          type: [integer, "null"]
+        totalStorageMB:
+          type: [integer, "null"]
+    Device:
+      type: object
+      required: [id, name, peripheralUUID]
+      properties:
+        id:
+          type: string
+          format: uuid
+        userId:
+          type: string
+          format: uuid
+        name:
+          type: string
+        peripheralUUID:
+          type: string
+        hardwareMAC:
+          type: string
+        batteryLevel:
+          type: integer
+        firmwareVersion:
+          type: string
+        freeStorageMB:
+          type: integer
+        totalStorageMB:
+          type: integer
+        isConnected:
+          type: boolean
+        boundAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+    DeviceEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              $ref: "#/components/schemas/Device"
+    EventUpsert:
+      type: object
+      required: [clientId, relativeTimeMs, eventType]
+      properties:
+        clientId:
+          type: string
+        relativeTimeMs:
+          type: integer
+          format: int64
+        eventType:
+          type: string
+          enum: [PHOTO, NOTE, MARKER, VOICE, CONTINUATION]
+        textContent:
+          type: [string, "null"]
+        voiceStartOffsetMs:
+          type: [integer, "null"]
+          format: int64
+        voiceEndOffsetMs:
+          type: [integer, "null"]
+          format: int64
+    SessionUpsertRequest:
+      type: object
+      required: [clientId, title, startTime, durationMs, events, deletedEventClientIds]
+      properties:
+        clientId:
+          type: string
+        title:
+          type: string
+        startTime:
+          type: string
+          format: date-time
+        endTime:
+          type: [string, "null"]
+          format: date-time
+        durationMs:
+          type: integer
+          format: int64
+        events:
+          type: array
+          items:
+            $ref: "#/components/schemas/EventUpsert"
+        baseRevision:
+          type: [integer, "null"]
+          format: int64
+        deletedEventClientIds:
+          type: array
+          items:
+            type: string
+    RemoteEvent:
+      allOf:
+        - $ref: "#/components/schemas/EventUpsert"
+        - type: object
+          required: [id]
+          properties:
+            id:
+              type: string
+              format: uuid
+            clientId:
+              type: [string, "null"]
+            createdAt:
+              type: string
+              format: date-time
+    RemoteSession:
+      type: object
+      required: [id, title, startTime, durationMs, revision]
+      properties:
+        id:
+          type: string
+          format: uuid
+        clientId:
+          type: [string, "null"]
+        title:
+          type: string
+        startTime:
+          type: string
+          format: date-time
+        endTime:
+          type: [string, "null"]
+          format: date-time
+        durationMs:
+          type: integer
+          format: int64
+        revision:
+          type: integer
+          format: int64
+        deletedAt:
+          type: [string, "null"]
+          format: date-time
+        events:
+          type: array
+          items:
+            $ref: "#/components/schemas/RemoteEvent"
+        assets:
+          type: array
+          items:
+            $ref: "#/components/schemas/Asset"
+    SessionEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              $ref: "#/components/schemas/RemoteSession"
+    SessionListEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: array
+              items:
+                $ref: "#/components/schemas/RemoteSession"
+    ConflictEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: object
+              required: [serverRevision]
+              properties:
+                serverRevision:
+                  type: integer
+                  format: int64
+    AssetKind:
+      type: string
+      enum: [AUDIO, PHOTO]
+    Asset:
+      type: object
+      required: [id, clientId, kind, fileName, mimeType, sizeBytes, sha256]
+      properties:
+        id:
+          type: string
+          format: uuid
+        clientId:
+          type: string
+        kind:
+          $ref: "#/components/schemas/AssetKind"
+        fileName:
+          type: string
+        mimeType:
+          type: string
+        sizeBytes:
+          type: integer
+          format: int64
+        sha256:
+          type: string
+          pattern: "^[a-fA-F0-9]{64}$"
+        createdAt:
+          type: string
+          format: date-time
+    AssetUploadData:
+      type: object
+      required: [asset, sessionRevision]
+      properties:
+        asset:
+          $ref: "#/components/schemas/Asset"
+        sessionRevision:
+          type: integer
+          format: int64
+    AssetUploadEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              oneOf:
+                - $ref: "#/components/schemas/AssetUploadData"
+                - $ref: "#/components/schemas/Asset"
+    ChunkUploadInitRequest:
+      type: object
+      required: [clientId, kind, fileName, mimeType, fileSize, chunkSize]
+      properties:
+        clientId:
+          type: string
+        kind:
+          $ref: "#/components/schemas/AssetKind"
+        fileName:
+          type: string
+        mimeType:
+          type: string
+        fileSize:
+          type: integer
+          format: int64
+          minimum: 1
+        chunkSize:
+          type: integer
+          minimum: 1
+    ChunkUploadInitEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: object
+              required: [uploadId, totalChunks, chunkSize, expiresAt]
+              properties:
+                uploadId:
+                  type: string
+                  format: uuid
+                totalChunks:
+                  type: integer
+                chunkSize:
+                  type: integer
+                expiresAt:
+                  type: string
+                  format: date-time
+    ChunkProgressEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: object
+              required: [uploadId, chunkIndex, uploadedChunks, totalChunks]
+              properties:
+                uploadId:
+                  type: string
+                  format: uuid
+                chunkIndex:
+                  type: integer
+                uploadedChunks:
+                  type: integer
+                totalChunks:
+                  type: integer
+    StorageQuota:
+      type: object
+      required: [totalBytes, usedBytes, remainingBytes]
+      properties:
+        totalBytes:
+          type: integer
+          format: int64
+        usedBytes:
+          type: integer
+          format: int64
+        remainingBytes:
+          type: integer
+          format: int64
+    StorageQuotaEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              $ref: "#/components/schemas/StorageQuota"
+    QuotaErrorEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              allOf:
+                - $ref: "#/components/schemas/StorageQuota"
+                - type: object
+                  required: [requiredBytes]
+                  properties:
+                    requiredBytes:
+                      type: integer
+                      format: int64
+    SyncCheckpointEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: object
+              required: [syncedAt, message]
+              properties:
+                syncedAt:
+                  type: string
+                  format: date-time
+                message:
+                  type: string
+    DeleteEnvelope:
+      allOf:
+        - $ref: "#/components/schemas/EnvelopeBase"
+        - type: object
+          required: [data]
+          properties:
+            data:
+              type: object
+              required: [deletedId]
+              properties:
+                deletedId:
+                  type: string
+
+security:
+  - bearerAuth: []