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