# Helix 对外 API(External API)

> 关联/下游系统用一把「按 scope 授权的 API 客户端凭证」读取(授权范围内可写)Helix 业务数据的编程接口。
>
> **读者:** 把外部系统对接到 Helix 的工程师。**English:** [external-api.md](external-api.md)。
>
> 这是一个**独立功能**,与「身份分发 / 目录 API」(分发员工+组织数据,有自己的凭证与后台,见
> [identity-pull-api.md](../integrations/identity-pull-api.md))**完全分开**:不同产品、不同凭证、不同菜单。
>
> A 部分=怎么用(认证/scope/约定);B 部分=有哪些(资源目录,随时间增长)。新增 scope/端点在同一 PR 内更新本文件
> (见 `CLAUDE.md` 的 doc-update 规则)。

**参考**:产品内参考页在 **`/api-docs`**(`https://helix.pingcap.net/api-docs`)——端点/参数/返回结构/示例,站内渲染。**任何已登录的 Helix 同事都能打开**(纯文档、无密钥),直接把这个链接发给同事即可;管理员也可从 对外 API 控制台的「API 文档」按钮进入。机读规范在 `GET /api/v1/open/openapi.json`(凭证门控,可导入 Postman / 生成客户端)。本文件是叙述性指南。

**快速开始**:拿到 key 先验证连通:

```bash
curl -H "Authorization: Bearer helix_api_…" https://helix.pingcap.net/api/v1/open/me
# → { "data": { "client": {...}, "scopes": [...] }, ... }
```

---

## A. 怎么用

### A.1 概念

每个对接在 Helix 里登记为一个 **API 客户端(API client)**:与任何员工账号无关的非人服务主体。它有名称 + 稳定
**slug**、一个在职 **owner**(问责人)、一组 **scope**(被授予的能力)、一把或多把 **令牌**(`helix_api_…`)。

API 客户端由全局管理员在 **管理后台 → 对外 API** 维护。**没有自助发放**:管理员按最小授权勾选 scope,把令牌(只显示一次)交给你。

### A.2 Base URL 与版本

```
https://helix.pingcap.net/api/v1/open/...
```

版本在路径里(`/api/v1`)。新增字段不算破坏;破坏性契约变更走新版本前缀。

### A.3 认证

每个请求带 API 客户端令牌:

```
Authorization: Bearer helix_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- 令牌由管理员签发/轮换/吊销。轮换有重叠窗口(每客户端 ≤3 个有效令牌):先发新、切换、再吊旧,不停服。
- 对外 API **只**接受 `helix_api_…`。个人令牌(`helix_pat_…`)、身份消费方令牌(`helix_svc_…`)、JWT 一律 `401`——各凭证平面互相隔离。
- 令牌失效/过期、客户端被停用 → 统一 `401`(不区分原因)。

### A.4 授权(scope)

请求只有在客户端持有端点要求的全部 scope 时才放行;否则 `403` 并写明缺哪个 scope(且被审计)。

| Scope | 授予 | 说明 |
|-------|------|------|
| `cloud.subscriptions:read` | 读云订阅清单。 | 名称/外部 ID/Owner/环境/状态/预算。 |
| `cloud.subscriptions:write` | PATCH 云订阅元数据。 | 附加项,需 `cloud.subscriptions:read`;仅元数据,不动云资源。 |
| `software.catalog:read` | 读软件目录(产品清单)。 | 驱动飞书外部选项端点(B.2)。仅名称/分类/状态,无坐席/成本/owner。 |
| `software.permissions:read` | 读软件权限目录。 | 驱动联动的飞书权限下拉(B.3)。仅权限键+名称。 |
| `hr.absences:read` | 按指定人员查询请假在岗状态(B.4)。 | 仅按邮箱查询(须点名),不提供列举。日期区间+粗分类+审批状态——不含事由/具体假别。 |
| `cloud.billing:read` | 读取可见范围内订阅的云花费(B.6)。 | 恒为业务折前(list)口径,不含实付净额;账号维度求和≠云厂商发票。 |
| `hr.holidays:read` | 按国家+日期窗口读取法定假期日历(B.5)。 | 非个人参考数据,不点名任何人。含调休上班日。未维护的「国家-年份」返回 `no_data`,绝不返回空假期列表。 |
| `people.github:read` | 查询人员 ↔ GitHub 用户名对应关系(B.7)——按邮箱正查、按 handle 反查、或按部门列人。 | 本表中**唯一能列人**的 scope,因此受每客户端的**人员范围**上限约束(见 B.7)。只返回身份+handle+部门,不含联系方式。 |

#### 云可见范围——不只是「哪类数据」,还有「哪些账号」

scope 决定你能读**哪一类**云数据;能读**哪些云账号**是另一项按客户端配置的设置,由 Helix 管理员维护:

- **不限制**(默认,也是 2026-08-15 之前所有客户端的状态)——全部云账号。
- **限制范围**——「所选云厂商下的全部订阅」+「另外点名的订阅」的并集。范围之外的账号对你的凭据而言等同不存在:不出现在列表里,直接查询或 PATCH 返回 `404`(不是 `403`)。

`GET /api/v1/open/me` 会回显你当前的设置,所以「我看到的账号比预期少」不必去问管理员:

```json
{ "cloud_visibility": { "restricted": true, "providers": ["aws"], "subscription_count": 3 } }
```

如果存下来的白名单变得无法解析,这里会明说(`"misconfigured": true`),同时云端点
会解析成**一个订阅都没有** —— 方向是安全的,但需要管理员重新保存一次白名单。所以
看到空结果时,先来这里确认一下,别直接当成「公司没有云账号」。

缺少某个账号时,请让 Helix 管理员把它加进你客户端的云可见范围——改 scope 没有用。

### A.5 约定

**响应信封**——所有端点返回 Helix 标准信封:

```json
{ "success": true, "data": ..., "meta": { "trace_id": "…" }, "errors": [] }
```

出错时 `success: false`、`errors: [{ "code": "...", "message": "..." }]`。`meta.trace_id` 关联审计/日志。

**分页**——列表用 offset 分页:`page`(1 起)+ `page_size`(≤200);`meta` 带 `page`/`page_size`/`total`。账单端点(B.6)的 `page_size` 上限放宽到 **1000**:一年 × 几百个订阅的日粒度行,按 200 分页要 500+ 次请求,而限流是 300 次/分钟——这些行本身很小且已聚合。

**错误码**——`400/422` 入参错;`401` 认证;`403` 缺 scope;`404` 不存在;`409` 冲突(如写已注销资源);`429` 限流(带 `Retry-After`)。

**限流**——每客户端固定窗口(默认 300 次/分钟,可按客户端调)。超限 → `429` + `Retry-After`(秒)。持续重试死令牌会被防爆破限流短路。

**幂等**——`PATCH` 幂等:重复发送同一 body 收敛到同一状态,无变化不写重复审计行。

**审计**——客户端动作归属到客户端(`actor_type=service` + slug + IP);机器调用不臆造人类 actor。

---

## B. 有哪些

### B.1 云订阅(Cloud Subscriptions)

云订阅清单——每个可计费云单元一行(AWS 账号、Azure 订阅、GCP 项目、阿里云账号……)。

```
GET   /api/v1/open/cloud/subscriptions          cloud.subscriptions:read
GET   /api/v1/open/cloud/subscriptions/{key}    cloud.subscriptions:read
PATCH /api/v1/open/cloud/subscriptions/{key}    cloud.subscriptions:write
```

`{key}` 为 Helix 订阅 id(UUID)**或**云原生外部 ID(如 AWS 账号号)。外部 ID 跨租户重复时用 Helix id。

**订阅对象字段:**

| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string(UUID) | Helix 订阅 id(稳定主键)。 |
| `external_id` | string | 云原生 ID。 |
| `provider` | string | `aws`/`gcp`/`azure`/`aliyun`/… |
| `integration_name` | string | 所属云租户/集成。 |
| `scope_type` | string | `aws_account`/`gcp_project`/… |
| `name` | string \| null | 展示名。 |
| `environment` | string | `dev`/`staging`/`prod`。 |
| `status` | string | 生命周期:`active`/`suspended` = 在用;`closed` = 已下线(注销)。 |
| `owner` | object \| null | 见下方 owner 对象;无 owner 为 null。 |
| `budget_usd_monthly` | number \| null | 月度预算(USD)。拿它与自己的花费数字对比前,先读 `budget_measure`。 |
| `budget_measure` | string | `budget_usd_monthly` 的计价口径:`"net"`(云上实际开票额,默认值,除特别说明外所有订阅都是这个)或 `"list"`(折前牌价,即扣除公司级合同折扣之前)。2026-07-29 新增;老记录上的 `null` 按 `"net"` 读。 |
| `account_email` | string \| null | **生效**账户联系邮箱——有 owner 覆盖值就用覆盖值,否则用云厂商上报的值(AWS Organizations 账号 `Email`)。**用这个字段即可**。云端未上报邮箱时为 `null`(当前非 AWS 厂商)。2026-08-04 新增。 |
| `cloud_account_email` | string \| null | 云厂商最近一次上报的原始邮箱,每次同步刷新。与 `account_email_override` 对比即可区分「同步值」与「人工改过的值」。 |
| `account_email_override` | string \| null | Helix 侧覆盖值;未设置为 `null`(此时 `account_email` 回落到 `cloud_account_email`)。可通过 PATCH 写。 |
| `updated_at` | string(ISO-8601) | 最后修改时间。 |

**owner 对象**(订阅有 owner 时):

| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | owner 展示名。 |
| `email` | string | owner 工作邮箱(运营联系用,不脱敏)。 |
| `department` | string \| null | 仅主部门**叶子名**(如 `RD Infra`)。owner 无主组织归属时为 `null`。 |
| `department_full_path` | string \| null | 完整部门链,斜杠拼接,已剥离租户根(`PingCAP`)——如 `Engineering Group / TiDB / RD Infra`。owner 无归属 / 只有租户根时为 `null`。2026-08-06 新增。 |
| `department_path_segments` | string[] | 同一部门链的预切分数组——`["Engineering Group", "TiDB", "RD Infra"]`,省得自己 split。无路径时为 `[]`。2026-08-06 新增。 |

> **要完整部门路径?** 就在 `owner` 上,**无需** Directory API
> (`/api/v1/open/directory/...`)、也无需 `directory.*` scope。Directory
> 是另一个产品、另一把 `helix_svc_…` 凭证;只有当你需要组织树本身
> (兄弟/父节点、节点 id)而不只是某个 owner 的部门链时才用它。

**字段名对照**(少数字段用的是 Helix 内部名,对应集成方常用的中性概念):

| 你可能预期 | Helix 字段 | 含义 |
|-----------|-----------|------|
| `cloud_account_tenant` | `integration_name` | 订阅所属的云租户 / 集成实例。 |
| `subscription_type` | `scope_type` | 计费单元的类型(`aws_account` / `gcp_project` / `azure_subscription` / …)。 |

**列表 — `GET /subscriptions`**——查询参数(均可选、可组合):

| 参数 | 说明 |
|------|------|
| `provider` | `aws`/`gcp`/`azure`/`aliyun`/… |
| `environment` | `dev`/`staging`/`prod` |
| `status` | `active`/`suspended`/`closed` |
| `owner_email` | 仅返回该工作邮箱负责的订阅(大小写不敏感)。 |
| `q` | 按名称或外部 ID 关键字匹配。 |
| `page` | 1 起,默认 1。 |
| `page_size` | 1–200,默认 50。 |

```bash
curl -s "https://helix.pingcap.net/api/v1/open/cloud/subscriptions?environment=prod" \
  -H "Authorization: Bearer helix_api_…"
```

**更新 — `PATCH /subscriptions/{key}`**(需 `cloud.subscriptions:write`)

Body——字段全可选,至少传一个:

| 字段 | 类型 | 规则 |
|------|------|------|
| `owner_email` | string | 须解析到**在职(非离职)**员工(工作邮箱,大小写不敏感)。 |
| `environment` | string | `dev`/`staging`/`prod`。 |
| `display_name` | string | 设展示名;`""` 清空。 |
| `notes` | string | 备注;`""` 清空。 |
| `budget_usd_monthly` | number | ≥ 0。见下方口径规则——在已记为 `list` 的订阅上单独发这个字段会被拒。 |
| `budget_measure` | string | `"net"` 或 `"list"`——你这次发的 `budget_usd_monthly` 是哪个口径的数。 |
| `account_email_override` | string | 覆盖账户联系邮箱。合法邮箱=设置;`""`=清除覆盖,`account_email` 回落到云上值。云厂商拥有的 `cloud_account_email` 本身不可写。非法邮箱→`422`。 |

不传 / `null` = 不改。本 API 不能把 `owner`/`budget` 置空(管理员后台操作)。错误:`409`(已注销 或 目标 owner 离职);`422`(邮箱无匹配 或 body 为空)。

**预算口径规则(2026-07-29)。** 预算数字脱离口径就没有意义,所以在
`budget_measure` 已记为 `"list"` 的订阅上写 `budget_usd_monthly` 而**不同时**
给出 `budget_measure`,会被 `422 CLOUD_BUDGET_MEASURE_REQUIRED` 拒绝。Helix
不猜:猜「沿用原口径」会把你本想填成发票额的数字重新虚高一遍(真实的假告警
事故就是这么发生的),猜 `"net"` 又会打断一次合法的 list 口径加预算。只有调用
方知道自己填的是哪个数。记为 `"net"` 的订阅(默认,即绝大多数)不受影响,照旧
单发 `budget_usd_monthly` 即可。

改动口径会重算该订阅上全部「按预算百分比」的告警阈值(`threshold_usd ≡ pct ×
budget`)并重打标记,所以口径与其阈值不可能不一致。`cloud.subscriptions:write`
**可以**改口径——对外 API 调用方是 IT 签发的服务主体,不是自助的 scope 归属人;
Helix 控制台里的人类归属人可以**陈述**当前口径但不能**移动**它(那里会返回
`403 CLOUD_BUDGET_MEASURE_FORBIDDEN`)。若你从本 API 收到该 403,说明令牌的
scope 不是 `cloud.subscriptions:write`。

写为**纯元数据**:只改 Helix 侧记录,不动云资源,因此不受云上 prod 写锁影响。

### B.2 飞书外部选项 —— 软件目录

一个**飞书审批专用**端点,给审批表单下拉字段的「关联外部选项」数据源
提供 Helix 软件目录 —— 例如按审批涉及的软件做审批分流 / 逻辑筛选条件。

```
POST /api/v1/open/feishu/software-options    software.catalog:read
```

该端点**不遵循**上面的标准约定(§A.5),因为调用方是飞书,不是通用集成方:

- **鉴权走 token-in-body,不是 bearer header。** 在飞书表单设计器里配置
  *URL*、*token*、以及(可选)*key*。每次拉取选项时飞书会把配置的 token
  放进 JSON body `POST` 到 URL。把该 token 设为一枚 `helix_api_…` 令牌
  (正常签发,持有 `software.catalog:read`)—— Helix 经与 bearer 面同一
  凭据面(轮换 / 撤销 / scope / 审计)校验。
- **响应用飞书信封,不是 Helix 信封。** 成功返回
  `{ "code": 0, "data": { "result": { … } } }`(V1 明文)。token 缺失/失效
  → `401`,缺 scope → `403`(配置错在建号期即暴露;飞书把任何非 200 视为
  拉取失败)。
- **未启用响应加密。** 飞书 *key* 字段留空 —— 负载是公开产品名清单,
  token + TLS 已足够鉴权与保护。

**请求**(飞书发出):

```json
{
  "token": "helix_api_…",
  "linkage_params": { "query": "zo", "page_token": "", "locale": "zh_cn" }
}
```

`linkage_params` 可选:`query` 对软件名称/版本模糊过滤,`page_token` 为
上一页返回的 `nextPageToken`,`locale` 为请求方语言。仅返回
`status='active'` 的目录行。

**响应**(option `id` = 软件名称;显示文案经 `@i18n@` 键在 `i18nResources`
中按各语言解析):

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "result": {
      "options": [
        { "id": "Zoom", "value": "@i18n@sw_<uuid>", "isDefault": false }
      ],
      "i18nResources": [
        { "locale": "zh_cn", "isDefault": true, "texts": { "@i18n@sw_<uuid>": "Zoom" } },
        { "locale": "en_us", "isDefault": false, "texts": { "@i18n@sw_<uuid>": "Zoom" } }
      ],
      "hasMore": false,
      "nextPageToken": ""
    }
  }
}
```

**配置步骤:** 在 *Admin → 对外 API* 建一个仅带 `software.catalog:read`
的 API client,签发一枚 token;再到飞书表单字段的外部选项配置里填入 URL
`https://helix.pingcap.net/api/v1/open/feishu/software-options` 与该
token,*key* 留空。

### B.3 飞书外部选项 —— 软件权限(联动)

B.2 的**联动**搭档:一个与软件下拉**联动**的权限下拉。审批人选中软件后,
飞书把选中值传入本端点的 `linkage_params`,返回该软件下可申请的权限。

```
POST /api/v1/open/feishu/permission-options    software.permissions:read
```

契约与 B.2 相同(token-in-body、飞书信封、不加密)。区别在**依赖参数**:

- 在飞书表单设计器里,给权限字段的外部选项配置加一个**依赖参数**(依赖参数),
  把键 `software_name` 映射到软件字段(B.2)的值,飞书就会随 `linkage_params`
  一起发来。
- 端点按该值(即软件选项的 `id`,也就是**名称**)解析出软件,返回其在用权限;
  未选软件时返回空选项列表。
- 若你把依赖参数键命名成别的,端点也接受 `software` / `software_id`,再不行
  就取唯一的那个额外参数 —— 但推荐用明确无歧义的 `software_name`。

**请求**(选中软件后飞书发出):

```json
{
  "token": "helix_api_…",
  "linkage_params": { "software_name": "Zoom", "query": "", "page_token": "" }
}
```

**响应** —— option `id` = 权限**键**(在该软件内稳定;与软件字段组合即可
唯一定位权限用于后续授权),显示文案 = 权限名称:

```json
{
  "code": 0,
  "data": {
    "result": {
      "options": [
        { "id": "host",   "value": "@i18n@opt_0", "isDefault": false },
        { "id": "cohost", "value": "@i18n@opt_1", "isDefault": false }
      ],
      "i18nResources": [
        { "locale": "zh_cn", "isDefault": true,
          "texts": { "@i18n@opt_0": "Host", "@i18n@opt_1": "Co-host" } }
      ],
      "hasMore": false,
      "nextPageToken": ""
    }
  }
}
```

**配置步骤:** 给同一个 API client 再加 `software.permissions:read`(B.2 的
`software.catalog:read` 之外),把权限字段的外部选项 URL 设为
`https://helix.pingcap.net/api/v1/open/feishu/permission-options`、用同一
token,并加上 `software_name` 依赖参数。

### B.4 请假在岗状态查询

按指定人员回答「某人在某段时间内是否在休假」——最初场景是 on-call 排班系统在
排班前确认 on-caller 是否休假。请假数据每天从 SuccessFactors EC Time-Off 镜像。

```
POST /api/v1/open/hr/absences/lookup    hr.absences:read
```

这是**按人查询,不是列举**:必须用工作邮箱点名——没有「列出所有人请假」的接口。
请假数据敏感、本接口又没有按客户端的人群过滤,所以「只能查你已经知道的人」本身就是
访问边界。

**请求 body:**

| 字段 | 类型 | 规则 |
|------|------|------|
| `emails` | string[] | 1–200 个工作邮箱(大小写不敏感、去重)。 |
| `from` | string(date) | 窗口起始,`YYYY-MM-DD`,含当天。 |
| `to` | string(date) | 窗口结束,含当天。须 ≥ `from`;窗口 ≤ 366 天。 |

**响应**——按(去重后的)输入邮箱逐个返回,顺序与输入一致:

| 字段 | 类型 | 说明 |
|------|------|------|
| `email` | string | 查询的邮箱(转小写)。 |
| `coverage` | string | `covered`——Helix 有该人的请假数据(有 SF 请假账户);`no_data`——人存在但 Helix **没有其请假数据源**(如不由 SF EC Time-Off 管理的海外员工,应视为**未知**,**不是**「在岗」);`unknown_person`——邮箱解析不到在职人员。 |
| `unavailable_spans` | array | 与窗口相交的请假区间(见下)。不在休假(或 `coverage != covered`)时为空。 |

**区间对象**(`unavailable_spans[]`):

| 字段 | 类型 | 说明 |
|------|------|------|
| `start` / `end` | string(date) | 请假区间(含端点)。 |
| `days` | number \| null | 天数(半天为 `0.5`);SF 未报数量时为 `null`。 |
| `category` | string | 粗分类——`leave`。**具体**假别(年假/病假/产育假)刻意从不暴露。 |
| `status` | string | 审批状态:`APPROVED` / `PENDING` / `PENDING_CANCELLATION`,或源系统未提供时为 `unknown`。已驳回/已取消的申请**不返回**。 |

> **哪些区间算「不在岗」?** 已批准、待审批、待取消(取消申请已提但尚未生效——假仍
> 成立)都会返回,各带 `status` 供你筛。状态缺失的区间返回 `unknown` 而非丢弃,
> 所以不会漏报缺勤。是否把 `PENDING` / `unknown` 当作阻塞,由你自己决定。

**`meta`** 带 `as_of`(数据源最后同步时间——每日 cron 刷新,今早新提的假可能要到下次
同步才出现,用它判断当天决策是否可信),以及回显的 `from` / `to` / `window_days`。

**错误:** token 缺 `hr.absences:read` → `403`;窗口非法(`to` < `from`、> 366 天)
或 `emails` 为空/超量 → `422`。

```bash
curl -s -X POST \
  "https://helix.pingcap.net/api/v1/open/hr/absences/lookup" \
  -H "Authorization: Bearer helix_api_…" \
  -H "Content-Type: application/json" \
  -d '{"emails":["zhangsan@pingcap.com"],"from":"2026-08-10","to":"2026-08-17"}'
```

---

### B.5 法定假期日历

列出某个国家/地区办公室放假的日期。消费方与 B.4 同类:on-call 排班不能在法定
假日呼人,任务调度需要跳过非工作日。HR 在 Helix HR 控制台按「国家/地区 + 自然
年」维护日历。

```
GET /api/v1/open/hr/holidays              hr.holidays:read
GET /api/v1/open/hr/holidays/countries    hr.holidays:read
```

与 B.4 不同,这个接口**是列举**:数据不点名任何人,且本身已对全体员工公示,
所以没有人群边界需要约束。

**`GET /holidays` 查询参数:**

| 参数 | 类型 | 规则 |
|------|------|------|
| `country` | string | 逗号分隔的 ISO-3166 alpha-2 / alpha-3 代码,或中英文国名(`CN`、`JPN`、`japan`、`中国` 都能解析)。单次最多 60 个。不传则返回**所有已维护的国家**。 |
| `from` | string (date) | 窗口起始(含)。默认今天(UTC)。 |
| `to` | string (date) | 窗口结束(含)。默认 `from` + 365 天。窗口 ≤ 1098 天。 |
| `year` | integer | 整年简写。**与 `from`/`to` 互斥** —— 同时传是 `422`。 |

**响应** —— 每个请求的国家一个对象:

| 字段 | 类型 | 说明 |
|------|------|------|
| `country` | string | ISO-3166 alpha-2 代码(无法解析时原样回显你的输入)。 |
| `country_name` | string \| null | 英文展示名;该国未维护时为 `null`。 |
| `coverage` | string | `covered` —— 窗口覆盖的年份中至少有一年 HR 已维护;`no_data` —— 是真实国家,但你问的年份**没有人填过日历**;`unknown_country` —— 该值根本没解析成国家代码。 |
| `years_covered` | integer[] | 与窗口相交的已维护年份。 |
| `holidays` | array | 日期条目(见下)。`coverage != covered` 时恒为 `[]`。 |

**日期条目**(`holidays[]`):

| 字段 | 类型 | 说明 |
|------|------|------|
| `date` | string (date) | 日期。 |
| `name` | string | 英文假期名。 |
| `name_local` | string \| null | HR 维护了本地名时给出(如中国的 `劳动节`)。 |
| `day_type` | string | `public_holiday` —— 放假日;`makeup_workday` —— **调休上班日**:为对冲长假而被官方指定为工作日的周末(中国)。 |
| `observed_group` | string | 同一个假期的所有天**以及**它的调休上班日共享的稳定 key,用于把多天假期还原成一段。 |

> **先读 `coverage`,再读 `holidays`。** `no_data` **不是**「该国没有假期」,而是
> 「Helix 还没被告知」。把两者当同一件事,正是调度系统在法定假日排班的原因。
> 先用 `GET /holidays/countries` 查清哪些已维护,再发起查询。

> **多天假期是一天一行。** 中国劳动节是 5 条 `public_holiday` 加 1 条
> `makeup_workday`;按 `observed_group` 聚合即可还原区间。要算工作日的话:减去
> `public_holiday` 日期,并把 `makeup_workday` 日期**加回来** —— 否则调休的那个
> 周六会被当成休息日。

**`meta`** 带 `as_of`(返回范围内最新的 `updated_at`,即 HR 最近一次改动这些
日历的时间)、回显的 `from` / `to`,以及 `day_types` 词表。

**错误:** token 缺 `hr.holidays:read` → `403`;`year` 与 `from`/`to` 同时传、
窗口反向、窗口超过 1098 天、国家超过 60 个 → `422`。

```bash
curl -s "https://helix.pingcap.net/api/v1/open/hr/holidays?country=CN&from=2026-05-01&to=2026-05-31" \
  -H "Authorization: Bearer helix_api_…"
```

```json
{
  "success": true,
  "data": [
    {
      "country": "CN",
      "country_name": "China",
      "coverage": "covered",
      "years_covered": [2026],
      "holidays": [
        { "date": "2026-05-01", "name": "Labor Day Holiday", "name_local": "劳动节", "day_type": "public_holiday", "observed_group": "cn-2026-labor-day-holiday" },
        { "date": "2026-05-02", "name": "Labor Day Holiday", "name_local": "劳动节", "day_type": "public_holiday", "observed_group": "cn-2026-labor-day-holiday" },
        { "date": "2026-05-09", "name": "Makeup Workday (Labor Day Holiday)", "name_local": "劳动节调休上班", "day_type": "makeup_workday", "observed_group": "cn-2026-labor-day-holiday" }
      ]
    }
  ],
  "meta": { "trace_id": "…", "as_of": "2026-08-12T02:15:00+00:00", "from": "2026-05-01", "to": "2026-05-31", "day_types": ["public_holiday", "makeup_workday"] },
  "errors": []
}
```

> **不在本 API 上:** HR 控制台另有 `/hr/holidays/suggest`(从离线数据集拉取某国某年
> 的法定假期供 HR 审阅)和 `/hr/holidays/calendars/roll-forward`(从今年的国家清单
> 开启下一年)。两者都是需要登录的 HR 端点,刻意不对外开放 —— 它们是录入工具,而本
> API 是只读面。

**发现接口 —— `GET /holidays/countries`**(可选 `?year=`)返回每个已维护
「国家-年份」一行:`country`、`country_name`、`year`、`holiday_days`(存储的日期
行数)、`stated_days`(HR 自己公示的口径:落在工作日的放假天数,不含周末假期且已
扣除调休上班日 —— 因此通常小于 `holiday_days`)。

---

### B.6 云账单(Cloud billing)

你的凭据可见范围内那些云订阅的花费。

```
GET /api/v1/open/cloud/billing/daily      cloud.billing:read
GET /api/v1/open/cloud/billing/monthly    cloud.billing:read
GET /api/v1/open/cloud/billing/breakdown  cloud.billing:read
GET /api/v1/open/cloud/billing/changes    cloud.billing:read
GET /api/v1/open/cloud/billing/tag-keys   cloud.billing:read
```

**用这些数字之前先读这张表:**

| 约定 | 对你意味着什么 |
|---|---|
| **恒为 `list` 一个口径** | 金额是**业务折前成本**——保留实际执行价(spot/阶梯),剥离公司层面的合同折扣、承诺折扣、赠金、税与调账。**不是**发票实付净额,也**没有**切换口径的参数。 |
| **⚠️ 2026-08-15 起金额有变(口径未变)** | 上面这条口径描述一直是本 API 的**契约**,但此前的实现没有完全兑现:退款/调账、承诺费、合同折扣、赠金等公司层面的行会漏进金额里(它们本该被剥离)。2026-08-15 起实现与契约对齐——只统计正常用量行。**影响**:同一时间窗重拉,金额会与你 2026-08-15 之前取到的不同,方向以**变大**为主(此前折扣行把数字压低了),阿里云这类有集中退款处理日的账号在个别窗口曾出现负数总额,现在不会了。如果你在做同比/环比或把历史值落库了,请对 2026-08-15 之前的数据重拉一次以统一口径。 |
| **不是发票** | 落不到子账号的费用(支持费、税、赠金、调账)在按订阅的视图里结构性缺席;行级与日汇总两个口径之间还有约 1% 的差。每个响应都带 `meta.excludes_unallocatable`。**不要拿本 API 与云厂商发票对账。** |
| **近几天的数会变** | 云账单导出会持续回补 2–3 天,当月本身也是未结。每行带 `is_provisional`,每个响应带 `data_through` 与 `provisional_from`。下次拉取时,从 `provisional_from` 起的数据要重拉覆盖。 |
| **只发 USD** | CNY 计费的云按平台汇率折算。折不出汇率的行**整行不发**并记入 `meta.unconverted_by_currency`——绝不会发成 `0`(发 0 会被读成「这天没花钱」)。 |
| **可见范围生效** | 只聚合你客户端云可见范围(A.4)之内的订阅。在 `?subscription=` 里点名范围外的订阅不会报错,只是匹配不到任何数据。 |

#### `GET /billing/daily`

一行 = (日期 × 订阅),可选再 × 服务。

| 参数 | 默认 | 说明 |
|------|------|------|
| `start` / `end` | 最近 30 天(截至今天) | 闭区间。最长 **366 天**(带服务拆分时为 **92 天**)。 |
| `subscription` | — | 可重复。Helix 订阅 id(UUID)或云原生 external id。 |
| `provider` | — | 可重复(`aws` / `gcp` / `azure` / `aliyun` / …)。 |
| `environment` | — | 可重复:`dev` / `staging` / `prod`。 |
| `owner_email` | — | 按订阅 Owner 的工作邮箱过滤。 |
| `group_by` | `subscription` | 或 `subscription,service`——多出 `service` 字段,并套用 92 天上限。 |
| `page` / `page_size` | 1 / 200 | 账单端点的 `page_size` 上限为 **1000**(见 A.5)。 |

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  "https://helix.pingcap.net/api/v1/open/cloud/billing/daily?start=2026-07-01&end=2026-07-31&provider=aws&page_size=1000"
```

```json
{
  "success": true,
  "data": [
    {
      "date": "2026-07-01",
      "subscription_id": "b1c2d3e4-0000-0000-0000-000000000000",
      "external_id": "123456789012",
      "name": "RD Compute",
      "provider": "aws",
      "environment": "prod",
      "amount_usd": 812.4531,
      "currency": "USD",
      "is_provisional": false,
      "source": "cost_facts"
    }
  ],
  "meta": {
    "page": 1, "page_size": 1000, "total": 1,
    "measure": "list",
    "currency": "USD",
    "window": { "start": "2026-07-01", "end": "2026-07-31" },
    "data_through": "2026-07-31",
    "provisional_from": "2026-08-12",
    "excludes_unallocatable": true,
    "sources": ["cost_facts"]
  },
  "errors": []
}
```

`source` 表示这行由哪张表回答:`cost_facts`(行级真理表)或 `billing_daily`(日汇总表,在行级摄取对该订阅-月尚未跟上时兜底)。两者都是 `list` 口径,口径差约 1%。

#### `GET /billing/monthly`

一行 = (月份 × 订阅),可选再 × 服务。由与日粒度相同的单元格折叠而来,所以在本 API 上「月合计 == 该月各天之和」恒成立。

| 参数 | 默认 | 说明 |
|------|------|------|
| `start_month` / `end_month` | 最近 12 个月 | `YYYY-MM`,闭区间。最长 **36 个月**(带服务拆分时为 **3 个月**)。 |
| 其余 | — | 与 `/billing/daily` 相同。 |

`month_is_partial` 在以下任一情况为 `true`:该月尚未结束、该月含任何未定型的日、或你的窗口只覆盖了该月的一部分——三者都意味着「这不是一个已结的数」。

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  "https://helix.pingcap.net/api/v1/open/cloud/billing/monthly?start_month=2026-01&end_month=2026-07"
```

#### `GET /billing/breakdown`

窗口总额按**一个**维度拆分,从大到小。

| 参数 | 默认 | 说明 |
|------|------|------|
| `start` / `end` | 最近 30 天 | 最长 **92 天**——一次分组要在一趟里聚合窗口内全部行级数据。 |
| `group_by` | `subscription` | **一个或两个**维度,逗号分隔(见下表)。 |
| `top` | 20(最大 100) | 行数上限,按花费从大到小;截断时 `meta.truncated_to_top` 会说明。 |
| `subscription` / `provider` / `environment` / `owner_email` | — | 与 `/billing/daily` 相同。 |

**可用维度:**

| 维度 | 说明 |
|---|---|
| `subscription` / `provider` / `environment` | 由订阅本身推出。 |
| `service` / `region` / `resource_type` | 来自账单行。 |
| `department` / `owner` / `cost_center` | Helix 的成本分摊归属,含显式的 `unallocated` 行。 |
| `resource` | 按花费 Top-N;另见 `without_resource_id_usd`。 |
| **`tag:<key>`** | 云资源标签(AWS cost-allocation tags / GCP labels / 阿里云标签)。可用的 key 用 `GET /billing/tag-keys` 发现。 |

**两个维度交叉**才能回答单轴回答不了的问题——「这个团队的钱,按环境怎么分」:

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  ".../billing/breakdown?group_by=tag:usedby,environment&start=2026-07-01&end=2026-07-31"
```

每行除了扁平的 `key` / `label`,还带 `keys` 与 `labels` 两个对象(每个维度一项),所以一维和二维的响应可以用同一套代码解析。

#### 标签:相信一张 tag 图之前先读这几条

- **tag key 区分大小写**,与云上存的完全一致。`tag:UsedBy` 与 `tag:usedby` 是两个不同的 key(GCP labels 按规则必须小写,AWS tags 不是)。匹配不到的 key **不会报错**,而是全部落进 `untagged`——所以下结论「这个团队没花钱」之前,先看 `meta.tag_coverage`。
- **`untagged` 是一等一的一行**。本功能上线当天在生产实测:约 **21%** 的计费额完全没有标签。
- **`meta.tag_coverage`** 给出本窗口的 `tagged_usd` / `untagged_usd` / `tagged_share`。
- **`meta.providers_without_tags`** 列出本窗口内花费完全不带标签的云(其账单源根本不提供)。tag 视图结构性看不见这些云——有了这个字段,你才不会从图里读出「某某云没花钱」。

#### `GET /billing/tag-keys`

你可见的花费上都有哪些 tag key,按覆盖金额排序,免得靠猜选维度。

| 参数 | 默认 | 说明 |
|------|------|------|
| `start` / `end` | 最近 30 天 | 最长 92 天。 |
| `limit` | 100(最大 500) | |
| `subscription` / `provider` / `environment` / `owner_email` | — | 与 `/billing/daily` 相同。 |

`sampled_spend_usd` 只用于**排序**,不是该 key 的总花费(tag 是没有索引的 JSON 列,扫描有上限且偏向近期)。要精确金额请用 `group_by=tag:<key>`。这里**不返回 tag 的值**——值请用分组端点取,那条路径同样受可见范围约束。

```bash
curl -H "Authorization: Bearer $HELIX_API_TOKEN" \
  "https://helix.pingcap.net/api/v1/open/cloud/billing/breakdown?start=2026-07-01&end=2026-07-31&group_by=service"
```

**画饼图之前先读 `meta` 里的三件事:**

- **`total_usd` 是窗口的全部花费**,不是所展示行的和。`group_by=resource&top=20` 时行只有前 20 条;`share` 是各行在分组总额中的占比。
- **`not_broken_down`**:当某些订阅的花费来自日汇总表(该订阅-月没有行级数据)时出现。日汇总表没有服务/owner/部门/资源维度,拆不开——于是把金额和责任订阅列在这里,而不是让它凭空消失。只有需要行级数据的四个维度会出现它;`subscription` / `provider` / `environment` 能正常吸收。
- **`data_through`**:有数据的最后一天,与日/月端点同一含义。
- **`without_resource_id_usd`**(仅资源维度):完全没有 resource_id 的那部分花费。GCP 标准账单导出没有 resource 列,所以 GCP 占比高的窗口里 Top-20 可能只覆盖很小一部分——就靠这个字段判断。

分组总额非正时(全是赠金的窗口没有有意义的分母),`share` 为 `null`。

`owner` 维度会**点名到人**——成本分摊阶梯归属到的那个人的展示名与工作邮箱。只有确实需要按人归属时才申请这个维度。

`department` / `owner` 维度会包含一条 **`unallocated`** 行,对应 Helix 成本分摊阶梯没能归属到任何人的行级数据。它既不会被并进别的 owner,也不会被丢弃。

#### `GET /billing/changes` —— 增量拉取

自某个时间点以来被重新摄取过的 `(订阅, 日期)` 单元格,让每晚的作业只重拉这些,而不是整个窗口。

| 参数 | 默认 | 说明 |
|------|------|------|
| `since` | *(必填)* | ISO-8601。`Z` 结尾、无时区(按 UTC 读)、以及未转义的 `+00:00` 偏移量都接受。 |
| `horizon_days` | 32(最大 92) | 往回看多久。 |
| `subscription` / `provider` / `environment` / `owner_email` | — | 与 `/billing/daily` 相同。 |
| `page` / `page_size` | 1 / 200 | `page_size` ≤ 1000。 |

**它到底是什么。** 是**摄取水位**,不是金额 diff:某天被重新抓取但数字没变,依然会出现在列表里。按 `(订阅, 日期)` 覆盖写入的消费方并不在意这一点,而漏报则是静默丢数据。

**它不会告诉你什么。** 早于 `horizon` 的日期发生的变化。管理员回填历史窗口时,请显式重拉那个窗口——端点对早于 horizon 的 `since` 直接报错,而不是返回一个「看起来完整」的部分答案。

`/billing/daily` 直接接受同一个游标:`?updated_since=<时间戳>`,把序列过滤到变更单元格(并在 `meta.incremental` 里回显同一个说明块),并接受同一个 `horizon_days` 旋钮、同样的上下限)。

**推荐的每晚循环:**

1. 首次全量拉取你需要的历史。
2. 每次运行:`GET /billing/daily?start=…&end=…&updated_since=<上次运行的时间戳>`。
3. 记录本次请求时间作为下次的游标,按 `(subscription_id, date)` 覆盖写入。
4. `meta.provisional_from` 之后的数据还会变几天——这是正常的,增量接口会一直返回它直到定型。

**推荐拉取方式**:首次拉一个宽窗口,之后每次只从 `provisional_from` 起重拉。每次读取都会按你的客户端写一条审计,所以「每晚全量拉一年」在审计里就是这个样子。

---

### B.7 人员 ↔ GitHub 用户名查询

```
POST /api/v1/open/people/github/lookup    people.github:read
```

回答「这个 GitHub handle 是谁」和「这个人的 handle 是什么」。最初的驱动场景是拿
GitHub 组织的成员名单和员工名册做对账。

拿这份数据有两条路,用途不同。如果你的系统要**同步整份名册**(自己存一份、增量更新),
用目录 Pull API(`helix_svc_` 凭证的 `github_id` 字段,见
`docs/integrations/identity-pull-api.md`);如果你只是**现在想问一个问题**,用这个端点。

**三个选择器**可组合,至少传一个:

| 参数 | 类型 | 规则 |
|------|------|------|
| `emails` | string[] | 按工作邮箱正查。单次 ≤200,大小写不敏感。 |
| `github_ids` | string[] | 按 handle **反查** —— GitHub 成员名单给你的正是这个方向。单次 ≤200,大小写不敏感;响应原样回显你传入的写法,便于和自己的列表对齐。 |
| `departments` | string[] | 某个组织路径下的全部人员。单次 ≤50。路径从租户根**之下**开始、斜杠分隔(`Engineering Group / TiDB`),自动包含子部门;同前缀的兄弟部门不会误中(`Engineering` 不会吃掉 `Engineering Support`)。 |
| `include_left` | boolean | 是否包含已离职,默认 `false`。做离职回收对账时设为 `true`。 |

**响应** —— 你传了哪个选择器就返回哪一块:

```json
{
  "data": {
    "emails": [
      { "email": "zhangsan@pingcap.com", "status": "found",
        "person": { "work_email": "zhangsan@pingcap.com",
                    "display_name": "Zhang San", "employee_no": "P12345",
                    "github_id": "zhangsan-pc", "employment_status": "active",
                    "department_full_path": "Engineering Group / TiDB" } },
      { "email": "nohandle@pingcap.com", "status": "no_github_id",
        "person": { "…": "…" } },
      { "email": "ghost@pingcap.com", "status": "unknown_person",
        "person": null }
    ],
    "github_ids": [
      { "github_id": "not-ours", "status": "unknown_handle", "person": null }
    ],
    "departments": {
      "requested": ["Engineering Group"], "unmatched": [], "people": [ … ]
    }
  },
  "meta": { "visibility": "unrestricted", "include_left": false,
            "person_count": 12 }
}
```

#### 在你从 GitHub 组织里移除任何人之前,先读这一段

`github_id` **只在入职时采集、从不回溯补采**,是稀疏字段。`null` 表示
「我们没有登记他的 handle」,**不表示**「这个人没有 GitHub 账号」。

所以按邮箱查的结果是三态而不是布尔:

| `status` | 含义 | 你该怎么做 |
|---|---|---|
| `found` | 找到人,且已登记 handle | 直接用 |
| `no_github_id` | 找到人,但**没登记 handle** | 这是数据缺口——去问本人,别据此动手 |
| `unknown_person` | 查无此人,**或不在你的人员范围内** | 人工核实 |

绝不能把 `no_github_id` 或 `null` 当作「非本公司员工」而据此移除成员。真正值得人工
过一眼的信号是反方向:GitHub 组织里存在、但 Helix 叫不出名字的 handle
(`unknown_handle`)。

#### 人员范围 —— 不只是「哪类数据」,还有「哪些人」

和云可见性(§A.4)同理:scope 决定你能读**哪类**人员数据,能读到**哪些人**是管理员
单独配置的每客户端设置:

- **不限制**(默认)—— 全部名册。
- **限定范围** —— 按部门 / 国家 / 雇佣实体 / 在职状态 / 用工类型 / 指定人员组成的筛选。范围外
  的人返回 `unknown_person` / `unknown_handle`,**与「查无此人」完全一致** ——
  端点绝不会告诉你「这个人存在,只是你看不到」。

`GET /api/v1/open/me` 会回显你自己的设置,所以「我看到的人比预期少」不用问管理员就能
自查:

```json
{ "person_visibility": { "restricted": true, "departments": ["50012345", "50012346"],
                         "department_count": 2, "include_subtree": true } }
```

限定你的那些维度会被完整列出(部门、国家、在职状态、`employee_type_codes`……);
点名人员较多时按采样返回,但始终附带总数。

`employee_type_codes` 里是 SuccessFactors 的原始用工类型编码 —— `1070` 正式员工、
`1071` EOR、`1072` 实习生、`1073` 劳务顾问、`1074` 外包人员、`1075` 派遣人员。
存编码而不是标签,是因为词表归上游 HRIS 所有,它改过标签但没改过编码。HRIS 没有
标注过用工类型的人不匹配任何用工类型筛选,所以带这个维度的上限天然会把未标注的
记录(服务账号、HRIS 之前的历史行)排除在外。

如果存下来的筛选变得无法解析,这里会明说(`"misconfigured": true`),同时数据端点
会解析成**一个人都没有** —— 方向是安全的,但需要管理员重新保存一次筛选。所以看到
空结果时,先来这里确认一下,别直接当成「没人登记 handle」。

**在职范围**:默认只返回在职人员(`active` 与 `leaver_pending` —— 待离职的人
仍然持有访问权限,做权限审计时还得算上)。`include_left=true` 才追加 `left`。
未入职(pre_hire)**在任何情况下都不返回**。

**其它约定**:响应只含身份 + handle + 部门,不含手机号、个人邮箱——不管你还持有什么
别的 scope。按部门查如果命中超过 1000 人会返回 `422`,而不是悄悄截断成一份看起来
「这就是全部」的名单。在 Helix 所有出口面都被排除的行(HRIS 已清除、SF 标记不推送、
非公司邮箱的脏数据),这里同样不会出现。

---

## Part C —— Claude / MCP 连接器(OAuth)

这与上面的 `helix_api_` 对外 API 是**两个不同的面**。MCP 端点把 Helix
能力以**工具**形式暴露给 AI 代理(Claude、Cursor…),鉴权可用个人令牌
或 OAuth。

**MCP 端点**:`https://helix.pingcap.net/api/v1/mcp`

两种接入方式:

1. **配置文件类客户端(Claude Code 等)** —— 在「个人令牌」签发 PAT,设
   `Authorization: Bearer helix_pat_…` 头:

   ```json
   {
     "mcpServers": {
       "helix": {
         "type": "http",
         "url": "https://helix.pingcap.net/api/v1/mcp",
         "headers": { "Authorization": "Bearer helix_pat_xxxxxxxx" }
       }
     }
   }
   ```

2. **GUI 连接器(claude.ai 的「Add custom connector」)** —— 只填 URL
   `https://helix.pingcap.net/api/v1/mcp`,选 **Individual sign-in**。连接器
   自动发现 Helix 的 OAuth 2.1 授权服务器,引导你走 SSO + 同意页;无需粘贴
   token、无需填 OAuth Client ID/Secret(Helix 动态注册客户端)。每位同事以
   各自身份连接。

**代理能做什么**:经 OAuth 接入的代理以你的**完整 Helix 权限**行事(与 PAT
对等)——可读取,并在你角色允许的范围内代表你操作。写操作仍走正常审批链,
你离职后即被拦截。可随时在「个人令牌」撤销(以 OAuth 授权形式展示)。

**OAuth 接口面**(遵循 MCP authorization spec —— RFC 9728 / 8414 / 7591 /
7636 / 8707):

| 端点 | 用途 |
|------|------|
| `GET /.well-known/oauth-protected-resource[/api/v1/mcp]` | 资源元数据(指向授权服务器)。 |
| `GET /.well-known/oauth-authorization-server` | 授权服务器元数据(端点、PKCE S256、授权类型)。 |
| `POST /api/v1/auth/oauth/register` | 动态客户端注册(公开客户端)。 |
| `GET /api/v1/auth/oauth/authorize` | 授权端点 → SSO + 同意。 |
| `POST /api/v1/auth/oauth/token` | `authorization_code`(PKCE)+ `refresh_token`。 |
| `POST /api/v1/auth/oauth/revoke` | 撤销 refresh token。 |

access token 短期(1h)且 **audience 绑定**到 MCP 端点;refresh token 单次轮换。

**工具之外**(2026-07-09):MCP 面还提供

- **工具注解** —— 每个工具带 `readOnlyHint` / `destructiveHint` /
  `idempotentHint`(`openWorldHint=false`),由服务端自身的读写事实推导。
  客户端可放行只读调用、只对危险子集弹确认。
- **Resources** —— `helix://platform/overview`(本接入面的行为说明)与
  `helix://platform/tool-catalog`(全工具读/写实时目录)。
- **Prompts** —— 五个官方剧本(`it_overview`、`approvals_review`、
  `cost_review`、`offboarding_handover`、`onboarding_progress`),在支持
  的客户端中以提示模板出现。Prompt 只提供指令,不附带任何权限。

---

## 变更记录

- **2026-08-18** —— 人员 ↔ GitHub 用户名查询(B.7,`people.github:read`):
  按邮箱正查、按 handle 反查、按部门列人。这是对外 API 上第一个**能列人**的面,
  因此同时上线了每客户端的**人员范围**上限(`person_visibility`)——见 B.7 与
  `GET /me`。

- **2026-07-09** —— MCP 满配:全量工具注解 + 平台 Resources ×2 + 官方
  Prompts ×5(见 Part C「工具之外」)。

- **2026-06-23** —— MCP 连接器 OAuth 2.1 + PKCE + 动态客户端注册:Helix
  现可作为 Claude/Cursor「custom connector」经 Individual sign-in 接入
  (Part C)。PAT 鉴权仍可用。
- **2026-06-15** —— 对外 API 拆为独立功能(`helix_api_` API 客户端 + 独立菜单)。云订阅资源:读 + 写。

> 员工/组织**目录**数据请用独立的身份分发 API([identity-pull-api.md](../integrations/identity-pull-api.md))——不同凭证(`helix_svc_`)与后台。
