openapi: 3.0.3
info:
  title: GoWu 开放 API
  description: |
    格物（GoWu）开放平台 API。本规格覆盖**开放平台页（https://www.gowu.net/open）上列出的接口**：
    商品检索、商品与类目管理、自有供应链入驻与发布、MiniShop 托管商城。

    ## 快速开始
    1. `POST /open/access-clients/apply` 自助申请沙箱 appKey（免鉴权，同一 IP 每天 3 次）。
    2. 所有请求带 `x-app-key`。沙箱 key 是只读的；写类接口（开放管理、自有供应链）需联系平台
       在「访问管理」按路径前缀授权。
    3. 权限是**白名单**语义：`api_permissions` 里没有的路径一律 403，不存在「默认放行」。

    ## HMAC 签名（配置 app_secret 后强制）
    为 appKey 配置 `app_secret` 后，该 appKey 的**所有**请求都要签名，包括原先裸 key 能通的那些。

    - 头：`x-timestamp`（Unix 秒，±300s 窗口）、`x-nonce`（随机串，防重放）、`x-signature`
    - 签名串（`\n` 连接五段）：
      `{timestamp}\n{METHOD}\n{pathname}\n{sha256hex(rawBody)}\n{x-open-user}`
    - 第三段是**纯路径**，不含域名与 query
    - 第四段：请求体原文的 sha256。**GET / 无 body 时取空串的 sha256**，即
      `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
    - 第五段：终端用户标识，取 `x-open-user` 头；本规格内的接口都不需要该头，**固定用空串**
    - `x-signature = HMAC-SHA256(app_secret, 签名串)` 的 hex

    ⚠️ 不要把 `app_secret` 打进小程序 / App 的客户端产物——包是可以被解出来的。
    要签名就放在你自己的服务端，客户端走自家后端中转。

    ## 限速
    只读 300 次/分/appKey。429 时看 `Retry-After`。自助申请另有同 IP 每天 3 次的限制。

    ## 错误
    错误响应统一是 `{ "code": "...", "message": "..." }`，HTTP 状态码同时表意。
    常见 `code`：`UNAUTHORIZED`（key 无效/停用）、`FORBIDDEN`（路径不在权限白名单）、
    `BAD_REQUEST`、`NOT_FOUND`、`TOO_MANY_REQUESTS`、`INTERNAL`。
  version: "2.0.0"
servers:
  - url: https://api.gowu-ai.com
    description: 生产
  - url: http://localhost:3003
    description: 本地开发（api-hub）
tags:
  - name: sandbox
    description: 接入与账户
  - name: products
    description: 商品检索
  - name: manage
    description: 开放管理 — 商品与类目
  - name: self-supply
    description: 自有供应链（商家入驻、商品草稿与发布）
  - name: minishop
    description: MiniShop 托管商城
security:
  - AppKey: []

paths:
  /open/health:
    get:
      tags: [sandbox]
      summary: 健康检查
      security: []
      responses:
        "200":
          description: 服务正常
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  db: { type: string, example: ok }
        "500": { description: 依赖不可用 }

  /open/access-clients/apply:
    post:
      tags: [sandbox]
      summary: 自助申请沙箱 appKey
      description: |
        免鉴权。当场发放**只读**沙箱 key，可直接跑通商品检索。
        申请人信息全部选填，但填了便于平台在开通更高权限时联系你。
        同一 IP 每天最多 3 次。
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                company: { type: string, maxLength: 120, description: 公司 / 团队名称 }
                contact: { type: string, maxLength: 80, description: 联系人 }
                email: { type: string, maxLength: 200, description: 邮箱或手机号 }
                scenario: { type: string, maxLength: 500, description: 接入场景简述 }
      responses:
        "200":
          description: 已发放
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  appKey: { type: string, description: 妥善保存，仅此一次返回 }
                  permissions:
                    type: array
                    items: { type: string }
                    description: 该 key 的路径白名单
                  note: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429":
          description: 同一 IP 当天申请次数已用尽
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /open/access-clients/me:
    get:
      tags: [sandbox]
      summary: 当前 appKey 的信息与调用量
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  appKey: { type: string }
                  remark: { type: string }
                  createdAt: { type: string }
                  apiCallCount: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /open/products:
    get:
      tags: [products]
      summary: 商品列表
      description: |
        按关键词、类目检索平台商品。返回标题、价格、图片等，`id` 可继续查详情和下单。

        查询参数：`keyword` / `category` / `categoryId` / `page` / `pageSize`。
        **未配置 `app_secret` 的 appKey 只能取第一页**（`page` 大于 1 返回 403 `PAGING_FORBIDDEN`）。
        配了签名密钥后可翻页；未带 `x-open-user` 时，关键词搜索、按类目仍会要求登录。
      parameters:
        - { name: keyword, in: query, schema: { type: string }, description: 搜索关键词 }
        - { name: category, in: query, schema: { type: string }, description: 类目名（精确匹配） }
        - { name: categoryId, in: query, schema: { type: string }, description: 类目 id }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: pageSize, in: query, schema: { type: integer, default: 20 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  page: { type: integer }
                  pageSize: { type: integer }
                  total: { type: integer }
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/BriefProduct" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /open/manage/products:
    get:
      tags: [manage]
      summary: 商品列表
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: categoryL1, in: query, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: pageSize, in: query, schema: { type: integer, default: 20 } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/ManageProduct" }
                  total: { type: integer }
                  page: { type: integer }
                  pageSize: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [manage]
      summary: 创建商品
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [title, imageUrl]
                - { $ref: "#/components/schemas/ManageProduct" }
      responses:
        "200":
          description: 已创建
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }

  /open/manage/products/{productId}:
    parameters:
      - { name: productId, in: path, required: true, schema: { type: string } }
    get:
      tags: [manage]
      summary: 商品详情（管理视角）
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ManageProduct" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [manage]
      summary: 部分字段更新
      description: 只传要改的字段，未传的保持原值。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ManageProduct" }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [manage]
      summary: 删除商品
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/manage/categories:
    get:
      tags: [manage]
      summary: 类目树
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Category" }
    post:
      tags: [manage]
      summary: 新建类目
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                parentId: { type: string, nullable: true }
                sortOrder: { type: integer }
      responses:
        "200":
          description: 已创建
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
    delete:
      tags: [manage]
      summary: 批量删除类目
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  items: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /open/manage/categories/{categoryId}:
    patch:
      tags: [manage]
      summary: 更新类目
      parameters:
        - { name: categoryId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                parentId: { type: string, nullable: true }
                sortOrder: { type: integer }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/manage/self-supply/merchants:
    get:
      tags: [self-supply]
      summary: 入驻商家列表
      parameters:
        - { name: q, in: query, schema: { type: string }, description: 关键词过滤店铺名、域名、联系人 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Merchant" }
    post:
      tags: [self-supply]
      summary: 新建店铺
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shopName]
              properties:
                shopName: { type: string, description: 店铺名称（展示用） }
                shopSlug:
                  type: string
                  pattern: "^[a-z0-9-]{2,40}$"
                  description: 店铺域名（URL 段，全局唯一）。不传则按 shopName 自动生成；冲突返回 400
                contactName: { type: string }
                contactPhone: { type: string }
      responses:
        "200":
          description: 已创建
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string, example: ssm_xxx }
                  shopSlug: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }

  /open/manage/self-supply/merchants/{merchantId}:
    parameters:
      - { name: merchantId, in: path, required: true, schema: { type: string } }
    patch:
      tags: [self-supply]
      summary: 修改商家入驻信息
      description: 未传字段保持原值。改 `shopSlug` 时须全局唯一。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                shopName: { type: string }
                shopSlug: { type: string, pattern: "^[a-z0-9-]{2,40}$" }
                contactName: { type: string }
                contactPhone: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [self-supply]
      summary: 删除店铺
      description: |
        ⚠️ 危险操作：连带删除该商家下**全部商品**（含已发布）以及购物车里引用这些商品的行。
        必须带确认串，否则 400。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, enum: [DELETE_SHOP] }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/manage/self-supply/merchants/{merchantId}/one-click-open:
    post:
      tags: [self-supply]
      summary: 一键开店
      description: 把商家状态置为 `active`。需先完成入驻信息。商品要发布到 MiniShop，商家必须先开店。
      parameters:
        - { name: merchantId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: 已开店
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  merchantId: { type: string }
                  status: { type: string, example: active }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/manage/self-supply/product-categories:
    get:
      tags: [self-supply]
      summary: 可选类目列表
      description: |
        新建/修改自有供应链商品时，`category` 必须传这里的 `path` 或 `id`，
        不能随便填一个系统里没有的字符串。末级名称在树中唯一时也可只传该名称。
      parameters:
        - { name: q, in: query, schema: { type: string }, description: 按路径、名称或 id 模糊筛选 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        path: { type: string, example: "食品 / 零食" }
                        name: { type: string }
                        level: { type: integer }

  /open/manage/self-supply/products:
    get:
      tags: [self-supply]
      summary: 商品草稿列表
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: merchantId, in: query, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/SelfSupplyProduct" }
    post:
      tags: [self-supply]
      summary: 新建商品草稿
      description: 新建出来状态是 `draft`，需商家已开店后再一键发布。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [merchantId, title, price]
              properties:
                merchantId: { type: string }
                title: { type: string }
                imageUrl: { type: string }
                price: { type: number, minimum: 0, description: 优惠后价 }
                originalPrice:
                  type: number
                  nullable: true
                  minimum: 0
                  description: 划线原价，须 ≥ price；不传或 null 表示无原价
                category:
                  type: string
                  description: 须为 product-categories 里的 path、id 或唯一末级名
      responses:
        "200":
          description: 已创建
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string, example: ssp_xxx }
        "400": { $ref: "#/components/responses/BadRequest" }

  /open/manage/self-supply/products/{productId}:
    parameters:
      - { name: productId, in: path, required: true, schema: { type: string } }
    patch:
      tags: [self-supply]
      summary: 修改商品草稿
      description: "字段均可选，未传保持原值。传 `category: null` 清空类目，`originalPrice: null` 清除原价。"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                imageUrl: { type: string }
                price: { type: number, minimum: 0 }
                originalPrice: { type: number, nullable: true, minimum: 0 }
                category: { type: string, nullable: true }
                merchantId: { type: string }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [self-supply]
      summary: 删除商品
      description: 删草稿或已发布记录，并移除购物车里该 `productId`。必须带确认串。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, enum: [DELETE_PRODUCT] }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/manage/self-supply/products/{productId}/one-click-publish:
    post:
      tags: [self-supply]
      summary: 一键发布到 MiniShop
      description: 要求该商品所属商家已 `active`（已一键开店），否则 400。
      parameters:
        - { name: productId, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: 已发布 }
        "400":
          description: 商家未开店等
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/minishops/catalog:
    get:
      tags: [minishop]
      summary: 商城目录
      parameters:
        - { name: keyword, in: query, schema: { type: string } }
        - { name: category, in: query, schema: { type: string } }
        - { name: shopId, in: query, schema: { type: string }, description: 商家内部 ID }
        - name: shopSlug
          in: query
          schema: { type: string }
          description: 店铺域名，与 shopId 二选一。域名不存在时返回空列表
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shops:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                  categories:
                    type: array
                    items: { type: string }
                  products:
                    type: array
                    items: { $ref: "#/components/schemas/MinishopProduct" }

  /open/minishops/products/{productId}:
    get:
      tags: [minishop]
      summary: 单品详情
      parameters:
        - { name: productId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MinishopProduct" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/minishops/cart:
    get:
      tags: [minishop]
      summary: 当前购物车
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CartItem" }
    delete:
      tags: [minishop]
      summary: 清空购物车
      responses:
        "200": { $ref: "#/components/responses/Ok" }

  /open/minishops/cart/items:
    post:
      tags: [minishop]
      summary: 加入购物车
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [productId, qty]
              properties:
                productId: { type: string, example: ssp_xxx }
                qty: { type: integer, minimum: 1 }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /open/minishops/cart/items/{productId}:
    parameters:
      - { name: productId, in: path, required: true, schema: { type: string } }
    patch:
      tags: [minishop]
      summary: 修改行数量
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [qty]
              properties:
                qty: { type: integer, minimum: 0, description: 设为该数量；0 等同删除该行 }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [minishop]
      summary: 删除购物车一行
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        "404": { $ref: "#/components/responses/NotFound" }

  /open/minishops/pay/prepare:
    post:
      tags: [minishop]
      summary: 下单预支付
      description: |
        返回一组表单字段，由前端 **POST 跳转**到 `formAction` 唤起支付网关。
        ⚠️ 同步回跳**不能作为到账凭证**——到账以异步通知为准，查询请轮询 `/open/minishops/pay/status`。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, items]
              properties:
                type: { type: string, enum: [wxpay, alipay] }
                items:
                  type: array
                  items:
                    type: object
                    required: [productId, qty]
                    properties:
                      productId: { type: string }
                      qty: { type: integer, minimum: 1 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  formAction: { type: string, description: 表单提交地址（支付网关） }
                  fields:
                    type: object
                    additionalProperties: { type: string }
                  outTradeNo: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }

  /open/minishops/pay/status:
    get:
      tags: [minishop]
      summary: 查询支付状态
      parameters:
        - { name: out_trade_no, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  outTradeNo: { type: string }
                  status: { type: string, enum: [pending, paid] }
                  money: { type: string, example: "9.90" }
                  name: { type: string }
                  paidAt: { type: string, nullable: true }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    AppKey:
      type: apiKey
      in: header
      name: x-app-key
    Signature:
      type: apiKey
      in: header
      name: x-signature
      description: HMAC-SHA256 签名（另带 x-timestamp / x-nonce）。配置 app_secret 后所有请求强制
  responses:
    Ok:
      description: 成功
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean, example: true }
    BadRequest:
      description: 参数不合法
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: appKey 无效或已停用
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: 该路径不在此 appKey 的权限白名单内
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: 资源不存在
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        code: { type: string, example: FORBIDDEN }
        message: { type: string }
    BriefProduct:
      type: object
      description: 平台商品。`marketPrice` / `brand` 没有时该字段不下发
      properties:
        id: { type: string, example: ep_self_679356918317 }
        title: { type: string }
        price: { type: number, description: 对外售价（元） }
        marketPrice: { type: number, description: 原价 / 划线价（元） }
        imageUrl: { type: string }
        imageUrls:
          type: array
          items: { type: string }
        brand: { type: string }
    ManageProduct:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        imageUrl: { type: string }
        subtitle: { type: string }
        brand: { type: string }
        category: { type: string }
        categoryL1: { type: string }
        categoryL2: { type: string }
        categoryL3: { type: string }
        mainImageUrl: { type: string }
        imageUrls:
          type: array
          items: { type: string }
        status: { type: string }
        url: { type: string }
        spuCode: { type: string }
        unit: { type: string }
        sourceChannels:
          type: array
          items: { type: string }
        tags:
          type: array
          items: { type: string }
        attributes:
          type: object
          additionalProperties: true
    Category:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        parentId: { type: string, nullable: true }
        sortOrder: { type: integer }
    Merchant:
      type: object
      properties:
        id: { type: string, example: ssm_xxx }
        name: { type: string, description: 展示名，与 shopName 对齐 }
        shopName: { type: string }
        shopSlug:
          type: string
          description: 店铺域名。前台可用 /minishops/<shopSlug> 直达该店
        contactName: { type: string }
        contactPhone: { type: string }
        status: { type: string, enum: [pending, active] }
    SelfSupplyProduct:
      type: object
      properties:
        id: { type: string, example: ssp_xxx }
        merchantId: { type: string }
        title: { type: string }
        imageUrl: { type: string }
        price: { type: number, description: 优惠后价 }
        originalPrice: { type: number, nullable: true, description: 划线原价，有则 ≥ price }
        category: { type: string, nullable: true }
        status: { type: string, enum: [draft, published] }
        publishedAt: { type: string, nullable: true }
    MinishopProduct:
      type: object
      properties:
        id: { type: string }
        merchantId: { type: string }
        merchantName: { type: string }
        title: { type: string }
        imageUrl: { type: string }
        price: { type: number, description: 优惠后价 }
        originalPrice: { type: number, nullable: true }
        category: { type: string, nullable: true }
    CartItem:
      type: object
      properties:
        productId: { type: string }
        qty: { type: integer }
        product: { $ref: "#/components/schemas/MinishopProduct" }
