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: []