openapi: 3.0.3
info:
  title: Search API
  version: 1.0.0
  description: |
    统一搜索接口，覆盖 recipe、mealplan、shopping、event、task 等资源。

    分页兼容策略：
    - Shopping search 推荐使用 `offset` + `limit`，其中 `offset` 从 0 开始。
    - `page` + `limit` 保留用于兼容已有客户端，其中 `page` 从 1 开始。
    - 同时传 `page` 与 `offset` 时，`page` 优先。
    - `page_size` 不是该接口字段，每页数量继续使用 `limit`。
servers:
  - url: /api/v1
    description: Gateway base path

tags:
  - name: Search
    description: Unified search across family-scoped resources.

security:
  - bearerAuth: []
    userContext: []
    familyContext: []

paths:
  /search:
    post:
      tags:
        - Search
      summary: 统一资源搜索
      operationId: searchResources
      description: |
        在当前家庭范围内搜索资源。请求至少需要提供一种搜索条件：
        `query`、任意 filter 对象，或 `category`。

        `recipe`、`mealplan` 搜索需要提供 `query` 或对应 filter。
        `event` 搜索在 `category` 包含 `event` 时必须提供 `event_filter`，
        其中 `event_filter.start_at` 必填；`query` 可选。
        `task` 和 `shopping` 可以只通过 `category` 查询。

        Task search 使用 due window 协议：
        - 当 `category` 包含 `task` 时，`tasks_filter` 必传。
        - `tasks_filter.due_to` 必传，表示本次查询 due window 的右边界。
        - `tasks_filter.due_from` 选填；不传时，服务端以 `due_to` 为右边界向左搜索，
          内部最多展开三年，防止 recurrence 无限展开。
        - `tasks_filter.is_completed` 必传。`overdue_tasks` 不再是 task search 的响应分块；
          过期未完成任务只要 due 落在查询窗口内，并且 `is_completed=false`，就返回在 `tasks` 中。
        - `tasks` 表示有 due 的 task instances；`no_due_tasks` 表示没有 `end_at` 且没有
          `end_on` 的任务，默认返回；当 `include_no_due_tasks=false` 时省略。

        Shopping search 支持 item 级分页。传入 `query` 时，shopping items 按向量距离排序
        （`distance ASC, id ASC`）；未传 `query` 时，按
        `is_checked ASC, created_at DESC, id ASC` 排序。

        Event search 与普通 DB 列表不同：recurring master 不直接作为最终展示项，
        服务端会先展开 occurrence，并 merge materialized occurrence / deleted exception，
        再过滤、排序和分页。传入 `query` 时，DB 只做向量候选召回，最终结果在展开与
        merge 后按 `distance ASC, effective_start ASC, id ASC` 排序；未传 `query` 时按
        `effective_start ASC, id ASC` 排序。`event.total` 表示展开、merge、过滤后的最终总数，
        最大为 2000；`event.limit` 最大为 30。
      parameters:
        - $ref: "../common/api.yml#/components/parameters/AuthorizationHeader"
        - $ref: "../common/api.yml#/components/parameters/FamilyIdHeader"
        - $ref: "../common/api.yml#/components/parameters/TimezoneHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SearchRequest"
            examples:
              shoppingOffset:
                summary: 使用 offset + limit 搜索购物项
                value:
                  category: ["shopping"]
                  query: "milk"
                  offset: 20
                  limit: 10
                  shopping_filter:
                    list_id: "00000000-0000-0000-0000-000000000001"
                    is_checked: false
              legacyPage:
                summary: 兼容已有客户端的 page 分页
                value:
                  category: ["shopping"]
                  limit: 10
                  page: 2
              taskDueWindow:
                summary: 按 due window 查询未完成任务
                value:
                  category: ["task"]
                  query: "chores"
                  page: 1
                  limit: 10
                  tasks_filter:
                    due_from: "2025-05-01T00:00:00-07:00"
                    due_to: "2025-06-07T23:59:59-07:00"
                    is_completed: false
                    include_no_due_tasks: true
              taskDueToOnly:
                summary: 未传 due_from 时从 due_to 向左查询
                value:
                  category: ["task"]
                  limit: 10
                  tasks_filter:
                    due_to: "2025-06-07T23:59:59-07:00"
                    is_completed: false
              eventWindow:
                summary: 按时间窗口查询 event，recurring 会在服务端展开后分页
                value:
                  category: ["event"]
                  query: "change"
                  limit: 3
                  offset: 0
                  event_filter:
                    start_at: "2026-05-19T00:00:00+08:00"
                    end_at: "2026-05-27T23:59:59+08:00"
                    ids: []
                    attendees: []
                    creator_role_ids: []
      responses:
        "200":
          description: 按资源类型分组返回搜索结果。
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "../common/api.yml#/components/schemas/ApiResponse"
                  - type: object
                    properties:
                      data:
                        $ref: "#/components/schemas/SearchResponse"
              examples:
                success:
                  value:
                    code: 0
                    message: null
                    data:
                      recipes:
                        items: []
                      mealplans:
                        list: []
                      shoppings:
                        items: []
                        total: 0
                        offset: 0
                        limit: 10
                      tasks:
                        items: []
                        total: 0
                        offset: 10
                        limit: 10
                      no_due_tasks:
                        items: []
                        total: 0
                        offset: 10
                        limit: 10
                      event:
                        items: []
                        total: 0
                        offset: 10
                        limit: 10
        default:
          $ref: "#/components/responses/ErrorEnvelope"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    userContext:
      type: apiKey
      in: header
      name: X-User-ID
    familyContext:
      type: apiKey
      in: header
      name: X-Family-ID

  schemas:
    SearchRequest:
      type: object
      properties:
        category:
          type: array
          items:
            type: string
            enum: [recipe, mealplan, shopping, event, task]
          description: 要搜索的资源类型。
        query:
          type: string
          nullable: true
          description: 搜索文本。传入时服务端会生成 embedding 并进行向量搜索。
        recipe_filter:
          type: object
          nullable: true
          additionalProperties: true
          description: Recipe 专用过滤条件。
        mealplan_filter:
          type: object
          nullable: true
          additionalProperties: true
          description: Mealplan 专用过滤条件。
        tasks_filter:
          $ref: "#/components/schemas/TaskSearchFilter"
        event_filter:
          $ref: "#/components/schemas/EventSearchFilter"
        shopping_filter:
          $ref: "#/components/schemas/ShoppingSearchFilter"
        page:
          type: integer
          minimum: 1
          default: 1
          description: 从 1 开始的页码，选填；保留用于兼容已有客户端，传入时优先于 `offset`。
        limit:
          type: integer
          minimum: 1
          maximum: 30
          default: 10
          description: 分页大小。为兼容旧客户端继续使用 `limit`，同时适用于 `page` 和 `offset`；handler 最大归一化为 30。
        offset:
          type: integer
          minimum: 0
          default: 0
          description: 从 0 开始的偏移量；shopping search 推荐与 `limit` 一起传。当传入 `page` 时会被忽略。

    EventSearchFilter:
      type: object
      nullable: true
      description: |
        Event 搜索过滤条件。当 `category` 包含 `event` 时必传。
        `start_at` 必填，是查询窗口起点，也是 recurring 展开的左边界。
        `end_at` 选填；不传时服务端按 `start_at + 3年` 作为查询窗口右边界。
        如果传入 `end_at`，必须满足 `end_at >= start_at` 且窗口跨度不超过 3 年。
      required:
        - start_at
      properties:
        start_at:
          type: string
          description: 查询窗口起点，支持 RFC3339 或 date-only；必填。
          example: "2026-05-19T00:00:00+08:00"
        end_at:
          type: string
          nullable: true
          description: 查询窗口终点，支持 RFC3339 或 date-only；选填，不传时最多查询到 `start_at + 3年`。
          example: "2026-05-27T23:59:59+08:00"
        ids:
          type: array
          items:
            type: string
          description: 按 event ID 或 `masterID_recurrenceID` 复合实例 ID 过滤。
        attendees:
          type: array
          items:
            type: string
            format: uuid
          description: 按参与人 family role ID 过滤。
        creator_role_ids:
          type: array
          items:
            type: string
            format: uuid
          description: 按创建者 family role ID 过滤。

    TaskSearchFilter:
      type: object
      nullable: true
      description: |
        Task search 过滤条件，使用 due window 语义。当 `category` 包含 `task` 时必传。
        `start_at`、`end_at`、`include_overdue_tasks` 不再属于 task search 新协议字段。
      required:
        - due_to
        - is_completed
      properties:
        due_from:
          type: string
          format: date-time
          nullable: true
          description: due window 的左边界，选填。不传时服务端以 `due_to` 为右边界向左查询，内部最多三年保护窗口。
        due_to:
          type: string
          format: date-time
          description: due window 的右边界，必填。due 落在 `[due_from, due_to]` 内的 task instances 会作为候选。
        is_completed:
          type: boolean
          description: 当前完成状态过滤条件，必填。传 `false` 表示查询未完成任务，其中也包括已经过期但仍未完成的任务。
        include_no_due_tasks:
          type: boolean
          nullable: true
          default: true
          description: 是否返回 `no_due_tasks` 响应分块。默认 true；传 false 时省略 `no_due_tasks`。
        task_type:
          type: string
          nullable: true
          description: 任务类型过滤条件，选填，例如 `routine` 或 `chore`。
        ids:
          type: array
          items:
            type: string
            format: uuid
          description: 限定只搜索指定 task ID。
        attendees:
          type: array
          items:
            type: string
            format: uuid
          description: 按 assignee family role ID 过滤。
        creator_role_ids:
          type: array
          items:
            type: string
            format: uuid
          description: 按 creator family role ID 过滤。

    ShoppingSearchFilter:
      type: object
      nullable: true
      properties:
        ids:
          type: array
          items:
            type: string
            format: uuid
          description: 限定只搜索指定 shopping item ID。
        is_checked:
          type: boolean
          nullable: true
          description: 按 shopping item 的 checked 状态过滤。
        list_id:
          type: string
          format: uuid
          nullable: true
          description: 限定只返回某个 shopping list 下的结果。

    SearchResponse:
      type: object
      properties:
        recipes:
          type: object
          additionalProperties: true
        mealplans:
          type: object
          additionalProperties: true
        shoppings:
          $ref: "#/components/schemas/ShoppingSearchResponse"
        tasks:
          $ref: "#/components/schemas/SearchVecResponse"
        no_due_tasks:
          allOf:
            - $ref: "#/components/schemas/SearchVecResponse"
          nullable: true
          description: "`no_due_tasks` 结果分块。task search 默认返回；当 `tasks_filter.include_no_due_tasks=false` 时省略。"
        event:
          $ref: "#/components/schemas/EventSearchResponse"

    ShoppingSearchResponse:
      type: object
      properties:
        items:
          type: array
          items:
            type: object
            additionalProperties: true
          description: 当前分页返回的 shopping items。
        total:
          type: integer
          format: int64
          description: 分页前命中的 shopping item 总数。传入 query 时，表示满足向量阈值和过滤条件的数量；未传 query 时，表示满足 family 和过滤条件的数量。
        offset:
          type: integer
          description: 当前请求实际使用的 0-based offset。
        limit:
          type: integer
          description: 本次 shopping search 响应实际使用的数量上限。

    EventSearchResponse:
      type: object
      description: |
        Event 搜索结果分块。`items` 是当前页 event detail 列表。
        recurring master 不直接作为最终展示项；服务端会展开为 occurrence，
        并在 merge materialized occurrence / deleted exception 后再排序和分页。
      properties:
        items:
          type: array
          items:
            type: object
            additionalProperties: true
          description: 当前页 event detail 列表；recurring occurrence 的 `id` 可能是复合实例 ID。
        total:
          type: integer
          format: int64
          maximum: 2000
          description: rrule 展开、merge、窗口过滤和排序后的最终总数，最大为 2000；不是当前页数量。
        offset:
          type: integer
          description: 当前返回页的 0-based offset，作用于最终 instance 集合。
        limit:
          type: integer
          maximum: 30
          description: 当前返回页的 page size，handler 最大归一化为 30。

    SearchVecResponse:
      type: object
      properties:
        items:
          type: array
          items:
            type: object
            additionalProperties: true
        total:
          type: integer
          format: int64
        offset:
          type: integer
        limit:
          type: integer

  responses:
    ErrorEnvelope:
      description: 错误响应 envelope。
      content:
        application/json:
          schema:
            $ref: "../common/api.yml#/components/schemas/ApiResponse"
