# Helix Identity Pull API — 接入指南(给消费方工程师)

> Helix 是公司的人事身份权威数据源(SuccessFactors → Helix)。本 API 让你的系统
> **主动拉取**人员与组织数据,替代「Helix 给你推」的对接方式。
>
> 面向申请人/接入方的简版手册在 Helix `/help` →「我要从 Helix 拉取人员/组织数据」
> (`/help#consumer-integration`);管理员视角见「身份源数据与认证 · 对外开放(身份消费方)」。
> 本文件是工程接入参考(端点契约、增量协议、错误语义)。

## 0. 一句话流程

1. 找 Helix 管理员在「管理后台 → 身份源数据与认证 → 身份消费方(拉取)」注册你的系统,拿到
   `helix_svc_…` 服务凭证(明文只显示一次)与授权范围(scope)。
2. 调 `GET /api/v1/open/directory/me` 验证凭证。
3. 首次全量:循环拉 `GET /persons` 直到 `has_more=false`,把每次响应的
   `next_cursor` 持久化(它始终代表扫描高水位)。
4. 之后增量:定时(分钟级)用保存的 cursor 继续拉;`has_more=false` 且
   `items` 为空 = 暂无变化,成本极低。

## 1. 认证

所有请求带请求头:

```
Authorization: Bearer helix_svc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- 凭证由 Helix 管理员签发/吊销/轮换;轮换时新旧凭证可并存(同消费方最多 3 个
  有效凭证),先切新再吊旧,不停服。
- 凭证失效 / 消费方被停用 → 统一 `401`(不区分原因)。
- 缺 scope → `403`,响应里写明缺哪个 scope(并被审计)。
- 限流:默认 300 次/分钟(管理员可按消费方调整)。超限 → `429` + `Retry-After`
  头(秒)。失败认证有独立的防爆破限流,持续用死凭证重试会被 `429` 短路。

## 2. 响应信封

所有端点返回 Helix 标准信封:

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

报障时请带上 `meta.trace_id`(响应头 `X-Trace-ID` 同值)。

## 3. Scope

| scope | 内容 |
|---|---|
| `directory.persons:read` | 人员核心字段;`mobile_phone` / `personal_email` **脱敏** |
| `directory.org:read` | 组织节点 + 人员-部门归属 |
| `directory.pii:read` | 在 persons:read 基础上 PII 明文(显式授予才有) |

## 4. 人群过滤(重要)

管理员给你的消费方配置了**人群过滤**:你只能看到过滤命中的人。要点:

- 默认(未配置)= **仅在职员工(active)**。
- **想看到离职转变**(`employment_status` 变为 `left`):请管理员把
  `left` / `leaver_pending` 加进过滤的 `employment_statuses`。Helix 不硬删
  人员,离职会作为一次行更新出现在你的增量流里 —— 前提是你的过滤包含离职状态。
- 过滤外的人:列表里不出现;按 key 直查返回 `404`(不是 403)。

## 5. 增量协议(keyset cursor)

列表端点按 `(updated_at, id)` 升序排列,返回:

```json
{ "items": [ ... ], "next_cursor": "eyJ1IjoiMjAyNi0w...", "has_more": false }
```

- **每次响应后都持久化 `next_cursor`**(它始终是扫描高水位),原样回传
  `?cursor=` 继续;`has_more=true` 表示还有数据立即再拉,`false` 表示当前
  已拉干净 —— 之后定时用同一个 cursor 轮询即增量(行一旦变化,`updated_at`
  推进,会重新出现在 cursor 之后)。
- `next_cursor` 仅在「无入参 cursor 且库为空」时为 `null`。
- 不要用纯时间戳自己做增量(批量 HRIS 同步会在同一秒更新大量行,纯时间戳会
  丢行);cursor 内含 id tiebreaker,无损。
- **可见性安全水位**:最近 ~5 秒内更新的行会被暂扣到下一次轮询,防止「事务
  晚提交但时间戳早」的行被游标越过 —— 你无需处理,只是数据新鲜度有 ~5s 延迟。
- 首次定位也可用 `?updated_since=2026-06-01T00:00:00`(ISO-8601,UTC)代替
  cursor;两者不可同时传。
- 所有时间戳输出均带 `Z` 后缀(UTC)。
- `page_size` 默认 100,上限 500。
- 处理务必幂等:同一行可能因多次字段更新出现多次。

## 6. 端点

### 6.1 `GET /api/v1/open/directory/me`

凭证冒烟测试。回显 `consumer`(slug)、`scopes`、`filter_config`。无需任何 scope。

### 6.2 `GET /api/v1/open/directory/persons` (`directory.persons:read`)

参数:`cursor` / `updated_since` / `page_size` / `employment_status`
(CSV,在你的过滤范围内再收窄,如 `active,left`)。

单条 item:

```json
{
  "id": "uuid",
  "employee_no": "P01478",
  "work_email": "alice@pingcap.com",
  "display_name": "Alice",
  "first_name": "…", "last_name": "…", "avatar_url": null,
  "employment_status": "active",
  "job_title": "Senior Engineer",
  "is_china_employee": true,
  "department": { "source_id": "D100", "name": "Engineering", "path": "PingCAP / Engineering" },
  "manager": { "employee_no": "P00100", "work_email": "boss@pingcap.com" },
  "hrbp":    { "employee_no": "P00200", "work_email": "hrbp@pingcap.com" },
  "work_location": { "code": "BJ01", "name": "北京", "country": "CN", "city": "Beijing" },
  "company": { "code": "1000", "name": "PingCAP" },
  "cost_center": "CC123",
  "hire_date": "2023-04-01",
  "termination_date": null,
  "preferred_language": "zh_CN",
  "email_aliases": ["alice.zhang@pingcap.com"],
  "github_id": "alice-pc",
  "mobile_phone": "+86 138****5678",
  "personal_email": "************l.com",
  "source_updated_at": "2026-06-10T02:00:11",
  "updated_at": "2026-06-10T02:00:12"
}
```

注意:

- `manager` / `hrbp` 是**纯标识引用**(employee_no + work_email),不内嵌对象;
  且**仅当被引用人也在你的人群过滤内**才返回,否则为 `null` —— 需要完整
  上级链时请管理员扩过滤。
- 持有 `directory.pii:read` 时 `mobile_phone` / `personal_email` 为明文
  (明文 PII 读取会被审计)。
- `github_id` 是入职时由 HRIS 采集的 GitHub 用户名,**稀疏字段**:`null`
  表示「未登记」,**不代表此人没有 GitHub 账号**。用它做 GitHub 组织成员
  对账时,`null` 只能当作「未知」处理,绝不能推断成「非本公司员工」而据此
  移除成员 —— 反向(GitHub 上有、但 Helix 里查不到这个 handle)才是需要
  人工核实的信号。若只需按人/按部门点查而不需要全量同步,另有对外 API 的
  `POST /api/v1/open/people/github/lookup`(见 `docs/api/external-api.md`)。

### 6.3 `GET /api/v1/open/directory/persons/{key}` (`directory.persons:read`)

`key` 可为人员 UUID、`employee_no`(如 `P01478`)或 `work_email`。
过滤外 / 不存在 → `404`。

### 6.4 `GET /api/v1/open/directory/org/nodes` (`directory.org:read`)

组织节点平铺列表(keyset 同上)。item:`source_id` / `type` / `code` / `name` /
`parent_source_id`(树用它重建)/ `path` / `level` / `status` / `updated_at`。

### 6.5 `GET /api/v1/open/directory/org/memberships` (`directory.org:read`)

人员-部门归属(keyset 同上),仅含你过滤范围内的人。item:`person_id` /
`employee_no` / `work_email` / `org_node_source_id` / `org_node_name` /
`is_primary` / `membership_status` / `updated_at`。

## 7. 错误速查

| HTTP | 含义 | 你该做什么 |
|---|---|---|
| 401 | 凭证无效/过期/消费方被停用 | 找管理员核对/重发凭证 |
| 403 | 缺 scope | 响应里写了缺哪个;找管理员授予 |
| 404 | key 不存在或在你的过滤之外 | 确认 key;或请管理员扩过滤 |
| 422 | 参数错(坏 cursor、非法状态值、cursor+updated_since 同传) | 修参数 |
| 429 | 限流 | 按 `Retry-After` 退避重试 |

## 8. 推荐拉取节奏

- 全量对账:每天一次完整 drain(空 cursor 起步,拉到 `has_more=false`)。
- 增量:每 1–5 分钟用保存的 cursor 拉一次;`items` 空 = 无变化,成本极低。

## 9. SCIM 2.0(标准协议接入)

如果你的系统原生支持 SCIM 拉取(IdP / SaaS 目录同步),可以不用上面的 REST 端点,直接对接:

- **Base URL**:`https://<helix-host>/scim/v2`
- **认证**:同样是 `Authorization: Bearer helix_svc_…`
- **端点**(全部只读,GET):`/ServiceProviderConfig`、`/ResourceTypes`、`/Schemas`、
  `/Users`、`/Users/{id}`、`/Groups`、`/Groups/{id}`
- **scope**:Users 需 `directory.persons:read`;Groups 需 `directory.org:read`;
  `phoneNumbers` 字段仅在持有 `directory.pii:read` 时出现(不输出打码值,直接省略)
- **分页**:`startIndex`(1 起)+ `count`(≤500),`totalResults` 为过滤后总数
- **增量与可见性水位**:用 `meta.lastModified gt "<上次水位>"` 做增量时,与 REST
  面同样受 ~5 秒可见性安全水位约束(最近 5s 的行暂不返回,防事务晚提交被水位
  越过)—— 把你的 lastModified 水位往回留 ≥5s 余量即可无损
- **filter 支持子集**(超出返回 400 `invalidFilter`):
  - Users:`userName eq "…"`、`externalId eq "…"`(externalId = 工号)、
    `meta.lastModified gt|ge "ISO8601"`;最多两个子句用 `and` 连接
  - Groups:`displayName eq "…"`、`externalId eq "…"`(externalId = HRIS 部门 source_id)
- **映射**:`userName`=工作邮箱;enterprise extension 携带
  `employeeNumber`(工号)/ `department`(主部门名)/ `costCenter` / `organization` /
  `manager`(value = 经理的 SCIM id,**仅当经理也在你的过滤内**,否则整块省略);
  `active` 严格等于 `employment_status == 'active'`(注意 `leaver_pending`
  尚未离职但也是 `active=false`);离职行出现的前提是你的人群过滤包含离职状态;
  Group = 部门,`members` 仅含过滤内人员
- **写操作**:Helix 是身份权威源,任何 POST/PUT/PATCH/DELETE 一律 `405`
- 人群过滤语义与 REST 完全一致(过滤外 Users 直查 404)
