openapi: 3.0.3
info:
  title: Gateway New API
  version: "0.2.0"
  description: |
    Gateway New 控制平面 API。

    四个入口分组：
    - `/v1/public/*`         无鉴权（注册 / 登录 / 健康 / 支付 webhook）
    - `/v1/user/*`           需 user JWT（控制台 BFF）
    - `/v1/admin/*`          需 admin JWT + RBAC（管理员台 BFF）
    - `/v1/api/*`            需 API Key Bearer + 余额校验（**SDK 调这里**，按 token 计费）

    内部入口（不在 SDK 范围内）：
    - `/v1/internal/agent/*` Gateway agent 反向连入；private wire-format 见 PROTOCOL-AGENT-WS.md。

    本文件仅列对外稳定 API；clientapi 路由组（/v1/api/*）按 Pricing rules 自动按路由扣费，
    具体路径列在 §`tag: clientapi` 下，schema 用通用 `RemoteCmd*`。

servers:
  - url: https://api.example.com
    description: 生产环境（按部署填）
  - url: http://localhost:8080
    description: 本地开发

tags:
  - name: public
    description: 注册 / 登录 / 健康
  - name: user
    description: 用户控制台 BFF
  - name: admin
    description: 管理员台 BFF
  - name: clientapi
    description: 用户客户端 API（SDK 调用，按路由扣 token）

security:
  - bearerAuth: []

# ============================================================
# Endpoints
# ============================================================
paths:
  # -------- public --------
  /v1/public/health:
    get:
      tags: [public]
      summary: 健康检查
      security: []
      responses:
        "200": { $ref: "#/components/responses/Healthz" }

  /v1/public/version:
    get:
      tags: [public]
      summary: 版本信息
      security: []
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string, example: "0.2.0" }

  /v1/public/auth/register:
    post:
      tags: [public]
      summary: 注册新用户
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterReq" }
      responses:
        "200": { $ref: "#/components/responses/AuthSuccess" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /v1/public/auth/login:
    post:
      tags: [public]
      summary: 登录（返回 access + refresh token）
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginReq" }
      responses:
        "200": { $ref: "#/components/responses/AuthSuccess" }
        "401": { $ref: "#/components/responses/Error" }
        "423": { $ref: "#/components/responses/Error" }

  /v1/public/auth/refresh:
    post:
      tags: [public]
      summary: 用 refresh_token 换新 access_token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
      responses:
        "200": { $ref: "#/components/responses/AuthSuccess" }
        "401": { $ref: "#/components/responses/Error" }

  /v1/public/auth/logout:
    post:
      tags: [public]
      summary: 撤销 refresh_token
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /v1/public/webhooks/payment/{provider}:
    post:
      tags: [public]
      summary: 支付回调（dummy / fourthparty）
      security: []
      parameters:
        - in: path
          name: provider
          required: true
          schema: { type: string, enum: [dummy, fourthparty] }
      requestBody:
        required: true
        content:
          application/json: { schema: { type: object } }
          application/x-www-form-urlencoded: { schema: { type: object } }
      responses:
        "200":
          description: 支付平台要求的成功响应（fourthparty 是字符串 "SUCCESS"）
          content:
            text/plain:
              schema: { type: string }

  # -------- user --------
  /v1/user/me:
    get:
      tags: [user]
      summary: 当前用户资料
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }

    put:
      tags: [user]
      summary: 修改用户资料
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }

  /v1/user/hosts:
    get:
      tags: [user]
      summary: 列出本用户的所有主机
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/HostListItem" }
    post:
      tags: [user]
      summary: 创建新主机
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 64 }
                listen_port: { type: integer, minimum: 1, maximum: 65535 }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Host" }

  /v1/user/hosts/{id}:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [user]
      summary: 主机详情（含 ws_connected 实时连接态）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HostDetail" }
        "404": { $ref: "#/components/responses/Error" }
    put:
      tags: [user]
      summary: 修改主机配置
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                listen_port: { type: integer }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Host" }
    delete:
      tags: [user]
      summary: 软删主机（立即失效所有 install_token）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /v1/user/hosts/{id}/install-token:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [user]
      summary: 单独颁发新 install_token（不下载 ps1）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  install_token: { type: string }
                  expires_at: { type: integer, format: int64 }

  /v1/user/hosts/{id}/installer:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [user]
      summary: 流式下载 install.ps1（一次性消费 install_token）
      responses:
        "200":
          description: ok
          content:
            text/plain:
              schema: { type: string }

  /v1/user/hosts/{id}/rotate-secret:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [user]
      summary: 旋转 host secretKey + ed25519 keypair（旧 install 立即失效）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Host" }

  /v1/user/hosts/{id}/terminal/ticket:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [user]
      summary: 颁发终端 WebSocket 一次性 ticket
      description: |
        浏览器 WebSocket 不能传 Authorization header；
        此端点用 user JWT 鉴权，返一个 30s 内一次性消费的 ticket，
        浏览器将该 ticket 拼到 `terminal/ws?ticket=` 连接。
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  ticket:     { type: string, description: "32B URL-safe base64" }
                  expires_in: { type: integer, description: "秒，默认 30" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/user/hosts/{id}/l1-config:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    put:
      tags: [user]
      summary: 编辑该主机的 L1 配置（M7-01）
      description: |
        每台主机独立一组 `{bid, secretKey, 反向上报 URL}`。
        修改成功后：(1) 写入 hosts/host_secrets 表；(2) 通过 wshub 向在线 agent
        下发 SettingsUpdate control 触发软重启；(3) 入 webhook_deliveries_v2
        队列向 hosts.report_url 推送 host.l1_config_updated 事件。
        鉴权：user JWT，仅本人主机。不走 step-up。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/L1ConfigReq" }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/L1ConfigResp" }
        "400": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }

  /v1/user/hosts/{id}/webhook-deliveries:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [user]
      summary: 该主机最近 50 条 webhook 推送历史
      description: |
        用户业务系统排查 webhook 投递问题用。含 pending / retrying / success /
        dead_letter / skipped_no_url 状态、attempts 次数、http_status、created_at。
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookDeliveryRow" }

  /v1/user/api-keys:
    get:
      tags: [user]
      summary: 列出本用户的 API Key
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/APIKey" }
    post:
      tags: [user]
      summary: 创建新 API Key（**完整 secret 仅本次可见**）
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeyCreated" }

  /v1/user/api-keys/{id}:
    parameters: [{ $ref: "#/components/parameters/UUID" }]
    delete:
      tags: [user]
      summary: 撤销 API Key
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /v1/user/billing/balance:
    get:
      tags: [user]
      summary: 当前 token 余额
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens: { type: number, format: double }

  /v1/user/billing/events:
    get:
      tags: [user]
      summary: 流水（充值 + 扣费）
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 50 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagedBillingEvents" }

  /v1/user/billing/recharge:
    post:
      tags: [user]
      summary: 创建充值订单
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cny]
              properties:
                amount_cny: { type: number, format: double, minimum: 0.01 }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_no: { type: string }
                  pay_url:  { type: string }

  /v1/user/billing/recharges:
    get:
      tags: [user]
      summary: 充值订单列表
      parameters:
        - { in: query, name: status, schema: { type: string, enum: [pending, paid, failed, refunded] } }
        - { in: query, name: limit,  schema: { type: integer, default: 50 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagedRecharges" }

  # -------- admin --------
  /v1/admin/auth/login:
    post:
      tags: [admin]
      summary: 管理员登录
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string }
                password: { type: string }
      responses:
        "200": { $ref: "#/components/responses/AuthSuccess" }
        "401": { $ref: "#/components/responses/Error" }

  /v1/admin/dashboard/summary:
    get:
      tags: [admin]
      summary: 看板汇总（实时在线数、今日营收、今日调用）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardSummary" }

  /v1/admin/users:
    get:
      tags: [admin]
      summary: 用户列表
      parameters:
        - { in: query, name: q,      schema: { type: string } }
        - { in: query, name: status, schema: { type: string, enum: [active, suspended] } }
        - { in: query, name: limit,  schema: { type: integer, default: 50 } }
        - { in: query, name: offset, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagedUsers" }

  /v1/admin/users/{id}/suspend:
    parameters: [{ $ref: "#/components/parameters/UUID" }]
    post:
      tags: [admin]
      summary: 封禁用户（强制下线所有 session）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /v1/admin/users/{id}/balance/adjust:
    parameters: [{ $ref: "#/components/parameters/UUID" }]
    post:
      tags: [admin]
      summary: 手工调整 token 余额（正/负）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tokens, reason]
              properties:
                tokens: { type: number, format: double }
                reason: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean }, balance_after: { type: number } } }

  /v1/admin/pricing-rules:
    get:
      tags: [admin]
      summary: 计费规则列表
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/PricingRule" }

  /v1/admin/pricing-rules/{id}:
    parameters: [{ $ref: "#/components/parameters/UUID" }]
    put:
      tags: [admin]
      summary: 修改单价（立即生效）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token_amount: { type: number, format: double }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PricingRule" }

  /v1/admin/gateway-versions:
    get:
      tags: [admin]
      summary: Gateway 版本列表
      parameters:
        - { in: query, name: channel, schema: { type: string, enum: [stable, beta, canary] } }
        - { in: query, name: include_yanked, schema: { type: string, enum: ["0", "1"] } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagedGatewayVersions" }
    post:
      tags: [admin]
      summary: 发布新版本
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PublishVersionReq" }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GatewayVersion" }
        "409": { $ref: "#/components/responses/Error" }

  /v1/admin/gateway-versions/{version}/yank:
    parameters:
      - { in: path, name: version, required: true, schema: { type: string } }
    post:
      tags: [admin]
      summary: 软禁用某版本（已安装 host 不受影响，但新主机不再分发）
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /v1/admin/hosts/{id}/push-upgrade:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [admin]
      summary: 推送升级（host 在线 + version 未 yank 时通过 wshub 下发 SELF_UPDATE）
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version]
              properties:
                version: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:      { type: boolean }
                  version: { type: string }
                  agent:   { type: object, additionalProperties: true }
        "409": { $ref: "#/components/responses/Error" }
        "504": { $ref: "#/components/responses/Error" }

  /v1/admin/hosts/{id}/metrics:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [admin]
      summary: 主机最近 N 条 metrics
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 100, maximum: 1000 } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/HostMetricsRow" }

  /v1/admin/hosts/{id}/events:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [admin]
      summary: 主机上下线事件流水
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 100 } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/HostEvent" }

  /v1/admin/audit-logs:
    get:
      tags: [admin]
      summary: 审计日志（admin 所有写操作流水）
      parameters:
        - { in: query, name: admin_id,    schema: { type: string, format: uuid } }
        - { in: query, name: action,      schema: { type: string, description: "前缀匹配" } }
        - { in: query, name: target_type, schema: { type: string } }
        - { in: query, name: limit,       schema: { type: integer, default: 50 } }
        - { in: query, name: offset,      schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PagedAuditLogs" }

  # -------- clientapi（用户客户端 API；SDK 调这里） --------

  /v1/api/hosts:
    post:
      tags: [clientapi]
      summary: 创建主机（M7-06）
      description: |
        创建主机壳 + KEK Seal secret_key 入 host_secrets。可选同时颁发
        InstallCredentials（with_credentials=true），脚本化部署一次到位。
        免计费（账户管理动作）。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateHostReq" }
      responses:
        "201":
          description: created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateHostResp" }
        "400": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    delete:
      tags: [clientapi]
      summary: 软删主机（M7-06）
      description: |
        软删（deleted_at=now）+ 失效未消费的 install_token。已上线 agent 在下次
        token refresh 时被中枢拒。免计费。
      responses:
        "204":
          description: no content
        "404": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/install-token:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [clientapi]
      summary: 颁发安装凭据（M7-06）
      description: |
        为已有主机签发一份新的 InstallCredentials：
        - 旋转 ed25519 keypair（覆写 hosts.agent_pubkey）
        - 失效旧 install_token，生成新 token（5 分钟过期，sha256 入库）
        - 解 KEK 返 secret_key 明文（仅此响应里出现）

        适用：批量重装、首次创建后单独取凭据、换台机器装。
        免计费但敏感：调用方拿到 secret_key + install_token 可立即部署 agent。
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InstallCredentials" }
        "404": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/installer:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [clientapi]
      summary: 下载安装包（ps1 / cmd / exe）（M7-06）
      description: |
        把 install credentials 渲染成可分发的 .ps1 / .cmd / .exe 安装包，
        浏览器/curl 直接下到本地后转给最终主机。副作用与 install-token 一致
        （旋转 keypair + 失效旧 token + 5 分钟新 token）。
        最常用：批量为新用户的多台 Windows 机器生成各自专属 install.ps1。
      parameters:
        - in: query
          name: format
          schema:
            type: string
            enum: [ps1, cmd, exe]
          description: 安装包格式；缺省返 .exe 单文件双击即装
      responses:
        "200":
          description: 安装包二进制（attachment）
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
            text/plain:
              schema: { type: string, description: ".ps1 脚本（format=ps1 时）" }
        "404": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/rotate-secret:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [clientapi]
      summary: 重置 secretKey 并颁发新凭据（M7-06）
      description: |
        除了 install-token 的全部副作用，还会重新生成 32 字符 hex secret_key
        并 Rotate host_secrets。**任何持旧 secretKey 加签的 L1 报文立即失效**。
        破坏性，调用前确认能立即重装；user-web 上此操作受 step-up 保护，
        Client API 依赖 API Key 自身的鉴权。
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InstallCredentials" }
        "404": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/dir/mk:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [clientapi]
      summary: 创建目录
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RemoteDirReq" }
      responses:
        "200": { $ref: "#/components/responses/RemoteCmdResp" }
        "402": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/process/shell:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [clientapi]
      summary: 同步执行 shell（PowerShell / cmd）+ 捕获 stdout/stderr/exit_code
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RemoteShellReq" }
      responses:
        "200": { $ref: "#/components/responses/RemoteCmdResp" }
        "402": { $ref: "#/components/responses/Error" }
        "409": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/process/exec:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [clientapi]
      summary: 异步执行程序（fire-and-forget；返 pid）
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RemoteExecReq" }
      responses:
        "200": { $ref: "#/components/responses/RemoteCmdResp" }

  /v1/api/hosts/{id}/orchestrate/start:
    parameters:
      - { $ref: "#/components/parameters/HostID" }
      - in: query
        name: base_path
        required: true
        schema: { type: string }
        description: |
          引擎包解压后服务端根目录（如 `C:\Mir2\MirServer`）。若 `resolve_game_dir=true`，
          agent 会在 base_path 下三层探测含 controller_exe 的子目录。
    post:
      tags: [clientapi]
      summary: 启动 mir2 引擎（GameCenter + 子进程编排）
      description: |
        agent 端流程：
        1. （可选）resolve_game_dir：在 base_path 下找含 controller_exe 的子目录
        2. write_game_center_config：merge 写 Config.ini（GBK 编码、CRLF 行尾）
        3. start_game_center：spawn 控制器（CREATE_NEW_PROCESS_GROUP | CREATE_NEW_CONSOLE）
        4. （可选）spawn_sub_processes：按 manifest 顺序 spawn DBServer / LoginSrv /
           LogServer / M2Server / RunGate × N / SelGate / LoginGate
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrchestrateStartReq" }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrchestrateStartResp" }
        "402": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "504": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/orchestrate/shutdown:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    post:
      tags: [clientapi]
      summary: 关停 mir2 引擎（三段式：先 hard kill 控制器 → GS_QUIT → WM_CLOSE → 硬 kill）
      description: |
        关停顺序：
        1. 先 hard kill 控制器（GameCenter.exe / 自定义 controller_exe），避免
           其内部 TimerCheckRun 重拉子进程
        2. 按反向启动顺序对每个子进程发 mir2 通用 `GS_QUIT`（WM_COPYDATA dwData =
           2000 << 16），等 perProc graceful timeout
        3. 仍存活 → WM_CLOSE，再不行 TerminateProcess 兜底
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OrchestrateShutdownReq" }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrchestrateShutdownResp" }
        "402": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "504": { $ref: "#/components/responses/Error" }

  /v1/api/hosts/{id}/system/info:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [clientapi]
      summary: 系统信息（OS / CPU / 磁盘）
      responses:
        "200": { $ref: "#/components/responses/RemoteCmdResp" }

  /v1/api/hosts/{id}/system/metrics:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    get:
      tags: [clientapi]
      summary: 当前性能采样（CPU/Mem/Disk%）
      responses:
        "200": { $ref: "#/components/responses/RemoteCmdResp" }

  /v1/api/hosts/{id}/l1-config:
    parameters: [{ $ref: "#/components/parameters/HostID" }]
    put:
      tags: [clientapi]
      summary: 用户业务系统改某主机 L1 配置（M7-01）
      description: |
        与 /v1/user/hosts/:id/l1-config 同套 commit 逻辑；区别仅是入口（API Key
        + 1 token 计费 `control.update_l1`）。修改成功后中枢:
        1. 写库（hosts + host_secrets）；
        2. 在线 agent 异步下发 SettingsUpdate control 触发软重启；
        3. 入 webhook 队列向主机 report_url 推送 host.l1_config_updated 事件。
        签名设计：HMAC key 用旧 secret_key，报文 data 内带新 secret_key。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/L1ConfigReq" }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/L1ConfigResp" }
        "402":
          description: 余额不足
        "404": { $ref: "#/components/responses/Error" }

  # 其余 clientapi 路径（dir/rm, file/{mk,rm,read,write,append}, zip/extract,
  # net/download, process/{kill,list}, service/ctl, registry/rw,
  # scheduled-task/ctl 等）schema 同 RemoteCmdReq/Resp，
  # 完整列表见 internal/handler/clientapi/clientapi.go::PricingRules()。

# ============================================================
# Components
# ============================================================
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        - 控制台路由（user/admin）传 user/admin JWT
        - clientapi 路由（/v1/api/*）传 API Key Bearer：`ak_<key_id>.<secret>`

  parameters:
    UUID:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    HostID:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }

  responses:
    Healthz:
      description: ok
      content:
        application/json:
          schema:
            type: object
            properties:
              status: { type: string, example: "ok" }

    Error:
      description: 标准错误响应
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

    AuthSuccess:
      description: 鉴权成功（颁发 access + refresh token）
      content:
        application/json:
          schema: { $ref: "#/components/schemas/AuthResp" }

    RemoteCmdResp:
      description: 远程命令执行结果
      content:
        application/json:
          schema:
            type: object
            properties:
              code: { type: integer, description: "0 = ok；其他值见 controls 实装" }
              msg:  { type: string }
              data: { type: object, additionalProperties: true }

  schemas:
    Error:
      type: object
      properties:
        error:   { type: string, description: "机器可读错误码" }
        message: { type: string, description: "可显示文案，可选" }

    # ============ 0.3.0 Capabilities & Session State (RFC §2/§5/§6) ============
    # 详见 docs/api/CAPABILITIES.md
    SessionState:
      type: string
      description: |
        Agent 当前所处的 session 状态。详见 docs/api/CAPABILITIES.md §2。
      enum:
        - cold_boot
        - autologon_active
        - locked
        - logged_off
        - rdp_seized_console
        - rdp_disconnected
        - multi_user
        - session_lost

    ActiveSession:
      type: object
      description: "Windows session 简略信息（来自 WTSEnumerateSessions）"
      properties:
        id:    { type: integer, description: "Windows session ID" }
        user:  { type: string, nullable: true, description: "登录用户名；null=无用户（services / listen / logon screen）" }
        state: { type: string, description: "WTS_CONNECTSTATE_CLASS 字符串：Active / Disconnected / Connected / Listen / Idle / 等" }

    Capabilities:
      type: object
      description: |
        Agent 当前可用能力矩阵。详见 docs/api/CAPABILITIES.md §3。
        集成方应在调用相应 controls 前 precheck 对应字段。
      properties:
        shell:   { type: boolean, description: "EXEC_SHELL（基线能力，目前永远 true）" }
        spawn:   { type: boolean, description: "EXEC_PROGRAM（需要 user session）" }
        pty:     { type: boolean, description: "SHELL_SESSION_OPEN（需要 user session + worker 在线）" }
        metrics: { type: boolean, description: "METRICS_SNAPSHOT（目前永远 true）" }

    HeartbeatPayload:
      type: object
      description: |
        Agent 每 30s 发到中枢的心跳 envelope payload（0.3.0+）。
        0.2.x 老 agent 不带 current_state / active_sessions / capabilities 字段;
        中枢侧应 graceful 处理缺失（按 legacy_unknown 路径）。
      properties:
        v:       { type: integer, enum: [1] }
        type:    { type: string, enum: ["ping"] }
        msg_id:  { type: string, format: uuid }
        ts:      { type: integer, description: "Epoch ms" }
        current_state:   { $ref: "#/components/schemas/SessionState" }
        active_sessions:
          type: array
          items: { $ref: "#/components/schemas/ActiveSession" }
        capabilities:    { $ref: "#/components/schemas/Capabilities" }

    AgentErrorCode:
      type: string
      description: |
        RFC §6 标准化错误码。当 cmd_resp 的 code=7（业务错），envelope 同时带
        error_code 字段（机器可读 enum），集成方按它做 fallback 路由策略。
        详见 docs/api/CAPABILITIES.md §5。
      enum:
        - PRECONDITION_NOT_READY      # cold_boot / agent 自身没初始化完，退避重试
        - NO_USER_SESSION             # S4/S8 无 user session，fallback EXEC_SHELL
        - WORKER_NOT_READY            # transient gap，退避重试 5s
        - MULTI_SESSION_AMBIGUOUS     # 多 user session，需指定 target_session_id
        - SESSION_DISCONNECTED        # S6，GUI 不可见但 PTY 可继续
        - CONPTY_INIT_FAILED          # 非 session 状态原因，检查 OS 版本

    CreateHostReq:
      type: object
      required: [name]
      properties:
        name:             { type: string, minLength: 1, maxLength: 64 }
        listen_port:      { type: integer, minimum: 0, maximum: 65535, description: "agent L1 端口；0=默认 23456" }
        with_credentials: { type: boolean, description: "true=同时返 InstallCredentials；false/缺省=仅返 host 元数据" }

    CreateHostResp:
      type: object
      required: [host]
      properties:
        host:        { type: object, description: "Host 元数据（id/bid/listen_port/...）" }
        credentials: { $ref: "#/components/schemas/InstallCredentials", description: "with_credentials=true 时存在" }

    InstallCredentials:
      type: object
      description: 一次性安装凭据；secret_key + install_token + agent_privkey_seed 都是敏感数据，必须 HTTPS 传输
      properties:
        host_id:                  { type: string, format: uuid }
        bid:                      { type: integer, format: int64 }
        secret_key:               { type: string, description: "32 字符 hex" }
        url:                      { type: string, description: "host-level report_url；未配则空" }
        install_token:            { type: string, description: "URL-safe base64 约 43 字符；5 分钟过期，一次性消费" }
        install_token_expires_at: { type: string, format: date-time }
        agent_privkey_seed:       { type: string, description: "base64 raw 32B ed25519 seed" }
        agent_pubkey:             { type: string, description: "ed25519:base64-of-pubkey" }
        api_endpoint:             { type: string, example: "https://api.gateway.wechas.com" }
        ws_url:                   { type: string, example: "wss://api.gateway.wechas.com/v1/internal/agent/ws" }
        listen_port:              { type: integer }
        exe_url:                  { type: string }
        exe_sha256:               { type: string, description: "hex64；可选" }

    L1ConfigReq:
      type: object
      required: [bid, secret_key]
      properties:
        bid:        { type: integer, format: int64, minimum: 1, description: "L1 商户 ID" }
        secret_key: { type: string, minLength: 32, maxLength: 32, description: "32 字符 hex" }
        url:        { type: string, description: "反向上报 URL（也作为 webhook 推送目标），可空" }

    L1ConfigResp:
      type: object
      properties:
        bid:            { type: integer, format: int64 }
        has_secret_key: { type: boolean }
        url:            { type: string }
        agent_online:   { type: boolean, description: "是否已下发 SettingsUpdate 让 agent 软重启" }
        delivery_id:    { type: string, format: uuid, description: "入 webhook_deliveries_v2 的 row id" }

    WebhookDeliveryRow:
      type: object
      properties:
        id:           { type: string, format: uuid }
        event_type:   { type: string, example: "host.l1_config_updated" }
        status:
          type: string
          enum: [pending, retrying, success, dead_letter, skipped_no_url]
        attempts:     { type: integer }
        target_url:   { type: string }
        http_status:  { type: integer, nullable: true }
        created_at:   { type: string, format: date-time }
        delivered_at: { type: string, format: date-time, nullable: true }

    L1ConfigUpdatedEvent:
      type: object
      description: |
        Webhook 推送给主机 report_url 的报文。HMAC key 是**旧** secret_key
        （报文里 data.secret_key 是新值）。签名头 `X-Gateway-Signature: t=<ts>,v1=<hex>`。
      properties:
        event:        { type: string, enum: ["host.l1_config_updated"] }
        delivery_id:  { type: string, format: uuid }
        user_id:      { type: string, format: uuid }
        host_id:      { type: string, format: uuid }
        source:
          type: string
          enum: [web, agent, client_api]
        occurred_at:  { type: string, format: date-time }
        data:
          type: object
          properties:
            bid:        { type: integer, format: int64 }
            secret_key: { type: string, description: "新 secret_key（用户系统应用此值替换本地存储）" }
            url:        { type: string }

    AuthResp:
      type: object
      properties:
        access_token:  { type: string }
        refresh_token: { type: string, description: "仅 user 路由返；admin 不返" }
        access_ttl_s:  { type: integer, format: int64 }
        user:
          $ref: "#/components/schemas/User"

    RegisterReq:
      type: object
      required: [email, password]
      properties:
        email:    { type: string, format: email }
        password: { type: string, minLength: 6 }

    LoginReq:
      type: object
      required: [email, password]
      properties:
        email:    { type: string, format: email }
        password: { type: string }

    User:
      type: object
      properties:
        id:           { type: string, format: uuid }
        email:        { type: string, format: email }
        display_name: { type: string }
        status:       { type: string, enum: [active, suspended] }
        created_at:   { type: string, format: date-time }

    Host:
      type: object
      properties:
        id:                { type: string, format: uuid }
        user_id:           { type: string, format: uuid }
        name:              { type: string }
        bid:               { type: integer, format: int64 }
        listen_port:       { type: integer }
        public_ip:         { type: string, nullable: true }
        os:                { type: string, nullable: true }
        installed_version: { type: string, nullable: true }
        status:
          type: string
          enum: [pending, online, stale, offline, banned]
          description: |
            主机生命周期状态（落库快照，由 scanner 周期更新；与 wshub.IsOnline
            的毫秒级实时态分离）：
            - **pending**：主机已 create 但 agent 还没第一次 ws 握手成功。
              触发 `online` 的条件是 agent 上线后第一次 heartbeat（任何 ws 入帧）。
            - **online**：agent 正常 heartbeat。`last_seen_at` 在 90s 内。
            - **stale**：`last_seen_at` 超过 90s 但不到 5min（scanner 周期 30s 标）。
              此时通常是网络抖动或 agent 短暂卡死；继续允许 controls 下发，
              超时由 dispatch 层 `cmd_timeout` 返。
            - **offline**：`last_seen_at` 超过 5min。dispatch 端会按 `host_offline`
              返 503；agent 重新上线后 `HostHeartbeat` 把状态从 offline/stale 拨回 online。
            - **banned**：admin 手动禁用，dispatch 一律拒绝。
            注：本字段是中央调度面看的状态；agent 端本身**不感知** status，因此
            所有 controls 在 pending 状态下都会被透传到 agent 执行（只要 ws 在线）。
        last_seen_at:      { type: string, format: date-time, nullable: true }
        last_online_at:    { type: string, format: date-time, nullable: true }
        created_at:        { type: string, format: date-time }

    HostListItem:
      allOf:
        - $ref: "#/components/schemas/Host"
        - type: object
          properties:
            ws_connected:
              type: boolean
              description: |
                wshub 实时态——主机当前是否有反向 WS 连接（毫秒级）。判断"现在能否
                下发控制指令"以这个为准；与 `status`（生命周期态、90s+ 落库快照）
                可能短暂不一致（例如 `status=pending` + `ws_connected=true` 表示
                已连上但还没首次心跳）。
            online:
              type: boolean
              description: |
                **deprecated**：与 `ws_connected` 同义。新调用方请用 `ws_connected`；
                此字段保留向后兼容，未来 minor 版本可能移除。

    HostDetail:
      type: object
      properties:
        host: { $ref: "#/components/schemas/Host" }
        ws_connected:
          type: boolean
          description: "wshub 实时态（毫秒级）。参考 HostListItem.ws_connected。"
        online:
          type: boolean
          description: "**deprecated**：与 ws_connected 同义。"

    APIKey:
      type: object
      properties:
        id:           { type: string, format: uuid }
        name:         { type: string }
        prefix:       { type: string, description: "ak_<key_id> 部分（safe to display）" }
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at:   { type: string, format: date-time }

    APIKeyCreated:
      allOf:
        - $ref: "#/components/schemas/APIKey"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: "ak_<key_id>.<secret> 完整字符串。**仅本次返回**——保存好。"

    BillingEvent:
      type: object
      properties:
        id:             { type: integer, format: int64 }
        type:           { type: string, enum: [recharge, charge, refund, adjust] }
        token_delta:    { type: number, format: double }
        balance_after:  { type: number, format: double }
        detail:         { type: object, additionalProperties: true }
        created_at:     { type: string, format: date-time }

    PagedBillingEvents:
      type: object
      properties:
        items:  { type: array, items: { $ref: "#/components/schemas/BillingEvent" } }
        total:  { type: integer, format: int64 }
        limit:  { type: integer }
        offset: { type: integer }

    Recharge:
      type: object
      properties:
        id:         { type: string, format: uuid }
        order_no:   { type: string }
        amount_cny: { type: number, format: double }
        status:     { type: string, enum: [pending, paid, failed, refunded] }
        provider:   { type: string }
        created_at: { type: string, format: date-time }
        paid_at:    { type: string, format: date-time, nullable: true }

    PagedRecharges:
      type: object
      properties:
        items:  { type: array, items: { $ref: "#/components/schemas/Recharge" } }
        total:  { type: integer, format: int64 }
        limit:  { type: integer }
        offset: { type: integer }

    DashboardSummary:
      type: object
      properties:
        online_hosts:         { type: integer, format: int64, description: "wshub 实时在线数" }
        total_hosts:          { type: integer, format: int64 }
        hosts_by_status:      { type: object, additionalProperties: { type: integer, format: int64 } }
        total_users:          { type: integer, format: int64 }
        today_revenue_cny:    { type: number, format: double }
        today_calls:          { type: integer, format: int64 }

    PagedUsers:
      type: object
      properties:
        items:  { type: array, items: { $ref: "#/components/schemas/User" } }
        total:  { type: integer, format: int64 }
        limit:  { type: integer }
        offset: { type: integer }

    PricingRule:
      type: object
      properties:
        id:             { type: string, format: uuid }
        code:           { type: string, description: "如 control.exec_shell.per_call" }
        description:    { type: string, nullable: true }
        unit:           { type: string }
        token_amount:   { type: number, format: double }
        effective_from: { type: string, format: date-time }
        effective_to:   { type: string, format: date-time, nullable: true }

    PublishVersionReq:
      type: object
      required: [version, exe_url, exe_sha256, exe_size_bytes]
      properties:
        version:                { type: string, description: "semver" }
        channel:                { type: string, enum: [stable, beta, canary], default: stable }
        exe_url:                { type: string }
        exe_sha256:             { type: string, description: "64 hex" }
        exe_size_bytes:         { type: integer, format: int64 }
        min_supported_protocol: { type: integer, default: 1 }
        changelog:              { type: string }

    GatewayVersion:
      type: object
      properties:
        id:                     { type: string, format: uuid }
        version:                { type: string }
        channel:                { type: string }
        exe_url:                { type: string }
        exe_sha256:             { type: string }
        exe_size_bytes:         { type: integer, format: int64 }
        min_supported_protocol: { type: integer }
        changelog:              { type: string, nullable: true }
        released_at:            { type: string, format: date-time }
        yanked_at:              { type: string, format: date-time, nullable: true }

    PagedGatewayVersions:
      type: object
      properties:
        items:  { type: array, items: { $ref: "#/components/schemas/GatewayVersion" } }
        total:  { type: integer, format: int64 }
        limit:  { type: integer }
        offset: { type: integer }

    HostMetricsRow:
      type: object
      properties:
        cpu_pct:    { type: number, format: double }
        mem_pct:    { type: number, format: double }
        disk_pct:   { type: number, format: double }
        net_rx_bps: { type: integer, format: int64 }
        net_tx_bps: { type: integer, format: int64 }
        detail:
          type: object
          properties:
            tcp_conns: { type: integer }
            uptime_s:  { type: integer, format: int64 }
        created_at: { type: string, format: date-time }

    HostEvent:
      type: object
      properties:
        id:         { type: integer, format: int64 }
        host_id:    { type: string, format: uuid }
        event:
          type: string
          enum: [online, offline, stale, configured, upgraded, restarted, token_refreshed]
        detail:     { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }

    AuditLog:
      type: object
      properties:
        id:             { type: integer, format: int64 }
        admin_id:       { type: string, format: uuid, nullable: true }
        admin_username: { type: string, nullable: true }
        action:         { type: string, description: "如 user.suspend / pricing.update" }
        target_type:    { type: string, nullable: true }
        target_id:      { type: string, nullable: true }
        ip:             { type: string, nullable: true }
        user_agent:     { type: string, nullable: true }
        created_at:     { type: string, format: date-time }

    PagedAuditLogs:
      type: object
      properties:
        items:  { type: array, items: { $ref: "#/components/schemas/AuditLog" } }
        total:  { type: integer, format: int64 }
        limit:  { type: integer }
        offset: { type: integer }

    # -------- clientapi 远程命令通用 schema --------
    RemoteDirReq:
      type: object
      required: [path]
      properties:
        path: { type: string, description: "目标主机绝对路径" }

    RemoteShellReq:
      type: object
      required: [path, args]
      properties:
        path:       { type: string, description: "可执行路径，如 powershell.exe / cmd.exe" }
        args:       { type: string, description: "完整参数串" }
        timeout_s:  { type: integer, default: 30 }

    RemoteExecReq:
      type: object
      required: [path]
      properties:
        path: { type: string }
        args: { type: string }

    OrchestrateStartReq:
      type: object
      description: |
        mir2 引擎启动入参。所有字段可选；空值表示保留 Config.ini 现有值。
        强制写入：[GameConf]GetAutoStartServer=1 + 显式列在 enabled 里的 [Section]GetStart。
      properties:
        controller_exe:
          type: string
          default: "GameCenter.exe"
          description: |
            控制器 exe 名。996PC/LFM 用 `GameCenter.exe`；GOM 系传
            `GameOfMir引擎控制器.exe`。
        resolve_game_dir:
          type: boolean
          default: false
          description: true 时 agent 在 query base_path 下三层探测含 controller_exe 的子目录
        spawn_subprocesses:
          type: boolean
          default: true
          description: false 时仅 spawn 控制器，不直接拉 DBServer 等子进程（依赖 GameCenter TimerCheckRun）
        spawn_delay_ms:
          type: integer
          default: 3000
          description: 两个子进程之间的间隔（让前置先 listen 端口）
        auto_start_delay_s:
          type: integer
          description: 覆盖 [GameConf]AutoStartDelayTime；0 时保留原值（默认 60s）
        stop_timeout_ms:
          type: integer
          description: 覆盖 [GameConf]dwStopTimeOut；0 时保留原值（默认 10000ms）
        game_name:    { type: string, description: "覆盖 [GameConf]GameName" }
        ext_ip_addr:  { type: string, description: "覆盖 [GameConf]ExtIPaddr" }
        hero_db_name: { type: string, description: "覆盖 [GameConf]HeroDBName" }
        enabled:
          type: object
          additionalProperties: { type: boolean }
          description: |
            每个 section 的 GetStart。例：`{"DBServer":true,"M2Server":true,
            "LoginServer":true,"LogServer":true,"RunGate":true,"SelGate":true,
            "LoginGate":true}`
        run_gate_count:
          type: integer
          minimum: 1
          maximum: 16
          description: 覆盖 [RunGate]Count；多实例 RunGate 用
        ports:
          type: object
          additionalProperties: { type: integer }
          description: |
            端口/任意 INI 字段覆盖；key 形如 `Section.Key`。例：
            `{"DBServer.GatePort": 5101}`

    OrchestrateStartResp:
      type: object
      properties:
        code: { type: integer, description: "0 = ok；7 = 业务失败" }
        msg:  { type: string }
        duration_ms: { type: integer, format: int64 }
        data:
          type: object
          properties:
            game_dir:       { type: string }
            config_path:    { type: string, description: "已写入的 Config.ini 绝对路径" }
            controller_pid: { type: integer, format: int32 }
            spawn:
              type: object
              description: 仅当 spawn_subprocesses=true 时存在
              properties:
                total_duration_ms: { type: integer, format: int64 }
                steps:
                  type: array
                  items: { $ref: "#/components/schemas/OrchestrateSpawnStep" }

    OrchestrateSpawnStep:
      type: object
      properties:
        section:     { type: string, description: "如 DBServer / M2Server" }
        exe_path:    { type: string }
        pid:         { type: integer, format: int32, description: "首个实例的 pid（多实例时）" }
        count:       { type: integer, description: "已启动的实例数（RunGate 可 > 1）" }
        started:     { type: boolean }
        skipped:     { type: boolean, description: "true 表示 [Section]GetStart != 1" }
        error:       { type: string }
        duration_ms: { type: integer, format: int64 }

    OrchestrateShutdownReq:
      type: object
      properties:
        controller_exe:
          type: string
          default: "GameCenter.exe"
          description: 控制器 exe 名，与 start 时保持一致
        timeout_override_s:
          type: integer
          description: |
            > 0 时覆盖默认 per-process graceful 超时（DBServer/M2Server=60s,
            Log/LoginServer=30s, *Gate=20s）

    OrchestrateShutdownResp:
      type: object
      properties:
        code: { type: integer }
        msg:  { type: string }
        duration_ms: { type: integer, format: int64 }
        data:
          type: object
          properties:
            total_duration_ms: { type: integer, format: int64 }
            steps:
              type: array
              items: { $ref: "#/components/schemas/OrchestrateShutdownStep" }

    OrchestrateShutdownStep:
      type: object
      properties:
        name:        { type: string, description: "exe 名，如 GameCenter.exe / DBServer.exe" }
        found:       { type: integer, description: "找到的进程数" }
        graceful_ok: { type: boolean, description: "true = GS_QUIT 阶段就退出" }
        wm_close:    { type: boolean, description: "true = 走到 WM_CLOSE 阶段才退" }
        hard_killed: { type: boolean, description: "true = 落到 TerminateProcess 兜底" }
        duration_ms: { type: integer, format: int64 }
