openapi: 3.0.3

info:
  title: Supernori 灰度 API
  version: 1.0.0
  description: |
    Supernori 灰度的客户端只读接口。客户端在进入 supernori 链路前调用该接口，判断某个 family 是否支持 supernori。

    接口只有一个：

    `GET /api/v1/supernori/rollout?family_id=<uuid>`

    该接口挂载在 `/api/v1`，需要客户端用户认证。

    ## family_id 是可选的

    - 不传 `family_id`：只返回全局灰度阶段 `data.stage`，不返回 `data.supernori`。
    - 传 `family_id`：额外返回该 family 的判定 `data.supernori`；此时必须同时携带与认证家庭一致的 `X-Family-ID`，否则拒绝。
    - `family_id` 必须是合法 UUID；非法 UUID 返回业务错误码 `10001`。
    - 空字符串 `family_id=` 等同于不传。
    - 传全零 UUID（`uuid.Nil`）也会返回 `10001`。

    `supernori` 字段是“整个字段省略”，不是返回 `null`。

    ## stage 的含义

    | stage | 未命中名单时的行为 | 典型用途 |
    | --- | --- | --- |
    | `stage1` | 默认拒绝；只有 `feature_states` 白名单 family 支持 | 小范围白名单验证 |
    | `stage2` | 检查 family 是否存在未删除的 device role；存在则支持 | 设备用户放量 |
    | `stage3` | 默认支持所有非黑名单 family | 全量放量 |

    stage 由部署环境变量 `SUPERNORI_ROLLOUT_STAGE` 控制，默认 `stage1`；在服务启动时读取一次，修改后需要重启 service。
    非法 stage 会回退到 `stage1`。

    总开关由 `SUPERNORI_ROLLOUT_ENABLED` 控制，默认 `true`；非法布尔值按关闭处理。

    ## 判定优先级

    1. `feature_states` 中命中 **blacklist**：拒绝，`reason=subject_blacklisted`。
    2. 命中 **whitelist**：放行，`reason=subject_whitelisted`，即使总开关关闭、stage1 未放量也放行。
    3. `SUPERNORI_ROLLOUT_ENABLED=false`：拒绝，`reason=kill_switch_disabled`。
    4. 按 stage 判定：
       - `stage1`：默认拒绝，`reason=stage1_not_whitelisted`。
       - `stage2`：存在 active device role 则放行；否则拒绝，`reason=stage2_policy_not_matched`。查询异常时拒绝，`reason=stage2_policy_check_failed`。
       - `stage3`：默认放行，`reason=stage3_default_allow`。

    名单和 device role 查询失败时一律 fail closed：HTTP 仍为 200、`code=0`，但 `support=false`，`reason` 为对应的底层读取失败原因。

    ## 名单读取

    白名单/黑名单直接查询 `feature_states`，不经过额外缓存：

    ```sql
    SELECT policy
    FROM feature_states
    WHERE feature_key = 'supernori'
      AND subject_type = 'family'
      AND subject_id = '<family_uuid>';
    ```

    修改 `feature_states` 后立即生效，不需要额外删除缓存。

    本接口 **不使用** `feature_gate_configs`，`rollout_percentage` 不参与 supernori 判定；supernori 的放量由 `SUPERNORI_ROLLOUT_STAGE` 和 `SUPERNORI_ROLLOUT_ENABLED` 控制。

    ## 错误语义

    业务错误通过统一错误中间件返回，HTTP 状态码通常仍为 **200**，以响应体 `code` 为准：

    - `10001`：`family_id` 不是合法 UUID，或为全零 UUID。
    - `10003`：传了 `family_id` 但没有认证家庭上下文（缺少或无效的 `X-Family-ID`）。
    - `10004`：`family_id` 与认证家庭不一致。
    - `10000`：内部错误。
    - 名单或 device role 读取失败不是 HTTP 错误；返回 `code=0`、`support=false` 和失败原因。

    该接口挂载在 `/api/v1`，需要客户端用户认证；`family_id` 仍为可选 query 参数。
    传 `family_id` 时必须同时携带与认证家庭一致的 `X-Family-ID`，避免查询非当前认证家庭。

    实现位置：

    - Handler：`internal/interfaces/http/handlers/supernori/handler.go`
    - Service：`internal/application/supernori/service.go`
    - 名单读取：FeatureGate owner contract `RolloutStateDB.GetSubjectRolloutState`（`internal/infrastructure/featuregate/store`）
    - device role 读取：Family application contract `FamilyDeviceRoleReader.HasActiveDeviceRole`（`internal/application/interface/family/v1`）

# 客户端经 Gateway 访问时使用 /api 作为 server base。
servers:
  - url: /api
    description: 经 Gateway 的客户端路径

tags:
  - name: supernori
    description: Supernori 灰度判定

paths:
  /v1/supernori/rollout:
    get:
      tags:
        - supernori
      summary: 查询 supernori 灰度状态
      security:
        - BearerAuth: []
      operationId: getSupernoriRollout
      description: |
        查询全局灰度阶段；如果传入 `family_id`，同时返回该 family 的支持结果。

        不传 `family_id` 时不会读取名单，也不会读取 device role，只返回 stage。

        传入 `family_id` 时按 blacklist > whitelist > 总开关 > stage 的顺序判定；stage2 才读取 device role。

        `stage` 来自服务启动时的部署配置，不是每次请求动态读取。修改 `SUPERNORI_ROLLOUT_STAGE` 或
        `SUPERNORI_ROLLOUT_ENABLED` 后必须重启 service。

        错误码：

        - `10001`：`family_id` 非法，或为全零 UUID。
        - `10003`：缺少认证家庭上下文。
        - `10004`：`family_id` 与认证家庭不一致。
        - `10000`：内部错误。

        名单或 device role 读取失败时不会返回错误码，而是返回 `code=0`、`support=false`，并在 `reason` 中说明底层读取失败原因。
      parameters:
        - name: X-Family-ID
          in: header
          required: false
          description: |
            认证家庭 ID。传 `family_id` 时必填，且必须与 `family_id` 一致。
          schema:
            type: string
            format: uuid
          example: d2d0af67-79cb-4df6-9bea-dece62171a33
        - name: family_id
          in: query
          required: false
          allowEmptyValue: true
          description: |
            可选的家庭 ID。传入后返回 `data.supernori`；不传、传空字符串时只返回 `data.stage`。
            必须是合法 UUID；全零 UUID 会返回 `10001`。
          schema:
            type: string
            format: uuid
          example: d2d0af67-79cb-4df6-9bea-dece62171a33
      x-codeSamples:
        - lang: cURL
          source: |
            curl -s 'https://<gateway>/api/v1/supernori/rollout?family_id=d2d0af67-79cb-4df6-9bea-dece62171a33' \
              -H 'Authorization: Bearer <token>' \
              -H 'X-Family-ID: d2d0af67-79cb-4df6-9bea-dece62171a33'
      responses:
        '200':
          description: |
            统一响应信封。成功时 `code=0`；`family_id` 非法时返回 `code=10001`；内部错误返回 `code=10000`。

            注意：即使 `code` 非 0，HTTP 状态码通常也是 200，请以响应体 `code` 为准。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupernoriRolloutEnvelope'
              examples:
                stage_only:
                  summary: 不传 family_id，只返回 stage
                  value:
                    code: 0
                    message: null
                    data:
                      stage: stage1
                stage1_whitelist_hit:
                  summary: stage1，family 命中白名单
                  value:
                    code: 0
                    message: null
                    data:
                      stage: stage1
                      supernori:
                        family_id: d2d0af67-79cb-4df6-9bea-dece62171a33
                        support: true
                        reason: subject_whitelisted
                stage1_not_whitelisted:
                  summary: stage1，family 未命中白名单
                  value:
                    code: 0
                    message: null
                    data:
                      stage: stage1
                      supernori:
                        family_id: d2d0af67-79cb-4df6-9bea-dece62171a33
                        support: false
                        reason: stage1_not_whitelisted
                stage2_device_matched:
                  summary: stage2，family 存在 active device role
                  value:
                    code: 0
                    message: null
                    data:
                      stage: stage2
                      supernori:
                        family_id: d2d0af67-79cb-4df6-9bea-dece62171a33
                        support: true
                        reason: stage2_policy_matched
                stage3_default_allow:
                  summary: stage3，默认放行
                  value:
                    code: 0
                    message: null
                    data:
                      stage: stage3
                      supernori:
                        family_id: d2d0af67-79cb-4df6-9bea-dece62171a33
                        support: true
                        reason: stage3_default_allow
                invalid_family_id:
                  summary: family_id 非法
                  value:
                    code: 10001
                    message: family_id is invalid
                internal_error:
                  summary: 内部错误
                  value:
                    code: 10000
                    message: internal error
        '401':
          description: 缺少或无效的客户端用户认证。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        default:
          description: |
            反向代理或 service 启动前失败时可能返回非 200；正常业务错误请参考 200 响应体中的 `code`。
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:
    SupernoriRolloutEnvelope:
      type: object
      description: supernori 灰度接口的统一响应信封。
      required:
        - code
      properties:
        code:
          type: integer
          format: int32
          description: |
            业务状态码。
            - `0`：成功
            - `10001`：`family_id` 非法或为全零 UUID
            - `10000`：内部错误
          example: 0
        message:
          type: string
          nullable: true
          description: 成功时为 `null`；失败时为错误信息。
          example: null
        data:
          $ref: '#/components/schemas/SupernoriRollout'

    SupernoriRollout:
      type: object
      description: 灰度结果。
      required:
        - stage
      properties:
        stage:
          type: string
          enum:
            - stage1
            - stage2
            - stage3
          description: |
            当前部署的全局灰度阶段，来自服务启动时读取的 `SUPERNORI_ROLLOUT_STAGE`。

            - `stage1`：只有白名单 family 支持
            - `stage2`：白名单或存在 active device role 的 family 支持
            - `stage3`：所有非黑名单 family 支持
          example: stage1
        supernori:
          $ref: '#/components/schemas/SupernoriFamilySupport'

    SupernoriFamilySupport:
      type: object
      description: |
        单个 family 的判定。整个字段只在请求传入 `family_id` 时存在；
        不传 `family_id` 时该字段省略，不会返回 `null`。
      required:
        - family_id
        - support
        - reason
      properties:
        family_id:
          type: string
          format: uuid
          description: 请求中传入的 family ID。
          example: d2d0af67-79cb-4df6-9bea-dece62171a33
        support:
          type: boolean
          description: 该 family 是否支持 supernori。
          example: true
        reason:
          type: string
          description: |
            稳定的机器可读判定原因。

            | reason | support | 含义 |
            | --- | --- | --- |
            | `subject_blacklisted` | false | 命中 `feature_states` 黑名单 |
            | `subject_whitelisted` | true | 命中 `feature_states` 白名单 |
            | `kill_switch_disabled` | false | `SUPERNORI_ROLLOUT_ENABLED=false` |
            | `stage1_not_whitelisted` | false | stage1 且未命中白名单 |
            | `stage2_policy_matched` | true | stage2 且存在 active device role |
            | `stage2_policy_not_matched` | false | stage2 且不存在 active device role |
            | `stage2_policy_check_failed` | false | stage2 的 device role 查询失败 |
            | `stage3_default_allow` | true | stage3 默认放行 |
            | `rollout_state_read_failed` | false | 名单 DB 读取失败 |

            说明：带 `_failed` 的 reason 仍返回 `code=0`，属于 fail closed 的业务结果，不是 HTTP 错误。
          example: subject_whitelisted

    ErrorEnvelope:
      type: object
      description: 非正常响应时的统一错误信封。
      required:
        - code
        - message
      properties:
        code:
          type: integer
          format: int32
          description: 业务错误码。
          example: 10001
        message:
          type: string
          description: 错误信息。
          example: family_id is invalid
