openapi: 3.1.2
info:
  title: 大翼低空开放平台 API
  version: 1.0.0
  description: |
    开放平台 v1 的机器可读契约。API 版本与本文采用的 OpenAPI 规范版本相互独立。
    `/v1` 内既有字段不会删除、改名或改变类型/语义；响应只做可选字段的向后兼容追加。
servers:
  - url: https://open.airdwing.com
security:
  - bearerApiKey: []
tags:
  - name: Airspace
  - name: Vision
paths:
  /v1/airspace/query:
    get:
      operationId: queryAirspacePoint
      tags: [Airspace]
      summary: 查询点位是否命中已接入禁飞区
      description: 未命中禁飞区返回 unknown，不代表适飞。
      parameters:
        - $ref: '#/components/parameters/Longitude'
        - $ref: '#/components/parameters/Latitude'
      responses:
        '200':
          description: 点位判定
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AirspacePointResult'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/airspace/no-fly/zones:
    get:
      operationId: listNoFlyZones
      tags: [Airspace]
      summary: 分页读取地图视口内的禁飞区
      parameters:
        - name: bbox
          in: query
          required: true
          description: minLon,minLat,maxLon,maxLat；不支持跨 180° 反经线。
          schema: { type: string, pattern: '^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$' }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: 禁飞区分页
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NoFlyZonePage'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/image-detections:
    post:
      operationId: createImageDetection
      tags: [Vision]
      summary: 同步批量图片检测
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              additionalProperties: false
              required: [files]
              properties:
                scene_code: { $ref: '#/components/schemas/SceneCode' }
                files:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items: { type: string, format: binary }
            encoding:
              files:
                contentType: image/jpeg, image/png, image/webp
      responses:
        '200':
          description: 同键同语义幂等重放
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImageDetectionResult' }
        '201':
          description: 首次检测完成
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImageDetectionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/video-tasks:
    post:
      operationId: createVideoFileTask
      tags: [Vision]
      summary: 上传视频并创建异步检测任务
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              additionalProperties: false
              required: [scene_code, file]
              properties:
                scene_code: { $ref: '#/components/schemas/SceneCode' }
                file: { type: string, format: binary }
            encoding:
              file:
                contentType: video/mp4, video/quicktime
      responses:
        '200': { $ref: '#/components/responses/TaskReplay' }
        '201': { $ref: '#/components/responses/TaskCreated' }
        '202': { $ref: '#/components/responses/TaskResolving' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/tasks:
    post:
      operationId: createLiveStreamTask
      tags: [Vision]
      summary: 创建直播流异步检测任务
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [scene_code, stream_url]
              properties:
                scene_code: { $ref: '#/components/schemas/SceneCode' }
                stream_url:
                  type: string
                  format: uri
                  description: 允许 http、https、rtsp、rtmp；入口执行 SSRF 防护与可达性探测。
      responses:
        '200': { $ref: '#/components/responses/TaskReplay' }
        '201': { $ref: '#/components/responses/TaskCreated' }
        '202': { $ref: '#/components/responses/TaskResolving' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/tasks/{id}:
    get:
      operationId: getVisionTask
      tags: [Vision]
      summary: 获取任务状态
      parameters:
        - $ref: '#/components/parameters/TaskID'
      responses:
        '200':
          description: 任务投影
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/tasks/{id}/results:
    get:
      operationId: listVisionTaskResults
      tags: [Vision]
      summary: 分页读取检测帧
      parameters:
        - $ref: '#/components/parameters/TaskID'
        - $ref: '#/components/parameters/Cursor'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500 }
        - name: min_score
          in: query
          description: 读时投影阈值，不改变任务或已存结果。
          schema: { type: number, minimum: 0, maximum: 1 }
      responses:
        '200':
          description: 检测帧分页
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FramePage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/tasks/{id}/playback:
    get:
      operationId: getVisionTaskPlayback
      tags: [Vision]
      summary: 获取短期 HTTP-FLV + SEI 播放入口
      parameters:
        - $ref: '#/components/parameters/TaskID'
      responses:
        '200':
          description: 播放投影
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Playback' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }
  /v1/vision/tasks/{id}/stop:
    post:
      operationId: stopVisionTask
      tags: [Vision]
      summary: 停止运行中的任务
      parameters:
        - $ref: '#/components/parameters/TaskID'
      responses:
        '200':
          description: 停止后的任务投影
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '503': { $ref: '#/components/responses/Unavailable' }
components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: OpenPlatform API Key
  parameters:
    Longitude:
      name: lon
      in: query
      required: true
      schema: { type: number, minimum: -180, maximum: 180 }
    Latitude:
      name: lat
      in: query
      required: true
      schema: { type: number, minimum: -90, maximum: 90 }
    Cursor:
      name: cursor
      in: query
      description: 服务端签发的不透明游标；客户端不得解析或拼接。
      schema: { type: string, minLength: 1 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: 标准 UUID。同键同主体同语义重放不重复扣额；同键异义返回错误。
      schema: { type: string, format: uuid }
    TaskID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
  schemas:
    SceneCode:
      type: string
      enum: [person_vehicle, ship, fishing, fire_smoke, cow_sheep]
    SourceType:
      type: string
      description: 任务输入形态。枚举值在 v1 内只允许追加。
      enum: [image_batch, video_file, live_stream]
    TaskStatus:
      type: string
      description: 本地对权威任务状态的稳定投影；终态值不会再回到非终态。
      enum: [counted, submitting, resolving, resolution_overdue, running, stopping, stopped, succeeded, failed, refunded]
    Task:
      type: object
      additionalProperties: false
      required: [task_id, request_id, scene_code, source_type, status, created_at, updated_at]
      properties:
        task_id: { type: string, format: uuid }
        request_id: { type: string, format: uuid }
        scene_code: { type: string }
        source_type: { $ref: '#/components/schemas/SourceType' }
        status: { $ref: '#/components/schemas/TaskStatus' }
        result_expires_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        replayed: { type: boolean }
    ImageDetectionResult:
      type: object
      additionalProperties: false
      required: [task_id, request_id, status, results]
      properties:
        task_id: { type: string, format: uuid }
        request_id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/TaskStatus' }
        replayed: { type: boolean }
        results:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [seq, status]
            properties:
              seq: { type: integer, minimum: 0 }
              status: { type: string, enum: [succeeded, failed] }
              error_code: { type: string }
              objects:
                type: array
                items: { $ref: '#/components/schemas/FrameObject' }
    FrameBBox:
      type: object
      additionalProperties: false
      required: [format, cx, cy, width, height]
      properties:
        format: { type: string, const: cxcywh_normalized }
        cx: { type: number, minimum: 0, maximum: 1 }
        cy: { type: number, minimum: 0, maximum: 1 }
        width: { type: number, exclusiveMinimum: 0, maximum: 1 }
        height: { type: number, exclusiveMinimum: 0, maximum: 1 }
    FrameObject:
      type: object
      additionalProperties: false
      required: [label, score, bbox]
      properties:
        label: { type: string }
        score: { type: number, minimum: 0, maximum: 1 }
        bbox: { $ref: '#/components/schemas/FrameBBox' }
        track_id: { type: string }
    FrameItem:
      type: object
      additionalProperties: true
      required: [frame_id, frame_time_ms, objects]
      properties:
        frame_id: { type: string }
        frame_time_ms:
          type: integer
          format: int64
          description: video_file 为源视频 PTS 毫秒；live_stream 为采集时间轴毫秒。
        image_url: { type: string, format: uri-reference }
        image:
          type: object
          additionalProperties: false
          required: [width, height]
          properties:
            width: { type: integer, minimum: 1 }
            height: { type: integer, minimum: 1 }
        objects:
          type: array
          items: { $ref: '#/components/schemas/FrameObject' }
        objects_total: { type: integer, minimum: 0 }
    FramePage:
      type: object
      additionalProperties: false
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/FrameItem' }
        next_cursor: { type: [string, 'null'] }
    Playback:
      type: object
      additionalProperties: false
      required: [mode, url, codec, sei]
      properties:
        mode: { type: string, const: http_flv_sei }
        url: { type: string, format: uri-reference }
        codec: { type: string, enum: [h264, h265] }
        expires_at: { type: string, format: date-time }
        sei:
          type: object
          additionalProperties: false
          required: [uuid, version]
          properties:
            uuid: { type: string, pattern: '^[0-9a-f]{32}$' }
            version: { type: integer, minimum: 1 }
    AirspacePointResult:
      type: object
      additionalProperties: false
      required: [status, zones, disclaimer, coverage]
      properties:
        status: { type: string, enum: [no_fly, unknown] }
        zones:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [object_id, name]
            properties:
              object_id: { type: string }
              name: { type: string }
        disclaimer: { type: string }
        coverage:
          type: object
          additionalProperties: false
          required: [checked, not_checked]
          properties:
            checked: { type: array, items: { type: string } }
            not_checked: { type: array, items: { type: string } }
    NoFlyZonePage:
      type: object
      additionalProperties: false
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [zone_id, name, geometry, valid_from, valid_until]
            properties:
              zone_id: { type: string, format: uuid }
              name: { type: string }
              geometry:
                type: object
                description: GeoJSON Polygon 或 MultiPolygon。
                required: [type, coordinates]
                properties:
                  type: { type: string, enum: [Polygon, MultiPolygon] }
                  coordinates: { type: array }
              valid_from: { type: [string, 'null'], format: date-time }
              valid_until: { type: [string, 'null'], format: date-time }
        next_cursor: { type: [string, 'null'] }
    ErrorEnvelope:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code: { type: string, pattern: '^OP-[A-Z]+-[A-Z0-9-]+$' }
            message: { type: string }
            trace_id: { type: string }
            details: { type: object, additionalProperties: true }
  responses:
    TaskCreated:
      description: 首次创建成功
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Task' }
    TaskReplay:
      description: 同键同语义幂等重放
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Task' }
    TaskResolving:
      description: 已受理但上游任务号仍在确认；不要换新幂等键重提
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Task' }
    BadRequest:
      description: 请求参数无效
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: API Key 缺失、无效或已吊销
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: 任务不存在或不属于当前主体
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: 幂等语义冲突、任务在途或状态不允许
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Gone:
      description: 结果已过留存期
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: 频率、并发或每日额度限制
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unavailable:
      description: 依赖、容量、熔断或播放入口暂不可用
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
