openapi: 3.1.0
info:
  title: 蜂巢采集 · 上报接口
  version: "1.0.0"
  description: |
    **[⬇ 下载 OpenAPI](openapi.yaml)** · **[⬇ 下载 Postman / Apifox 集合](hd-ingest.postman_collection.json)** · **[📄 文字版接口文档](https://hdedge-api.demo.yijiangsoft.com/)**

    采集盒（collector）把多品牌血透机归一后的标准记录，**主动 HTTP POST** 到「上游数据需求方」（HIS / 院内系统 / 任何接收方）提供的上报地址。

    - **这是我方自定的标准协议**：盒子按本规范发，上游数据需求方按本规范收，不依赖对方先给契约。
    - **投递语义 = at-least-once**：网络/服务抖动时盒子会重发，同一条 `id` 可能到达多次；**接收方必须按 `id` 幂等去重**，已存在视为成功（返回 2xx）。
    - **ACK 以 HTTP 状态码为准**：仅 **2xx = 已持久化**，盒子据此从缓存删除该批；非 2xx 一律保留并退避重试。**切记：未落库前不要返回 2xx，否则会丢数据。**

    > 文中示例上报地址 `https://eye.example.com` 为**占位**，正式地址、是否启用 Token、单批上限等另行约定。
servers:
  - url: https://eye.example.com
    description: 上游数据需求方提供的上报地址（占位，正式地址另行约定）

paths:
  /api/hd/ingest:
    post:
      operationId: ingest
      summary: 上报标准记录（批量信封）
      description: |
        一次 POST 携带 1～N 条标准记录（批量省连接、省 4G 流量）。
        接收方遍历 `batch[]`，按 `id` 幂等 upsert；全部落库成功（含幂等命中）才返回 **200**。
      parameters:
        - name: X-Device-Id
          in: header
          required: true
          description: 采集盒 / 设备唯一号（= collector `-i`）。接收方据此识别来源盒子。
          schema:
            type: string
          example: BSLRS23220260379
        - name: X-Token
          in: header
          required: false
          description: 预共享令牌（collector `-a`）。接收方下发、盒子按盒配置；启用后校验不过应返回 401。
          schema:
            type: string
          example: ""
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Envelope'
            examples:
              treat:
                summary: 一条治疗实时数据（treat）
                value:
                  ver: "1.0"
                  dev: BSLRS23220260379
                  batch:
                    - ver: "1.0"
                      type: treat
                      id: BSLRS23220260379-1781515321-0
                      dev: BSLRS23220260379
                      brand: fresenius
                      model: "4008A"
                      port: ttyUSB0
                      ts: "2026-06-15T13:21:05+08:00"
                      data:
                        qb: 250
                        vp: 120
                        ap: -150
                        tmp: 140
                        uf: 1200
                        ufr: 800
                        cond: 14.0
                        temp_d: 36.5
                        mode: HD
      responses:
        "200":
          description: |
            **已持久化**（含幂等重复命中）。盒子视为成功，从缓存删除该批。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Ack'
              example:
                code: 0
                msg: ok
        "401":
          description: 鉴权失败（X-Token 不通过）。盒子保留并退避重试，运维需修配置。
        "413":
          description: 请求体过大。盒子会**自动减半 batch** 后重试。
        "4XX":
          description: 其它客户端错误（格式等）。盒子保留并退避重试（不静默丢，靠缓存上限兜底）。
        "5XX":
          description: 服务器 / 网络问题。盒子保留并退避重试。

components:
  schemas:
    Envelope:
      type: object
      required: [ver, dev, batch]
      properties:
        ver:
          type: string
          description: 信封版本
          example: "1.0"
        dev:
          type: string
          description: 采集盒 / 设备唯一号（与各记录内 `dev` 一致，便于不拆 batch 时路由）
          example: BSLRS23220260379
        batch:
          type: array
          minItems: 1
          description: 1～N 条标准记录，可混排不同 `type`
          items:
            $ref: '#/components/schemas/Record'

    Record:
      type: object
      required: [ver, type, id, dev, ts, data]
      properties:
        ver:
          type: string
          example: "1.0"
        type:
          type: string
          description: 记录类型
          enum: [treat, bp, alarm, status, setting]
        id:
          type: string
          description: |
            幂等键，格式 `<dev>-<盒子启动时刻epoch>-<自启动起的序号>`。
            跨重启不撞、单次运行内单调递增。**接收方据此去重。**
          example: BSLRS23220260379-1781515321-0
        dev:
          type: string
          example: BSLRS23220260379
        sn:
          type: string
          description: 血透机序列号（取不到时用盒子号兜底）
          example: "12345"
        brand:
          type: string
          description: 品牌枚举（小写短码）
          enum: [fresenius, gambro, baxter, bbraun, toray, wego, nipro, sws, wesley, biolight, jms, maxlinear, mednovel]
          example: fresenius
        model:
          type: string
          example: "4008A"
        port:
          type: string
          description: 采集串口
          example: ttyUSB0
        ts:
          type: string
          format: date-time
          description: ISO8601，**必带时区偏移**，严禁裸本地时间
          example: "2026-06-15T13:21:05+08:00"
        data:
          description: 按 `type` 不同的负载；treat/setting 用 TreatData 字段，bp/alarm/status 见各 schema
          oneOf:
            - $ref: '#/components/schemas/TreatData'
            - $ref: '#/components/schemas/BpData'
            - $ref: '#/components/schemas/AlarmData'
            - $ref: '#/components/schemas/StatusData'

    TreatData:
      type: object
      description: type=treat 治疗实时数据（type=setting 复用同名字段表达预设值）。机器不支持的字段省略该 key；支持但本条无值用 null。
      properties:
        elapsed: { type: integer, description: "已治疗时间（s）" }
        remain: { type: integer, description: "剩余时间（s）" }
        treat_set: { type: integer, description: "预设治疗时长（s）" }
        filter_life: { type: integer, description: "滤器使用时间 CRRT（s）" }
        mode: { type: string, description: "治疗模式（归一名）", example: HD }
        mode_raw: { type: string, description: "治疗模式原始值" }
        qb: { type: number, description: "血流量（mL/min）", example: 250 }
        qb_avg: { type: number, description: "平均血流量（mL/min）" }
        bv: { type: number, description: "累计血容量（L）" }
        vp: { type: number, description: "静脉压（mmHg）", example: 120 }
        ap: { type: number, description: "动脉压（mmHg）", example: -150 }
        tmp: { type: number, description: "跨膜压（mmHg）", example: 140 }
        uf: { type: number, description: "超滤量（mL）", example: 1200 }
        uf_goal: { type: number, description: "预设超滤量（mL）" }
        ufr: { type: number, description: "超滤率（mL/h）", example: 800 }
        qd: { type: number, description: "透析液流速（mL/min）" }
        qd_avg: { type: number, description: "平均透析液流速（mL/min）" }
        dvol: { type: number, description: "透析液总量（mL）" }
        cond: { type: number, description: "电导率（mS/cm）", example: 14.0 }
        temp_d: { type: number, description: "透析液温度（°C）", example: 36.5 }
        temp_d_avg: { type: number, description: "平均透析液温度（°C）" }
        temp_b: { type: number, description: "血温（°C）" }
        temp_body: { type: number, description: "体温（°C）" }
        qsub: { type: number, description: "置换液率（mL/min）" }
        sub_vol: { type: number, description: "置换液量（mL）" }
        qrp: { type: number, description: "RP 液流量（mL/min）" }
        hepr: { type: number, description: "肝素速率（mL/h）" }
        hep_vol: { type: number, description: "肝素量（mL）" }
        k: { type: number, description: "清除率（mL/min）" }
        ktv: { type: number, description: "Kt/V" }
        balance: { type: number, description: "液体平衡 CRRT（mL）" }
        intake_goal: { type: number, description: "液体摄入目标（mL）" }
        pid: { type: string, description: "患者卡号（预留）" }
        pname: { type: string, description: "患者姓名（预留）" }

    BpData:
      type: object
      description: type=bp 血压
      properties:
        sys: { type: integer, description: "收缩压（mmHg）" }
        dia: { type: integer, description: "舒张压（mmHg）" }
        map: { type: integer, description: "平均动脉压（mmHg，可算/可缺省）" }
        pulse: { type: integer, description: "心率（bpm）" }

    AlarmData:
      type: object
      description: type=alarm 报警/事件
      properties:
        text: { type: string, description: "事件/报警文本（归一或原文）" }
        code: { type: string, description: "机器报警码（有则带）" }
        level: { type: string, description: "级别（有则带）", enum: [info, warn, alarm] }
        active: { type: boolean, description: "触发/解除（有则带）" }

    StatusData:
      type: object
      description: type=status 设备状态
      properties:
        state: { type: string, description: "设备状态", enum: [offline, standby, treating, alarm, ended] }
        phase: { type: string, description: "治疗阶段（预冲/治疗/回血…，可缺省）" }
        alarm: { type: boolean, description: "当前是否有报警（可缺省）" }

    Ack:
      type: object
      description: 响应体仅供日志/排错；盒子只认 HTTP 状态码，不解析体。
      properties:
        code: { type: integer, description: "0=ok（信息性）", example: 0 }
        msg: { type: string, example: ok }
